eerste versie

This commit is contained in:
bart
2026-11-03 22:21:03 +01:00
parent 8a08620866
commit 99cbc00357
33 changed files with 11157 additions and 2163 deletions
+1
View File
@@ -0,0 +1 @@
../AGENTS.md
+4193
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[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 }
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
+210
View File
@@ -0,0 +1,210 @@
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)]
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,
}
fn default_true() -> bool {
true
}
fn default_http_port() -> u16 {
8080
}
impl Default for KodiRpcConfig {
fn default() -> Self {
Self {
enabled: default_true(),
http_port: default_http_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())
}
}
+658
View File
@@ -0,0 +1,658 @@
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::mpsc::UnboundedSender;
use crate::player::PlayerController;
use crate::yatse::RemoteNavAction;
// 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: UnboundedSender<RemoteNavAction>,
}
impl KodiEventServer {
pub fn new(port: u16, player: Arc<PlayerController>, nav_tx: UnboundedSender<RemoteNavAction>) -> Self {
Self { port, player, nav_tx }
}
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: &UnboundedSender<RemoteNavAction>,
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" | "menu" | "topmenu" | "contextmenu" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"togglefullscreen" | "fullscreen" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"osd" | "showosd" | "subtitles" | "nextsubtitle" => {
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
}
"play" | "pause" | "playpause" => {
let p = player.clone();
tokio::spawn(async move { p.play_pause().await; });
}
"stop" => {
let _ = nav_tx.send(RemoteNavAction::Stop);
let p = player.clone();
tokio::spawn(async move { p.stop().await; });
}
"volumeup" | "vol_up" => {
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
let p = player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
"volumedown" | "vol_down" => {
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
let p = player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
"mute" | "volumemute" => {
let p = player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
_ => {
// 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); }
_ => {}
}
}
}
}
pub fn dispatch_action(
action: &str,
nav_tx: &UnboundedSender<RemoteNavAction>,
player: &Arc<PlayerController>,
) {
let lower = action.to_lowercase();
if lower == "up" || lower == "action(3)" || lower == "cursorup" {
let _ = nav_tx.send(RemoteNavAction::Up);
} else if lower == "down" || lower == "action(4)" || lower == "cursordown" {
let _ = nav_tx.send(RemoteNavAction::Down);
} else if lower == "left" || lower == "action(1)" || lower == "cursorleft" {
let _ = nav_tx.send(RemoteNavAction::Left);
} else if lower == "right" || lower == "action(2)" || lower == "cursorright" {
let _ = nav_tx.send(RemoteNavAction::Right);
} else if lower == "select" || lower == "enter" || lower == "action(7)" || lower == "selectitem" {
let _ = nav_tx.send(RemoteNavAction::Select);
} else if lower == "back" || lower == "action(9)" || lower == "action(10)" || lower == "previousmenu" || lower == "parentdir" || lower == "close" {
let _ = nav_tx.send(RemoteNavAction::Back);
} else if lower.contains("togglefullscreen") {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
} else if lower.contains("action(osd)") || lower == "osd" || lower.contains("nextsubtitle") {
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
} else if lower.contains("volumeup") || lower == "action(88)" {
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
let p = player.clone();
tokio::spawn(async move { p.volume_up().await; });
} else if lower.contains("volumedown") || lower == "action(89)" {
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
let p = player.clone();
tokio::spawn(async move { p.volume_down().await; });
} else if lower.contains("activatewindow(videos,movietitles") || lower.contains("activatewindow(videos)") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(1));
} else if lower.contains("activatewindow(videos,tvshowtitles") || lower.contains("activatewindow(tvshows") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(2));
} else if lower.contains("activatewindow(music") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(3));
} else if lower.contains("activatewindow(pictures") || lower.contains("activatewindow(photos") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(4));
} else if lower.contains("activatewindow(home") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(0));
} else if lower.contains("playpause") || lower == "pause" || lower == "play" {
let p = player.clone();
tokio::spawn(async move { p.play_pause().await; });
} else if lower.contains("stop") {
let _ = nav_tx.send(RemoteNavAction::Stop);
let p = player.clone();
tokio::spawn(async move { p.stop().await; });
} else if lower.contains("mute") {
let p = player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
}
#[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 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_tx, &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 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_tx, &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 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),
];
for (act, expected) in cases {
dispatch_action(act, &nav_tx, &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);
}
}
+651
View File
@@ -0,0 +1,651 @@
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 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 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;
}
// 3. 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;
}
// 4. 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, sort them by index_number
if items.iter().any(|i| i.item_type == "Audio") {
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()
};
let user_id = {
let guard = self.user_id.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 -> resolve next unplayed episode
if item.item_type == "Series" {
if let Ok(headers) = self.get_authed_headers().await {
let next_url = format!("{}/Shows/NextUp?seriesId={}&userId={}&fields=Genres", base_url, item.id, user_id);
let episodes = fetch_endpoint(&self.client, &next_url, &headers).await;
if let Some(ep) = episodes.first() {
return Ok(PlayableTarget::Stream {
url: ep.stream_url(base_url, &token),
title: format!("{}: {}", item.name, ep.name),
media_type: "video".to_string(),
artist: None,
genre: ep.genre.clone().or_else(|| item.genre.clone()),
});
}
// Fallback: episode 1
let ep1_url = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Episode&recursive=true&sortBy=SortName&limit=1&fields=Genres",
base_url, user_id, item.id
);
let ep1 = fetch_endpoint(&self.client, &ep1_url, &headers).await;
if let Some(ep) = ep1.first() {
return Ok(PlayableTarget::Stream {
url: ep.stream_url(base_url, &token),
title: format!("{}: {}", item.name, ep.name),
media_type: "video".to_string(),
artist: None,
genre: ep.genre.clone().or_else(|| item.genre.clone()),
});
}
}
}
// 3. PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI
if item.item_type == "PhotoAlbum"
|| item.item_type == "MusicArtist"
|| item.item_type == "MusicAlbum"
|| (item.is_folder && item.item_type != "Series")
{
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
}
+147
View File
@@ -0,0 +1,147 @@
use eframe::egui::Vec2;
use tokio::sync::mpsc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod app;
mod config;
mod eventserver;
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)
let (nav_tx, nav_rx) = mpsc::unbounded_channel();
// 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);
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 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();
// 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| {
// Install egui image loaders (http, png, jpg)
egui_extras::install_image_loaders(&cc.egui_ctx);
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
if let Ok(handle) = cc.window_handle() {
match handle.as_raw() {
RawWindowHandle::Xlib(h) => {
let p = app_player.clone();
let wid = h.window as u64;
tokio::spawn(async move {
p.set_window_id(Some(wid)).await;
});
}
RawWindowHandle::Xcb(h) => {
let p = app_player.clone();
let wid = h.window.get() as u64;
tokio::spawn(async move {
p.set_window_id(Some(wid)).await;
});
}
_ => {}
}
}
Ok(Box::new(KioskApp::new(
app_config,
app_client,
app_player,
nav_rx,
)))
}),
)
})
.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");
}
}
+494
View File
@@ -0,0 +1,494 @@
use serde::{Deserialize, Serialize};
use std::process::{Child, Command};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
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,
}
}
}
pub struct PlayerController {
config: PlayerConfig,
child_process: Mutex<Option<Child>>,
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>>,
}
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());
Arc::new(Self {
config,
child_process: Mutex::new(None),
status: Arc::new(Mutex::new(initial_status)),
status_tx,
status_rx,
window_id: Mutex::new(None),
})
}
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;
if let Some(w) = wid {
tracing::info!("Registered window handle for MPV embedding: {}", w);
}
}
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;
// Clean up socket file if exists
let sock_path = std::path::Path::new(&self.config.ipc_socket);
if sock_path.exists() {
let _ = std::fs::remove_file(sock_path);
}
// Kill any existing instance
{
let mut proc_guard = self.child_process.lock().await;
if let Some(mut child) = proc_guard.take() {
let _ = child.kill();
}
}
// Build MPV command
let mut cmd = Command::new(&self.config.mpv_path);
cmd.arg(format!("--input-ipc-server={}", self.config.ipc_socket));
cmd.arg(format!("--hwdec={}", self.config.hwdec));
cmd.arg(format!("--vo={}", self.config.vo));
// Configure yt-dlp for YouTube streams
let ytdlp_path = if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() {
"/home/bart/.local/bin/yt-dlp"
} else {
"yt-dlp"
};
cmd.arg(format!("--script-opts=ytdl_hook-ytdl_path={}", ytdlp_path));
cmd.arg("--ytdl-format=bestvideo[height<=?1080]+bestaudio/best");
cmd.arg("--ytdl-raw-options=no-playlist=");
let wid = *self.window_id.lock().await;
if let Some(w) = wid {
tracing::info!("Embedding MPV directly into window ID: {}", w);
cmd.arg(format!("--wid={}", w));
} else if self.config.fullscreen {
cmd.arg("--fs");
}
cmd.arg(format!("--title={}", title));
// 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()
};
cmd.arg(play_url);
match cmd.spawn() {
Ok(child) => {
let mut proc_guard = self.child_process.lock().await;
*proc_guard = Some(child);
}
Err(e) => {
let msg = format!("MPV niet gevonden ('{}')! Installeer MPV via: sudo apt install mpv", self.config.mpv_path);
tracing::warn!("{}: {}", msg, e);
let mut st = self.status.lock().await;
st.state = PlaybackState::Stopped;
st.error_message = Some(msg);
return Err(format!("Could not launch MPV: {}", e).into());
}
}
// Start background IPC monitoring task
let sock_file = self.config.ipc_socket.clone();
let status_arc = self.status.clone();
let status_tx = self.status_tx.clone();
tokio::spawn(async move {
let _ = monitor_mpv_ipc(sock_file, status_arc, status_tx).await;
});
Ok(())
}
pub async fn play_pause(&self) {
let current_state = {
let st = self.status.lock().await;
st.state
};
let new_state = match current_state {
PlaybackState::Playing => PlaybackState::Paused,
PlaybackState::Paused => PlaybackState::Playing,
PlaybackState::Stopped => PlaybackState::Stopped,
};
let _ = self.send_ipc_cmd(&["cycle", "pause"]).await;
{
let mut st = self.status.lock().await;
st.state = new_state;
}
self.notify_status().await;
}
pub async fn stop(&self) {
let _ = self.send_ipc_cmd(&["stop"]).await;
let mut proc_guard = self.child_process.lock().await;
if let Some(mut child) = proc_guard.take() {
let _ = child.kill();
}
{
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) {
let _ = self.send_ipc_cmd(&["seek", &seconds.to_string(), "relative"]).await;
}
pub async fn toggle_mute(&self) {
let new_muted = {
let mut st = self.status.lock().await;
st.is_muted = !st.is_muted;
st.is_muted
};
let _ = self.send_ipc_cmd(&["cycle", "mute"]).await;
self.notify_status().await;
tracing::info!("Player mute toggled to {}", new_muted);
}
pub async fn set_mute(&self, muted: bool) {
{
let mut st = self.status.lock().await;
st.is_muted = muted;
}
let arg = if muted { "yes" } else { "no" };
let _ = self.send_ipc_cmd(&["set", "mute", arg]).await;
self.notify_status().await;
tracing::info!("Player mute set to {}", muted);
}
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;
}
let _ = self.send_ipc_cmd(&["set", "volume", &vol.to_string()]).await;
self.notify_status().await;
tracing::info!("Player volume set to {}", vol);
}
pub async fn volume_up(&self) {
let new_vol = {
let mut st = self.status.lock().await;
st.volume = (st.volume + 5.0).min(100.0);
st.volume
};
let _ = self.send_ipc_cmd(&["add", "volume", "5"]).await;
self.notify_status().await;
tracing::info!("Player volume increased to {}", new_vol);
}
pub async fn volume_down(&self) {
let new_vol = {
let mut st = self.status.lock().await;
st.volume = (st.volume - 5.0).max(0.0);
st.volume
};
let _ = self.send_ipc_cmd(&["add", "volume", "-5"]).await;
self.notify_status().await;
tracing::info!("Player volume decreased to {}", new_vol);
}
pub async fn cycle_subtitle(&self) {
let _ = self.send_ipc_cmd(&["cycle", "sub"]).await;
tracing::info!("Cycled subtitle track");
}
pub async fn cycle_audio(&self) {
let _ = self.send_ipc_cmd(&["cycle", "audio"]).await;
tracing::info!("Cycled audio track");
}
async fn send_ipc_cmd(&self, args: &[&str]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut stream = UnixStream::connect(&self.config.ipc_socket).await?;
let cmd_json = serde_json::json!({
"command": args
});
stream.write_all(format!("{}\n", cmd_json).as_bytes()).await?;
Ok(())
}
}
async fn monitor_mpv_ipc(
sock_path: String,
status: Arc<Mutex<PlayerStatus>>,
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Wait for socket to become available
for _ in 0..30 {
if std::path::Path::new(&sock_path).exists() {
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
let stream = match UnixStream::connect(&sock_path).await {
Ok(s) => s,
Err(_) => return Ok(()),
};
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
// Observe properties
let observe_cmds = [
r#"{"command":["observe_property",1,"time-pos"]}"#,
r#"{"command":["observe_property",2,"duration"]}"#,
r#"{"command":["observe_property",3,"pause"]}"#,
r#"{"command":["observe_property",4,"volume"]}"#,
r#"{"command":["observe_property",5,"media-title"]}"#,
r#"{"command":["observe_property",6,"icy-title"]}"#,
r#"{"command":["observe_property",7,"metadata"]}"#,
r#"{"command":["observe_property",8,"mute"]}"#,
];
for c in &observe_cmds {
let _ = writer.write_all(format!("{}\n", c).as_bytes()).await;
}
while let Ok(Some(line)) = lines.next_line().await {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
if let Some(event) = v.get("event").and_then(|e| e.as_str()) {
if event == "property-change" {
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("");
let data = v.get("data");
let mut st = status.lock().await;
let mut notify = false;
match name {
"mute" => {
if let Some(m) = data.and_then(|d| d.as_bool()) {
if st.is_muted != m {
st.is_muted = m;
notify = true;
}
}
}
"time-pos" => {
if let Some(pos) = data.and_then(|d| d.as_f64()) {
st.time_pos = pos;
}
}
"duration" => {
if let Some(dur) = data.and_then(|d| d.as_f64()) {
st.duration = dur;
}
}
"pause" => {
if let Some(paused) = data.and_then(|d| d.as_bool()) {
let new_state = if paused { PlaybackState::Paused } else { PlaybackState::Playing };
if st.state != new_state {
st.state = new_state;
notify = true;
}
}
}
"volume" => {
if let Some(vol) = data.and_then(|d| d.as_f64()) {
if (st.volume - vol).abs() > 0.01 {
st.volume = vol;
notify = true;
}
}
}
"icy-title" => {
if let Some(icy) = data.and_then(|d| d.as_str()) {
let trimmed = icy.trim();
if !trimmed.is_empty() && st.stream_title != trimmed {
tracing::info!("MPV ICY Stream title detected: {}", trimmed);
st.stream_title = trimmed.to_string();
if st.media_type.is_empty() || st.media_type != "stream" {
st.media_type = "stream".to_string();
}
notify = true;
}
}
}
"media-title" => {
if let Some(mt) = data.and_then(|d| d.as_str()) {
let trimmed = mt.trim();
if !trimmed.is_empty() {
if st.media_type == "stream" {
if st.stream_title.is_empty() || st.stream_title != trimmed {
tracing::info!("MPV Stream media-title: {}", trimmed);
st.stream_title = trimmed.to_string();
notify = true;
}
} else if st.title.is_empty() {
st.title = trimmed.to_string();
notify = true;
}
}
}
}
"metadata" => {
if let Some(meta_obj) = data.and_then(|d| d.as_object()) {
let stream_t = meta_obj.get("icy-title")
.or_else(|| meta_obj.get("icy_title"))
.or_else(|| meta_obj.get("StreamTitle"))
.or_else(|| meta_obj.get("title"))
.or_else(|| meta_obj.get("Title"))
.and_then(|val| val.as_str());
if let Some(s) = stream_t {
let trimmed = s.trim();
if !trimmed.is_empty() && (st.media_type == "stream" || meta_obj.contains_key("icy-title")) {
if st.stream_title != trimmed {
st.stream_title = trimmed.to_string();
st.media_type = "stream".to_string();
notify = true;
}
}
}
if let Some(a) = meta_obj.get("artist").or_else(|| meta_obj.get("Artist")).and_then(|val| val.as_str()) {
let trimmed = a.trim();
if !trimmed.is_empty() && st.artist != trimmed {
st.artist = trimmed.to_string();
notify = true;
}
}
if let Some(g) = meta_obj.get("genre").or_else(|| meta_obj.get("Genre")).or_else(|| meta_obj.get("icy-genre")).and_then(|val| val.as_str()) {
let trimmed = g.trim();
if !trimmed.is_empty() && st.genre != trimmed {
st.genre = trimmed.to_string();
notify = true;
}
}
}
}
_ => {}
}
if notify {
let _ = status_tx.send(st.clone());
}
} else if event == "end-file" {
let mut st = 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;
let _ = status_tx.send(st.clone());
break;
}
}
}
}
Ok(())
}
+505
View File
@@ -0,0 +1,505 @@
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,
TogglePlaybackView,
ActivateSection(usize),
VolumeUp,
VolumeDown,
Stop,
}
#[derive(Clone)]
pub struct YatseServerState {
pub player: Arc<PlayerController>,
pub nav_tx: UnboundedSender<RemoteNavAction>,
}
#[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"
}
async fn handle_jsonrpc_get() -> impl IntoResponse {
Json(json!({
"jsonrpc": "2.0",
"result": {
"version": {"major": 19, "minor": 0, "patch": 0}
},
"id": 1
}))
}
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())))
.and_then(|v| v.as_f64());
let new_vol = match vol_param {
Some(v) => {
state.player.set_volume(v).await;
v.clamp(0.0, 100.0) as i64
}
None => {
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 {
json!({
"jsonrpc": "2.0",
"result": [{"playerid": 1, "playertype": "internal", "type": "video"}],
"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 };
json!({
"jsonrpc": "2.0",
"result": {
"canseek": true,
"speed": speed,
"time": time,
"totaltime": totaltime,
"percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 }
},
"id": id
})
}
"Player.GetItem" => {
let st = state.player.status.lock().await;
json!({
"jsonrpc": "2.0",
"result": {
"item": {
"label": st.title,
"title": st.title,
"type": "movie"
}
},
"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;
json!({
"jsonrpc": "2.0",
"result": "OK",
"id": id
})
}
"Player.Seek" => {
if let Some(params) = req.params {
if let Some(sec) = params.get("value").and_then(|v| v.as_f64()) {
state.player.seek(sec).await;
}
}
json!({
"jsonrpc": "2.0",
"result": {"percentage": 50.0},
"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 file_str = file.to_string();
let p = state.player.clone();
tokio::spawn(async move {
let _ = p.play(&file_str, "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 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("")
.to_lowercase();
match action.as_str() {
"up" | "cursorup" => { let _ = state.nav_tx.send(RemoteNavAction::Up); }
"down" | "cursordown" => { let _ = state.nav_tx.send(RemoteNavAction::Down); }
"left" | "cursorleft" => { let _ = state.nav_tx.send(RemoteNavAction::Left); }
"right" | "cursorright" => { let _ = state.nav_tx.send(RemoteNavAction::Right); }
"select" | "enter" => { let _ = state.nav_tx.send(RemoteNavAction::Select); }
"back" | "previousmenu" | "parentdir" | "close" => { let _ = state.nav_tx.send(RemoteNavAction::Back); }
"togglefullscreen" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); }
"osd" | "nextsubtitle" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); }
"title" | "menu" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); }
"volumeup" => {
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
let p = state.player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
"volumedown" => {
let _ = state.nav_tx.send(RemoteNavAction::VolumeDown);
let p = state.player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
"playpause" | "pause" | "play" => { state.player.play_pause().await; }
"stop" => {
let _ = state.nav_tx.send(RemoteNavAction::Stop);
state.player.stop().await;
}
_ => {}
}
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"GUI.ActivateWindow" => {
if let Some(params) = req.params {
let window = params.get("window").and_then(|w| w.as_str()).unwrap_or("").to_lowercase();
match window.as_str() {
"videos" | "video" | "movies" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1)); }
"tvshows" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2)); }
"music" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3)); }
"pictures" | "photos" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4)); }
"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})
}
_ => {
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
})
}
+650
View File
@@ -0,0 +1,650 @@
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());
}
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| format!("Netwerkfout bij starten authenticatie: {}", e))?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(format!("Google API fout ({}): {}", status, text));
}
let resp: DeviceCodeResponse = res
.json()
.await
.map_err(|e| format!("Ongeldig antwoord van Google: {}", e))?;
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
}