Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f2cdbd817 | ||
|
|
d77cdfedab | ||
|
|
99cbc00357 |
@@ -7,3 +7,4 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
jellyfin-kiosk-rs/target
|
||||
@@ -0,0 +1,42 @@
|
||||
# AI Agent & Contributor Guidelines: Rust Dependency & Feature Minimization
|
||||
|
||||
## Core Principle: Minimal Features & Fast Compilation
|
||||
|
||||
To ensure fast compilation times and prevent binary and dependency bloat on development and embedded/kiosk hardware:
|
||||
|
||||
1. **Always use `default-features = false` by default** on all external crates unless explicitly proven unnecessary.
|
||||
2. **Never enable meta-features or "all" feature flags**:
|
||||
- ❌ Never use `tokio = { features = ["full"] }`.
|
||||
- ❌ Never use `egui_extras = { features = ["all_loaders"] }`.
|
||||
- ❌ Never enable default formats in the `image` crate (pulls in `rayon`, `exr`, `tiff`, `dds`, `hdr`, etc.).
|
||||
3. **Avoid heavy rasterization / rendering stacks**:
|
||||
- Do **NOT** enable `svg` or `resvg` in `egui_extras`. The `resvg`/`usvg`/`tiny-skia` dependency tree adds dozens of crates and heavily impacts compilation time.
|
||||
- Do **NOT** enable `wgpu` in `eframe`. Use `glow` (OpenGL) only.
|
||||
4. **Iterate with `cargo check`**:
|
||||
- Always run `cargo check` instead of `cargo build` during development cycles to test type checking and borrow checking in seconds.
|
||||
|
||||
---
|
||||
|
||||
## Approved Dependency Feature Matrix
|
||||
|
||||
When editing or updating `jellyfin-kiosk-rs/Cargo.toml`, adhere strictly to the following configurations:
|
||||
|
||||
| Crate | Approved Configuration | Rationale / Prohibited Features |
|
||||
| :--- | :--- | :--- |
|
||||
| `eframe` | `default-features = false, features = ["default_fonts", "glow", "wayland", "x11"]` | Keep `wgpu` disabled. Only build necessary windowing and OpenGL backends. |
|
||||
| `egui_extras` | `default-features = false, features = ["http", "image"]` | Only needed for fetching/rendering HTTP posters & backdrops. **Never enable `all_loaders` or `svg`**. |
|
||||
| `image` | `default-features = false, features = ["jpeg", "png"]` | Jellyfin images are JPEGs/PNGs. Disabling default features avoids `rayon` and heavy decoders. |
|
||||
| `tokio` | `default-features = false, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"]` | Never use `"full"`. `fs`, `process`, and `signal` from Tokio are not used (standard library equivalents are used). |
|
||||
| `axum` | `default-features = false, features = ["http1", "json", "tokio"]` | The REST API only handles JSON POST/GET requests and Tokio serving. No `form` or `query` parsers needed. |
|
||||
| `reqwest` | `default-features = false, features = ["json", "default-tls", "http2"]` | Minimal HTTP client features required for communication with the Jellyfin server. |
|
||||
| `tracing-subscriber`| `features = ["env-filter"]` | Standard lightweight log filtering and formatting. |
|
||||
| `rumqttc` | `default-features = false` | MQTT client for Home Assistant discovery and state reporting. Avoids rustls/aws-lc-rs crypto bloat. |
|
||||
|
||||
---
|
||||
|
||||
## Verification Before Committing
|
||||
|
||||
Whenever dependencies or features are changed:
|
||||
1. Run `cargo check` in `jellyfin-kiosk-rs` to ensure compilation without warnings/errors.
|
||||
2. Check dependency count in `Cargo.lock` (`grep "name = " Cargo.lock | wc -l`) to confirm no unexpected transitive bloat was introduced.
|
||||
3. Verify that `cargo build` links successfully.
|
||||
@@ -47,3 +47,21 @@ youtube:
|
||||
max_height: 1080
|
||||
# Automatically extract metadata (title, uploader) using yt-dlp before playback
|
||||
fetch_metadata: true
|
||||
# Google OAuth 2.0 Client ID (Google Cloud Console -> APIs -> Credentials, type: TVs and Limited Input / Desktop)
|
||||
client_id: ""
|
||||
client_secret: ""
|
||||
# Pad voor het opslaan van de ingelogde YouTube sessie
|
||||
token_path: "youtube_token.json"
|
||||
|
||||
mqtt:
|
||||
# Enable MQTT client and Home Assistant auto-discovery
|
||||
enabled: false
|
||||
# MQTT broker host/IP
|
||||
host: "localhost"
|
||||
port: 1883
|
||||
username: ""
|
||||
password: ""
|
||||
# Home Assistant MQTT discovery prefix (default: homeassistant)
|
||||
discovery_prefix: "homeassistant"
|
||||
# Base topic prefix for state updates and commands
|
||||
topic_prefix: "jellyfin-kiosk"
|
||||
|
||||
+8
-10
@@ -5,14 +5,11 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: jellyfin-client
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080" # Kodi JSON-RPC (Yatse)
|
||||
- "9090:9090" # Kodi JSON-RPC WebSocket & TCP Socket
|
||||
- "9777:9777/udp" # Kodi EventServer (UDP)
|
||||
- "8097:8097" # Web Remote & REST API
|
||||
network_mode: host
|
||||
|
||||
environment:
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
- MPV_VO=${MPV_VO:-null} # 'null' ensures stable background test; set to 'gpu' with xhost for video window
|
||||
- MPV_VO=${MPV_VO:-gpu} # 'gpu' for video window, 'null' for headless
|
||||
- MPV_HWDEC=${MPV_HWDEC:-auto} # 'auto', 'vaapi', 'nvdec', or 'no'
|
||||
- MPV_FULLSCREEN=${MPV_FULLSCREEN:-false} # Windowed by default for desktop testing
|
||||
volumes:
|
||||
@@ -20,7 +17,8 @@ services:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
# X11 Display passthrough to show video window on host screen
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix:ro
|
||||
# Optional hardware acceleration devices (uncomment if available on Linux host)
|
||||
# devices:
|
||||
# - /dev/dri:/dev/dri
|
||||
# - /dev/snd:/dev/snd
|
||||
# Hardware acceleration devices
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
- /dev/snd:/dev/snd
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../AGENTS.md
|
||||
Generated
+4201
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "jellyfin-kiosk-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
|
||||
egui_extras = { version = "0.29", default-features = false, features = ["http", "image"] }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
tokio = { version = "1.40", default-features = false, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
||||
axum = { version = "0.7", default-features = false, features = ["http1", "json", "tokio"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls", "http2"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
raw-window-handle = "0.6"
|
||||
rumqttc = { version = "0.25", default-features = false }
|
||||
libc = "0.2"
|
||||
libmpv-sys = { version = "3.1", default-features = false }
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let lib_dir = Path::new(&out_dir).join("libmpv_link");
|
||||
let _ = std::fs::create_dir_all(&lib_dir);
|
||||
|
||||
let candidates = [
|
||||
"/lib/x86_64-linux-gnu/libmpv.so.2",
|
||||
"/usr/lib/x86_64-linux-gnu/libmpv.so.2",
|
||||
"/usr/lib64/libmpv.so.2",
|
||||
"/lib/x86_64-linux-gnu/libmpv.so.1",
|
||||
"/usr/lib/x86_64-linux-gnu/libmpv.so.1",
|
||||
"/usr/lib/libmpv.so.2",
|
||||
"/usr/lib/libmpv.so.1",
|
||||
];
|
||||
|
||||
let dest = lib_dir.join("libmpv.so");
|
||||
for candidate in &candidates {
|
||||
if Path::new(candidate).exists() {
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
#[cfg(unix)]
|
||||
let _ = std::os::unix::fs::symlink(candidate, &dest);
|
||||
println!("cargo:rustc-link-search=native={}", lib_dir.display());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
println!("cargo:rustc-link-lib=mpv");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub jellyfin: JellyfinConfig,
|
||||
#[serde(default)]
|
||||
pub player: PlayerConfig,
|
||||
#[serde(default)]
|
||||
pub kodi_rpc: KodiRpcConfig,
|
||||
#[serde(default)]
|
||||
pub mqtt: MqttConfig,
|
||||
#[serde(default)]
|
||||
pub youtube: YouTubeConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct JellyfinConfig {
|
||||
pub server_url: String,
|
||||
pub username: String,
|
||||
pub password: Option<String>,
|
||||
#[serde(default = "default_client_name")]
|
||||
pub client_name: String,
|
||||
#[serde(default = "default_device_name")]
|
||||
pub device_name: String,
|
||||
#[serde(default = "default_device_id")]
|
||||
pub device_id: String,
|
||||
}
|
||||
|
||||
fn default_client_name() -> String {
|
||||
"Jellyfin HTPC Kiosk".to_string()
|
||||
}
|
||||
fn default_device_name() -> String {
|
||||
"HTPC Kiosk".to_string()
|
||||
}
|
||||
fn default_device_id() -> String {
|
||||
"htpc-kiosk-01".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PlayerConfig {
|
||||
#[serde(default = "default_mpv_path")]
|
||||
pub mpv_path: String,
|
||||
#[serde(default = "default_ipc_socket")]
|
||||
pub ipc_socket: String,
|
||||
#[serde(default = "default_hwdec")]
|
||||
pub hwdec: String,
|
||||
#[serde(default = "default_vo")]
|
||||
pub vo: String,
|
||||
#[serde(default = "default_fullscreen")]
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
fn default_mpv_path() -> String {
|
||||
"mpv".to_string()
|
||||
}
|
||||
fn default_ipc_socket() -> String {
|
||||
"/tmp/mpv-kiosk.sock".to_string()
|
||||
}
|
||||
fn default_hwdec() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
fn default_vo() -> String {
|
||||
"gpu".to_string()
|
||||
}
|
||||
fn default_fullscreen() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for PlayerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mpv_path: default_mpv_path(),
|
||||
ipc_socket: default_ipc_socket(),
|
||||
hwdec: default_hwdec(),
|
||||
vo: default_vo(),
|
||||
fullscreen: default_fullscreen(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct KodiRpcConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_http_port")]
|
||||
pub http_port: u16,
|
||||
#[serde(default = "default_ws_port")]
|
||||
pub ws_port: u16,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_http_port() -> u16 {
|
||||
8080
|
||||
}
|
||||
fn default_ws_port() -> u16 {
|
||||
9090
|
||||
}
|
||||
|
||||
impl Default for KodiRpcConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
http_port: default_http_port(),
|
||||
ws_port: default_ws_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MqttConfig {
|
||||
#[serde(default = "default_false")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_mqtt_host")]
|
||||
pub host: String,
|
||||
#[serde(default = "default_mqtt_port")]
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
#[serde(default = "default_discovery_prefix")]
|
||||
pub discovery_prefix: String,
|
||||
#[serde(default = "default_topic_prefix")]
|
||||
pub topic_prefix: String,
|
||||
pub client_id: Option<String>,
|
||||
}
|
||||
|
||||
fn default_false() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_mqtt_host() -> String {
|
||||
"localhost".to_string()
|
||||
}
|
||||
fn default_mqtt_port() -> u16 {
|
||||
1883
|
||||
}
|
||||
fn default_discovery_prefix() -> String {
|
||||
"homeassistant".to_string()
|
||||
}
|
||||
fn default_topic_prefix() -> String {
|
||||
"jellyfin-kiosk".to_string()
|
||||
}
|
||||
|
||||
impl Default for MqttConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_false(),
|
||||
host: default_mqtt_host(),
|
||||
port: default_mqtt_port(),
|
||||
username: None,
|
||||
password: None,
|
||||
discovery_prefix: default_discovery_prefix(),
|
||||
topic_prefix: default_topic_prefix(),
|
||||
client_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct YouTubeConfig {
|
||||
#[serde(default = "default_max_height")]
|
||||
pub max_height: u32,
|
||||
#[serde(default = "default_true")]
|
||||
pub fetch_metadata: bool,
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub client_secret: Option<String>,
|
||||
#[serde(default = "default_youtube_token_path")]
|
||||
pub token_path: String,
|
||||
}
|
||||
|
||||
fn default_max_height() -> u32 {
|
||||
1080
|
||||
}
|
||||
|
||||
fn default_youtube_token_path() -> String {
|
||||
"youtube_token.json".to_string()
|
||||
}
|
||||
|
||||
impl Default for YouTubeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_height: default_max_height(),
|
||||
fetch_metadata: default_true(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
token_path: default_youtube_token_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let candidates = [
|
||||
PathBuf::from("config.yaml"),
|
||||
PathBuf::from("../config.yaml"),
|
||||
PathBuf::from("/etc/jellyfin-client/config.yaml"),
|
||||
PathBuf::from("config.example.yaml"),
|
||||
PathBuf::from("../config.example.yaml"),
|
||||
];
|
||||
|
||||
for path in &candidates {
|
||||
if path.exists() {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let cfg: AppConfig = serde_yaml::from_str(&content)?;
|
||||
tracing::info!("Loaded config from {}", path.display());
|
||||
return Ok(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
Err("No config.yaml found in search paths".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use crate::player::PlayerController;
|
||||
use crate::yatse::{RemoteNavAction, RemoteNavSender};
|
||||
|
||||
// Official Kodi EventServer Packet Types
|
||||
pub const PT_HELO: u16 = 0x01;
|
||||
pub const PT_BYE: u16 = 0x02;
|
||||
pub const PT_BUTTON: u16 = 0x03;
|
||||
pub const _PT_MOUSE: u16 = 0x04;
|
||||
pub const PT_PING: u16 = 0x05;
|
||||
pub const _PT_BROADCAST: u16 = 0x06;
|
||||
pub const _PT_NOTIFICATION: u16 = 0x07;
|
||||
pub const _PT_BLOB: u16 = 0x08;
|
||||
pub const _PT_LOG: u16 = 0x09;
|
||||
pub const PT_ACTION: u16 = 0x0A; // 10 in decimal!
|
||||
|
||||
// Button packet flags
|
||||
pub const _PTB_USE_NAME: u16 = 0x01;
|
||||
pub const PTB_DOWN: u16 = 0x02;
|
||||
pub const PTB_UP: u16 = 0x04;
|
||||
|
||||
pub const HEADER_SIZE: usize = 32;
|
||||
|
||||
pub struct KodiEventServer {
|
||||
port: u16,
|
||||
player: Arc<PlayerController>,
|
||||
nav_tx: RemoteNavSender,
|
||||
}
|
||||
|
||||
impl KodiEventServer {
|
||||
pub fn new(port: u16, player: Arc<PlayerController>, nav_tx: impl Into<RemoteNavSender>) -> Self {
|
||||
Self { port, player, nav_tx: nav_tx.into() }
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<u16, std::io::Error> {
|
||||
let addr = format!("0.0.0.0:{}", self.port);
|
||||
let socket = match UdpSocket::bind(&addr).await {
|
||||
Ok(s) => {
|
||||
let bound_port = s.local_addr()?.port();
|
||||
tracing::info!("Kodi EventServer listening on UDP 0.0.0.0:{}", bound_port);
|
||||
(Arc::new(s), bound_port)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to bind Kodi EventServer on UDP {}: {}", addr, e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let (socket, bound_port) = socket;
|
||||
let player = self.player.clone();
|
||||
let nav_tx = self.nav_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
let (len, peer) = match socket.recv_from(&mut buf).await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
tracing::warn!("EventServer recv error: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if len < HEADER_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
if &buf[0..4] != b"XBMC" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let majver = buf[4];
|
||||
let minver = buf[5];
|
||||
let pkttype = u16::from_be_bytes([buf[6], buf[7]]);
|
||||
let _seq = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let _maxseq = u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let paylen = u16::from_be_bytes([buf[16], buf[17]]) as usize;
|
||||
let client_token = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
|
||||
|
||||
let payload = if len >= HEADER_SIZE + paylen {
|
||||
&buf[HEADER_SIZE..HEADER_SIZE + paylen]
|
||||
} else {
|
||||
&buf[HEADER_SIZE..len]
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"EventServer packet from {}: ver={}.{}, type=0x{:02X}, paylen={}, token={}",
|
||||
peer, majver, minver, pkttype, paylen, client_token
|
||||
);
|
||||
|
||||
match pkttype {
|
||||
PT_HELO | PT_PING => {
|
||||
let dev_name = payload
|
||||
.split(|&b| b == 0)
|
||||
.next()
|
||||
.map(|s| String::from_utf8_lossy(s).to_string())
|
||||
.unwrap_or_default();
|
||||
tracing::info!("EventServer HELO/PING from {} (device: '{}')", peer, dev_name);
|
||||
|
||||
// Echo ping ack back to client (Yatse expects this handshake)
|
||||
let mut ack = [0u8; HEADER_SIZE];
|
||||
ack[0..4].copy_from_slice(b"XBMC");
|
||||
ack[4] = 2; // majver
|
||||
ack[5] = 0; // minver
|
||||
ack[6..8].copy_from_slice(&PT_PING.to_be_bytes());
|
||||
ack[8..12].copy_from_slice(&1u32.to_be_bytes());
|
||||
ack[12..16].copy_from_slice(&1u32.to_be_bytes());
|
||||
ack[16..18].copy_from_slice(&0u16.to_be_bytes());
|
||||
ack[18..22].copy_from_slice(&client_token.to_be_bytes());
|
||||
|
||||
let _ = socket.send_to(&ack, peer).await;
|
||||
tracing::info!("EventServer sent PING-ACK to {}", peer);
|
||||
}
|
||||
PT_BUTTON => {
|
||||
if payload.len() >= 6 {
|
||||
// Format: code(2B) + flags(2B) + amount(2B) + map_name(null-term) + button_name(null-term)
|
||||
let bcode = u16::from_be_bytes([payload[0], payload[1]]);
|
||||
let flags = u16::from_be_bytes([payload[2], payload[3]]);
|
||||
let _amount = u16::from_be_bytes([payload[4], payload[5]]);
|
||||
|
||||
let is_down = (flags & PTB_DOWN) != 0 || (flags & PTB_UP) == 0;
|
||||
if !is_down {
|
||||
// Ignore key release
|
||||
continue;
|
||||
}
|
||||
|
||||
let str_data = &payload[6..];
|
||||
let mut parts = str_data.split(|&b| b == 0);
|
||||
let map_name = parts
|
||||
.next()
|
||||
.map(|s| String::from_utf8_lossy(s).to_string())
|
||||
.unwrap_or_default();
|
||||
let btn_name = parts
|
||||
.next()
|
||||
.map(|s| String::from_utf8_lossy(s).to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
"EventServer BUTTON: code=0x{:04X}, flags=0x{:04X}, map='{}', btn='{}'",
|
||||
bcode, flags, map_name, btn_name
|
||||
);
|
||||
|
||||
dispatch_button_or_code(&btn_name, bcode, &nav_tx, &player);
|
||||
}
|
||||
}
|
||||
PT_ACTION => {
|
||||
if payload.len() >= 2 {
|
||||
// Format: action_type(1B) + action_string(null-terminated string)
|
||||
let action_type = payload[0];
|
||||
let action_str = String::from_utf8_lossy(&payload[1..])
|
||||
.trim_matches('\0')
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
tracing::info!("EventServer ACTION: type={}, action='{}'", action_type, action_str);
|
||||
dispatch_action(&action_str, &nav_tx, &player);
|
||||
}
|
||||
}
|
||||
PT_BYE => {
|
||||
tracing::info!("EventServer client BYE from {}", peer);
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("EventServer unhandled packet type: 0x{:02X}", pkttype);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(bound_port)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn build_packet_header(pkttype: u16, paylen: u16, client_token: u32) -> [u8; HEADER_SIZE] {
|
||||
let mut header = [0u8; HEADER_SIZE];
|
||||
header[0..4].copy_from_slice(b"XBMC");
|
||||
header[4] = 2; // majver
|
||||
header[5] = 0; // minver
|
||||
header[6..8].copy_from_slice(&pkttype.to_be_bytes());
|
||||
header[8..12].copy_from_slice(&1u32.to_be_bytes());
|
||||
header[12..16].copy_from_slice(&1u32.to_be_bytes());
|
||||
header[16..18].copy_from_slice(&paylen.to_be_bytes());
|
||||
header[18..22].copy_from_slice(&client_token.to_be_bytes());
|
||||
header
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn build_helo_packet(device_name: &str, token: u32) -> Vec<u8> {
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(device_name.as_bytes());
|
||||
payload.push(0);
|
||||
// icon_type (0 = none), port (0), reserved (8)
|
||||
payload.push(0);
|
||||
payload.extend_from_slice(&0u16.to_be_bytes());
|
||||
payload.extend_from_slice(&[0u8; 8]);
|
||||
|
||||
let header = build_packet_header(PT_HELO, payload.len() as u16, token);
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(&payload);
|
||||
packet
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn build_ping_packet(token: u32) -> Vec<u8> {
|
||||
let header = build_packet_header(PT_PING, 0, token);
|
||||
header.to_vec()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn build_button_packet(
|
||||
code: u16,
|
||||
flags: u16,
|
||||
map_name: &str,
|
||||
button_name: &str,
|
||||
token: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(&code.to_be_bytes());
|
||||
payload.extend_from_slice(&flags.to_be_bytes());
|
||||
payload.extend_from_slice(&0u16.to_be_bytes());
|
||||
payload.extend_from_slice(map_name.as_bytes());
|
||||
payload.push(0);
|
||||
payload.extend_from_slice(button_name.as_bytes());
|
||||
payload.push(0);
|
||||
|
||||
let header = build_packet_header(PT_BUTTON, payload.len() as u16, token);
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(&payload);
|
||||
packet
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn build_action_packet(action_str: &str, token: u32) -> Vec<u8> {
|
||||
let mut payload = Vec::new();
|
||||
payload.push(1); // ACTION_EXECBUILTIN
|
||||
payload.extend_from_slice(action_str.as_bytes());
|
||||
payload.push(0);
|
||||
|
||||
let header = build_packet_header(PT_ACTION, payload.len() as u16, token);
|
||||
let mut packet = header.to_vec();
|
||||
packet.extend_from_slice(&payload);
|
||||
packet
|
||||
}
|
||||
|
||||
pub fn dispatch_button_or_code(
|
||||
btn: &str,
|
||||
code: u16,
|
||||
nav_tx: &RemoteNavSender,
|
||||
player: &Arc<PlayerController>,
|
||||
) {
|
||||
let lower = btn.to_lowercase();
|
||||
match lower.as_str() {
|
||||
"up" | "bigstepforward" | "cursorup" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Up);
|
||||
}
|
||||
"down" | "bigstepback" | "cursordown" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Down);
|
||||
}
|
||||
"left" | "stepback" | "cursorleft" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Left);
|
||||
}
|
||||
"right" | "stepforward" | "cursorright" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Right);
|
||||
}
|
||||
"select" | "enter" | "ok" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Select);
|
||||
}
|
||||
"back" | "parentdir" | "previousmenu" | "escape" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Back);
|
||||
}
|
||||
"title" | "topmenu" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
||||
}
|
||||
"menu" | "contextmenu" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||
}
|
||||
"togglefullscreen" | "fullscreen" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
||||
}
|
||||
"osd" | "showosd" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||
}
|
||||
"subtitles" | "nextsubtitle" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
|
||||
}
|
||||
"audionextlanguage" | "nextaudio" | "cycleaudio" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
|
||||
}
|
||||
"play" | "pause" | "playpause" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.play_pause().await; });
|
||||
}
|
||||
"stop" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||
}
|
||||
"volumeup" | "vol_up" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.volume_up().await; });
|
||||
}
|
||||
"volumedown" | "vol_down" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.volume_down().await; });
|
||||
}
|
||||
"mute" | "volumemute" | "togglemute" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.toggle_mute().await; });
|
||||
}
|
||||
"info" | "showcodecinfo" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||
}
|
||||
_ => {
|
||||
// Fallback to raw keycode if button string was not matched
|
||||
match code {
|
||||
0xF010 | 0xF050 | 273 => { let _ = nav_tx.send(RemoteNavAction::Up); }
|
||||
0xF011 | 0xF051 | 274 => { let _ = nav_tx.send(RemoteNavAction::Down); }
|
||||
0xF012 | 0xF052 | 276 => { let _ = nav_tx.send(RemoteNavAction::Left); }
|
||||
0xF013 | 0xF053 | 275 => { let _ = nav_tx.send(RemoteNavAction::Right); }
|
||||
0xF001 | 0xF014 | 13 => { let _ = nav_tx.send(RemoteNavAction::Select); }
|
||||
0xF016 | 27 | 8 => { let _ = nav_tx.send(RemoteNavAction::Back); }
|
||||
0xF020 | 0xF022 => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.play_pause().await; });
|
||||
}
|
||||
0xF021 => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||
}
|
||||
0xF02B => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.toggle_mute().await; });
|
||||
}
|
||||
0xF02C => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.volume_down().await; });
|
||||
}
|
||||
0xF02D => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.volume_up().await; });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_action(action: &str) -> String {
|
||||
let mut lower = action.trim().to_lowercase();
|
||||
if lower.starts_with("action(") && lower.ends_with(')') {
|
||||
lower = lower[7..lower.len() - 1].trim().to_string();
|
||||
}
|
||||
if lower.ends_with("()") {
|
||||
lower = lower[..lower.len() - 2].trim().to_string();
|
||||
}
|
||||
lower
|
||||
}
|
||||
|
||||
pub fn dispatch_action(
|
||||
action: &str,
|
||||
nav_tx: &RemoteNavSender,
|
||||
player: &Arc<PlayerController>,
|
||||
) {
|
||||
let lower = action.trim().to_lowercase();
|
||||
|
||||
// Check window activation first
|
||||
if lower.starts_with("activatewindow") {
|
||||
if lower.contains("tvshowtitles") || lower.contains("tvshows") || lower.contains("shows") || lower.contains("series") {
|
||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(3));
|
||||
} else if lower.contains("movietitles") || lower.contains("videos") || lower.contains("movies") {
|
||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(2));
|
||||
} else if lower.contains("music") || lower.contains("songs") || lower.contains("artists") || lower.contains("albums") {
|
||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(4));
|
||||
} else if lower.contains("pictures") || lower.contains("photos") {
|
||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(5));
|
||||
} else if lower.contains("livetv") || lower.contains("channels") || lower.contains("tv") {
|
||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(1));
|
||||
} else if lower.contains("home") {
|
||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(0));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let norm = normalize_action(action);
|
||||
match norm.as_str() {
|
||||
"up" | "cursorup" | "3" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Up);
|
||||
}
|
||||
"down" | "cursordown" | "4" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Down);
|
||||
}
|
||||
"left" | "cursorleft" | "1" | "stepback" | "smallstepback" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Left);
|
||||
}
|
||||
"right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Right);
|
||||
}
|
||||
"bigstepforward" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.seek(60.0).await; });
|
||||
}
|
||||
"bigstepback" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.seek(-60.0).await; });
|
||||
}
|
||||
"select" | "enter" | "7" | "selectitem" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Select);
|
||||
}
|
||||
"back" | "parentdir" | "previousmenu" | "close" | "9" | "10" | "escape" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Back);
|
||||
}
|
||||
"title" | "topmenu" | "11" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
||||
}
|
||||
"menu" | "contextmenu" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||
}
|
||||
"togglefullscreen" | "fullscreen" | "12" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
||||
}
|
||||
"osd" | "showosd" | "114" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||
}
|
||||
"nextsubtitle" | "subtitles" | "showsubtitles" | "122" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
|
||||
}
|
||||
"audionextlanguage" | "nextaudio" | "nextaudiostream" | "cycleaudio" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
|
||||
}
|
||||
"play" | "pause" | "playpause" | "15" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.play_pause().await; });
|
||||
}
|
||||
"stop" | "14" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||
}
|
||||
"volumeup" | "vol_up" | "88" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.volume_up().await; });
|
||||
}
|
||||
"volumedown" | "vol_down" | "89" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.volume_down().await; });
|
||||
}
|
||||
"mute" | "togglemute" | "volumemute" | "13" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.toggle_mute().await; });
|
||||
}
|
||||
"info" | "showcodecinfo" => {
|
||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||
}
|
||||
"skipprevious" | "prev" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.seek_time(0.0).await; });
|
||||
}
|
||||
"skipnext" | "next" => {
|
||||
let p = player.clone();
|
||||
tokio::spawn(async move { p.seek(60.0).await; });
|
||||
}
|
||||
other => {
|
||||
tracing::info!("EventServer unhandled action: '{}' (norm: '{}')", action, other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::PlayerConfig;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
#[test]
|
||||
fn test_build_packet_header() {
|
||||
let hdr = build_packet_header(PT_PING, 10, 0xAABBCCDD);
|
||||
assert_eq!(&hdr[0..4], b"XBMC");
|
||||
assert_eq!(hdr[4], 2);
|
||||
assert_eq!(hdr[5], 0);
|
||||
assert_eq!(u16::from_be_bytes([hdr[6], hdr[7]]), PT_PING);
|
||||
assert_eq!(u32::from_be_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]), 1);
|
||||
assert_eq!(u32::from_be_bytes([hdr[12], hdr[13], hdr[14], hdr[15]]), 1);
|
||||
assert_eq!(u16::from_be_bytes([hdr[16], hdr[17]]), 10);
|
||||
assert_eq!(u32::from_be_bytes([hdr[18], hdr[19], hdr[20], hdr[21]]), 0xAABBCCDD);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_button_navigation() {
|
||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||
let player = PlayerController::new(PlayerConfig::default());
|
||||
let nav_sender: RemoteNavSender = nav_tx.into();
|
||||
|
||||
let cases = vec![
|
||||
("up", RemoteNavAction::Up),
|
||||
("bigstepforward", RemoteNavAction::Up),
|
||||
("cursorup", RemoteNavAction::Up),
|
||||
("down", RemoteNavAction::Down),
|
||||
("bigstepback", RemoteNavAction::Down),
|
||||
("cursordown", RemoteNavAction::Down),
|
||||
("left", RemoteNavAction::Left),
|
||||
("stepback", RemoteNavAction::Left),
|
||||
("cursorleft", RemoteNavAction::Left),
|
||||
("right", RemoteNavAction::Right),
|
||||
("stepforward", RemoteNavAction::Right),
|
||||
("cursorright", RemoteNavAction::Right),
|
||||
("select", RemoteNavAction::Select),
|
||||
("enter", RemoteNavAction::Select),
|
||||
("ok", RemoteNavAction::Select),
|
||||
("back", RemoteNavAction::Back),
|
||||
("parentdir", RemoteNavAction::Back),
|
||||
("previousmenu", RemoteNavAction::Back),
|
||||
("escape", RemoteNavAction::Back),
|
||||
];
|
||||
|
||||
for (btn, expected) in cases {
|
||||
dispatch_button_or_code(btn, 0, &nav_sender, &player);
|
||||
let action = nav_rx.recv().await.expect("Expected nav action");
|
||||
assert_eq!(action, expected, "Button '{}' should map to {:?}", btn, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_button_raw_keycodes() {
|
||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||
let player = PlayerController::new(PlayerConfig::default());
|
||||
let nav_sender: RemoteNavSender = nav_tx.into();
|
||||
|
||||
let cases = vec![
|
||||
(0xF010, RemoteNavAction::Up),
|
||||
(0xF050, RemoteNavAction::Up),
|
||||
(273, RemoteNavAction::Up),
|
||||
(0xF011, RemoteNavAction::Down),
|
||||
(0xF051, RemoteNavAction::Down),
|
||||
(274, RemoteNavAction::Down),
|
||||
(0xF012, RemoteNavAction::Left),
|
||||
(0xF052, RemoteNavAction::Left),
|
||||
(276, RemoteNavAction::Left),
|
||||
(0xF013, RemoteNavAction::Right),
|
||||
(0xF053, RemoteNavAction::Right),
|
||||
(275, RemoteNavAction::Right),
|
||||
(0xF001, RemoteNavAction::Select),
|
||||
(0xF014, RemoteNavAction::Select),
|
||||
(13, RemoteNavAction::Select),
|
||||
(0xF016, RemoteNavAction::Back),
|
||||
(27, RemoteNavAction::Back),
|
||||
(8, RemoteNavAction::Back),
|
||||
];
|
||||
|
||||
for (code, expected) in cases {
|
||||
dispatch_button_or_code("unknown_btn", code, &nav_sender, &player);
|
||||
let action = nav_rx.recv().await.expect("Expected nav action for keycode");
|
||||
assert_eq!(action, expected, "Keycode 0x{:04X} should map to {:?}", code, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_action_navigation() {
|
||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||
let player = PlayerController::new(PlayerConfig::default());
|
||||
let nav_sender: RemoteNavSender = nav_tx.into();
|
||||
|
||||
let cases = vec![
|
||||
("up", RemoteNavAction::Up),
|
||||
("action(3)", RemoteNavAction::Up),
|
||||
("cursorup", RemoteNavAction::Up),
|
||||
("down", RemoteNavAction::Down),
|
||||
("action(4)", RemoteNavAction::Down),
|
||||
("cursordown", RemoteNavAction::Down),
|
||||
("left", RemoteNavAction::Left),
|
||||
("action(1)", RemoteNavAction::Left),
|
||||
("cursorleft", RemoteNavAction::Left),
|
||||
("right", RemoteNavAction::Right),
|
||||
("action(2)", RemoteNavAction::Right),
|
||||
("cursorright", RemoteNavAction::Right),
|
||||
("select", RemoteNavAction::Select),
|
||||
("enter", RemoteNavAction::Select),
|
||||
("action(7)", RemoteNavAction::Select),
|
||||
("selectitem", RemoteNavAction::Select),
|
||||
("back", RemoteNavAction::Back),
|
||||
("action(9)", RemoteNavAction::Back),
|
||||
("action(10)", RemoteNavAction::Back),
|
||||
("previousmenu", RemoteNavAction::Back),
|
||||
("parentdir", RemoteNavAction::Back),
|
||||
("close", RemoteNavAction::Back),
|
||||
("ActivateWindow(Videos,TvShowTitles)", RemoteNavAction::ActivateSection(3)),
|
||||
("ActivateWindow(Videos,MovieTitles)", RemoteNavAction::ActivateSection(2)),
|
||||
("ActivateWindow(Music)", RemoteNavAction::ActivateSection(4)),
|
||||
("ActivateWindow(Pictures)", RemoteNavAction::ActivateSection(5)),
|
||||
("ActivateWindow(Home)", RemoteNavAction::ActivateSection(0)),
|
||||
];
|
||||
|
||||
for (act, expected) in cases {
|
||||
dispatch_action(act, &nav_sender, &player);
|
||||
let action = nav_rx.recv().await.expect("Expected nav action for action string");
|
||||
assert_eq!(action, expected, "Action '{}' should map to {:?}", act, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp_helo_and_ping_ack() {
|
||||
let (nav_tx, _nav_rx) = mpsc::unbounded_channel();
|
||||
let player = PlayerController::new(PlayerConfig::default());
|
||||
let server = KodiEventServer::new(0, player, nav_tx);
|
||||
let port = server.start().await.expect("Failed to start EventServer on port 0");
|
||||
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
|
||||
let server_addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
// 1. Send HELO
|
||||
let token = 0x12345678;
|
||||
let helo_pkt = build_helo_packet("Test Yatse Device", token);
|
||||
client.send_to(&helo_pkt, &server_addr).await.expect("Send HELO");
|
||||
|
||||
// Expect PING ACK reply
|
||||
let mut buf = [0u8; 128];
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut buf))
|
||||
.await
|
||||
.expect("Timeout waiting for HELO ack")
|
||||
.expect("Recv HELO ack");
|
||||
|
||||
assert!(len >= HEADER_SIZE);
|
||||
assert_eq!(&buf[0..4], b"XBMC");
|
||||
let resp_type = u16::from_be_bytes([buf[6], buf[7]]);
|
||||
assert_eq!(resp_type, PT_PING);
|
||||
let resp_token = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
|
||||
assert_eq!(resp_token, token);
|
||||
|
||||
// 2. Send PING
|
||||
let ping_token = 0x87654321;
|
||||
let ping_pkt = build_ping_packet(ping_token);
|
||||
client.send_to(&ping_pkt, &server_addr).await.expect("Send PING");
|
||||
|
||||
let (len2, _) = timeout(Duration::from_secs(2), client.recv_from(&mut buf))
|
||||
.await
|
||||
.expect("Timeout waiting for PING ack")
|
||||
.expect("Recv PING ack");
|
||||
|
||||
assert!(len2 >= HEADER_SIZE);
|
||||
assert_eq!(&buf[0..4], b"XBMC");
|
||||
let resp_type2 = u16::from_be_bytes([buf[6], buf[7]]);
|
||||
assert_eq!(resp_type2, PT_PING);
|
||||
let resp_token2 = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
|
||||
assert_eq!(resp_token2, ping_token);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp_button_events_over_socket() {
|
||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||
let player = PlayerController::new(PlayerConfig::default());
|
||||
let server = KodiEventServer::new(0, player, nav_tx);
|
||||
let port = server.start().await.expect("Failed to start EventServer on port 0");
|
||||
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
|
||||
let server_addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
// Send 'Up' button (DOWN flag)
|
||||
let btn_pkt = build_button_packet(0, PTB_DOWN, "R1", "up", 1);
|
||||
client.send_to(&btn_pkt, &server_addr).await.expect("Send button");
|
||||
|
||||
let action = timeout(Duration::from_secs(2), nav_rx.recv())
|
||||
.await
|
||||
.expect("Timeout waiting for button up")
|
||||
.expect("Channel closed");
|
||||
assert_eq!(action, RemoteNavAction::Up);
|
||||
|
||||
// Send 'Select' button (DOWN flag)
|
||||
let btn_pkt = build_button_packet(0, PTB_DOWN, "R1", "select", 1);
|
||||
client.send_to(&btn_pkt, &server_addr).await.expect("Send button");
|
||||
|
||||
let action = timeout(Duration::from_secs(2), nav_rx.recv())
|
||||
.await
|
||||
.expect("Timeout waiting for button select")
|
||||
.expect("Channel closed");
|
||||
assert_eq!(action, RemoteNavAction::Select);
|
||||
|
||||
// Send key release ONLY (PTB_UP flag, without PTB_DOWN) -> Should be ignored
|
||||
let release_pkt = build_button_packet(0, PTB_UP, "R1", "up", 1);
|
||||
client.send_to(&release_pkt, &server_addr).await.expect("Send button release");
|
||||
|
||||
// Wait short time and ensure no action was dispatched
|
||||
let res = timeout(Duration::from_millis(100), nav_rx.recv()).await;
|
||||
assert!(res.is_err(), "Key release should be ignored and not trigger an action");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp_action_events_over_socket() {
|
||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||
let player = PlayerController::new(PlayerConfig::default());
|
||||
let server = KodiEventServer::new(0, player, nav_tx);
|
||||
let port = server.start().await.expect("Failed to start EventServer on port 0");
|
||||
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
|
||||
let server_addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
// Send ACTION "Action(1)" which is Left
|
||||
let action_pkt = build_action_packet("Action(1)", 1);
|
||||
client.send_to(&action_pkt, &server_addr).await.expect("Send action");
|
||||
|
||||
let action = timeout(Duration::from_secs(2), nav_rx.recv())
|
||||
.await
|
||||
.expect("Timeout waiting for action Left")
|
||||
.expect("Channel closed");
|
||||
assert_eq!(action, RemoteNavAction::Left);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp_malformed_packets_gracefully_ignored() {
|
||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||
let player = PlayerController::new(PlayerConfig::default());
|
||||
let server = KodiEventServer::new(0, player, nav_tx);
|
||||
let port = server.start().await.expect("Failed to start EventServer on port 0");
|
||||
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
|
||||
let server_addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
// Packet smaller than header size
|
||||
client.send_to(b"too small", &server_addr).await.expect("Send small");
|
||||
|
||||
// Packet with invalid signature
|
||||
let mut bad_sig = build_button_packet(0, PTB_DOWN, "R1", "up", 1);
|
||||
bad_sig[0..4].copy_from_slice(b"NOPE");
|
||||
client.send_to(&bad_sig, &server_addr).await.expect("Send bad sig");
|
||||
|
||||
// Immediately following with a valid packet must still succeed
|
||||
let valid_pkt = build_button_packet(0, PTB_DOWN, "R1", "right", 1);
|
||||
client.send_to(&valid_pkt, &server_addr).await.expect("Send valid");
|
||||
|
||||
let action = timeout(Duration::from_secs(2), nav_rx.recv())
|
||||
.await
|
||||
.expect("Timeout waiting for action Right")
|
||||
.expect("Channel closed");
|
||||
assert_eq!(action, RemoteNavAction::Right);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp_action_mute() {
|
||||
let (nav_tx, _nav_rx) = mpsc::unbounded_channel();
|
||||
let player = PlayerController::new(PlayerConfig::default());
|
||||
let server = KodiEventServer::new(0, player.clone(), nav_tx);
|
||||
let port = server.start().await.expect("Failed to start EventServer on port 0");
|
||||
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
|
||||
let server_addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
assert!(!player.status.lock().await.is_muted);
|
||||
|
||||
// Yatse sends "Mute()" action over EventServer
|
||||
let mute_pkt = build_action_packet("Mute()", 1);
|
||||
client.send_to(&mute_pkt, &server_addr).await.expect("Send Mute()");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(player.status.lock().await.is_muted);
|
||||
|
||||
// Send second "Mute()" to unmute
|
||||
client.send_to(&mute_pkt, &server_addr).await.expect("Send second Mute()");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(!player.status.lock().await.is_muted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::{mpsc, RwLock, Semaphore};
|
||||
use eframe::egui::{ColorImage, Context, TextureHandle, TextureOptions};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ImageStatus {
|
||||
Loaded(TextureHandle),
|
||||
Loading,
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn hash_url(url: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
struct CacheInner {
|
||||
textures: HashMap<String, (u64, TextureHandle)>,
|
||||
in_flight: HashSet<String>,
|
||||
failed: HashSet<String>,
|
||||
access_counter: u64,
|
||||
}
|
||||
|
||||
pub struct AsyncImageCache {
|
||||
inner: Mutex<CacheInner>,
|
||||
tx: mpsc::UnboundedSender<(String, Option<ColorImage>)>,
|
||||
rx: Mutex<mpsc::UnboundedReceiver<(String, Option<ColorImage>)>>,
|
||||
http_client: reqwest::Client,
|
||||
token: Arc<RwLock<Option<String>>>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl AsyncImageCache {
|
||||
pub fn new(token: Arc<RwLock<Option<String>>>) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
inner: Mutex::new(CacheInner {
|
||||
textures: HashMap::new(),
|
||||
in_flight: HashSet::new(),
|
||||
failed: HashSet::new(),
|
||||
access_counter: 0,
|
||||
}),
|
||||
tx,
|
||||
rx: Mutex::new(rx),
|
||||
http_client: reqwest::Client::new(),
|
||||
token,
|
||||
semaphore: Arc::new(Semaphore::new(10)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&self, ctx: &Context) {
|
||||
let mut any_new = false;
|
||||
if let Ok(mut rx_guard) = self.rx.try_lock() {
|
||||
while let Ok((url, opt_img)) = rx_guard.try_recv() {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
inner.in_flight.remove(&url);
|
||||
match opt_img {
|
||||
Some(color_image) => {
|
||||
inner.access_counter += 1;
|
||||
let counter = inner.access_counter;
|
||||
let handle = ctx.load_texture(&url, color_image, TextureOptions::LINEAR);
|
||||
inner.textures.insert(url, (counter, handle));
|
||||
any_new = true;
|
||||
}
|
||||
None => {
|
||||
inner.failed.insert(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit cached textures to 500
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
if inner.textures.len() > 500 {
|
||||
let mut entries: Vec<(String, u64)> = inner
|
||||
.textures
|
||||
.iter()
|
||||
.map(|(k, (c, _))| (k.clone(), *c))
|
||||
.collect();
|
||||
entries.sort_by_key(|(_, c)| *c);
|
||||
let to_remove = entries.len() - 400;
|
||||
for (k, _) in entries.into_iter().take(to_remove) {
|
||||
inner.textures.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if any_new {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_or_load(&self, ctx: &Context, url: &str) -> ImageStatus {
|
||||
if url.is_empty() {
|
||||
return ImageStatus::Failed;
|
||||
}
|
||||
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
inner.access_counter += 1;
|
||||
let current_count = inner.access_counter;
|
||||
if let Some((counter, handle)) = inner.textures.get_mut(url) {
|
||||
*counter = current_count;
|
||||
return ImageStatus::Loaded(handle.clone());
|
||||
}
|
||||
|
||||
if inner.failed.contains(url) {
|
||||
return ImageStatus::Failed;
|
||||
}
|
||||
|
||||
if inner.in_flight.contains(url) {
|
||||
return ImageStatus::Loading;
|
||||
}
|
||||
|
||||
inner.in_flight.insert(url.to_string());
|
||||
} else {
|
||||
return ImageStatus::Loading;
|
||||
}
|
||||
|
||||
let url_clone = url.to_string();
|
||||
let tx = self.tx.clone();
|
||||
let client = self.http_client.clone();
|
||||
let sem = self.semaphore.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let token_opt = self.token.try_read().ok().and_then(|g| g.clone());
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await;
|
||||
let cache_dir = std::path::Path::new("/tmp/jellyfin-cache");
|
||||
let file_name = format!("{:016x}.cache", hash_url(&url_clone));
|
||||
let cache_file = cache_dir.join(file_name);
|
||||
|
||||
let mut bytes_opt = if let Ok(data) = std::fs::read(&cache_file) {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if bytes_opt.is_none() {
|
||||
let mut req = client.get(&url_clone);
|
||||
if let Some(ref t) = token_opt {
|
||||
req = req.header("X-Emby-Token", t);
|
||||
req = req.header("Authorization", format!("MediaBrowser Token=\"{}\"", t));
|
||||
}
|
||||
if let Ok(resp) = req.send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(b) = resp.bytes().await {
|
||||
let vec_bytes = b.to_vec();
|
||||
let _ = std::fs::create_dir_all(cache_dir);
|
||||
let _ = std::fs::write(&cache_file, &vec_bytes);
|
||||
bytes_opt = Some(vec_bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let color_img = if let Some(bytes) = bytes_opt {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Ok(dyn_img) = image::load_from_memory(&bytes) {
|
||||
let rgba = dyn_img.to_rgba8();
|
||||
let size = [rgba.width() as usize, rgba.height() as usize];
|
||||
let pixels = rgba.into_raw();
|
||||
Some(ColorImage::from_rgba_unmultiplied(size, &pixels))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let _ = tx.send((url_clone, color_img));
|
||||
ctx_clone.request_repaint();
|
||||
});
|
||||
|
||||
ImageStatus::Loading
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::config::JellyfinConfig;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CardLayout {
|
||||
Poster, // 2:3 ratio (Movies, TV Shows) - wat kleinere plaatjes (135 x 200)
|
||||
Square, // 1:1 ratio (Music Albums, Artists) - vierkant (160 x 160)
|
||||
LandscapeThumb,// 16:9 ratio (Verder kijken, Music Videos) - liggend (250 x 140)
|
||||
PhotoLarge, // Foto albums - wat grotere plaatjes (280 x 190)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PlayableTarget {
|
||||
Stream {
|
||||
url: String,
|
||||
title: String,
|
||||
media_type: String,
|
||||
artist: Option<String>,
|
||||
genre: Option<String>,
|
||||
},
|
||||
Photo { url: String, title: String },
|
||||
OpenFolder { folder_id: String, name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub sort_name: Option<String>,
|
||||
pub item_type: String,
|
||||
pub series_name: Option<String>,
|
||||
pub artist_name: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
pub index_number: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub overview: Option<String>,
|
||||
pub rating: Option<f32>,
|
||||
pub runtime_ticks: Option<u64>,
|
||||
pub played_percentage: Option<f32>,
|
||||
pub is_folder: bool,
|
||||
pub has_primary_image: bool,
|
||||
pub has_backdrop_image: bool,
|
||||
pub parent_primary_image_item_id: Option<String>,
|
||||
pub parent_backdrop_item_id: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
pub fn primary_image_id(&self) -> &str {
|
||||
if let Some(ref p_id) = self.parent_primary_image_item_id {
|
||||
if p_id.starts_with("http://") || p_id.starts_with("https://") {
|
||||
return p_id;
|
||||
}
|
||||
}
|
||||
if let Some(ref b_id) = self.parent_backdrop_item_id {
|
||||
if b_id.starts_with("http://") || b_id.starts_with("https://") {
|
||||
return b_id;
|
||||
}
|
||||
}
|
||||
if self.has_primary_image {
|
||||
&self.id
|
||||
} else if let Some(ref p_id) = self.parent_primary_image_item_id {
|
||||
p_id
|
||||
} else if let Some(ref b_id) = self.parent_backdrop_item_id {
|
||||
b_id
|
||||
} else {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poster_url(&self, server_url: &str) -> String {
|
||||
let img = self.primary_image_id();
|
||||
if img.starts_with("http://") || img.starts_with("https://") {
|
||||
return img.to_string();
|
||||
}
|
||||
format!("{}/Items/{}/Images/Primary?fillWidth=320&fillHeight=480&quality=85", server_url.trim_end_matches('/'), img)
|
||||
}
|
||||
|
||||
pub fn thumb_url(&self, server_url: &str) -> String {
|
||||
let img = self.primary_image_id();
|
||||
if img.starts_with("http://") || img.starts_with("https://") {
|
||||
return img.to_string();
|
||||
}
|
||||
format!("{}/Items/{}/Images/Primary?fillWidth=480&fillHeight=270&quality=85", server_url.trim_end_matches('/'), img)
|
||||
}
|
||||
|
||||
pub fn square_url(&self, server_url: &str) -> String {
|
||||
let img = self.primary_image_id();
|
||||
if img.starts_with("http://") || img.starts_with("https://") {
|
||||
return img.to_string();
|
||||
}
|
||||
format!("{}/Items/{}/Images/Primary?fillWidth=300&fillHeight=300&quality=85", server_url.trim_end_matches('/'), img)
|
||||
}
|
||||
|
||||
pub fn photo_url(&self, server_url: &str) -> String {
|
||||
let img = self.primary_image_id();
|
||||
if img.starts_with("http://") || img.starts_with("https://") {
|
||||
return img.to_string();
|
||||
}
|
||||
format!("{}/Items/{}/Images/Primary?fillWidth=1280&quality=90", server_url.trim_end_matches('/'), img)
|
||||
}
|
||||
|
||||
pub fn backdrop_url(&self, server_url: &str) -> String {
|
||||
let img = self.primary_image_id();
|
||||
if img.starts_with("http://") || img.starts_with("https://") {
|
||||
return img.to_string();
|
||||
}
|
||||
let base = server_url.trim_end_matches('/');
|
||||
if self.has_backdrop_image {
|
||||
format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, self.id)
|
||||
} else if let Some(ref p_id) = self.parent_backdrop_item_id {
|
||||
format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, p_id)
|
||||
} else {
|
||||
format!("{}/Items/{}/Images/Primary?maxWidth=1280&quality=85", base, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream_url(&self, server_url: &str, token: &str) -> String {
|
||||
let base = server_url.trim_end_matches('/');
|
||||
if self.item_type == "Audio" || self.item_type == "MusicAlbum" {
|
||||
format!("{}/Audio/{}/stream?static=true&api_key={}", base, self.id, token)
|
||||
} else {
|
||||
format!("{}/Videos/{}/stream?static=true&api_key={}", base, self.id, token)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn formatted_runtime(&self) -> String {
|
||||
if let Some(ticks) = self.runtime_ticks {
|
||||
let total_mins = ticks / (10_000_000 * 60);
|
||||
let hours = total_mins / 60;
|
||||
let mins = total_mins % 60;
|
||||
if hours > 0 {
|
||||
format!("{}u {}m", hours, mins)
|
||||
} else {
|
||||
format!("{}m", mins)
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_letter(&self) -> char {
|
||||
let text = self.sort_name.as_deref().unwrap_or(&self.name);
|
||||
let trimmed = text.trim().trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
for c in trimmed.chars() {
|
||||
if c.is_ascii_digit() {
|
||||
return '#';
|
||||
}
|
||||
if c.is_ascii_alphabetic() {
|
||||
return c.to_ascii_uppercase();
|
||||
}
|
||||
}
|
||||
'#'
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_media_items_alphabetically(items: &mut [MediaItem]) {
|
||||
items.sort_by(|a, b| {
|
||||
let la = a.sort_letter();
|
||||
let lb = b.sort_letter();
|
||||
let order_a = if la == '#' { 0 } else { (la as u32) + 1 };
|
||||
let order_b = if lb == '#' { 0 } else { (lb as u32) + 1 };
|
||||
order_a.cmp(&order_b).then_with(|| {
|
||||
let na = a.sort_name.as_deref().unwrap_or(&a.name).trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
let nb = b.sort_name.as_deref().unwrap_or(&b.name).trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
na.to_lowercase().cmp(&nb.to_lowercase())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MediaLibrary {
|
||||
// Dashboard (Home)
|
||||
pub resume_items: Vec<MediaItem>,
|
||||
pub latest_movies: Vec<MediaItem>,
|
||||
pub latest_shows: Vec<MediaItem>,
|
||||
pub latest_music: Vec<MediaItem>,
|
||||
|
||||
// Libraries
|
||||
pub all_movies: Vec<MediaItem>,
|
||||
pub all_shows: Vec<MediaItem>,
|
||||
pub music_artists: Vec<MediaItem>,
|
||||
pub music_albums: Vec<MediaItem>,
|
||||
pub music_videos: Vec<MediaItem>,
|
||||
pub photos: Vec<MediaItem>,
|
||||
pub live_channels: Vec<MediaItem>,
|
||||
pub favorites: Vec<MediaItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JellyfinClient {
|
||||
pub config: JellyfinConfig,
|
||||
client: reqwest::Client,
|
||||
pub token: Arc<RwLock<Option<String>>>,
|
||||
pub user_id: Arc<RwLock<Option<String>>>,
|
||||
}
|
||||
|
||||
impl JellyfinClient {
|
||||
pub fn new(config: JellyfinConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: reqwest::Client::new(),
|
||||
token: Arc::new(RwLock::new(None)),
|
||||
user_id: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_header_value(&self, token: Option<&str>) -> String {
|
||||
let base = format!(
|
||||
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"1.0.0\"",
|
||||
self.config.client_name, self.config.device_name, self.config.device_id
|
||||
);
|
||||
if let Some(t) = token {
|
||||
format!("{}, Token=\"{}\"", base, t)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authenticate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/Users/AuthenticateByName", self.config.server_url.trim_end_matches('/'));
|
||||
let auth_val = self.auth_header_value(None);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"Username": self.config.username,
|
||||
"Pw": self.config.password.clone().unwrap_or_default(),
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url)
|
||||
.headers(headers)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
return Err(format!("Jellyfin authentication failed: {}", err).into());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
let token = data["AccessToken"].as_str().ok_or("No AccessToken in response")?.to_string();
|
||||
let user_id = data["User"]["Id"].as_str().ok_or("No User.Id in response")?.to_string();
|
||||
|
||||
*self.token.write().await = Some(token);
|
||||
*self.user_id.write().await = Some(user_id);
|
||||
|
||||
tracing::info!("Authenticated successfully with Jellyfin as {}", self.config.username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_authed_headers(&self) -> Result<HeaderMap, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let token_guard = self.token.read().await;
|
||||
let token = token_guard.as_deref().ok_or("Not authenticated")?;
|
||||
let auth_val = self.auth_header_value(Some(token));
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?);
|
||||
headers.insert("X-Emby-Token", HeaderValue::from_str(token)?);
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub async fn fetch_folder_items(&self, folder_id: &str) -> Vec<MediaItem> {
|
||||
let base_url = self.config.server_url.trim_end_matches('/');
|
||||
if let Ok(headers) = self.get_authed_headers().await {
|
||||
let user_id = self.user_id.read().await.clone().unwrap_or_default();
|
||||
// 1. Try standard folder items with parentId
|
||||
let url = format!(
|
||||
"{}/Users/{}/Items?parentId={}&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||
base_url, user_id, folder_id
|
||||
);
|
||||
let mut items = fetch_endpoint(&self.client, &url, &headers).await;
|
||||
|
||||
// 2. If empty, maybe this is a TV Show (Series): fetch seasons
|
||||
if items.is_empty() {
|
||||
let url_seasons = format!(
|
||||
"{}/Shows/{}/Seasons?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||
base_url, folder_id, user_id
|
||||
);
|
||||
items = fetch_endpoint(&self.client, &url_seasons, &headers).await;
|
||||
}
|
||||
|
||||
// 3. If empty, maybe this is a Season: fetch episodes
|
||||
if items.is_empty() {
|
||||
let url_episodes = format!(
|
||||
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||
base_url, folder_id, user_id
|
||||
);
|
||||
items = fetch_endpoint(&self.client, &url_episodes, &headers).await;
|
||||
}
|
||||
|
||||
// 4. If empty, maybe this is an artist: try fetching albums via albumArtistIds
|
||||
if items.is_empty() {
|
||||
let url_albums = format!(
|
||||
"{}/Users/{}/Items?albumArtistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||
base_url, user_id, folder_id
|
||||
);
|
||||
items = fetch_endpoint(&self.client, &url_albums, &headers).await;
|
||||
}
|
||||
|
||||
// 5. If still empty, try artistIds for albums
|
||||
if items.is_empty() {
|
||||
let url_albums2 = format!(
|
||||
"{}/Users/{}/Items?artistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||
base_url, user_id, folder_id
|
||||
);
|
||||
items = fetch_endpoint(&self.client, &url_albums2, &headers).await;
|
||||
}
|
||||
|
||||
// 6. If still empty (e.g. ABBA, *NSYNC who have no albums, only compilation tracks), fetch individual audio songs directly
|
||||
if items.is_empty() {
|
||||
let url_songs = format!(
|
||||
"{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||
base_url, user_id, folder_id
|
||||
);
|
||||
items = fetch_endpoint(&self.client, &url_songs, &headers).await;
|
||||
}
|
||||
|
||||
// If the folder items are Audio tracks, Seasons, or Episodes, sort them by index_number
|
||||
if items.iter().any(|i| i.item_type == "Audio" || i.item_type == "Season" || i.item_type == "Episode") {
|
||||
items.sort_by_key(|i| i.index_number.unwrap_or(u32::MAX));
|
||||
} else {
|
||||
sort_media_items_alphabetically(&mut items);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub async fn resolve_playable_item(&self, item: &MediaItem) -> Result<PlayableTarget, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_url = self.config.server_url.trim_end_matches('/');
|
||||
let token = {
|
||||
let guard = self.token.read().await;
|
||||
guard.clone().unwrap_or_default()
|
||||
};
|
||||
|
||||
// 0. YouTube Video or direct stream URL
|
||||
if item.item_type == "YouTubeVideo"
|
||||
|| item.id.starts_with("https://www.youtube.com")
|
||||
|| item.id.starts_with("https://youtu.be")
|
||||
{
|
||||
return Ok(PlayableTarget::Stream {
|
||||
url: item.id.clone(),
|
||||
title: item.name.clone(),
|
||||
media_type: "stream".to_string(),
|
||||
artist: item.series_name.clone(),
|
||||
genre: item.genre.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Photo item -> view photo
|
||||
if item.item_type == "Photo" {
|
||||
return Ok(PlayableTarget::Photo {
|
||||
url: item.photo_url(base_url),
|
||||
title: item.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Series, Season, PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI
|
||||
if item.item_type == "Series"
|
||||
|| item.item_type == "Season"
|
||||
|| item.item_type == "PhotoAlbum"
|
||||
|| item.item_type == "MusicArtist"
|
||||
|| item.item_type == "MusicAlbum"
|
||||
|| item.is_folder
|
||||
{
|
||||
return Ok(PlayableTarget::OpenFolder {
|
||||
folder_id: item.id.clone(),
|
||||
name: item.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Standard playable stream (Movie, Episode, Video, MusicVideo, Audio, TvChannel)
|
||||
let (media_type, artist) = match item.item_type.as_str() {
|
||||
"Audio" => ("music".to_string(), item.artist_name.clone()),
|
||||
"TvChannel" => ("stream".to_string(), None),
|
||||
"MusicVideo" => ("video".to_string(), item.artist_name.clone()),
|
||||
_ => ("video".to_string(), None),
|
||||
};
|
||||
|
||||
Ok(PlayableTarget::Stream {
|
||||
url: item.stream_url(base_url, &token),
|
||||
title: item.name.clone(),
|
||||
media_type,
|
||||
artist,
|
||||
genre: item.genre.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_all_media(&self) -> Result<MediaLibrary, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if self.token.read().await.is_none() {
|
||||
self.authenticate().await?;
|
||||
}
|
||||
|
||||
let user_id = {
|
||||
let guard = self.user_id.read().await;
|
||||
guard.clone().ok_or("Missing user_id")?
|
||||
};
|
||||
|
||||
let headers = self.get_authed_headers().await?;
|
||||
let base_url = self.config.server_url.trim_end_matches('/');
|
||||
|
||||
// 1. Fetch Views to discover all library categories
|
||||
let views_url = format!("{}/Users/{}/Views", base_url, user_id);
|
||||
let views_resp: serde_json::Value = self.client.get(&views_url)
|
||||
.headers(headers.clone())
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let mut movies_id: Option<String> = None;
|
||||
let mut shows_id: Option<String> = None;
|
||||
let mut music_id: Option<String> = None;
|
||||
let mut photo_id: Option<String> = None;
|
||||
let mut music_video_id: Option<String> = None;
|
||||
|
||||
if let Some(items) = views_resp["Items"].as_array() {
|
||||
for v in items {
|
||||
let col_type = v["CollectionType"].as_str().unwrap_or("");
|
||||
let name = v["Name"].as_str().unwrap_or("").to_lowercase();
|
||||
let id = v["Id"].as_str().unwrap_or("").to_string();
|
||||
|
||||
if col_type == "movies" || name.contains("movie") || name.contains("film") {
|
||||
if movies_id.is_none() { movies_id = Some(id); }
|
||||
} else if col_type == "tvshows" || name.contains("show") || name.contains("serie") {
|
||||
if shows_id.is_none() { shows_id = Some(id); }
|
||||
} else if col_type == "music" && !name.contains("video") {
|
||||
if music_id.is_none() { music_id = Some(id); }
|
||||
} else if col_type == "musicvideos" || name.contains("music video") {
|
||||
if music_video_id.is_none() { music_video_id = Some(id); }
|
||||
} else if col_type == "homevideos" || col_type == "photos" || name.contains("foto") || name.contains("photo") {
|
||||
if photo_id.is_none() { photo_id = Some(id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Resume / Next Up
|
||||
let resume_url = format!(
|
||||
"{}/UserItems/Resume?userId={}&limit=16&fields=Overview,PrimaryImageAspectRatio,SeriesName,ParentBackdropItemId,UserData",
|
||||
base_url, user_id
|
||||
);
|
||||
let resume_items = fetch_endpoint(&self.client, &resume_url, &headers).await;
|
||||
|
||||
// 3. Fetch Latest & All Movies
|
||||
let mut latest_movies = Vec::new();
|
||||
let mut all_movies = Vec::new();
|
||||
if let Some(ref m_id) = movies_id {
|
||||
let url_latest = format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
|
||||
base_url, user_id, m_id
|
||||
);
|
||||
latest_movies = fetch_endpoint(&self.client, &url_latest, &headers).await;
|
||||
|
||||
let url_all = format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
|
||||
base_url, user_id, m_id
|
||||
);
|
||||
all_movies = fetch_endpoint(&self.client, &url_all, &headers).await;
|
||||
sort_media_items_alphabetically(&mut all_movies);
|
||||
}
|
||||
|
||||
// 4. Fetch Latest & All Shows
|
||||
let mut latest_shows = Vec::new();
|
||||
let mut all_shows = Vec::new();
|
||||
if let Some(ref s_id) = shows_id {
|
||||
let url_latest = format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
|
||||
base_url, user_id, s_id
|
||||
);
|
||||
latest_shows = fetch_endpoint(&self.client, &url_latest, &headers).await;
|
||||
|
||||
let url_all = format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=SortName&sortOrder=Ascending&limit=1000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
|
||||
base_url, user_id, s_id
|
||||
);
|
||||
all_shows = fetch_endpoint(&self.client, &url_all, &headers).await;
|
||||
sort_media_items_alphabetically(&mut all_shows);
|
||||
}
|
||||
|
||||
// 5. Fetch Music (Latest Albums, Artists, and All Albums)
|
||||
let mut latest_music = Vec::new();
|
||||
let mut music_artists = Vec::new();
|
||||
let mut music_albums = Vec::new();
|
||||
if let Some(ref mus_id) = music_id {
|
||||
let url_latest_mus = format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,AlbumArtist,SortName",
|
||||
base_url, user_id, mus_id
|
||||
);
|
||||
latest_music = fetch_endpoint(&self.client, &url_latest_mus, &headers).await;
|
||||
|
||||
// Fetch artists using dedicated /Artists endpoint first
|
||||
let url_artists = format!(
|
||||
"{}/Artists?userId={}&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName",
|
||||
base_url, user_id
|
||||
);
|
||||
music_artists = fetch_endpoint(&self.client, &url_artists, &headers).await;
|
||||
if music_artists.is_empty() {
|
||||
let url_artists_fb = format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicArtist&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName",
|
||||
base_url, user_id, mus_id
|
||||
);
|
||||
music_artists = fetch_endpoint(&self.client, &url_artists_fb, &headers).await;
|
||||
}
|
||||
sort_media_items_alphabetically(&mut music_artists);
|
||||
|
||||
let url_albums = format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,AlbumArtist,SortName",
|
||||
base_url, user_id, mus_id
|
||||
);
|
||||
music_albums = fetch_endpoint(&self.client, &url_albums, &headers).await;
|
||||
sort_media_items_alphabetically(&mut music_albums);
|
||||
}
|
||||
|
||||
// 6. Fetch Music Videos
|
||||
let mut music_videos = Vec::new();
|
||||
if let Some(ref mv_id) = music_video_id {
|
||||
let url_mv = format!(
|
||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicVideo&sortBy=DateCreated&sortOrder=Descending&limit=200&fields=Overview,Artist,SortName",
|
||||
base_url, user_id, mv_id
|
||||
);
|
||||
music_videos = fetch_endpoint(&self.client, &url_mv, &headers).await;
|
||||
sort_media_items_alphabetically(&mut music_videos);
|
||||
}
|
||||
|
||||
// 7. Fetch Photos / Home Videos
|
||||
let mut photos = Vec::new();
|
||||
if let Some(ref p_id) = photo_id {
|
||||
let url_photo = format!(
|
||||
"{}/Users/{}/Items?parentId={}&sortBy=DateCreated&sortOrder=Descending&limit=500&fields=Overview,SortName",
|
||||
base_url, user_id, p_id
|
||||
);
|
||||
photos = fetch_endpoint(&self.client, &url_photo, &headers).await;
|
||||
sort_media_items_alphabetically(&mut photos);
|
||||
}
|
||||
|
||||
// 8. Fetch Live TV Channels
|
||||
let url_live = format!("{}/LiveTv/Channels?userId={}&limit=100", base_url, user_id);
|
||||
let live_channels = fetch_endpoint(&self.client, &url_live, &headers).await;
|
||||
|
||||
// 9. Fetch Favorites
|
||||
let url_fav = format!(
|
||||
"{}/Users/{}/Items?userId={}&isFavorite=true&recursive=true&limit=200&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,SortName",
|
||||
base_url, user_id, user_id
|
||||
);
|
||||
let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await;
|
||||
sort_media_items_alphabetically(&mut favorites);
|
||||
|
||||
Ok(MediaLibrary {
|
||||
resume_items,
|
||||
latest_movies,
|
||||
latest_shows,
|
||||
latest_music,
|
||||
all_movies,
|
||||
all_shows,
|
||||
music_artists,
|
||||
music_albums,
|
||||
music_videos,
|
||||
photos,
|
||||
live_channels,
|
||||
favorites,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec<MediaItem> {
|
||||
if let Ok(resp) = client.get(url).headers(headers.clone()).send().await {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
return parse_items(&json);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(items) = json["Items"].as_array() {
|
||||
for it in items {
|
||||
let id = it["Id"].as_str().unwrap_or("").to_string();
|
||||
let name = it["Name"].as_str().unwrap_or("Onbekend").to_string();
|
||||
let item_type = it["Type"].as_str().unwrap_or("Video").to_string();
|
||||
let series_name = it["SeriesName"].as_str().map(|s| s.to_string());
|
||||
let artist_name = it["AlbumArtist"].as_str().or_else(|| it["Artist"].as_str()).map(|s| s.to_string());
|
||||
let genre = it["Genres"].as_array().and_then(|arr| {
|
||||
let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect();
|
||||
if list.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(list.join(", "))
|
||||
}
|
||||
}).or_else(|| it["Genre"].as_str().map(|s| s.to_string()));
|
||||
let year = it["ProductionYear"].as_u64().map(|y| y as u32);
|
||||
let overview = it["Overview"].as_str().map(|s| s.to_string());
|
||||
let rating = it["CommunityRating"].as_f64().map(|r| r as f32);
|
||||
let runtime_ticks = it["RunTimeTicks"].as_u64();
|
||||
let played_percentage = it["UserData"]["PlayedPercentage"].as_f64().map(|p| p as f32);
|
||||
let is_folder = it["IsFolder"].as_bool().unwrap_or(false);
|
||||
let has_primary_image = it["ImageTags"]["Primary"].is_string();
|
||||
let has_backdrop_image = it["BackdropImageTags"].as_array().map_or(false, |b| !b.is_empty());
|
||||
let parent_primary_image_item_id = it["ParentPrimaryImageItemId"]
|
||||
.as_str()
|
||||
.or_else(|| it["AlbumId"].as_str())
|
||||
.or_else(|| it["SeriesId"].as_str())
|
||||
.map(|s| s.to_string());
|
||||
let parent_backdrop_item_id = it["ParentBackdropItemId"]
|
||||
.as_str()
|
||||
.or_else(|| it["SeriesId"].as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let sort_name = it["SortName"].as_str().map(|s| s.to_string());
|
||||
let index_number = it["IndexNumber"].as_u64().map(|n| n as u32);
|
||||
|
||||
if !id.is_empty() {
|
||||
out.push(MediaItem {
|
||||
id,
|
||||
name,
|
||||
sort_name,
|
||||
item_type,
|
||||
series_name,
|
||||
artist_name,
|
||||
genre,
|
||||
index_number,
|
||||
year,
|
||||
overview,
|
||||
rating,
|
||||
runtime_ticks,
|
||||
played_percentage,
|
||||
is_folder,
|
||||
has_primary_image,
|
||||
has_backdrop_image,
|
||||
parent_primary_image_item_id,
|
||||
parent_backdrop_item_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use eframe::egui::Vec2;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
mod eventserver;
|
||||
mod image_cache;
|
||||
mod jellyfin;
|
||||
mod mqtt;
|
||||
mod player;
|
||||
mod yatse;
|
||||
mod youtube;
|
||||
|
||||
use app::KioskApp;
|
||||
use config::AppConfig;
|
||||
use eventserver::KodiEventServer;
|
||||
use jellyfin::JellyfinClient;
|
||||
use player::PlayerController;
|
||||
use yatse::{create_yatse_router, YatseServerState};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting Jellyfin HTPC Kiosk (Rust)...");
|
||||
|
||||
// 1. Load Configuration
|
||||
let config = AppConfig::load()?;
|
||||
|
||||
// 2. Initialize Media Player (MPV Controller)
|
||||
let player = PlayerController::new(config.player.clone());
|
||||
|
||||
// 3. Navigation channel (Yatse Remote -> UI) with immediate egui repaint trigger
|
||||
let (nav_tx_raw, nav_rx) = mpsc::unbounded_channel();
|
||||
let egui_ctx_holder: std::sync::Arc<std::sync::Mutex<Option<eframe::egui::Context>>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let nav_tx = crate::yatse::RemoteNavSender::new(nav_tx_raw, egui_ctx_holder.clone());
|
||||
|
||||
// 4. Start Yatse Kodi JSON-RPC Server & EventServer
|
||||
if config.kodi_rpc.enabled {
|
||||
let yatse_state = YatseServerState {
|
||||
player: player.clone(),
|
||||
nav_tx: nav_tx.clone(),
|
||||
};
|
||||
let app_router = create_yatse_router(yatse_state.clone());
|
||||
let port = config.kodi_rpc.http_port;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
tracing::info!("Yatse Kodi JSON-RPC server listening on http://{}", addr);
|
||||
if let Ok(listener) = tokio::net::TcpListener::bind(&addr).await {
|
||||
let _ = axum::serve(listener, app_router).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Start Kodi TCP JSON-RPC socket server on port 9090 for Yatse
|
||||
let ws_port = config.kodi_rpc.ws_port;
|
||||
crate::yatse::start_tcp_jsonrpc_server(ws_port, yatse_state).await;
|
||||
|
||||
// Start Kodi EventServer (UDP 9777) for Yatse
|
||||
let event_server = KodiEventServer::new(9777, player.clone(), nav_tx);
|
||||
let _ = event_server.start().await;
|
||||
|
||||
// Zeroconf / mDNS Advertisement for Yatse automatic discovery
|
||||
tokio::spawn(async move {
|
||||
let hostname = std::fs::read_to_string("/etc/hostname")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| std::env::var("HOSTNAME").unwrap_or_else(|_| "kiosk".into()));
|
||||
let service_name = format!("Kodi ({})", hostname);
|
||||
|
||||
// Announce JSON-RPC HTTP on port 8080
|
||||
let _http_child = std::process::Command::new("avahi-publish-service")
|
||||
.args(["-s", &service_name, "_xbmc-jsonrpc-h._tcp", &port.to_string()])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
|
||||
// Announce EventServer UDP on port 9777
|
||||
let _event_child = std::process::Command::new("avahi-publish-service")
|
||||
.args(["-s", &service_name, "_xbmc-events._udp", "9777"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
|
||||
tracing::info!("Published Zeroconf services for Yatse discovery: '{}'", service_name);
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Start MQTT Service & Home Assistant Discovery if enabled
|
||||
if config.mqtt.enabled {
|
||||
mqtt::MqttService::start(config.clone(), player.clone());
|
||||
}
|
||||
|
||||
// 6. Initialize Jellyfin Client
|
||||
let client = JellyfinClient::new(config.jellyfin.clone());
|
||||
|
||||
// 7. Launch Native GUI via eframe (runs on main thread)
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: eframe::egui::ViewportBuilder::default()
|
||||
.with_title("Jellyfin HTPC Kiosk")
|
||||
.with_inner_size(Vec2::new(1280.0, 720.0))
|
||||
.with_fullscreen(config.player.fullscreen),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let app_config = config.clone();
|
||||
let app_client = client.clone();
|
||||
let app_player = player.clone();
|
||||
|
||||
let egui_ctx_for_nav = egui_ctx_holder.clone();
|
||||
// Run eframe app (blocks until window is closed)
|
||||
tokio::task::block_in_place(|| {
|
||||
eframe::run_native(
|
||||
"Jellyfin HTPC Kiosk",
|
||||
native_options,
|
||||
Box::new(move |cc| {
|
||||
*egui_ctx_for_nav.lock().unwrap() = Some(cc.egui_ctx.clone());
|
||||
|
||||
// Install egui image loaders (http, png, jpg)
|
||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||
|
||||
let mut window_id: Option<u64> = None;
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
if let Ok(handle) = cc.window_handle() {
|
||||
match handle.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => {
|
||||
window_id = Some(h.window as u64);
|
||||
}
|
||||
RawWindowHandle::Xcb(h) => {
|
||||
window_id = Some(h.window.get() as u64);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(wid) = window_id {
|
||||
let p = app_player.clone();
|
||||
tokio::spawn(async move {
|
||||
p.set_window_id(Some(wid)).await;
|
||||
});
|
||||
}
|
||||
Ok(Box::new(KioskApp::new(
|
||||
app_config,
|
||||
app_client,
|
||||
app_player,
|
||||
nav_rx,
|
||||
window_id,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.map_err(|e| format!("GUI runtime error: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -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, ¤t_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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config::PlayerConfig;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PlaybackState {
|
||||
Stopped,
|
||||
Playing,
|
||||
Paused,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlayerStatus {
|
||||
pub state: PlaybackState,
|
||||
pub media_type: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub genre: String,
|
||||
pub stream_title: String,
|
||||
pub time_pos: f64,
|
||||
pub duration: f64,
|
||||
pub volume: f64,
|
||||
pub is_muted: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PlayerStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: PlaybackState::Stopped,
|
||||
media_type: String::new(),
|
||||
title: String::new(),
|
||||
artist: String::new(),
|
||||
genre: String::new(),
|
||||
stream_title: String::new(),
|
||||
time_pos: 0.0,
|
||||
duration: 0.0,
|
||||
volume: 100.0,
|
||||
is_muted: false,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MpvHandle(pub *mut libmpv_sys::mpv_handle);
|
||||
unsafe impl Send for MpvHandle {}
|
||||
unsafe impl Sync for MpvHandle {}
|
||||
|
||||
impl MpvHandle {
|
||||
pub fn cmd(&self, args: &[&str]) {
|
||||
if self.0.is_null() {
|
||||
return;
|
||||
}
|
||||
let c_strings: Vec<CString> = args.iter().filter_map(|s| CString::new(*s).ok()).collect();
|
||||
let mut ptrs: Vec<*const std::ffi::c_char> = c_strings.iter().map(|c| c.as_ptr()).collect();
|
||||
ptrs.push(std::ptr::null());
|
||||
unsafe {
|
||||
libmpv_sys::mpv_command(self.0, ptrs.as_mut_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_option_string(&self, name: &str, value: &str) {
|
||||
if self.0.is_null() {
|
||||
return;
|
||||
}
|
||||
if let (Ok(n), Ok(v)) = (CString::new(name), CString::new(value)) {
|
||||
unsafe {
|
||||
libmpv_sys::mpv_set_option_string(self.0, n.as_ptr(), v.as_ptr());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MpvRenderContext(pub *mut libmpv_sys::mpv_render_context);
|
||||
unsafe impl Send for MpvRenderContext {}
|
||||
unsafe impl Sync for MpvRenderContext {}
|
||||
|
||||
impl MpvRenderContext {
|
||||
pub unsafe fn render(&self, params: &mut [libmpv_sys::mpv_render_param]) {
|
||||
if !self.0.is_null() {
|
||||
libmpv_sys::mpv_render_context_render(self.0, params.as_mut_ptr());
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe fn report_swap(&self) {
|
||||
if !self.0.is_null() {
|
||||
libmpv_sys::mpv_render_context_report_swap(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GetProcAddressFn = unsafe extern "C" fn(name: *const u8) -> *mut std::ffi::c_void;
|
||||
|
||||
unsafe extern "C" fn get_proc_address_cb(
|
||||
_ctx: *mut std::ffi::c_void,
|
||||
name: *const std::ffi::c_char,
|
||||
) -> *mut std::ffi::c_void {
|
||||
if name.is_null() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// 1. Try glXGetProcAddressARB
|
||||
let glx_name = b"glXGetProcAddressARB\0";
|
||||
let glx_ptr = libc::dlsym(std::ptr::null_mut(), glx_name.as_ptr() as *const _);
|
||||
if !glx_ptr.is_null() {
|
||||
let glx_fn: GetProcAddressFn = std::mem::transmute(glx_ptr);
|
||||
let ptr = glx_fn(name as *const u8);
|
||||
if !ptr.is_null() {
|
||||
return ptr;
|
||||
}
|
||||
}
|
||||
// 2. Try eglGetProcAddress
|
||||
let egl_name = b"eglGetProcAddress\0";
|
||||
let egl_ptr = libc::dlsym(std::ptr::null_mut(), egl_name.as_ptr() as *const _);
|
||||
if !egl_ptr.is_null() {
|
||||
let egl_fn: GetProcAddressFn = std::mem::transmute(egl_ptr);
|
||||
let ptr = egl_fn(name as *const u8);
|
||||
if !ptr.is_null() {
|
||||
return ptr;
|
||||
}
|
||||
}
|
||||
// 3. Fallback: dlsym
|
||||
libc::dlsym(std::ptr::null_mut(), name)
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_mpv_render_update(ctx_ptr: *mut std::ffi::c_void) {
|
||||
if !ctx_ptr.is_null() {
|
||||
let egui_ctx = &*(ctx_ptr as *const eframe::egui::Context);
|
||||
egui_ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
enum InternalMpvEvent {
|
||||
StartFile,
|
||||
FileLoaded,
|
||||
TimePos(f64),
|
||||
Duration(f64),
|
||||
Pause(bool),
|
||||
Volume(f64),
|
||||
Mute(bool),
|
||||
MediaTitle(String),
|
||||
IcyTitle(String),
|
||||
Metadata {
|
||||
artist: Option<String>,
|
||||
genre: Option<String>,
|
||||
stream_title: Option<String>,
|
||||
},
|
||||
EndFile {
|
||||
reason: i32,
|
||||
error: i32,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct PlayerController {
|
||||
_config: PlayerConfig,
|
||||
pub mpv: MpvHandle,
|
||||
pub render_ctx: Arc<std::sync::Mutex<Option<MpvRenderContext>>>,
|
||||
pub status: Arc<Mutex<PlayerStatus>>,
|
||||
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
|
||||
status_rx: tokio::sync::watch::Receiver<PlayerStatus>,
|
||||
pub window_id: Mutex<Option<u64>>,
|
||||
is_render_initialized: AtomicBool,
|
||||
is_running: Arc<AtomicBool>,
|
||||
event_thread: std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl PlayerController {
|
||||
pub fn new(config: PlayerConfig) -> Arc<Self> {
|
||||
let initial_status = PlayerStatus::default();
|
||||
let (status_tx, status_rx) = tokio::sync::watch::channel(initial_status.clone());
|
||||
|
||||
let raw_mpv = unsafe { libmpv_sys::mpv_create() };
|
||||
if raw_mpv.is_null() {
|
||||
tracing::error!("Failed to create libmpv instance!");
|
||||
}
|
||||
|
||||
let mpv = MpvHandle(raw_mpv);
|
||||
|
||||
// Configure MPV options
|
||||
mpv.set_option_string("vo", "libmpv");
|
||||
mpv.set_option_string("hwdec", &config.hwdec);
|
||||
mpv.set_option_string("idle", "yes");
|
||||
mpv.set_option_string("keep-open", "yes");
|
||||
mpv.set_option_string("osd-bar", "no");
|
||||
mpv.set_option_string("osd-level", "0");
|
||||
mpv.set_option_string("ytdl", "yes");
|
||||
|
||||
let ytdlp_path = if std::path::Path::new("/usr/local/bin/yt-dlp").exists() {
|
||||
"/usr/local/bin/yt-dlp"
|
||||
} else if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() {
|
||||
"/home/bart/.local/bin/yt-dlp"
|
||||
} else {
|
||||
"yt-dlp"
|
||||
};
|
||||
mpv.set_option_string("script-opts", &format!("ytdl_hook-ytdl_path={}", ytdlp_path));
|
||||
mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080]+bestaudio/best");
|
||||
mpv.set_option_string("ytdl-raw-options", "no-playlist=");
|
||||
|
||||
let init_res = unsafe { libmpv_sys::mpv_initialize(raw_mpv) };
|
||||
if init_res != 0 {
|
||||
tracing::warn!("mpv_initialize returned error code: {}", init_res);
|
||||
} else {
|
||||
tracing::info!("libmpv initialized successfully (vo=libmpv, hwdec={})", config.hwdec);
|
||||
}
|
||||
|
||||
// Observe properties
|
||||
unsafe {
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 1, b"time-pos\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 2, b"duration\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 3, b"pause\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 4, b"volume\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 5, b"media-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 6, b"icy-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 7, b"mute\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
|
||||
libmpv_sys::mpv_observe_property(raw_mpv, 8, b"metadata\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_NODE);
|
||||
}
|
||||
|
||||
let status_arc = Arc::new(Mutex::new(initial_status));
|
||||
let render_ctx = Arc::new(std::sync::Mutex::new(None));
|
||||
let is_running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
// Create internal event channel to decouple MPV C thread from Tokio async tasks
|
||||
let (ev_tx, mut ev_rx) = tokio::sync::mpsc::unbounded_channel::<InternalMpvEvent>();
|
||||
|
||||
// Spawn Tokio consumer task
|
||||
let status_consumer = status_arc.clone();
|
||||
let status_tx_consumer = status_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = ev_rx.recv().await {
|
||||
let mut st = status_consumer.lock().await;
|
||||
let mut notify = false;
|
||||
match event {
|
||||
InternalMpvEvent::StartFile | InternalMpvEvent::FileLoaded => {
|
||||
st.state = PlaybackState::Playing;
|
||||
st.error_message = None;
|
||||
notify = true;
|
||||
}
|
||||
InternalMpvEvent::TimePos(pos) => {
|
||||
st.time_pos = pos;
|
||||
}
|
||||
InternalMpvEvent::Duration(dur) => {
|
||||
st.duration = dur;
|
||||
}
|
||||
InternalMpvEvent::Pause(paused) => {
|
||||
let new_state = if paused { PlaybackState::Paused } else { PlaybackState::Playing };
|
||||
if st.state != new_state && st.state != PlaybackState::Stopped {
|
||||
st.state = new_state;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::Volume(vol) => {
|
||||
if (st.volume - vol).abs() > 0.01 {
|
||||
st.volume = vol;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::Mute(m) => {
|
||||
if st.is_muted != m {
|
||||
st.is_muted = m;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::MediaTitle(mt) => {
|
||||
if st.media_type == "stream" {
|
||||
if st.stream_title.is_empty() || st.stream_title != mt {
|
||||
st.stream_title = mt;
|
||||
notify = true;
|
||||
}
|
||||
} else if st.title.is_empty() {
|
||||
st.title = mt;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::IcyTitle(icy) => {
|
||||
if st.stream_title != icy {
|
||||
st.stream_title = icy;
|
||||
st.media_type = "stream".to_string();
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::Metadata { artist, genre, stream_title } => {
|
||||
if let Some(a) = artist {
|
||||
if st.artist != a {
|
||||
st.artist = a;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
if let Some(g) = genre {
|
||||
if st.genre != g {
|
||||
st.genre = g;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
if let Some(s) = stream_title {
|
||||
if st.stream_title != s {
|
||||
st.stream_title = s;
|
||||
st.media_type = "stream".to_string();
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
InternalMpvEvent::EndFile { reason, error } => {
|
||||
if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_ERROR as i32 {
|
||||
let err_msg = if error != 0 {
|
||||
let msg = unsafe {
|
||||
let p = libmpv_sys::mpv_error_string(error);
|
||||
if !p.is_null() {
|
||||
CStr::from_ptr(p).to_str().unwrap_or("Afspeelfout")
|
||||
} else {
|
||||
"Afspeelfout"
|
||||
}
|
||||
};
|
||||
format!("Afspeelfout: {}", msg)
|
||||
} else {
|
||||
"Afspeelfout".to_string()
|
||||
};
|
||||
tracing::warn!("MPV playback error: {}", err_msg);
|
||||
st.state = PlaybackState::Stopped;
|
||||
st.error_message = Some(err_msg);
|
||||
notify = true;
|
||||
} else if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32 {
|
||||
st.state = PlaybackState::Stopped;
|
||||
st.time_pos = 0.0;
|
||||
st.media_type.clear();
|
||||
st.title.clear();
|
||||
st.artist.clear();
|
||||
st.genre.clear();
|
||||
st.stream_title.clear();
|
||||
notify = true;
|
||||
}
|
||||
// Note: If reason == STOP (2) or REDIRECT (5), this was triggered by loadfile replacing
|
||||
// the previous file, so we intentionally keep the new file playing!
|
||||
}
|
||||
}
|
||||
if notify {
|
||||
let _ = status_tx_consumer.send(st.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn background event thread for MPV
|
||||
let mpv_for_thread = mpv;
|
||||
let is_running_for_thread = is_running.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
run_mpv_event_loop(mpv_for_thread, is_running_for_thread, ev_tx);
|
||||
});
|
||||
|
||||
Arc::new(Self {
|
||||
_config: config,
|
||||
mpv,
|
||||
render_ctx,
|
||||
status: status_arc,
|
||||
status_tx,
|
||||
status_rx,
|
||||
window_id: Mutex::new(None),
|
||||
is_render_initialized: AtomicBool::new(false),
|
||||
is_running,
|
||||
event_thread: std::sync::Mutex::new(Some(handle)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn status_watch_rx(&self) -> tokio::sync::watch::Receiver<PlayerStatus> {
|
||||
self.status_rx.clone()
|
||||
}
|
||||
|
||||
pub async fn notify_status(&self) {
|
||||
let st = self.status.lock().await.clone();
|
||||
let _ = self.status_tx.send(st);
|
||||
}
|
||||
|
||||
pub async fn set_window_id(&self, wid: Option<u64>) {
|
||||
let mut guard = self.window_id.lock().await;
|
||||
*guard = wid;
|
||||
}
|
||||
|
||||
pub fn render_context(&self) -> Option<MpvRenderContext> {
|
||||
*self.render_ctx.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn ensure_render_context(&self, ctx: &eframe::egui::Context) {
|
||||
if self.is_render_initialized.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.mpv.0.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let mut gl_init = libmpv_sys::mpv_opengl_init_params {
|
||||
get_proc_address: Some(get_proc_address_cb),
|
||||
get_proc_address_ctx: std::ptr::null_mut(),
|
||||
extra_exts: std::ptr::null(),
|
||||
};
|
||||
|
||||
let mut params = [
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
|
||||
data: libmpv_sys::MPV_RENDER_API_TYPE_OPENGL.as_ptr() as *mut _,
|
||||
},
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
|
||||
data: &mut gl_init as *mut _ as *mut _,
|
||||
},
|
||||
libmpv_sys::mpv_render_param {
|
||||
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_INVALID,
|
||||
data: std::ptr::null_mut(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut rc = std::ptr::null_mut();
|
||||
let res = libmpv_sys::mpv_render_context_create(&mut rc, self.mpv.0, params.as_mut_ptr());
|
||||
if res == 0 {
|
||||
tracing::info!("libmpv OpenGL render context created successfully!");
|
||||
let ctx_ptr = Box::into_raw(Box::new(ctx.clone())) as *mut std::ffi::c_void;
|
||||
libmpv_sys::mpv_render_context_set_update_callback(rc, Some(on_mpv_render_update), ctx_ptr);
|
||||
*self.render_ctx.lock().unwrap() = Some(MpvRenderContext(rc));
|
||||
} else {
|
||||
tracing::warn!("Failed to initialize libmpv OpenGL render context (code: {})", res);
|
||||
self.is_render_initialized.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn play(&self, url: &str, title: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let media_type = if url.contains("youtube.com") || url.contains("youtu.be") || (url.starts_with("http") && !url.contains("/Videos/") && !url.contains("/Audio/")) {
|
||||
"stream"
|
||||
} else {
|
||||
"video"
|
||||
};
|
||||
self.play_media(url, title, media_type, None, None).await
|
||||
}
|
||||
|
||||
pub async fn play_media(
|
||||
&self,
|
||||
url: &str,
|
||||
title: &str,
|
||||
media_type: &str,
|
||||
artist: Option<&str>,
|
||||
genre: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing::info!("Playing media [type={}]: '{}' -> {}", media_type, title, url);
|
||||
|
||||
// Update status immediately
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.state = PlaybackState::Playing;
|
||||
st.media_type = media_type.to_string();
|
||||
st.title = title.to_string();
|
||||
st.artist = artist.unwrap_or_default().to_string();
|
||||
st.genre = genre.unwrap_or_default().to_string();
|
||||
st.stream_title = if media_type == "stream" { title.to_string() } else { String::new() };
|
||||
st.time_pos = 0.0;
|
||||
st.error_message = None;
|
||||
}
|
||||
self.notify_status().await;
|
||||
|
||||
// Strip playlist/index params from single YouTube video playback
|
||||
let play_url = if url.contains("youtube.com") || url.contains("youtu.be") {
|
||||
let mut u = url.to_string();
|
||||
if let Some(pos) = u.find("&list=") {
|
||||
u.truncate(pos);
|
||||
}
|
||||
if let Some(pos) = u.find("&index=") {
|
||||
u.truncate(pos);
|
||||
}
|
||||
u
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
|
||||
// Always reset video and audio track selection to auto so subsequent content plays normally
|
||||
self.mpv.cmd(&["set", "vid", "auto"]);
|
||||
self.mpv.cmd(&["set", "aid", "auto"]);
|
||||
self.mpv.cmd(&["loadfile", &play_url, "replace"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn play_pause(&self) {
|
||||
self.mpv.cmd(&["cycle", "pause"]);
|
||||
}
|
||||
|
||||
pub async fn stop(&self) {
|
||||
self.mpv.cmd(&["stop"]);
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.state = PlaybackState::Stopped;
|
||||
st.media_type.clear();
|
||||
st.title.clear();
|
||||
st.artist.clear();
|
||||
st.genre.clear();
|
||||
st.stream_title.clear();
|
||||
st.time_pos = 0.0;
|
||||
st.duration = 0.0;
|
||||
}
|
||||
self.notify_status().await;
|
||||
}
|
||||
|
||||
pub async fn seek(&self, seconds: f64) {
|
||||
self.mpv.cmd(&["seek", &seconds.to_string(), "relative"]);
|
||||
}
|
||||
|
||||
pub async fn seek_percent(&self, percent: f64) {
|
||||
let p = percent.clamp(0.0, 100.0);
|
||||
self.mpv.cmd(&["seek", &p.to_string(), "absolute-percent"]);
|
||||
}
|
||||
|
||||
pub async fn seek_time(&self, seconds: f64) {
|
||||
let s = seconds.max(0.0);
|
||||
self.mpv.cmd(&["seek", &s.to_string(), "absolute"]);
|
||||
}
|
||||
|
||||
pub async fn toggle_mute(&self) {
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.is_muted = !st.is_muted;
|
||||
}
|
||||
self.mpv.cmd(&["cycle", "mute"]);
|
||||
self.notify_status().await;
|
||||
}
|
||||
|
||||
pub async fn set_mute(&self, muted: bool) {
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.is_muted = muted;
|
||||
}
|
||||
self.mpv.cmd(&["set", "mute", if muted { "yes" } else { "no" }]);
|
||||
self.notify_status().await;
|
||||
}
|
||||
|
||||
pub async fn set_volume(&self, volume: f64) {
|
||||
let vol = volume.clamp(0.0, 100.0);
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.volume = vol;
|
||||
}
|
||||
self.mpv.cmd(&["set", "volume", &vol.to_string()]);
|
||||
self.notify_status().await;
|
||||
}
|
||||
|
||||
pub async fn volume_up(&self) {
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.volume = (st.volume + 5.0).min(100.0);
|
||||
}
|
||||
self.mpv.cmd(&["add", "volume", "5"]);
|
||||
self.notify_status().await;
|
||||
}
|
||||
|
||||
pub async fn volume_down(&self) {
|
||||
{
|
||||
let mut st = self.status.lock().await;
|
||||
st.volume = (st.volume - 5.0).max(0.0);
|
||||
}
|
||||
self.mpv.cmd(&["add", "volume", "-5"]);
|
||||
self.notify_status().await;
|
||||
}
|
||||
|
||||
pub async fn cycle_subtitle(&self) {
|
||||
self.mpv.cmd(&["cycle", "sub"]);
|
||||
tracing::info!("Cycled subtitle track");
|
||||
}
|
||||
|
||||
pub async fn cycle_audio(&self) {
|
||||
self.mpv.cmd(&["cycle", "audio"]);
|
||||
tracing::info!("Cycled audio track");
|
||||
}
|
||||
|
||||
pub async fn set_audio_track(&self, track_id: i32) {
|
||||
self.mpv.cmd(&["set", "aid", &track_id.to_string()]);
|
||||
tracing::info!("Set audio track to aid={}", track_id);
|
||||
}
|
||||
|
||||
pub async fn set_video_visible(&self, _visible: bool) {
|
||||
// No-op with in-process libmpv OpenGL rendering; egui handles visibility directly.
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PlayerController {
|
||||
fn drop(&mut self) {
|
||||
self.is_running.store(false, Ordering::SeqCst);
|
||||
if !self.mpv.0.is_null() {
|
||||
unsafe {
|
||||
libmpv_sys::mpv_wakeup(self.mpv.0);
|
||||
}
|
||||
}
|
||||
if let Ok(mut guard) = self.event_thread.lock() {
|
||||
if let Some(handle) = guard.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
let rc_opt = self.render_ctx.lock().unwrap().take();
|
||||
if let Some(rc) = rc_opt {
|
||||
unsafe {
|
||||
libmpv_sys::mpv_render_context_free(rc.0);
|
||||
}
|
||||
}
|
||||
if !self.mpv.0.is_null() {
|
||||
unsafe {
|
||||
libmpv_sys::mpv_destroy(self.mpv.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_mpv_event_loop(
|
||||
mpv: MpvHandle,
|
||||
is_running: Arc<AtomicBool>,
|
||||
event_tx: tokio::sync::mpsc::UnboundedSender<InternalMpvEvent>,
|
||||
) {
|
||||
if mpv.0.is_null() {
|
||||
return;
|
||||
}
|
||||
|
||||
while is_running.load(Ordering::SeqCst) {
|
||||
let ev = unsafe { libmpv_sys::mpv_wait_event(mpv.0, 0.05) };
|
||||
if !is_running.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
if ev.is_null() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event_id = unsafe { (*ev).event_id };
|
||||
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_SHUTDOWN {
|
||||
break;
|
||||
}
|
||||
|
||||
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_START_FILE {
|
||||
let _ = event_tx.send(InternalMpvEvent::StartFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_FILE_LOADED {
|
||||
let _ = event_tx.send(InternalMpvEvent::FileLoaded);
|
||||
continue;
|
||||
}
|
||||
|
||||
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_END_FILE {
|
||||
let end_data = unsafe { (*ev).data as *const libmpv_sys::mpv_event_end_file };
|
||||
let (reason, error) = if !end_data.is_null() {
|
||||
unsafe { ((*end_data).reason, (*end_data).error) }
|
||||
} else {
|
||||
(libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32, 0)
|
||||
};
|
||||
let _ = event_tx.send(InternalMpvEvent::EndFile { reason, error });
|
||||
continue;
|
||||
}
|
||||
|
||||
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_PROPERTY_CHANGE {
|
||||
let prop = unsafe { (*ev).data as *const libmpv_sys::mpv_event_property };
|
||||
if prop.is_null() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prop_name = unsafe {
|
||||
if (*prop).name.is_null() {
|
||||
""
|
||||
} else {
|
||||
CStr::from_ptr((*prop).name).to_str().unwrap_or("")
|
||||
}
|
||||
};
|
||||
|
||||
match prop_name {
|
||||
"time-pos" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
|
||||
let pos = unsafe { *((*prop).data as *const f64) };
|
||||
let _ = event_tx.send(InternalMpvEvent::TimePos(pos));
|
||||
}
|
||||
}
|
||||
"duration" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
|
||||
let dur = unsafe { *((*prop).data as *const f64) };
|
||||
let _ = event_tx.send(InternalMpvEvent::Duration(dur));
|
||||
}
|
||||
}
|
||||
"pause" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
|
||||
let paused = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
|
||||
let _ = event_tx.send(InternalMpvEvent::Pause(paused));
|
||||
}
|
||||
}
|
||||
"volume" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
|
||||
let vol = unsafe { *((*prop).data as *const f64) };
|
||||
let _ = event_tx.send(InternalMpvEvent::Volume(vol));
|
||||
}
|
||||
}
|
||||
"mute" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
|
||||
let m = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
|
||||
let _ = event_tx.send(InternalMpvEvent::Mute(m));
|
||||
}
|
||||
}
|
||||
"media-title" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING } && !unsafe { (*prop).data.is_null() } {
|
||||
let str_ptr = unsafe { *((*prop).data as *const *const std::ffi::c_char) };
|
||||
if !str_ptr.is_null() {
|
||||
let mt = unsafe { CStr::from_ptr(str_ptr).to_str().unwrap_or("") }.trim();
|
||||
if !mt.is_empty() {
|
||||
let _ = event_tx.send(InternalMpvEvent::MediaTitle(mt.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"icy-title" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING } && !unsafe { (*prop).data.is_null() } {
|
||||
let str_ptr = unsafe { *((*prop).data as *const *const std::ffi::c_char) };
|
||||
if !str_ptr.is_null() {
|
||||
let icy = unsafe { CStr::from_ptr(str_ptr).to_str().unwrap_or("") }.trim();
|
||||
if !icy.is_empty() {
|
||||
let _ = event_tx.send(InternalMpvEvent::IcyTitle(icy.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"metadata" => {
|
||||
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_NODE } && !unsafe { (*prop).data.is_null() } {
|
||||
let node = unsafe { &*((*prop).data as *const libmpv_sys::mpv_node) };
|
||||
if node.format == libmpv_sys::mpv_format_MPV_FORMAT_NODE_MAP {
|
||||
let mut artist = None;
|
||||
let mut genre = None;
|
||||
let mut stream_title = None;
|
||||
unsafe {
|
||||
let list = &*node.u.list;
|
||||
for i in 0..list.num {
|
||||
let key_ptr = *list.keys.offset(i as isize);
|
||||
let val_node = list.values.offset(i as isize);
|
||||
if !key_ptr.is_null() && !val_node.is_null() && (*val_node).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING {
|
||||
let key = CStr::from_ptr(key_ptr).to_str().unwrap_or("");
|
||||
let val_ptr = (*val_node).u.string;
|
||||
if !val_ptr.is_null() {
|
||||
let val = CStr::from_ptr(val_ptr).to_str().unwrap_or("").trim();
|
||||
if !val.is_empty() {
|
||||
if key.eq_ignore_ascii_case("artist") {
|
||||
artist = Some(val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("genre") {
|
||||
genre = Some(val.to_string());
|
||||
} else if key.eq_ignore_ascii_case("icy-title") || key.eq_ignore_ascii_case("StreamTitle") {
|
||||
stream_title = Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if artist.is_some() || genre.is_some() || stream_title.is_some() {
|
||||
let _ = event_tx.send(InternalMpvEvent::Metadata { artist, genre, stream_title });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::{IntoResponse, Json},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::player::{PlaybackState, PlayerController};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteNavAction {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Select,
|
||||
Back,
|
||||
ToggleOSD,
|
||||
CycleSubtitle,
|
||||
CycleAudio,
|
||||
ToggleMute,
|
||||
TogglePlaybackView,
|
||||
ActivateSection(usize),
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
PlayPause,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteNavSender {
|
||||
tx: UnboundedSender<RemoteNavAction>,
|
||||
ctx: Arc<std::sync::Mutex<Option<eframe::egui::Context>>>,
|
||||
}
|
||||
|
||||
impl RemoteNavSender {
|
||||
pub fn new(tx: UnboundedSender<RemoteNavAction>, ctx: Arc<std::sync::Mutex<Option<eframe::egui::Context>>>) -> Self {
|
||||
Self { tx, ctx }
|
||||
}
|
||||
|
||||
pub fn send(&self, action: RemoteNavAction) -> Result<(), tokio::sync::mpsc::error::SendError<RemoteNavAction>> {
|
||||
let res = self.tx.send(action);
|
||||
if let Ok(guard) = self.ctx.lock() {
|
||||
if let Some(ref c) = *guard {
|
||||
c.request_repaint();
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UnboundedSender<RemoteNavAction>> for RemoteNavSender {
|
||||
fn from(tx: UnboundedSender<RemoteNavAction>) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
ctx: Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct YatseServerState {
|
||||
pub player: Arc<PlayerController>,
|
||||
pub nav_tx: RemoteNavSender,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[allow(dead_code)]
|
||||
jsonrpc: Option<String>,
|
||||
method: String,
|
||||
params: Option<Value>,
|
||||
id: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn create_yatse_router(state: YatseServerState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(handle_root))
|
||||
.route("/jsonrpc", post(handle_jsonrpc).get(handle_jsonrpc_get))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn handle_root() -> impl IntoResponse {
|
||||
"Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse"
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
let mut chars = input.bytes();
|
||||
while let Some(b) = chars.next() {
|
||||
if b == b'%' {
|
||||
let h1 = chars.next().unwrap_or(b'0');
|
||||
let h2 = chars.next().unwrap_or(b'0');
|
||||
let hex = [h1, h2];
|
||||
if let Ok(s) = std::str::from_utf8(&hex) {
|
||||
if let Ok(val) = u8::from_str_radix(s, 16) {
|
||||
bytes.push(val);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
bytes.push(b'%');
|
||||
bytes.push(h1);
|
||||
bytes.push(h2);
|
||||
} else if b == b'+' {
|
||||
bytes.push(b' ');
|
||||
} else {
|
||||
bytes.push(b);
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).to_string()
|
||||
}
|
||||
|
||||
pub fn normalize_playback_url(file: &str) -> String {
|
||||
let trimmed = file.trim();
|
||||
if trimmed.starts_with("plugin://plugin.video.youtube") {
|
||||
if let Some(query) = trimmed.split('?').nth(1) {
|
||||
for param in query.split('&') {
|
||||
let mut kv = param.splitn(2, '=');
|
||||
if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
|
||||
let k_lower = k.to_lowercase();
|
||||
if k_lower == "video_id" || k_lower == "videoid" {
|
||||
let vid = percent_decode(v);
|
||||
return format!("https://www.youtube.com/watch?v={}", vid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("plugin://plugin.video.sendtokodi") {
|
||||
if let Some(query) = trimmed.split('?').nth(1) {
|
||||
let decoded = percent_decode(query);
|
||||
if decoded.starts_with("http://") || decoded.starts_with("https://") {
|
||||
return decoded;
|
||||
}
|
||||
if let Some(url_part) = decoded.split("url=").nth(1) {
|
||||
return percent_decode(url_part);
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
async fn handle_jsonrpc_get(
|
||||
State(state): State<YatseServerState>,
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(query) = uri.query() {
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if let (Some(k), Some(v)) = (parts.next(), parts.next()) {
|
||||
if k == "request" {
|
||||
let decoded = percent_decode(v);
|
||||
if let Ok(val) = serde_json::from_str::<Value>(&decoded) {
|
||||
if let Some(arr) = val.as_array() {
|
||||
let mut results = Vec::new();
|
||||
for item in arr {
|
||||
if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(item.clone()) {
|
||||
results.push(dispatch_rpc(&state, parsed).await);
|
||||
}
|
||||
}
|
||||
return Json(Value::Array(results));
|
||||
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(val) {
|
||||
let res = dispatch_rpc(&state, parsed).await;
|
||||
return Json(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"version": {"major": 19, "minor": 0, "patch": 0}
|
||||
},
|
||||
"id": 1
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn start_tcp_jsonrpc_server(port: u16, state: YatseServerState) {
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(l) => {
|
||||
tracing::info!("Yatse Kodi TCP JSON-RPC socket server listening on {}", addr);
|
||||
l
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to bind TCP JSON-RPC server on {}: {}", addr, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (socket, peer) = match listener.accept().await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
tracing::warn!("Error accepting TCP JSON-RPC connection: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Yatse TCP JSON-RPC connection accepted from {}", peer);
|
||||
let state_clone = state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
let (reader, mut writer) = socket.into_split();
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match buf_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
|
||||
if let Some(arr) = val.as_array() {
|
||||
let mut results = Vec::new();
|
||||
for item in arr {
|
||||
if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(item.clone()) {
|
||||
results.push(dispatch_rpc(&state_clone, parsed).await);
|
||||
}
|
||||
}
|
||||
let out = serde_json::to_string(&Value::Array(results)).unwrap_or_default() + "\n";
|
||||
let _ = writer.write_all(out.as_bytes()).await;
|
||||
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(val) {
|
||||
let res = dispatch_rpc(&state_clone, parsed).await;
|
||||
let out = serde_json::to_string(&res).unwrap_or_default() + "\n";
|
||||
let _ = writer.write_all(out.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("TCP client {} read error: {}", peer, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!("Yatse TCP connection closed from {}", peer);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn handle_jsonrpc(
|
||||
State(state): State<YatseServerState>,
|
||||
Json(req): Json<Value>,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(arr) = req.as_array() {
|
||||
let mut results = Vec::new();
|
||||
for item in arr {
|
||||
if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(item.clone()) {
|
||||
results.push(dispatch_rpc(&state, parsed).await);
|
||||
}
|
||||
}
|
||||
Json(Value::Array(results))
|
||||
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(req) {
|
||||
let res = dispatch_rpc(&state, parsed).await;
|
||||
Json(res)
|
||||
} else {
|
||||
Json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32700, "message": "Parse error"},
|
||||
"id": Value::Null
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
||||
let id = req.id.unwrap_or(Value::Null);
|
||||
let method = req.method.as_str();
|
||||
tracing::info!("Yatse JSON-RPC: method={}, params={:?}", method, req.params);
|
||||
|
||||
match method {
|
||||
"JSONRPC.Ping" => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": "pong",
|
||||
"id": id
|
||||
}),
|
||||
"JSONRPC.Version" => json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {"version": {"major": 19, "minor": 0, "patch": 0}},
|
||||
"id": id
|
||||
}),
|
||||
"Application.GetProperties" => {
|
||||
let st = state.player.status.lock().await;
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"volume": st.volume as i64,
|
||||
"muted": st.is_muted,
|
||||
"name": "Kodi",
|
||||
"version": {"major": 19, "minor": 0, "revision": "20260401", "tag": "stable"},
|
||||
"language": "en_GB",
|
||||
"sorttokens": ["The ", "The.", "The_"]
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Application.SetMute" => {
|
||||
let mute_param = req.params.as_ref()
|
||||
.and_then(|p| p.get("mute").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|m| m.as_bool());
|
||||
|
||||
let is_muted = match mute_param {
|
||||
Some(m) => {
|
||||
state.player.set_mute(m).await;
|
||||
m
|
||||
}
|
||||
None => {
|
||||
state.player.toggle_mute().await;
|
||||
state.player.status.lock().await.is_muted
|
||||
}
|
||||
};
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": is_muted,
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Application.SetVolume" => {
|
||||
let vol_param = req.params.as_ref()
|
||||
.and_then(|p| p.get("volume").or_else(|| p.as_array().and_then(|a| a.first())));
|
||||
|
||||
let new_vol = if let Some(v_str) = vol_param.and_then(|v| v.as_str()) {
|
||||
if v_str == "increment" {
|
||||
state.player.volume_up().await;
|
||||
state.player.status.lock().await.volume as i64
|
||||
} else if v_str == "decrement" {
|
||||
state.player.volume_down().await;
|
||||
state.player.status.lock().await.volume as i64
|
||||
} else {
|
||||
state.player.status.lock().await.volume as i64
|
||||
}
|
||||
} else if let Some(v) = vol_param.and_then(|v| v.as_f64()) {
|
||||
state.player.set_volume(v).await;
|
||||
v.clamp(0.0, 100.0) as i64
|
||||
} else {
|
||||
state.player.status.lock().await.volume as i64
|
||||
};
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": new_vol,
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.GetActivePlayers" => {
|
||||
let st = state.player.status.lock().await;
|
||||
if st.state != PlaybackState::Stopped {
|
||||
let pid = if st.media_type == "audio" { 0 } else { 1 };
|
||||
let ptype = if st.media_type == "audio" { "audio" } else { "video" };
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": [{"playerid": pid, "playertype": "internal", "type": ptype}],
|
||||
"id": id
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": [],
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
}
|
||||
"Player.GetProperties" => {
|
||||
let st = state.player.status.lock().await;
|
||||
let time = seconds_to_kodi_time(st.time_pos);
|
||||
let totaltime = seconds_to_kodi_time(st.duration);
|
||||
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
|
||||
let ptype = if st.media_type == "audio" { "audio" } else { "video" };
|
||||
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"canseek": true,
|
||||
"canchangespeed": true,
|
||||
"canmove": false,
|
||||
"canzoom": false,
|
||||
"canrotate": false,
|
||||
"speed": speed,
|
||||
"time": time,
|
||||
"totaltime": totaltime,
|
||||
"percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 },
|
||||
"type": ptype,
|
||||
"live": false,
|
||||
"subtitleenabled": true,
|
||||
"subtitles": [
|
||||
{"index": 0, "name": "Ondertitel 1", "language": ""},
|
||||
{"index": 1, "name": "Ondertitel 2", "language": ""}
|
||||
],
|
||||
"currentsubtitle": {"index": 0, "name": "Ondertitel", "language": ""},
|
||||
"audiostreams": [
|
||||
{"index": 0, "name": "Audiostroom 1", "language": ""},
|
||||
{"index": 1, "name": "Audiostroom 2", "language": ""}
|
||||
],
|
||||
"currentaudiostream": {"index": 0, "name": "Audiostroom", "language": ""}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.GetItem" => {
|
||||
let st = state.player.status.lock().await;
|
||||
let title = if !st.stream_title.is_empty() {
|
||||
&st.stream_title
|
||||
} else if !st.title.is_empty() {
|
||||
&st.title
|
||||
} else {
|
||||
"Media"
|
||||
};
|
||||
let itype = if st.media_type == "audio" { "song" } else { "movie" };
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"item": {
|
||||
"label": title,
|
||||
"title": title,
|
||||
"artist": if !st.artist.is_empty() { vec![st.artist.clone()] } else { Vec::new() },
|
||||
"type": itype
|
||||
}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.PlayPause" => {
|
||||
state.player.play_pause().await;
|
||||
let st = state.player.status.lock().await;
|
||||
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {"speed": speed},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.Stop" => {
|
||||
state.player.stop().await;
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Stop);
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": "OK",
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.Seek" => {
|
||||
let mut result_pct = 0.0;
|
||||
if let Some(ref params) = req.params {
|
||||
let val = params.get("value").or_else(|| params.as_array().and_then(|a| a.get(1)));
|
||||
if let Some(v) = val {
|
||||
if let Some(pct) = v.get("percentage").and_then(|p| p.as_f64()) {
|
||||
state.player.seek_percent(pct).await;
|
||||
result_pct = pct;
|
||||
} else if let Some(time_obj) = v.get("time") {
|
||||
let h = time_obj.get("hours").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||
let m = time_obj.get("minutes").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||
let s = time_obj.get("seconds").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||
let total_secs = h * 3600.0 + m * 60.0 + s;
|
||||
state.player.seek_time(total_secs).await;
|
||||
} else if let Some(step) = v.as_str() {
|
||||
match step.to_lowercase().as_str() {
|
||||
"smallforward" => { state.player.seek(10.0).await; }
|
||||
"smallbackward" => { state.player.seek(-10.0).await; }
|
||||
"bigforward" => { state.player.seek(60.0).await; }
|
||||
"bigbackward" => { state.player.seek(-60.0).await; }
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(n) = v.as_f64() {
|
||||
if n >= 0.0 && n <= 100.0 {
|
||||
state.player.seek_percent(n).await;
|
||||
result_pct = n;
|
||||
} else {
|
||||
state.player.seek(n).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let st = state.player.status.lock().await;
|
||||
let time = seconds_to_kodi_time(st.time_pos);
|
||||
let totaltime = seconds_to_kodi_time(st.duration);
|
||||
let pct = if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { result_pct };
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"percentage": pct,
|
||||
"time": time,
|
||||
"totaltime": totaltime
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Player.SetSubtitle" => {
|
||||
state.player.cycle_subtitle().await;
|
||||
let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Player.SetAudioStream" => {
|
||||
if let Some(ref params) = req.params {
|
||||
let stream_param = params.get("stream").or_else(|| params.as_array().and_then(|a| a.get(1)));
|
||||
if let Some(s) = stream_param {
|
||||
if let Some(idx) = s.as_i64() {
|
||||
state.player.set_audio_track((idx + 1) as i32).await;
|
||||
} else if s.as_str() == Some("next") {
|
||||
state.player.cycle_audio().await;
|
||||
} else if s.as_str() == Some("previous") {
|
||||
state.player.cycle_audio().await;
|
||||
}
|
||||
} else {
|
||||
state.player.cycle_audio().await;
|
||||
}
|
||||
} else {
|
||||
state.player.cycle_audio().await;
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Player.SetSpeed" => {
|
||||
if let Some(ref params) = req.params {
|
||||
let speed_val = params.get("speed").or_else(|| params.as_array().and_then(|a| a.get(1)));
|
||||
if let Some(s) = speed_val.and_then(|v| v.as_i64()) {
|
||||
if s == 0 {
|
||||
let st = state.player.status.lock().await;
|
||||
if st.state == PlaybackState::Playing {
|
||||
drop(st);
|
||||
state.player.play_pause().await;
|
||||
}
|
||||
} else if s == 1 {
|
||||
let st = state.player.status.lock().await;
|
||||
if st.state == PlaybackState::Paused {
|
||||
drop(st);
|
||||
state.player.play_pause().await;
|
||||
}
|
||||
}
|
||||
} else if let Some(s_str) = speed_val.and_then(|v| v.as_str()) {
|
||||
if s_str == "increment" || s_str == "decrement" {
|
||||
state.player.play_pause().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let st = state.player.status.lock().await;
|
||||
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
|
||||
json!({"jsonrpc": "2.0", "result": {"speed": speed}, "id": id})
|
||||
}
|
||||
"Player.Open" => {
|
||||
if let Some(params) = req.params {
|
||||
if let Some(item) = params.get("item") {
|
||||
if let Some(file) = item.get("file").and_then(|f| f.as_str()) {
|
||||
let norm_url = normalize_playback_url(file);
|
||||
tracing::info!("Player.Open requested file: '{}' -> normalized: '{}'", file, norm_url);
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = p.play(&norm_url, "Media from Yatse").await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": "OK",
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
// Remote D-Pad inputs forwarded directly to the on-screen UI
|
||||
"Input.Up" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Up);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Down" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Down);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Left" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Left);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Right" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Right);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Select" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Select);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.Back" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Back);
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.ContextMenu" | "Input.Info" => {
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Input.ExecuteAction" => {
|
||||
let raw_action = req.params.as_ref()
|
||||
.and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("");
|
||||
let action = if raw_action.to_lowercase().starts_with("action(") && raw_action.ends_with(')') {
|
||||
raw_action[7..raw_action.len() - 1].trim().to_lowercase()
|
||||
} else {
|
||||
raw_action.to_lowercase()
|
||||
};
|
||||
match action.as_str() {
|
||||
"up" | "cursorup" | "3" => { let _ = state.nav_tx.send(RemoteNavAction::Up); }
|
||||
"down" | "cursordown" | "4" => { let _ = state.nav_tx.send(RemoteNavAction::Down); }
|
||||
"left" | "cursorleft" | "1" | "stepback" | "smallstepback" => { let _ = state.nav_tx.send(RemoteNavAction::Left); }
|
||||
"right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => { let _ = state.nav_tx.send(RemoteNavAction::Right); }
|
||||
"bigstepforward" => {
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move { p.seek(60.0).await; });
|
||||
}
|
||||
"bigstepback" => {
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move { p.seek(-60.0).await; });
|
||||
}
|
||||
"select" | "enter" | "7" => { let _ = state.nav_tx.send(RemoteNavAction::Select); }
|
||||
"back" | "previousmenu" | "parentdir" | "close" | "9" | "10" | "escape" => { let _ = state.nav_tx.send(RemoteNavAction::Back); }
|
||||
"togglefullscreen" | "12" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); }
|
||||
"osd" | "nextsubtitle" | "114" | "122" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); }
|
||||
"audionextlanguage" | "nextaudio" => {
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move { p.cycle_audio().await; });
|
||||
}
|
||||
"title" | "menu" | "11" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); }
|
||||
"volumeup" | "88" => {
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move { p.volume_up().await; });
|
||||
}
|
||||
"volumedown" | "89" => {
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move { p.volume_down().await; });
|
||||
}
|
||||
"playpause" | "pause" | "play" | "15" => { state.player.play_pause().await; }
|
||||
"stop" | "14" => {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::Stop);
|
||||
}
|
||||
"mute" | "togglemute" | "13" => {
|
||||
let p = state.player.clone();
|
||||
tokio::spawn(async move { p.toggle_mute().await; });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"GUI.ActivateWindow" => {
|
||||
if let Some(params) = req.params {
|
||||
let p_str = params.to_string().to_lowercase();
|
||||
if p_str.contains("tvshow") || p_str.contains("series") {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3));
|
||||
} else if p_str.contains("movietitles") || p_str.contains("movie") || p_str.contains("video") {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2));
|
||||
} else if p_str.contains("music") || p_str.contains("song") || p_str.contains("artist") || p_str.contains("album") {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4));
|
||||
} else if p_str.contains("picture") || p_str.contains("photo") {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(5));
|
||||
} else if p_str.contains("livetv") || p_str.contains("channel") || p_str.contains("tv") {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1));
|
||||
} else if p_str.contains("home") {
|
||||
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(0));
|
||||
}
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"XBMC.GetInfoBooleans" => {
|
||||
let mut res = serde_json::Map::new();
|
||||
let mut requested = Vec::new();
|
||||
|
||||
if let Some(ref params) = req.params {
|
||||
if let Some(arr) = params.get("booleans").and_then(|b| b.as_array()) {
|
||||
for b in arr {
|
||||
if let Some(s) = b.as_str() {
|
||||
requested.push(s.to_string());
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = params.as_array().and_then(|a| a.first()).and_then(|b| b.as_array()) {
|
||||
for b in arr {
|
||||
if let Some(s) = b.as_str() {
|
||||
requested.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for s in &requested {
|
||||
let val = match s.as_str() {
|
||||
"Services.EventServer" | "Services.Webserver" | "Services.UPnP" => true,
|
||||
"System.Platform.Linux" => true,
|
||||
s if s.starts_with("System.Platform.") => false,
|
||||
_ => true,
|
||||
};
|
||||
res.insert(s.clone(), json!(val));
|
||||
}
|
||||
|
||||
if res.is_empty() {
|
||||
res.insert("Services.EventServer".to_string(), json!(true));
|
||||
res.insert("Services.Webserver".to_string(), json!(true));
|
||||
res.insert("System.Platform.Linux".to_string(), json!(true));
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": res, "id": id})
|
||||
}
|
||||
"XBMC.GetInfoLabels" => {
|
||||
let mut res = serde_json::Map::new();
|
||||
let mut requested = Vec::new();
|
||||
|
||||
if let Some(ref params) = req.params {
|
||||
if let Some(arr) = params.get("labels").and_then(|b| b.as_array()) {
|
||||
for b in arr {
|
||||
if let Some(s) = b.as_str() {
|
||||
requested.push(s.to_string());
|
||||
}
|
||||
}
|
||||
} else if let Some(arr) = params.as_array().and_then(|a| a.first()).and_then(|b| b.as_array()) {
|
||||
for b in arr {
|
||||
if let Some(s) = b.as_str() {
|
||||
requested.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for s in &requested {
|
||||
let val = match s.as_str() {
|
||||
"System.BuildVersion" => "19.4 (19.4.0) Kodi",
|
||||
"System.FriendlyName" => "HTPC",
|
||||
"System.KernelVersion" => "Linux 6.8.0",
|
||||
_ => "",
|
||||
};
|
||||
res.insert(s.clone(), json!(val));
|
||||
}
|
||||
json!({"jsonrpc": "2.0", "result": res, "id": id})
|
||||
}
|
||||
"Settings.GetSettingValue" => {
|
||||
let setting = req.params.as_ref()
|
||||
.and_then(|p| p.get("setting").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let val = if setting.contains("port") {
|
||||
if setting.contains("es") { json!(9777) } else { json!(8080) }
|
||||
} else if setting.contains("passthrough") {
|
||||
// Audio passthrough is FALSE, so standard volume controls work!
|
||||
json!(false)
|
||||
} else {
|
||||
json!(true)
|
||||
};
|
||||
json!({"jsonrpc": "2.0", "result": {"value": val}, "id": id})
|
||||
}
|
||||
"Settings.GetSettings" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"settings": [
|
||||
{"id": "services.eventserver", "value": true},
|
||||
{"id": "services.esenabled", "value": true},
|
||||
{"id": "services.esport", "value": 9777},
|
||||
{"id": "services.esallinterfaces", "value": true},
|
||||
{"id": "services.webserver", "value": true},
|
||||
{"id": "services.webserverport", "value": 8080},
|
||||
{"id": "audiooutput.passthrough", "value": false}
|
||||
]
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Settings.SetSettingValue" => {
|
||||
json!({"jsonrpc": "2.0", "result": true, "id": id})
|
||||
}
|
||||
"JSONRPC.Permission" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"ControlGUI": true,
|
||||
"ControlNotify": true,
|
||||
"ControlPlayback": true,
|
||||
"ControlPower": true,
|
||||
"ControlSystem": true,
|
||||
"ExecuteAddon": true,
|
||||
"ManageFiles": true,
|
||||
"Navigate": true,
|
||||
"ReadData": true,
|
||||
"WriteData": true
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"GUI.GetProperties" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"currentwindow": {"id": 10000, "label": "Home"},
|
||||
"fullscreen": true,
|
||||
"skin": {"id": "skin.estuary", "name": "Estuary"}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"System.GetProperties" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"canreboot": true,
|
||||
"canshutdown": true,
|
||||
"cansuspend": false
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"GUI.ShowNotification" => {
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Addons.GetAddonDetails" => {
|
||||
let addon_id = req.params.as_ref()
|
||||
.and_then(|p| p.get("addonid").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("plugin.video.youtube");
|
||||
|
||||
let (name, a_type, version) = match addon_id {
|
||||
"plugin.video.youtube" => ("YouTube", "xbmc.python.pluginsource", "7.0.0"),
|
||||
"script.yatse.kodi" => ("Yatse Kodi Helper Script", "xbmc.service", "2.0.0"),
|
||||
"plugin.video.sendtokodi" | "plugin.video.sendtokodi.python3" => ("SendToKodi", "xbmc.python.pluginsource", "1.0.0"),
|
||||
other => (other, "xbmc.python.pluginsource", "1.0.0"),
|
||||
};
|
||||
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"addon": {
|
||||
"addonid": addon_id,
|
||||
"name": name,
|
||||
"type": a_type,
|
||||
"version": version,
|
||||
"summary": name,
|
||||
"description": name,
|
||||
"author": "Yatse / Community",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Addons.GetAddons" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"addons": [
|
||||
{
|
||||
"addonid": "plugin.video.youtube",
|
||||
"name": "YouTube",
|
||||
"type": "xbmc.python.pluginsource",
|
||||
"version": "7.0.0",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"addonid": "script.yatse.kodi",
|
||||
"name": "Yatse Kodi Helper Script",
|
||||
"type": "xbmc.service",
|
||||
"version": "2.0.0",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"addonid": "plugin.video.sendtokodi",
|
||||
"name": "SendToKodi",
|
||||
"type": "xbmc.python.pluginsource",
|
||||
"version": "1.0.0",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"limits": {
|
||||
"start": 0,
|
||||
"end": 3,
|
||||
"total": 3
|
||||
}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Addons.ExecuteAddon" => {
|
||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||
}
|
||||
"Files.GetSources" => {
|
||||
let media = req.params.as_ref()
|
||||
.and_then(|p| p.get("media").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("video");
|
||||
|
||||
let sources = if media == "video" {
|
||||
vec![
|
||||
json!({
|
||||
"file": "plugin://plugin.video.youtube/",
|
||||
"label": "YouTube",
|
||||
"media": "video"
|
||||
})
|
||||
]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"sources": sources,
|
||||
"limits": {"start": 0, "end": 1, "total": 1}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
"Files.GetDirectory" => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"files": [],
|
||||
"limits": {"start": 0, "end": 0, "total": 0}
|
||||
},
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"result": "OK",
|
||||
"id": id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds_to_kodi_time(seconds: f64) -> Value {
|
||||
let s = seconds.max(0.0) as u64;
|
||||
let hours = s / 3600;
|
||||
let minutes = (s % 3600) / 60;
|
||||
let secs = s % 60;
|
||||
let millis = ((seconds - s as f64) * 1000.0) as u64;
|
||||
json!({
|
||||
"hours": hours,
|
||||
"minutes": minutes,
|
||||
"seconds": secs,
|
||||
"milliseconds": millis
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_playback_url() {
|
||||
assert_eq!(
|
||||
normalize_playback_url("plugin://plugin.video.youtube/play/?video_id=6G45Qk0HQNY"),
|
||||
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_playback_url("plugin://plugin.video.youtube/?action=play_video&videoid=6G45Qk0HQNY"),
|
||||
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_playback_url("plugin://plugin.video.sendtokodi/?https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3D6G45Qk0HQNY"),
|
||||
"https://youtube.com/watch?v=6G45Qk0HQNY"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"),
|
||||
"https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::config::YouTubeConfig;
|
||||
use crate::jellyfin::MediaItem;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_url: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredToken {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: u64,
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DevicePollResult {
|
||||
Pending,
|
||||
SlowDown,
|
||||
Success,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct YouTubeClient {
|
||||
config: YouTubeConfig,
|
||||
client: reqwest::Client,
|
||||
token: Arc<RwLock<Option<StoredToken>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SubscriptionsResponse {
|
||||
items: Option<Vec<SubscriptionItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SubscriptionItem {
|
||||
snippet: Option<SubscriptionSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SubscriptionSnippet {
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
#[serde(rename = "resourceId")]
|
||||
resource_id: Option<ResourceId>,
|
||||
thumbnails: Option<Thumbnails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResourceId {
|
||||
#[serde(rename = "channelId")]
|
||||
channel_id: Option<String>,
|
||||
#[serde(rename = "videoId")]
|
||||
video_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Thumbnails {
|
||||
default: Option<ThumbnailItem>,
|
||||
medium: Option<ThumbnailItem>,
|
||||
high: Option<ThumbnailItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThumbnailItem {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PlaylistItemsResponse {
|
||||
items: Option<Vec<PlaylistItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PlaylistItem {
|
||||
snippet: Option<PlaylistItemSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PlaylistItemSnippet {
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
#[serde(rename = "publishedAt")]
|
||||
published_at: Option<String>,
|
||||
#[serde(rename = "channelTitle")]
|
||||
channel_title: Option<String>,
|
||||
#[serde(rename = "resourceId")]
|
||||
resource_id: Option<ResourceId>,
|
||||
thumbnails: Option<Thumbnails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchResponse {
|
||||
items: Option<Vec<SearchItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchItem {
|
||||
id: Option<ResourceId>,
|
||||
snippet: Option<PlaylistItemSnippet>,
|
||||
}
|
||||
|
||||
fn best_thumbnail(t: &Option<Thumbnails>) -> Option<String> {
|
||||
if let Some(t) = t {
|
||||
if let Some(h) = &t.high {
|
||||
return Some(h.url.clone());
|
||||
}
|
||||
if let Some(m) = &t.medium {
|
||||
return Some(m.url.clone());
|
||||
}
|
||||
if let Some(d) = &t.default {
|
||||
return Some(d.url.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_token_file(path_str: &str) -> PathBuf {
|
||||
if path_str.starts_with("~/") {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
return PathBuf::from(home).join(&path_str[2..]);
|
||||
}
|
||||
}
|
||||
PathBuf::from(path_str)
|
||||
}
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
impl YouTubeClient {
|
||||
pub fn new(config: YouTubeConfig) -> Self {
|
||||
let mut initial_token = None;
|
||||
let token_path = resolve_token_file(&config.token_path);
|
||||
if token_path.exists() {
|
||||
if let Ok(data) = std::fs::read_to_string(&token_path) {
|
||||
if let Ok(t) = serde_json::from_str::<StoredToken>(&data) {
|
||||
tracing::info!("Loaded cached YouTube token from {}", token_path.display());
|
||||
initial_token = Some(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
config,
|
||||
client: reqwest::Client::builder().build().unwrap_or_default(),
|
||||
token: Arc::new(RwLock::new(initial_token)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_configured(&self) -> bool {
|
||||
!self.config.client_id.trim().is_empty()
|
||||
}
|
||||
|
||||
pub async fn is_logged_in(&self) -> bool {
|
||||
let guard = self.token.read().await;
|
||||
if let Some(t) = guard.as_ref() {
|
||||
t.expires_at > current_timestamp() || t.refresh_token.is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(&self) {
|
||||
{
|
||||
let mut guard = self.token.write().await;
|
||||
*guard = None;
|
||||
}
|
||||
let token_path = resolve_token_file(&self.config.token_path);
|
||||
if token_path.exists() {
|
||||
let _ = std::fs::remove_file(token_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_token(&self, token: &StoredToken) {
|
||||
let token_path = resolve_token_file(&self.config.token_path);
|
||||
if let Some(parent) = token_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(data) = serde_json::to_string_pretty(token) {
|
||||
let _ = std::fs::write(&token_path, data);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_device_auth(&self) -> Result<DeviceCodeResponse, String> {
|
||||
if !self.is_configured() {
|
||||
return Err("YouTube Client ID is niet ingesteld in config.yaml".to_string());
|
||||
}
|
||||
|
||||
tracing::info!("Starting YouTube OAuth Device Code flow for client_id: {}...", self.config.client_id);
|
||||
|
||||
let params = [
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
("scope", "https://www.googleapis.com/auth/youtube.readonly"),
|
||||
];
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post("https://oauth2.googleapis.com/device/code")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Network error starting YouTube device auth: {}", e);
|
||||
format!("Netwerkfout bij starten authenticatie: {}", e)
|
||||
})?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
tracing::error!("Google OAuth device/code error (status {}): {}", status, text);
|
||||
if text.contains("Invalid client type") {
|
||||
return Err("Ongeldig OAuth Client ID type. Kies in Google Cloud Console bij het aanmaken van het Client ID het type: 'Tv's en apparaten met beperkte invoer' (TVs and Limited Input devices).".to_string());
|
||||
}
|
||||
return Err(format!("Google API fout ({}): {}", status, text));
|
||||
}
|
||||
|
||||
let resp: DeviceCodeResponse = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to parse Google response: {}", e);
|
||||
format!("Ongeldig antwoord van Google: {}", e)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"YouTube Device Code generated: user_code='{}', url='{}'",
|
||||
resp.user_code,
|
||||
resp.verification_url
|
||||
);
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn poll_device_token(&self, device_code: &str) -> DevicePollResult {
|
||||
let mut params = vec![
|
||||
("client_id", self.config.client_id.clone()),
|
||||
("device_code", device_code.to_string()),
|
||||
(
|
||||
"grant_type",
|
||||
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(secret) = &self.config.client_secret {
|
||||
if !secret.trim().is_empty() {
|
||||
params.push(("client_secret", secret.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let res = match self
|
||||
.client
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.form(¶ms)
|
||||
.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(¶ms)
|
||||
.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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"access_token": "ya29.a0AehfTOkVjtpy5kB4TQRuD7E0MXJAKSipRjAkysg0HnjvBvZlnb1y2WQ6UCSPxOLPliLxdri6A9AB_MlgMPHp1AXnsKHXL3dA_tkVYp3yuAhud4nUCniG0WDpgUpJ_paOAGYf5vtiJqDprd7W_8612JfnVwKW2ymR1ptNJpJ-aZivVvMsebsJcjDruq05haZGvmQtTkLTaCgYKAYESARESFQHGX2MiCxxRMG9_Lvhm7KGNtUmMIw0207",
|
||||
"refresh_token": "1//09y6yuAbCKpGPCgYIARAAGAkSNwF-L9Ira4vVAsGF3K-oU7bEma5ZfUMy87MfInMEA_RvnDhxi_koUBGHGuLLAR5tbB9UZ6jcQOk",
|
||||
"expires_at": 1793817948,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
REMOTE_HOST="${REMOTE_HOST:-bart-workstation.lan}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-~/builds/jellyfin-client}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CLIENT_DIR="${SCRIPT_DIR}"
|
||||
KIOSK_DIR="${SCRIPT_DIR}/jellyfin-kiosk-rs"
|
||||
|
||||
COMMAND="${1:-release}"
|
||||
shift || true
|
||||
|
||||
case "${COMMAND}" in
|
||||
check)
|
||||
CARGO_CMD="cargo check $*"
|
||||
FETCH_BIN=false
|
||||
;;
|
||||
build)
|
||||
CARGO_CMD="cargo build $*"
|
||||
FETCH_BIN=false
|
||||
;;
|
||||
release)
|
||||
CARGO_CMD="cargo build --release $*"
|
||||
FETCH_BIN=true
|
||||
;;
|
||||
test)
|
||||
CARGO_CMD="cargo test $*"
|
||||
FETCH_BIN=false
|
||||
;;
|
||||
cargo)
|
||||
CARGO_CMD="cargo $*"
|
||||
FETCH_BIN=false
|
||||
;;
|
||||
*)
|
||||
CARGO_CMD="cargo ${COMMAND} $*"
|
||||
FETCH_BIN=false
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "==> 📡 Syncing source code to ${REMOTE_HOST}:${REMOTE_DIR}..."
|
||||
rsync -az --delete \
|
||||
--exclude 'target' \
|
||||
--exclude '.git' \
|
||||
--exclude '.venv' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '.pytest_cache' \
|
||||
"${CLIENT_DIR}/" "${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||
|
||||
echo "==> 🚀 Running on ${REMOTE_HOST}: ${CARGO_CMD}..."
|
||||
ssh -t "${REMOTE_HOST}" "cd ${REMOTE_DIR}/jellyfin-kiosk-rs && ${CARGO_CMD}"
|
||||
|
||||
if [ "${FETCH_BIN}" = true ]; then
|
||||
echo "==> 📥 Downloading compiled release binary to laptop..."
|
||||
mkdir -p "${KIOSK_DIR}/target/release"
|
||||
rsync -avz "${REMOTE_HOST}:${REMOTE_DIR}/jellyfin-kiosk-rs/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/target/release/"
|
||||
install -m 755 "${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/jellyfin-kiosk"
|
||||
echo "==> ✅ Klaar! Binary beschikbaar in:"
|
||||
echo " - ${KIOSK_DIR}/target/release/jellyfin-kiosk-rs"
|
||||
echo " - ${KIOSK_DIR}/jellyfin-kiosk"
|
||||
fi
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class JellyfinConfig:
|
||||
server_url: str = "http://127.0.0.1:8096"
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
token: Optional[str] = None
|
||||
client_name: str = "Jellyfin HTPC Client"
|
||||
device_name: str = "HTPC"
|
||||
device_id: str = "htpc-default-01"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerConfig:
|
||||
mpv_path: str = "mpv"
|
||||
ipc_socket: str = "/tmp/mpv-jellyfin.sock"
|
||||
hwdec: str = "auto"
|
||||
vo: str = "gpu"
|
||||
fullscreen: bool = True
|
||||
audio_device: str = "auto"
|
||||
extra_args: List[str] = field(default_factory=lambda: [
|
||||
"--keep-open=yes",
|
||||
"--force-window=immediate",
|
||||
"--osd-font=sans-serif",
|
||||
"--osd-font-size=28",
|
||||
])
|
||||
|
||||
|
||||
@dataclass
|
||||
class KodiRpcConfig:
|
||||
enabled: bool = True
|
||||
http_port: int = 8080
|
||||
ws_port: int = 9090
|
||||
event_port: int = 9777
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebApiConfig:
|
||||
enabled: bool = True
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8097
|
||||
|
||||
|
||||
@dataclass
|
||||
class YoutubeConfig:
|
||||
max_height: int = 1080
|
||||
fetch_metadata: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
jellyfin: JellyfinConfig = field(default_factory=JellyfinConfig)
|
||||
player: PlayerConfig = field(default_factory=PlayerConfig)
|
||||
kodi_rpc: KodiRpcConfig = field(default_factory=KodiRpcConfig)
|
||||
web_api: WebApiConfig = field(default_factory=WebApiConfig)
|
||||
youtube: YoutubeConfig = field(default_factory=YoutubeConfig)
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path: Optional[str | Path] = None) -> AppConfig:
|
||||
paths_to_check = []
|
||||
if config_path:
|
||||
paths_to_check.append(Path(config_path))
|
||||
if env_path := os.environ.get("JELLYFIN_CLIENT_CONFIG"):
|
||||
paths_to_check.append(Path(env_path))
|
||||
|
||||
paths_to_check.extend([
|
||||
Path.cwd() / "config.yaml",
|
||||
Path.home() / ".config" / "jellyfin-client" / "config.yaml",
|
||||
Path("/etc/jellyfin-client/config.yaml"),
|
||||
])
|
||||
|
||||
data: Dict[str, Any] = {}
|
||||
found_path = None
|
||||
for p in paths_to_check:
|
||||
if p.is_file():
|
||||
found_path = p
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
break
|
||||
|
||||
cfg = cls()
|
||||
if not data:
|
||||
return cfg
|
||||
|
||||
if "jellyfin" in data:
|
||||
jf = data["jellyfin"]
|
||||
cfg.jellyfin = JellyfinConfig(
|
||||
server_url=jf.get("server_url", cfg.jellyfin.server_url).rstrip("/"),
|
||||
username=jf.get("username", cfg.jellyfin.username),
|
||||
password=jf.get("password", cfg.jellyfin.password),
|
||||
token=jf.get("token", cfg.jellyfin.token),
|
||||
client_name=jf.get("client_name", cfg.jellyfin.client_name),
|
||||
device_name=jf.get("device_name", cfg.jellyfin.device_name),
|
||||
device_id=jf.get("device_id", cfg.jellyfin.device_id),
|
||||
)
|
||||
|
||||
if "player" in data:
|
||||
pl = data["player"]
|
||||
cfg.player = PlayerConfig(
|
||||
mpv_path=pl.get("mpv_path", cfg.player.mpv_path),
|
||||
ipc_socket=pl.get("ipc_socket", cfg.player.ipc_socket),
|
||||
hwdec=pl.get("hwdec", cfg.player.hwdec),
|
||||
vo=pl.get("vo", cfg.player.vo),
|
||||
fullscreen=pl.get("fullscreen", cfg.player.fullscreen),
|
||||
audio_device=pl.get("audio_device", cfg.player.audio_device),
|
||||
extra_args=pl.get("extra_args", cfg.player.extra_args),
|
||||
)
|
||||
|
||||
if "kodi_rpc" in data:
|
||||
kr = data["kodi_rpc"]
|
||||
cfg.kodi_rpc = KodiRpcConfig(
|
||||
enabled=kr.get("enabled", cfg.kodi_rpc.enabled),
|
||||
http_port=kr.get("http_port", cfg.kodi_rpc.http_port),
|
||||
ws_port=kr.get("ws_port", cfg.kodi_rpc.ws_port),
|
||||
)
|
||||
|
||||
if "web_api" in data:
|
||||
wa = data["web_api"]
|
||||
cfg.web_api = WebApiConfig(
|
||||
enabled=wa.get("enabled", cfg.web_api.enabled),
|
||||
host=wa.get("host", cfg.web_api.host),
|
||||
port=wa.get("port", cfg.web_api.port),
|
||||
)
|
||||
|
||||
if "youtube" in data:
|
||||
yt = data["youtube"]
|
||||
cfg.youtube = YoutubeConfig(
|
||||
max_height=yt.get("max_height", cfg.youtube.max_height),
|
||||
fetch_metadata=yt.get("fetch_metadata", cfg.youtube.fetch_metadata),
|
||||
)
|
||||
|
||||
# Environment variable overrides (useful for Docker & CI)
|
||||
if jf_url := os.environ.get("JELLYFIN_SERVER_URL"):
|
||||
cfg.jellyfin.server_url = jf_url.rstrip("/")
|
||||
if jf_user := os.environ.get("JELLYFIN_USERNAME"):
|
||||
cfg.jellyfin.username = jf_user
|
||||
if jf_pass := os.environ.get("JELLYFIN_PASSWORD"):
|
||||
cfg.jellyfin.password = jf_pass
|
||||
if jf_dev := os.environ.get("JELLYFIN_DEVICE_NAME"):
|
||||
cfg.jellyfin.device_name = jf_dev
|
||||
|
||||
if mpv_vo := os.environ.get("MPV_VO"):
|
||||
cfg.player.vo = mpv_vo
|
||||
if mpv_hwdec := os.environ.get("MPV_HWDEC"):
|
||||
cfg.player.hwdec = mpv_hwdec
|
||||
if mpv_fs := os.environ.get("MPV_FULLSCREEN"):
|
||||
cfg.player.fullscreen = mpv_fs.lower() in ("1", "true", "yes")
|
||||
if mpv_ao := os.environ.get("MPV_AO"):
|
||||
cfg.player.audio_device = mpv_ao
|
||||
|
||||
return cfg
|
||||
@@ -1,349 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
import aiohttp
|
||||
|
||||
from src.config import JellyfinConfig
|
||||
from src.player.models import MediaMetadata, PlaybackState, PlayerStatus
|
||||
from src.player.mpv_controller import MpvController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TICKS_PER_SECOND = 10_000_000
|
||||
|
||||
|
||||
class JellyfinClient:
|
||||
"""Handles communication with the Jellyfin server, session registration, and remote control commands."""
|
||||
|
||||
def __init__(self, config: JellyfinConfig, player: MpvController):
|
||||
self.config = config
|
||||
self.player = player
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
||||
self.auth_token: Optional[str] = config.token
|
||||
self.user_id: Optional[str] = None
|
||||
self._running = False
|
||||
self._ws_task: Optional[asyncio.Task] = None
|
||||
self._report_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def auth_header(self) -> str:
|
||||
return (
|
||||
f'MediaBrowser Client="{self.config.client_name}", '
|
||||
f'Device="{self.config.device_name}", '
|
||||
f'DeviceId="{self.config.device_id}", '
|
||||
f'Version="1.0.0"'
|
||||
)
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": self.auth_header,
|
||||
"X-Emby-Authorization": self.auth_header,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if self.auth_token:
|
||||
headers["X-Emby-Token"] = self.auth_token
|
||||
headers["Authorization"] = f'{self.auth_header}, Token="{self.auth_token}"'
|
||||
return headers
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Jellyfin client connection loop."""
|
||||
if not self.config.server_url or not self.config.username:
|
||||
logger.info("Jellyfin server URL or username not configured; skipping Jellyfin server connection")
|
||||
return
|
||||
|
||||
if not self.config.password and not self.config.token:
|
||||
logger.info(
|
||||
"Jellyfin password is empty in config.yaml. Kodi RPC (Yatse) and YouTube sharing are fully active. Set your password in config.yaml to also enable Jellyfin casting."
|
||||
)
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self.session = aiohttp.ClientSession(headers=self._get_headers())
|
||||
self._ws_task = asyncio.create_task(self._connection_manager())
|
||||
self._report_task = asyncio.create_task(self._progress_reporter())
|
||||
|
||||
async def _connection_manager(self) -> None:
|
||||
"""Keeps the connection and WebSocket session alive with auto-reconnect."""
|
||||
while self._running:
|
||||
try:
|
||||
if not self.auth_token:
|
||||
await self._authenticate()
|
||||
|
||||
if self.auth_token:
|
||||
await self._report_capabilities()
|
||||
await self._listen_websocket()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Jellyfin connection error: %s. Reconnecting in 10s...", e)
|
||||
|
||||
await asyncio.sleep(10)
|
||||
|
||||
async def _authenticate(self) -> None:
|
||||
"""Authenticate user and obtain access token."""
|
||||
url = f"{self.config.server_url}/Users/AuthenticateByName"
|
||||
payload = {
|
||||
"Username": self.config.username,
|
||||
"Pw": self.config.password or "",
|
||||
}
|
||||
logger.info("Authenticating to Jellyfin server: %s as %s", self.config.server_url, self.config.username)
|
||||
async with self.session.post(url, json=payload, headers=self._get_headers()) as resp:
|
||||
if resp.status != 200:
|
||||
err_text = await resp.text()
|
||||
raise RuntimeError(f"Authentication failed ({resp.status}): {err_text}")
|
||||
data = await resp.json()
|
||||
self.auth_token = data.get("AccessToken")
|
||||
self.user_id = data.get("User", {}).get("Id")
|
||||
self.session.headers["X-Emby-Token"] = self.auth_token
|
||||
self.session.headers["Authorization"] = f'{self.auth_header}, Token="{self.auth_token}"'
|
||||
logger.info("Authenticated successfully with Jellyfin (UserId: %s)", self.user_id)
|
||||
|
||||
async def _report_capabilities(self) -> None:
|
||||
"""Notify Jellyfin of client capabilities for remote control."""
|
||||
url = f"{self.config.server_url}/Sessions/Capabilities/Full"
|
||||
capabilities = {
|
||||
"PlayableMediaTypes": ["Video", "Audio"],
|
||||
"SupportedCommands": [
|
||||
"VolumeUp",
|
||||
"VolumeDown",
|
||||
"Mute",
|
||||
"Unmute",
|
||||
"ToggleMute",
|
||||
"SetVolume",
|
||||
"SetAudioStreamIndex",
|
||||
"SetSubtitleStreamIndex",
|
||||
"DisplayMessage",
|
||||
],
|
||||
"SupportsMediaControl": True,
|
||||
"SupportsPersistentIdentifier": True,
|
||||
"DeviceProfile": {
|
||||
"Name": "HTPC MPV Player",
|
||||
"DirectPlayProfiles": [
|
||||
{"Type": "Video"},
|
||||
{"Type": "Audio"},
|
||||
],
|
||||
},
|
||||
}
|
||||
async with self.session.post(url, json=capabilities) as resp:
|
||||
if resp.status in (200, 204):
|
||||
logger.info("Registered Jellyfin remote control capabilities successfully")
|
||||
else:
|
||||
logger.warning("Failed to register capabilities: HTTP %d", resp.status)
|
||||
|
||||
async def _listen_websocket(self) -> None:
|
||||
"""Maintain WebSocket connection for remote commands from Yatse / Jellyfin Web."""
|
||||
ws_url = (
|
||||
f"{self.config.server_url.replace('http://', 'ws://').replace('https://', 'wss://')}"
|
||||
f"/socket?api_key={self.auth_token}&deviceId={self.config.device_id}"
|
||||
)
|
||||
ws_headers = {
|
||||
"Authorization": f'MediaBrowser Token="{self.auth_token}"',
|
||||
"X-Emby-Token": self.auth_token,
|
||||
}
|
||||
logger.info("Connecting to Jellyfin WebSocket: %s", ws_url)
|
||||
async with self.session.ws_connect(ws_url, headers=ws_headers) as ws:
|
||||
|
||||
self.ws = ws
|
||||
logger.info("Jellyfin WebSocket connected; waiting for remote control commands")
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
payload = json.loads(msg.data)
|
||||
await self._handle_remote_message(payload)
|
||||
except Exception as e:
|
||||
logger.error("Error processing Jellyfin WebSocket message: %s", e)
|
||||
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
||||
break
|
||||
|
||||
async def _handle_remote_message(self, msg: Dict[str, Any]) -> None:
|
||||
msg_type = msg.get("MessageType")
|
||||
data = msg.get("Data", {})
|
||||
|
||||
logger.debug("Received Jellyfin message: %s", msg_type)
|
||||
|
||||
if msg_type == "Play":
|
||||
await self._handle_play_command(data)
|
||||
elif msg_type == "Playstate":
|
||||
await self._handle_playstate_command(data)
|
||||
elif msg_type == "GeneralCommand":
|
||||
await self._handle_general_command(data)
|
||||
elif msg_type == "KeepAlive":
|
||||
pass
|
||||
|
||||
async def _handle_play_command(self, data: Dict[str, Any]) -> None:
|
||||
item_ids = data.get("ItemIds", [])
|
||||
if not item_ids:
|
||||
return
|
||||
|
||||
item_id = item_ids[0]
|
||||
start_ticks = data.get("StartPositionTicks") or 0
|
||||
start_seconds = start_ticks / TICKS_PER_SECOND
|
||||
|
||||
logger.info("Remote play command for Jellyfin item: %s", item_id)
|
||||
|
||||
# Retrieve item metadata from Jellyfin
|
||||
item_url = f"{self.config.server_url}/Users/{self.user_id}/Items/{item_id}"
|
||||
async with self.session.get(item_url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error("Failed to fetch item info for %s: %s", item_id, resp.status)
|
||||
return
|
||||
item_data = await resp.json()
|
||||
|
||||
title = item_data.get("Name", "Unknown")
|
||||
series_name = item_data.get("SeriesName")
|
||||
run_time_ticks = item_data.get("RunTimeTicks") or 0
|
||||
duration = run_time_ticks / TICKS_PER_SECOND
|
||||
|
||||
stream_url = (
|
||||
f"{self.config.server_url}/Videos/{item_id}/stream"
|
||||
f"?static=true&api_key={self.auth_token}"
|
||||
)
|
||||
|
||||
metadata = MediaMetadata(
|
||||
url=stream_url,
|
||||
title=title,
|
||||
artist=series_name,
|
||||
album=item_data.get("SeasonName"),
|
||||
duration=duration,
|
||||
thumbnail=f"{self.config.server_url}/Items/{item_id}/Images/Primary",
|
||||
source_type="jellyfin",
|
||||
jellyfin_item_id=item_id,
|
||||
jellyfin_media_source_id=data.get("MediaSourceId"),
|
||||
)
|
||||
|
||||
await self.player.play_url(stream_url, metadata=metadata, start_seconds=start_seconds)
|
||||
await self._report_playing(start_ticks)
|
||||
|
||||
async def _handle_playstate_command(self, data: Dict[str, Any]) -> None:
|
||||
cmd = data.get("Command")
|
||||
logger.info("Jellyfin Playstate command: %s", cmd)
|
||||
if cmd == "PlayPause":
|
||||
await self.player.play_pause()
|
||||
elif cmd == "Pause":
|
||||
await self.player.pause()
|
||||
elif cmd == "Unpause":
|
||||
await self.player.resume()
|
||||
elif cmd == "Stop":
|
||||
await self.player.stop()
|
||||
elif cmd == "Seek":
|
||||
seek_ticks = data.get("SeekPositionTicks") or 0
|
||||
await self.player.seek(seek_ticks / TICKS_PER_SECOND, absolute=True)
|
||||
|
||||
async def _handle_general_command(self, data: Dict[str, Any]) -> None:
|
||||
name = data.get("Name")
|
||||
args = data.get("Arguments", {})
|
||||
logger.info("Jellyfin GeneralCommand: %s (%s)", name, args)
|
||||
|
||||
if name == "SetVolume":
|
||||
vol = int(args.get("Volume", 100))
|
||||
await self.player.set_volume(vol)
|
||||
elif name == "Mute":
|
||||
await self.player.set_mute(True)
|
||||
elif name == "Unmute":
|
||||
await self.player.set_mute(False)
|
||||
elif name == "ToggleMute":
|
||||
await self.player.toggle_mute()
|
||||
elif name == "SetAudioStreamIndex":
|
||||
idx = int(args.get("Index", 1))
|
||||
await self.player.set_audio_track(idx)
|
||||
elif name == "SetSubtitleStreamIndex":
|
||||
idx = int(args.get("Index", 0))
|
||||
await self.player.set_subtitle_track(idx)
|
||||
elif name == "DisplayMessage":
|
||||
text = args.get("Text", "")
|
||||
await self.player.show_osd_message(text)
|
||||
|
||||
async def _report_playing(self, position_ticks: int = 0) -> None:
|
||||
if not self.auth_token or not self.session:
|
||||
return
|
||||
meta = self.player.status.metadata
|
||||
if not meta or not meta.jellyfin_item_id:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"ItemId": meta.jellyfin_item_id,
|
||||
"MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id,
|
||||
"PositionTicks": position_ticks,
|
||||
"IsPaused": self.player.status.state == PlaybackState.PAUSED,
|
||||
"CanSeek": True,
|
||||
}
|
||||
url = f"{self.config.server_url}/Sessions/Playing"
|
||||
try:
|
||||
await self.session.post(url, json=payload)
|
||||
except Exception as e:
|
||||
logger.debug("Error reporting playing to Jellyfin: %s", e)
|
||||
|
||||
async def _report_progress(self) -> None:
|
||||
if not self.auth_token or not self.session:
|
||||
return
|
||||
meta = self.player.status.metadata
|
||||
if not meta or not meta.jellyfin_item_id:
|
||||
return
|
||||
|
||||
position_ticks = int(self.player.status.current_time * TICKS_PER_SECOND)
|
||||
payload = {
|
||||
"ItemId": meta.jellyfin_item_id,
|
||||
"MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id,
|
||||
"PositionTicks": position_ticks,
|
||||
"IsPaused": self.player.status.state == PlaybackState.PAUSED,
|
||||
"CanSeek": True,
|
||||
}
|
||||
url = f"{self.config.server_url}/Sessions/Playing/Progress"
|
||||
try:
|
||||
await self.session.post(url, json=payload)
|
||||
except Exception as e:
|
||||
logger.debug("Error reporting progress to Jellyfin: %s", e)
|
||||
|
||||
async def _report_stopped(self) -> None:
|
||||
if not self.auth_token or not self.session:
|
||||
return
|
||||
meta = self.player.status.metadata
|
||||
if not meta or not meta.jellyfin_item_id:
|
||||
return
|
||||
|
||||
position_ticks = int(self.player.status.current_time * TICKS_PER_SECOND)
|
||||
payload = {
|
||||
"ItemId": meta.jellyfin_item_id,
|
||||
"MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id,
|
||||
"PositionTicks": position_ticks,
|
||||
}
|
||||
url = f"{self.config.server_url}/Sessions/Playing/Stopped"
|
||||
try:
|
||||
await self.session.post(url, json=payload)
|
||||
except Exception as e:
|
||||
logger.debug("Error reporting stopped to Jellyfin: %s", e)
|
||||
|
||||
async def _progress_reporter(self) -> None:
|
||||
"""Periodically update Jellyfin server with current playback status."""
|
||||
last_reported_state = self.player.status.state
|
||||
while self._running:
|
||||
try:
|
||||
curr_state = self.player.status.state
|
||||
if curr_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
|
||||
await self._report_progress()
|
||||
elif last_reported_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) and curr_state == PlaybackState.STOPPED:
|
||||
await self._report_stopped()
|
||||
|
||||
last_reported_state = curr_state
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("Reporter error: %s", e)
|
||||
|
||||
await asyncio.sleep(4)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self._running = False
|
||||
if self._ws_task:
|
||||
self._ws_task.cancel()
|
||||
if self._report_task:
|
||||
self._report_task.cancel()
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from src.config import AppConfig
|
||||
from src.jellyfin.client import JellyfinClient
|
||||
from src.player.mpv_controller import MpvController
|
||||
from src.remotes.kodi_eventserver import KodiEventServer
|
||||
from src.remotes.kodi_rpc import KodiRpcServer
|
||||
from src.remotes.rest_api import RestApiServer
|
||||
|
||||
logger = logging.getLogger("jellyfin_client")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Jellyfin HTPC Client & YouTube Player")
|
||||
parser.add_argument("-c", "--config", help="Path to config.yaml file")
|
||||
parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def run_app(config: AppConfig) -> None:
|
||||
player = MpvController(config.player)
|
||||
jellyfin = JellyfinClient(config.jellyfin, player)
|
||||
kodi_rpc = KodiRpcServer(config.kodi_rpc, player)
|
||||
kodi_event = KodiEventServer(config.kodi_rpc.event_port, player)
|
||||
rest_api = RestApiServer(config.web_api, player)
|
||||
|
||||
# Start services
|
||||
await player.start()
|
||||
await jellyfin.start()
|
||||
await kodi_rpc.start()
|
||||
await kodi_event.start()
|
||||
await rest_api.start()
|
||||
|
||||
logger.info("Jellyfin HTPC Client is fully initialized and ready.")
|
||||
|
||||
# Setup signal handling for graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
def _sig_handler() -> None:
|
||||
logger.info("Shutdown signal received")
|
||||
stop_event.set()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, _sig_handler)
|
||||
except NotImplementedError:
|
||||
pass # Windows or non-main thread
|
||||
|
||||
await stop_event.wait()
|
||||
|
||||
logger.info("Shutting down services...")
|
||||
await rest_api.shutdown()
|
||||
await kodi_event.shutdown()
|
||||
await kodi_rpc.shutdown()
|
||||
await jellyfin.shutdown()
|
||||
await player.shutdown()
|
||||
|
||||
logger.info("Graceful shutdown complete.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
log_level = logging.DEBUG if args.debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(asctime)s [%(levelname)s] (%(name)s) %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
config = AppConfig.load(args.config)
|
||||
try:
|
||||
asyncio.run(run_app(config))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.critical("Fatal error: %s", e, exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class PlaybackState(str, Enum):
|
||||
IDLE = "idle"
|
||||
PLAYING = "playing"
|
||||
PAUSED = "paused"
|
||||
STOPPED = "stopped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackInfo:
|
||||
id: int
|
||||
type: str # "video", "audio", "sub"
|
||||
title: Optional[str] = None
|
||||
lang: Optional[str] = None
|
||||
codec: Optional[str] = None
|
||||
selected: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaMetadata:
|
||||
url: str
|
||||
title: str = "Unknown"
|
||||
artist: Optional[str] = None # Channel name for YouTube, artist for music, series name for episodes
|
||||
album: Optional[str] = None # Playlist for YouTube, album for music, season for episodes
|
||||
duration: float = 0.0 # Seconds
|
||||
thumbnail: Optional[str] = None
|
||||
source_type: str = "generic" # "jellyfin", "youtube", "url"
|
||||
jellyfin_item_id: Optional[str] = None
|
||||
jellyfin_media_source_id: Optional[str] = None
|
||||
jellyfin_play_session_id: Optional[str] = None
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerStatus:
|
||||
state: PlaybackState = PlaybackState.IDLE
|
||||
current_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
volume: int = 100
|
||||
muted: bool = False
|
||||
metadata: Optional[MediaMetadata] = None
|
||||
tracks: List[TrackInfo] = field(default_factory=list)
|
||||
speed: float = 1.0
|
||||
|
||||
@property
|
||||
def percentage(self) -> float:
|
||||
if self.duration > 0:
|
||||
return min(100.0, max(0.0, (self.current_time / self.duration) * 100.0))
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
|
||||
@@ -1,335 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from src.config import PlayerConfig
|
||||
from src.player.models import MediaMetadata, PlaybackState, PlayerStatus, TrackInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MpvController:
|
||||
"""Manages MPV media player process and IPC communication via Unix domain socket."""
|
||||
|
||||
def __init__(self, config: PlayerConfig):
|
||||
self.config = config
|
||||
self.status = PlayerStatus()
|
||||
self.process: Optional[asyncio.subprocess.Process] = None
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._request_id = 0
|
||||
self._pending_requests: Dict[int, asyncio.Future[Any]] = {}
|
||||
self._listeners: List[Callable[[PlayerStatus], None]] = []
|
||||
self._stopping = False
|
||||
self._read_task: Optional[asyncio.Task] = None
|
||||
|
||||
def add_listener(self, callback: Callable[[PlayerStatus], None]) -> None:
|
||||
self._listeners.append(callback)
|
||||
|
||||
def _notify(self) -> None:
|
||||
for cb in self._listeners:
|
||||
try:
|
||||
cb(self.status)
|
||||
except Exception as e:
|
||||
logger.error("Error in status listener callback: %s", e)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Launch mpv in idle mode and establish IPC connection, with fallback to headless mode."""
|
||||
success = await self._spawn_mpv(vo=self.config.vo, hwdec=self.config.hwdec)
|
||||
if not success and self.config.vo != "null":
|
||||
logger.warning(
|
||||
"MPV failed to start with vo=%s (possibly no active X11/Wayland display). Falling back to headless mode (--vo=null --ao=null)...",
|
||||
self.config.vo,
|
||||
)
|
||||
success = await self._spawn_mpv(vo="null", hwdec="no", extra_fallback_args=["--ao=null"])
|
||||
|
||||
if not success:
|
||||
logger.error("Unable to initialize MPV in either display or headless mode.")
|
||||
|
||||
async def _spawn_mpv(
|
||||
self,
|
||||
vo: str,
|
||||
hwdec: str,
|
||||
extra_fallback_args: Optional[List[str]] = None,
|
||||
) -> bool:
|
||||
sock_path = Path(self.config.ipc_socket)
|
||||
if sock_path.exists():
|
||||
try:
|
||||
sock_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
cmd = [
|
||||
self.config.mpv_path,
|
||||
"--idle=yes",
|
||||
f"--input-ipc-server={self.config.ipc_socket}",
|
||||
f"--hwdec={hwdec}",
|
||||
f"--vo={vo}",
|
||||
"--fs" if (self.config.fullscreen and vo != "null") else "--fs=no",
|
||||
]
|
||||
|
||||
if self.config.audio_device and self.config.audio_device != "auto" and not extra_fallback_args:
|
||||
cmd.append(f"--audio-device={self.config.audio_device}")
|
||||
|
||||
cmd.extend(self.config.extra_args)
|
||||
if extra_fallback_args:
|
||||
cmd.extend(extra_fallback_args)
|
||||
|
||||
logger.info("Starting MPV: %s", " ".join(cmd))
|
||||
try:
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to spawn mpv process: %s", e)
|
||||
return False
|
||||
|
||||
# Wait for socket to become available
|
||||
for _ in range(30):
|
||||
if sock_path.exists():
|
||||
break
|
||||
# If process already exited, abort early
|
||||
if self.process.returncode is not None:
|
||||
return False
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if not sock_path.exists() or (self.process and self.process.returncode is not None):
|
||||
if self.process and self.process.returncode is not None:
|
||||
logger.warning("MPV process exited prematurely with code %s", self.process.returncode)
|
||||
return False
|
||||
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.open_unix_connection(str(sock_path))
|
||||
self._read_task = asyncio.create_task(self._socket_reader_loop())
|
||||
await self._setup_observers()
|
||||
logger.info("Successfully connected to MPV IPC socket")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to MPV IPC socket: %s", e)
|
||||
return False
|
||||
|
||||
async def _setup_observers(self) -> None:
|
||||
"""Observe relevant MPV properties."""
|
||||
properties = [
|
||||
"time-pos",
|
||||
"duration",
|
||||
"pause",
|
||||
"volume",
|
||||
"mute",
|
||||
"speed",
|
||||
"track-list",
|
||||
"idle-active",
|
||||
]
|
||||
for prop in properties:
|
||||
await self.command("observe_property", 0, prop)
|
||||
|
||||
async def _socket_reader_loop(self) -> None:
|
||||
"""Read and dispatch messages from MPV IPC."""
|
||||
while not self._stopping and self._reader:
|
||||
try:
|
||||
line = await self._reader.readline()
|
||||
if not line:
|
||||
break
|
||||
data = json.loads(line.decode("utf-8").strip())
|
||||
self._handle_ipc_message(data)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error reading from MPV socket: %s", e)
|
||||
break
|
||||
|
||||
def _handle_ipc_message(self, msg: Dict[str, Any]) -> None:
|
||||
req_id = msg.get("request_id")
|
||||
if req_id is not None and req_id in self._pending_requests:
|
||||
fut = self._pending_requests.pop(req_id)
|
||||
if not fut.done():
|
||||
if msg.get("error") == "success":
|
||||
fut.set_result(msg.get("data"))
|
||||
else:
|
||||
fut.set_exception(RuntimeError(f"MPV error: {msg.get('error')}"))
|
||||
return
|
||||
|
||||
event = msg.get("event")
|
||||
if not event:
|
||||
return
|
||||
|
||||
if event == "property-change":
|
||||
name = msg.get("name")
|
||||
value = msg.get("data")
|
||||
self._on_property_change(name, value)
|
||||
elif event == "end-file":
|
||||
reason = msg.get("reason")
|
||||
logger.info("MPV file playback ended: reason=%s", reason)
|
||||
self.status.state = PlaybackState.STOPPED
|
||||
self._notify()
|
||||
elif event == "playback-restart":
|
||||
self.status.state = PlaybackState.PLAYING
|
||||
self._notify()
|
||||
|
||||
def _on_property_change(self, name: str, value: Any) -> None:
|
||||
if name == "time-pos" and value is not None:
|
||||
self.status.current_time = float(value)
|
||||
elif name == "duration" and value is not None:
|
||||
self.status.duration = float(value)
|
||||
elif name == "pause":
|
||||
if value is True:
|
||||
self.status.state = PlaybackState.PAUSED
|
||||
elif value is False:
|
||||
if self.status.state != PlaybackState.IDLE:
|
||||
self.status.state = PlaybackState.PLAYING
|
||||
elif name == "volume" and value is not None:
|
||||
self.status.volume = int(round(value))
|
||||
elif name == "mute":
|
||||
self.status.muted = bool(value)
|
||||
elif name == "speed" and value is not None:
|
||||
self.status.speed = float(value)
|
||||
elif name == "idle-active" and value is True:
|
||||
self.status.state = PlaybackState.IDLE
|
||||
self.status.current_time = 0.0
|
||||
self.status.duration = 0.0
|
||||
elif name == "track-list" and isinstance(value, list):
|
||||
tracks = []
|
||||
for t in value:
|
||||
tracks.append(
|
||||
TrackInfo(
|
||||
id=t.get("id", 0),
|
||||
type=t.get("type", "unknown"),
|
||||
title=t.get("title"),
|
||||
lang=t.get("lang"),
|
||||
codec=t.get("codec"),
|
||||
selected=t.get("selected", False),
|
||||
)
|
||||
)
|
||||
self.status.tracks = tracks
|
||||
|
||||
self._notify()
|
||||
|
||||
async def command(self, *args: Any) -> Any:
|
||||
"""Send a command to MPV and wait for response."""
|
||||
if not self._writer or (self.process and self.process.returncode is not None):
|
||||
logger.debug("Cannot send command, MPV is disconnected: %s", args)
|
||||
return None
|
||||
|
||||
self._request_id += 1
|
||||
req_id = self._request_id
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
self._pending_requests[req_id] = fut
|
||||
|
||||
msg = {"command": list(args), "request_id": req_id}
|
||||
payload = (json.dumps(msg) + "\n").encode("utf-8")
|
||||
try:
|
||||
self._writer.write(payload)
|
||||
await self._writer.drain()
|
||||
return await asyncio.wait_for(fut, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
self._pending_requests.pop(req_id, None)
|
||||
logger.warning("MPV command timed out: %s", args)
|
||||
return None
|
||||
except Exception as e:
|
||||
self._pending_requests.pop(req_id, None)
|
||||
logger.warning("MPV command error (%s): %s", args, e)
|
||||
return None
|
||||
|
||||
|
||||
async def play_url(
|
||||
self,
|
||||
url: str,
|
||||
metadata: Optional[MediaMetadata] = None,
|
||||
start_seconds: float = 0.0,
|
||||
) -> None:
|
||||
"""Play a media URL (Jellyfin direct stream, YouTube, or direct file)."""
|
||||
logger.info("Loading media into MPV: %s (start=%s)", url, start_seconds)
|
||||
self.status.metadata = metadata or MediaMetadata(url=url)
|
||||
self.status.state = PlaybackState.PLAYING
|
||||
self.status.current_time = start_seconds
|
||||
|
||||
options = []
|
||||
if start_seconds > 0:
|
||||
options.append(f"start={int(start_seconds)}")
|
||||
|
||||
if options:
|
||||
await self.command("loadfile", url, "replace", ",".join(options))
|
||||
else:
|
||||
await self.command("loadfile", url, "replace")
|
||||
|
||||
# Set title in MPV OSD if known
|
||||
if self.status.metadata and self.status.metadata.title:
|
||||
title = self.status.metadata.title
|
||||
if self.status.metadata.artist:
|
||||
title = f"{self.status.metadata.artist} - {title}"
|
||||
await self.command("set_property", "force-media-title", title)
|
||||
|
||||
self._notify()
|
||||
|
||||
async def pause(self) -> None:
|
||||
await self.command("set_property", "pause", True)
|
||||
self.status.state = PlaybackState.PAUSED
|
||||
self._notify()
|
||||
|
||||
async def resume(self) -> None:
|
||||
await self.command("set_property", "pause", False)
|
||||
self.status.state = PlaybackState.PLAYING
|
||||
self._notify()
|
||||
|
||||
async def play_pause(self) -> None:
|
||||
if self.status.state == PlaybackState.PAUSED:
|
||||
await self.resume()
|
||||
elif self.status.state == PlaybackState.PLAYING:
|
||||
await self.pause()
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self.command("stop")
|
||||
self.status.state = PlaybackState.STOPPED
|
||||
self._notify()
|
||||
|
||||
async def seek(self, seconds: float, absolute: bool = False) -> None:
|
||||
seek_type = "absolute" if absolute else "relative"
|
||||
await self.command("seek", seconds, seek_type)
|
||||
|
||||
async def set_volume(self, volume: int) -> None:
|
||||
clamped = max(0, min(100, volume))
|
||||
await self.command("set_property", "volume", clamped)
|
||||
self.status.volume = clamped
|
||||
self._notify()
|
||||
|
||||
async def set_mute(self, muted: bool) -> None:
|
||||
await self.command("set_property", "mute", muted)
|
||||
self.status.muted = muted
|
||||
self._notify()
|
||||
|
||||
async def toggle_mute(self) -> None:
|
||||
await self.set_mute(not self.status.muted)
|
||||
|
||||
async def set_audio_track(self, track_id: int) -> None:
|
||||
await self.command("set_property", "aid", track_id)
|
||||
|
||||
async def set_subtitle_track(self, track_id: int) -> None:
|
||||
await self.command("set_property", "sid", track_id)
|
||||
|
||||
async def show_osd_message(self, message: str, duration_ms: int = 3000) -> None:
|
||||
await self.command("show-text", message, duration_ms)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self._stopping = True
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
if self._writer:
|
||||
try:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||
except Exception:
|
||||
if self.process:
|
||||
self.process.kill()
|
||||
@@ -1,150 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
from typing import Optional
|
||||
|
||||
from src.player.mpv_controller import MpvController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Kodi EventServer Packet Types
|
||||
PT_HELO = 0x01
|
||||
PT_BYE = 0x02
|
||||
PT_BUTTON = 0x03
|
||||
PT_MOUSE = 0x04
|
||||
PT_PING = 0x05
|
||||
PT_NOTIFICATION = 0x06
|
||||
PT_BLOB = 0x07
|
||||
PT_LOG = 0x08
|
||||
PT_ACTION = 0x09
|
||||
|
||||
HEADER_FORMAT = "!4sBBHIIHI10s"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 32 bytes
|
||||
|
||||
|
||||
class KodiEventServerProtocol(asyncio.DatagramProtocol):
|
||||
"""UDP protocol handler for Kodi EventServer packets from Yatse."""
|
||||
|
||||
def __init__(self, player: MpvController):
|
||||
self.player = player
|
||||
self.transport: Optional[asyncio.DatagramTransport] = None
|
||||
|
||||
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
||||
self.transport = transport
|
||||
logger.info("Kodi EventServer UDP protocol initialized")
|
||||
|
||||
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
||||
if len(data) < HEADER_SIZE:
|
||||
return
|
||||
|
||||
try:
|
||||
sig, majver, minver, pkttype, seq, maxseq, paylen, client_token, _ = struct.unpack(
|
||||
HEADER_FORMAT, data[:HEADER_SIZE]
|
||||
)
|
||||
if sig != b"XBMC":
|
||||
return
|
||||
|
||||
payload = data[HEADER_SIZE : HEADER_SIZE + paylen]
|
||||
self._handle_packet(pkttype, payload, addr, client_token)
|
||||
except Exception as e:
|
||||
logger.debug("Error parsing EventServer packet: %s", e)
|
||||
|
||||
def _handle_packet(self, pkttype: int, payload: bytes, addr: tuple[str, int], client_token: int) -> None:
|
||||
if pkttype == PT_HELO:
|
||||
# Client registration
|
||||
devname = payload.split(b"\x00")[0].decode("utf-8", errors="ignore")
|
||||
logger.info("Yatse EventServer connected from %s (Device: %s)", addr, devname)
|
||||
# Echo ping ack back to client
|
||||
if self.transport:
|
||||
ack = struct.pack(HEADER_FORMAT, b"XBMC", 2, 0, PT_PING, 1, 1, 0, client_token, b"\x00" * 10)
|
||||
self.transport.sendto(ack, addr)
|
||||
elif pkttype == PT_PING:
|
||||
logger.debug("Yatse EventServer PING from %s", addr)
|
||||
if self.transport:
|
||||
ack = struct.pack(HEADER_FORMAT, b"XBMC", 2, 0, PT_PING, 1, 1, 0, client_token, b"\x00" * 10)
|
||||
self.transport.sendto(ack, addr)
|
||||
|
||||
elif pkttype == PT_BUTTON:
|
||||
self._handle_button(payload)
|
||||
elif pkttype == PT_ACTION:
|
||||
self._handle_action(payload)
|
||||
elif pkttype == PT_BYE:
|
||||
logger.info("Yatse EventServer disconnected from %s", addr)
|
||||
|
||||
def _handle_button(self, payload: bytes) -> None:
|
||||
# Format: code(2B) + map_name(null-terminated) + button_name(null-terminated) + flags(2B) + amount(2B)
|
||||
if len(payload) < 6:
|
||||
return
|
||||
try:
|
||||
parts = payload[2:].split(b"\x00")
|
||||
if len(parts) >= 2:
|
||||
map_name = parts[0].decode("utf-8", errors="ignore")
|
||||
button_name = parts[1].decode("utf-8", errors="ignore").lower()
|
||||
|
||||
# Check flags: 0x01 is KEY_DOWN
|
||||
tail = b"\x00".join(parts[2:])
|
||||
if len(tail) >= 2:
|
||||
flags = struct.unpack("!H", tail[:2])[0]
|
||||
if not (flags & 0x01): # Ignore key up events
|
||||
return
|
||||
|
||||
logger.info("EventServer button pressed: map=%s, btn=%s", map_name, button_name)
|
||||
self._dispatch_button(button_name)
|
||||
except Exception as e:
|
||||
logger.debug("Error handling EventServer button: %s", e)
|
||||
|
||||
def _handle_action(self, payload: bytes) -> None:
|
||||
# Format: action_type(1B) + action_name(null-terminated string)
|
||||
if len(payload) < 2:
|
||||
return
|
||||
action = payload[1:].split(b"\x00")[0].decode("utf-8", errors="ignore").lower()
|
||||
logger.info("EventServer action received: %s", action)
|
||||
if "playpause" in action or "play" in action:
|
||||
asyncio.create_task(self.player.play_pause())
|
||||
elif "stop" in action:
|
||||
asyncio.create_task(self.player.stop())
|
||||
|
||||
def _dispatch_button(self, btn: str) -> None:
|
||||
if btn in ("left", "stepback"):
|
||||
asyncio.create_task(self.player.seek(-10))
|
||||
elif btn in ("right", "stepforward"):
|
||||
asyncio.create_task(self.player.seek(10))
|
||||
elif btn in ("up", "bigstepforward"):
|
||||
asyncio.create_task(self.player.seek(60))
|
||||
elif btn in ("down", "bigstepback"):
|
||||
asyncio.create_task(self.player.seek(-60))
|
||||
elif btn in ("select", "enter", "play", "pause", "playpause"):
|
||||
asyncio.create_task(self.player.play_pause())
|
||||
elif btn in ("back", "stop"):
|
||||
asyncio.create_task(self.player.stop())
|
||||
elif btn in ("volumeup", "volup"):
|
||||
asyncio.create_task(self.player.set_volume(self.player.status.volume + 5))
|
||||
elif btn in ("volumedown", "voldown"):
|
||||
asyncio.create_task(self.player.set_volume(self.player.status.volume - 5))
|
||||
|
||||
|
||||
class KodiEventServer:
|
||||
"""Manages the UDP socket for Kodi EventServer."""
|
||||
|
||||
def __init__(self, port: int, player: MpvController):
|
||||
self.port = port
|
||||
self.player = player
|
||||
self.transport: Optional[asyncio.DatagramTransport] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: KodiEventServerProtocol(self.player),
|
||||
local_addr=("0.0.0.0", self.port),
|
||||
)
|
||||
self.transport = transport
|
||||
logger.info("Kodi EventServer UDP listener started on 0.0.0.0:%d (Yatse compatible)", self.port)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start Kodi EventServer on UDP port %d: %s", self.port, e)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
@@ -1,552 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from aiohttp import web
|
||||
|
||||
from src.config import KodiRpcConfig
|
||||
from src.player.models import MediaMetadata, PlaybackState, PlayerStatus
|
||||
from src.player.mpv_controller import MpvController
|
||||
from src.utils.youtube import fetch_youtube_metadata, is_youtube_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _seconds_to_kodi_time(seconds: float) -> Dict[str, int]:
|
||||
total_sec = max(0, int(seconds))
|
||||
hrs = total_sec // 3600
|
||||
mins = (total_sec % 3600) // 60
|
||||
secs = total_sec % 60
|
||||
millis = int((seconds - total_sec) * 1000)
|
||||
return {"hours": hrs, "minutes": mins, "seconds": secs, "milliseconds": millis}
|
||||
|
||||
|
||||
class KodiRpcServer:
|
||||
"""Emulates Kodi JSON-RPC server (HTTP, WebSocket, and raw TCP socket) for Yatse."""
|
||||
|
||||
def __init__(self, config: KodiRpcConfig, player: MpvController):
|
||||
self.config = config
|
||||
self.player = player
|
||||
self.app = web.Application()
|
||||
self._runner: Optional[web.AppRunner] = None
|
||||
self._site: Optional[web.TCPSite] = None
|
||||
self._ws_clients: Set[web.WebSocketResponse] = set()
|
||||
self._tcp_clients: Set[asyncio.StreamWriter] = set()
|
||||
self._tcp_server: Optional[asyncio.Server] = None
|
||||
|
||||
self.app.router.add_post("/jsonrpc", self._handle_post_jsonrpc)
|
||||
self.app.router.add_get("/jsonrpc", self._handle_get_jsonrpc)
|
||||
self.app.router.add_get("/", self._handle_root)
|
||||
|
||||
self.player.add_listener(self._on_player_status_changed)
|
||||
|
||||
def _on_player_status_changed(self, status: PlayerStatus) -> None:
|
||||
asyncio.create_task(self._broadcast_player_event(status))
|
||||
|
||||
async def _broadcast_player_event(self, status: PlayerStatus) -> None:
|
||||
event_name = "Player.OnPlay" if status.state == PlaybackState.PLAYING else (
|
||||
"Player.OnPause" if status.state == PlaybackState.PAUSED else "Player.OnStop"
|
||||
)
|
||||
notification = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": event_name,
|
||||
"params": {
|
||||
"data": {
|
||||
"player": {"playerid": 1, "speed": 1 if status.state == PlaybackState.PLAYING else 0},
|
||||
"item": {"type": "movie" if status.is_active else "unknown"},
|
||||
},
|
||||
"sender": "xbmc",
|
||||
},
|
||||
}
|
||||
|
||||
# Broadcast to WebSockets
|
||||
dead_ws = set()
|
||||
for ws in self._ws_clients:
|
||||
try:
|
||||
await ws.send_json(notification)
|
||||
except Exception:
|
||||
dead_ws.add(ws)
|
||||
self._ws_clients -= dead_ws
|
||||
|
||||
# Broadcast to raw TCP clients (Yatse socket)
|
||||
payload = (json.dumps(notification) + "\n").encode("utf-8")
|
||||
dead_tcp = set()
|
||||
for writer in self._tcp_clients:
|
||||
try:
|
||||
writer.write(payload)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
dead_tcp.add(writer)
|
||||
self._tcp_clients -= dead_tcp
|
||||
|
||||
async def _handle_root(self, request: web.Request) -> web.Response:
|
||||
return web.Response(text="Kodi JSON-RPC emulation server for Yatse")
|
||||
|
||||
async def _handle_post_jsonrpc(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None}
|
||||
)
|
||||
|
||||
if isinstance(body, list):
|
||||
results = [await self._dispatch(item) for item in body]
|
||||
return web.json_response(results)
|
||||
else:
|
||||
result = await self._dispatch(body)
|
||||
return web.json_response(result)
|
||||
|
||||
async def _handle_get_jsonrpc(self, request: web.Request) -> web.StreamResponse:
|
||||
# Check if client is requesting a WebSocket upgrade
|
||||
if request.headers.get("Upgrade", "").lower() == "websocket":
|
||||
return await self._handle_websocket_jsonrpc(request)
|
||||
|
||||
# Standard HTTP GET with ?request=<url-encoded-json> (used by Yatse probe)
|
||||
req_param = request.query.get("request")
|
||||
if req_param:
|
||||
try:
|
||||
body = json.loads(req_param)
|
||||
if isinstance(body, list):
|
||||
res = [await self._dispatch(item) for item in body]
|
||||
return web.json_response(res)
|
||||
res = await self._dispatch(body)
|
||||
return web.json_response(res)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse GET /jsonrpc request query param: %s", e)
|
||||
return web.json_response(
|
||||
{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None}
|
||||
)
|
||||
|
||||
return web.json_response({"jsonrpc": "2.0", "result": "Kodi JSON-RPC Active", "id": None})
|
||||
|
||||
async def _handle_websocket_jsonrpc(self, request: web.Request) -> web.WebSocketResponse:
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self._ws_clients.add(ws)
|
||||
logger.info("Yatse WebSocket connected via JSON-RPC")
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
resp = await self._dispatch(data)
|
||||
await ws.send_json(resp)
|
||||
except Exception as e:
|
||||
logger.error("Error in Kodi WebSocket JSON-RPC: %s", e)
|
||||
elif msg.type in (web.WSMsgType.CLOSED, web.WSMsgType.ERROR):
|
||||
break
|
||||
finally:
|
||||
self._ws_clients.discard(ws)
|
||||
logger.info("Yatse WebSocket disconnected")
|
||||
|
||||
return ws
|
||||
|
||||
async def _handle_tcp_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
self._tcp_clients.add(writer)
|
||||
addr = writer.get_extra_info("peername")
|
||||
logger.info("Yatse TCP connection established from %s", addr)
|
||||
|
||||
buffer = ""
|
||||
decoder = json.JSONDecoder()
|
||||
try:
|
||||
while not reader.at_eof():
|
||||
data = await reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode("utf-8", errors="ignore")
|
||||
while buffer:
|
||||
buffer = buffer.lstrip()
|
||||
if not buffer:
|
||||
break
|
||||
try:
|
||||
obj, idx = decoder.raw_decode(buffer)
|
||||
buffer = buffer[idx:]
|
||||
res = await self._dispatch(obj)
|
||||
out = (json.dumps(res) + "\n").encode("utf-8")
|
||||
writer.write(out)
|
||||
await writer.drain()
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Error in TCP client connection %s: %s", addr, e)
|
||||
finally:
|
||||
self._tcp_clients.discard(writer)
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Yatse TCP connection closed from %s", addr)
|
||||
|
||||
async def _dispatch(self, req: Dict[str, Any]) -> Dict[str, Any]:
|
||||
req_id = req.get("id")
|
||||
method = req.get("method", "")
|
||||
params = req.get("params", {})
|
||||
|
||||
logger.info("Kodi JSON-RPC method called: %s (params=%s, id=%s)", method, params, req_id)
|
||||
|
||||
try:
|
||||
handler = self._methods.get(method)
|
||||
if handler:
|
||||
result = await handler(self, params)
|
||||
return {"jsonrpc": "2.0", "result": result, "id": req_id}
|
||||
else:
|
||||
logger.info("Handled Kodi JSON-RPC method with generic OK: %s", method)
|
||||
return {"jsonrpc": "2.0", "result": "OK", "id": req_id}
|
||||
except Exception as e:
|
||||
logger.error("Error dispatching method %s: %s", method, e)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32603, "message": f"Internal error: {str(e)}"},
|
||||
"id": req_id,
|
||||
}
|
||||
|
||||
# --- JSON-RPC Method Handlers ---
|
||||
|
||||
async def _rpc_ping(self, params: Any) -> str:
|
||||
return "pong"
|
||||
|
||||
async def _rpc_version(self, params: Any) -> Dict[str, Any]:
|
||||
return {"version": {"major": 19, "minor": 0, "patch": 0, "tag": "release"}}
|
||||
|
||||
async def _rpc_introspect(self, params: Any) -> Dict[str, Any]:
|
||||
return {"methods": {m: {} for m in self._methods.keys()}}
|
||||
|
||||
async def _rpc_jsonrpc_permission(self, params: Any) -> Dict[str, bool]:
|
||||
return {
|
||||
"ControlGUI": True,
|
||||
"ControlNotify": True,
|
||||
"ControlPlayback": True,
|
||||
"ControlPower": True,
|
||||
"ControlSystem": True,
|
||||
"ExecuteAddon": True,
|
||||
"ManageFiles": True,
|
||||
"Navigate": True,
|
||||
"ReadData": True,
|
||||
"WriteData": True,
|
||||
}
|
||||
|
||||
async def _rpc_app_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
props = params.get("properties", [])
|
||||
result = {}
|
||||
for prop in props:
|
||||
if prop == "volume":
|
||||
result["volume"] = self.player.status.volume
|
||||
elif prop == "muted":
|
||||
result["muted"] = self.player.status.muted
|
||||
elif prop == "name":
|
||||
result["name"] = "Kodi"
|
||||
elif prop == "version":
|
||||
result["version"] = {"major": 19, "minor": 0, "revision": "0", "tag": "release"}
|
||||
return result
|
||||
|
||||
async def _rpc_gui_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"currentwindow": {"id": 10000, "label": "Home"},
|
||||
"currentcontrol": {"label": ""},
|
||||
"skin": {"id": "skin.estuary", "name": "Estuary"},
|
||||
"fullscreen": True,
|
||||
}
|
||||
|
||||
async def _rpc_system_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"canhibernate": False,
|
||||
"canreboot": True,
|
||||
"canshutdown": True,
|
||||
"cansuspend": False,
|
||||
}
|
||||
|
||||
async def _rpc_xbmc_get_info_booleans(self, params: Dict[str, Any]) -> Dict[str, bool]:
|
||||
booleans = params.get("booleans", [])
|
||||
result = {}
|
||||
for b in booleans:
|
||||
if b in ("System.Platform.Linux", "Services.EventServer", "Services.Webserver"):
|
||||
result[b] = True
|
||||
elif b.startswith("System.Platform."):
|
||||
result[b] = False
|
||||
elif b == "System.HasModalDialog":
|
||||
result[b] = False
|
||||
else:
|
||||
result[b] = True
|
||||
return result
|
||||
|
||||
async def _rpc_xbmc_get_info_labels(self, params: Dict[str, Any]) -> Dict[str, str]:
|
||||
labels = params.get("labels", [])
|
||||
result = {}
|
||||
for l in labels:
|
||||
if l == "System.BuildVersion":
|
||||
result[l] = "19.4 (19.4.0) Git:20220302-e26f2334f3 Media Center Kodi"
|
||||
elif l == "System.KernelVersion":
|
||||
result[l] = "Linux 6.8.0"
|
||||
elif l == "System.FriendlyName":
|
||||
result[l] = "HTPC"
|
||||
else:
|
||||
result[l] = ""
|
||||
return result
|
||||
|
||||
async def _rpc_app_set_volume(self, params: Dict[str, Any]) -> int:
|
||||
vol = params.get("volume", 100)
|
||||
await self.player.set_volume(int(vol))
|
||||
return self.player.status.volume
|
||||
|
||||
async def _rpc_app_set_mute(self, params: Dict[str, Any]) -> bool:
|
||||
mute = params.get("mute")
|
||||
if mute == "toggle":
|
||||
await self.player.toggle_mute()
|
||||
else:
|
||||
await self.player.set_mute(bool(mute))
|
||||
return self.player.status.muted
|
||||
|
||||
async def _rpc_player_get_active_players(self, params: Any) -> List[Dict[str, Any]]:
|
||||
if self.player.status.is_active:
|
||||
return [{"playerid": 1, "playertype": "internal", "type": "video"}]
|
||||
return []
|
||||
|
||||
async def _rpc_player_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
props = params.get("properties", [])
|
||||
result = {}
|
||||
status = self.player.status
|
||||
|
||||
for prop in props:
|
||||
if prop == "percentage":
|
||||
result["percentage"] = status.percentage
|
||||
elif prop == "time":
|
||||
result["time"] = _seconds_to_kodi_time(status.current_time)
|
||||
elif prop == "totaltime":
|
||||
result["totaltime"] = _seconds_to_kodi_time(status.duration)
|
||||
elif prop == "speed":
|
||||
result["speed"] = 1 if status.state == PlaybackState.PLAYING else 0
|
||||
elif prop == "partymode":
|
||||
result["partymode"] = False
|
||||
elif prop == "playlistid":
|
||||
result["playlistid"] = 0
|
||||
elif prop == "position":
|
||||
result["position"] = 0
|
||||
elif prop == "repeat":
|
||||
result["repeat"] = "off"
|
||||
elif prop == "shuffled":
|
||||
result["shuffled"] = False
|
||||
elif prop == "canseek":
|
||||
result["canseek"] = True
|
||||
elif prop == "live":
|
||||
result["live"] = False
|
||||
return result
|
||||
|
||||
async def _rpc_player_get_item(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
meta = self.player.status.metadata
|
||||
item: Dict[str, Any] = {
|
||||
"label": meta.title if meta else "Nothing playing",
|
||||
"type": "movie",
|
||||
}
|
||||
if meta:
|
||||
item["title"] = meta.title
|
||||
item["artist"] = [meta.artist] if meta.artist else []
|
||||
item["album"] = meta.album or ""
|
||||
item["duration"] = int(meta.duration)
|
||||
item["file"] = meta.url
|
||||
if meta.thumbnail:
|
||||
item["thumbnail"] = meta.thumbnail
|
||||
item["art"] = {"thumb": meta.thumbnail, "poster": meta.thumbnail}
|
||||
|
||||
return {"item": item}
|
||||
|
||||
async def _rpc_player_play_pause(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
play_arg = params.get("play")
|
||||
if play_arg is True or play_arg == "true":
|
||||
await self.player.resume()
|
||||
elif play_arg is False or play_arg == "false":
|
||||
await self.player.pause()
|
||||
else:
|
||||
await self.player.play_pause()
|
||||
|
||||
speed = 1 if self.player.status.state == PlaybackState.PLAYING else 0
|
||||
return {"speed": speed}
|
||||
|
||||
async def _rpc_player_stop(self, params: Any) -> str:
|
||||
await self.player.stop()
|
||||
return "OK"
|
||||
|
||||
async def _rpc_player_seek(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
value = params.get("value")
|
||||
status = self.player.status
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
target = (float(value) / 100.0) * status.duration
|
||||
await self.player.seek(target, absolute=True)
|
||||
elif isinstance(value, dict):
|
||||
hrs = value.get("hours", 0)
|
||||
mins = value.get("minutes", 0)
|
||||
secs = value.get("seconds", 0)
|
||||
target = hrs * 3600 + mins * 60 + secs
|
||||
await self.player.seek(target, absolute=True)
|
||||
elif value == "smallforward":
|
||||
await self.player.seek(10, absolute=False)
|
||||
elif value == "smallbackward":
|
||||
await self.player.seek(-10, absolute=False)
|
||||
elif value == "bigforward":
|
||||
await self.player.seek(60, absolute=False)
|
||||
elif value == "bigbackward":
|
||||
await self.player.seek(-60, absolute=False)
|
||||
|
||||
return {
|
||||
"percentage": self.player.status.percentage,
|
||||
"time": _seconds_to_kodi_time(self.player.status.current_time),
|
||||
"totaltime": _seconds_to_kodi_time(self.player.status.duration),
|
||||
}
|
||||
|
||||
async def _rpc_player_open(self, params: Dict[str, Any]) -> str:
|
||||
item = params.get("item", {})
|
||||
file_url = item.get("file") or item.get("path")
|
||||
if not file_url:
|
||||
raise ValueError("No file or path provided in Player.Open")
|
||||
|
||||
logger.info("Yatse Player.Open requested URL: %s", file_url)
|
||||
|
||||
metadata: Optional[MediaMetadata] = None
|
||||
if is_youtube_url(file_url):
|
||||
logger.info("Detected YouTube media via Yatse share: %s", file_url)
|
||||
metadata = await fetch_youtube_metadata(file_url)
|
||||
|
||||
await self.player.play_url(file_url, metadata=metadata)
|
||||
return "OK"
|
||||
|
||||
async def _rpc_playlist_add(self, params: Dict[str, Any]) -> str:
|
||||
item = params.get("item", {})
|
||||
file_url = item.get("file")
|
||||
if file_url:
|
||||
await self._rpc_player_open({"item": {"file": file_url}})
|
||||
return "OK"
|
||||
|
||||
async def _rpc_playlist_get_items(self, params: Any) -> Dict[str, Any]:
|
||||
meta = self.player.status.metadata
|
||||
items = []
|
||||
if meta:
|
||||
items.append({"title": meta.title, "file": meta.url, "duration": int(meta.duration)})
|
||||
return {"items": items, "limits": {"start": 0, "total": len(items), "end": len(items)}}
|
||||
|
||||
async def _rpc_gui_show_notification(self, params: Dict[str, Any]) -> str:
|
||||
title = params.get("title", "")
|
||||
msg = params.get("message", "")
|
||||
text = f"{title}: {msg}" if title else msg
|
||||
await self.player.show_osd_message(text)
|
||||
return "OK"
|
||||
|
||||
async def _rpc_input_ok(self, params: Any) -> str:
|
||||
return "OK"
|
||||
|
||||
async def _rpc_input_back(self, params: Any) -> str:
|
||||
await self.player.stop()
|
||||
return "OK"
|
||||
|
||||
async def _rpc_settings_get_setting_value(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
setting = params.get("setting", "").lower()
|
||||
if "port" in setting:
|
||||
val = 9777 if "es" in setting else 8080
|
||||
elif any(k in setting for k in ("enabled", "eventserver", "webserver", "interface", "upnp", "zeroconf")):
|
||||
val = True
|
||||
else:
|
||||
val = True
|
||||
return {"value": val}
|
||||
|
||||
async def _rpc_settings_set_setting_value(self, params: Dict[str, Any]) -> bool:
|
||||
return True
|
||||
|
||||
async def _rpc_settings_get_settings(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"settings": [
|
||||
{"id": "services.eventserver", "value": True},
|
||||
{"id": "services.esenabled", "value": True},
|
||||
{"id": "services.esport", "value": 9777},
|
||||
{"id": "services.esallinterfaces", "value": True},
|
||||
{"id": "services.webserver", "value": True},
|
||||
{"id": "services.webserverport", "value": 8080},
|
||||
]
|
||||
}
|
||||
|
||||
async def _rpc_playlist_get_playlists(self, params: Any) -> List[Dict[str, Any]]:
|
||||
return [{"playlistid": 0, "type": "audio"}, {"playlistid": 1, "type": "video"}]
|
||||
|
||||
async def _rpc_files_get_sources(self, params: Any) -> Dict[str, Any]:
|
||||
return {"sources": [], "limits": {"start": 0, "total": 0, "end": 0}}
|
||||
|
||||
_methods = {
|
||||
"JSONRPC.Ping": _rpc_ping,
|
||||
"JSONRPC.Version": _rpc_version,
|
||||
"JSONRPC.Introspect": _rpc_introspect,
|
||||
"JSONRPC.Permission": _rpc_jsonrpc_permission,
|
||||
"Application.GetProperties": _rpc_app_get_properties,
|
||||
"Application.SetVolume": _rpc_app_set_volume,
|
||||
"Application.SetMute": _rpc_app_set_mute,
|
||||
"GUI.GetProperties": _rpc_gui_get_properties,
|
||||
"System.GetProperties": _rpc_system_get_properties,
|
||||
"Settings.GetSettingValue": _rpc_settings_get_setting_value,
|
||||
"Settings.SetSettingValue": _rpc_settings_set_setting_value,
|
||||
"Settings.GetSettings": _rpc_settings_get_settings,
|
||||
"XBMC.GetInfoBooleans": _rpc_xbmc_get_info_booleans,
|
||||
"XBMC.GetInfoLabels": _rpc_xbmc_get_info_labels,
|
||||
"Player.GetActivePlayers": _rpc_player_get_active_players,
|
||||
"Player.GetProperties": _rpc_player_get_properties,
|
||||
"Player.GetItem": _rpc_player_get_item,
|
||||
"Player.PlayPause": _rpc_player_play_pause,
|
||||
"Player.Stop": _rpc_player_stop,
|
||||
"Player.Seek": _rpc_player_seek,
|
||||
"Player.Open": _rpc_player_open,
|
||||
"Playlist.Add": _rpc_playlist_add,
|
||||
"Playlist.GetItems": _rpc_playlist_get_items,
|
||||
"Playlist.GetPlaylists": _rpc_playlist_get_playlists,
|
||||
"Files.GetSources": _rpc_files_get_sources,
|
||||
"GUI.ShowNotification": _rpc_gui_show_notification,
|
||||
"Input.Back": _rpc_input_back,
|
||||
"Input.Home": _rpc_input_ok,
|
||||
"Input.Up": _rpc_input_ok,
|
||||
"Input.Down": _rpc_input_ok,
|
||||
"Input.Left": _rpc_input_ok,
|
||||
"Input.Right": _rpc_input_ok,
|
||||
"Input.Select": _rpc_input_ok,
|
||||
"Input.ContextMenu": _rpc_input_ok,
|
||||
"Input.Info": _rpc_input_ok,
|
||||
"Input.ShowOSD": _rpc_input_ok,
|
||||
}
|
||||
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self.config.enabled:
|
||||
logger.info("Kodi RPC emulation server is disabled in config")
|
||||
return
|
||||
|
||||
# Start HTTP & WebSocket server on http_port (8080)
|
||||
self._runner = web.AppRunner(self.app)
|
||||
await self._runner.setup()
|
||||
self._site = web.TCPSite(self._runner, "0.0.0.0", self.config.http_port)
|
||||
await self._site.start()
|
||||
logger.info(
|
||||
"Kodi JSON-RPC HTTP server listening on http://0.0.0.0:%d (Yatse compatible)",
|
||||
self.config.http_port,
|
||||
)
|
||||
|
||||
# Start raw TCP socket server on ws_port (9090)
|
||||
await self._start_tcp_server()
|
||||
|
||||
async def _start_tcp_server(self) -> None:
|
||||
try:
|
||||
self._tcp_server = await asyncio.start_server(
|
||||
self._handle_tcp_client,
|
||||
"0.0.0.0",
|
||||
self.config.ws_port,
|
||||
)
|
||||
logger.info("Kodi TCP JSON-RPC socket server listening on port %d", self.config.ws_port)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start TCP JSON-RPC server on port %d: %s", self.config.ws_port, e)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._tcp_server:
|
||||
self._tcp_server.close()
|
||||
await self._tcp_server.wait_closed()
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
@@ -1,343 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from aiohttp import web
|
||||
|
||||
from src.config import WebApiConfig
|
||||
from src.player.models import MediaMetadata, PlaybackState
|
||||
from src.player.mpv_controller import MpvController
|
||||
from src.utils.youtube import fetch_youtube_metadata, is_youtube_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HTPC Media Remote</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card-bg: #1e293b;
|
||||
--accent: #38bdf8;
|
||||
--text: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--button-bg: #334155;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
.artist {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--button-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
width: 0%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.time-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
button {
|
||||
background: var(--button-bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:active {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--button-bg);
|
||||
background: #0b1120;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.share-btn {
|
||||
background: var(--accent);
|
||||
color: #0b1120;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>HTPC Media Player</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="input-group">
|
||||
<label for="urlInput" style="font-size: 0.9rem; color: var(--text-muted);">Share YouTube / Media URL:</label>
|
||||
<input type="text" id="urlInput" placeholder="Paste YouTube link here..." />
|
||||
<button class="share-btn" onclick="playUrl()">Play on TV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="title" id="mediaTitle">Nothing Playing</div>
|
||||
<div class="artist" id="mediaArtist">-</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="time-info">
|
||||
<span id="currentTime">00:00</span>
|
||||
<span id="totalTime">00:00</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="control('seek', {seconds: -15})">-15s</button>
|
||||
<button onclick="control('playpause')">⏯ Play/Pause</button>
|
||||
<button onclick="control('stop')">⏹ Stop</button>
|
||||
<button onclick="control('seek', {seconds: 15})">+15s</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.25rem;">
|
||||
<label style="font-size: 0.85rem; color: var(--text-muted);">Volume: <span id="volLabel">100%</span></label>
|
||||
<input type="range" id="volRange" min="0" max="100" value="100" onchange="setVolume(this.value)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fmtTime(sec) {
|
||||
sec = Math.max(0, Math.floor(sec || 0));
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m < 10 ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('mediaTitle').innerText = data.title || (data.state === 'idle' ? 'Ready to Play' : 'Unknown Media');
|
||||
document.getElementById('mediaArtist').innerText = data.artist || (data.source_type ? `Source: ${data.source_type}` : '-');
|
||||
document.getElementById('progressFill').style.width = `${data.percentage || 0}%`;
|
||||
document.getElementById('currentTime').innerText = fmtTime(data.current_time);
|
||||
document.getElementById('totalTime').innerText = fmtTime(data.duration);
|
||||
document.getElementById('volRange').value = data.volume;
|
||||
document.getElementById('volLabel').innerText = `${data.volume}%`;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function playUrl() {
|
||||
const input = document.getElementById('urlInput');
|
||||
const url = input.value.trim();
|
||||
if (!url) return;
|
||||
await fetch('/api/play', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({url})
|
||||
});
|
||||
input.value = '';
|
||||
setTimeout(updateStatus, 500);
|
||||
}
|
||||
|
||||
async function control(action, body = {}) {
|
||||
await fetch(`/api/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
setTimeout(updateStatus, 300);
|
||||
}
|
||||
|
||||
async function setVolume(vol) {
|
||||
await fetch('/api/volume', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({volume: parseInt(vol)})
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(updateStatus, 2000);
|
||||
updateStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class RestApiServer:
|
||||
"""Provides HTTP REST API endpoints and web interface for playback control and URL sharing."""
|
||||
|
||||
def __init__(self, config: WebApiConfig, player: MpvController):
|
||||
self.config = config
|
||||
self.player = player
|
||||
self.app = web.Application()
|
||||
self._runner: Optional[web.AppRunner] = None
|
||||
self._site: Optional[web.TCPSite] = None
|
||||
|
||||
self.app.router.add_get("/", self._handle_index)
|
||||
self.app.router.add_get("/api/status", self._handle_status)
|
||||
self.app.router.add_post("/api/play", self._handle_play)
|
||||
self.app.router.add_post("/api/playpause", self._handle_playpause)
|
||||
self.app.router.add_post("/api/pause", self._handle_pause)
|
||||
self.app.router.add_post("/api/resume", self._handle_resume)
|
||||
self.app.router.add_post("/api/stop", self._handle_stop)
|
||||
self.app.router.add_post("/api/seek", self._handle_seek)
|
||||
self.app.router.add_post("/api/volume", self._handle_volume)
|
||||
|
||||
async def _handle_index(self, request: web.Request) -> web.Response:
|
||||
return web.Response(text=HTML_TEMPLATE, content_type="text/html")
|
||||
|
||||
async def _handle_status(self, request: web.Request) -> web.Response:
|
||||
st = self.player.status
|
||||
meta = st.metadata
|
||||
data = {
|
||||
"state": st.state.value,
|
||||
"current_time": st.current_time,
|
||||
"duration": st.duration,
|
||||
"percentage": st.percentage,
|
||||
"volume": st.volume,
|
||||
"muted": st.muted,
|
||||
"title": meta.title if meta else None,
|
||||
"artist": meta.artist if meta else None,
|
||||
"album": meta.album if meta else None,
|
||||
"thumbnail": meta.thumbnail if meta else None,
|
||||
"source_type": meta.source_type if meta else None,
|
||||
}
|
||||
return web.json_response(data)
|
||||
|
||||
async def _handle_play(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
url = body.get("url")
|
||||
if not url:
|
||||
return web.json_response({"error": "Missing 'url' parameter"}, status=400)
|
||||
|
||||
metadata: Optional[MediaMetadata] = None
|
||||
if is_youtube_url(url):
|
||||
logger.info("REST API received YouTube URL: %s", url)
|
||||
metadata = await fetch_youtube_metadata(url)
|
||||
else:
|
||||
logger.info("REST API received media URL: %s", url)
|
||||
metadata = MediaMetadata(url=url, title=body.get("title") or url, source_type="url")
|
||||
|
||||
start_time = float(body.get("start", 0.0))
|
||||
await self.player.play_url(url, metadata=metadata, start_seconds=start_time)
|
||||
return web.json_response({"status": "playing", "title": metadata.title if metadata else url})
|
||||
|
||||
async def _handle_playpause(self, request: web.Request) -> web.Response:
|
||||
await self.player.play_pause()
|
||||
return web.json_response({"status": self.player.status.state.value})
|
||||
|
||||
async def _handle_pause(self, request: web.Request) -> web.Response:
|
||||
await self.player.pause()
|
||||
return web.json_response({"status": "paused"})
|
||||
|
||||
async def _handle_resume(self, request: web.Request) -> web.Response:
|
||||
await self.player.resume()
|
||||
return web.json_response({"status": "playing"})
|
||||
|
||||
async def _handle_stop(self, request: web.Request) -> web.Response:
|
||||
await self.player.stop()
|
||||
return web.json_response({"status": "stopped"})
|
||||
|
||||
async def _handle_seek(self, request: web.Request) -> web.Response:
|
||||
body = await request.json()
|
||||
seconds = float(body.get("seconds", 0.0))
|
||||
absolute = bool(body.get("absolute", False))
|
||||
await self.player.seek(seconds, absolute=absolute)
|
||||
return web.json_response({"current_time": self.player.status.current_time})
|
||||
|
||||
async def _handle_volume(self, request: web.Request) -> web.Response:
|
||||
body = await request.json()
|
||||
vol = int(body.get("volume", 100))
|
||||
await self.player.set_volume(vol)
|
||||
return web.json_response({"volume": self.player.status.volume})
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self.config.enabled:
|
||||
logger.info("REST API server is disabled in config")
|
||||
return
|
||||
|
||||
self._runner = web.AppRunner(self.app)
|
||||
await self._runner.setup()
|
||||
self._site = web.TCPSite(self._runner, self.config.host, self.config.port)
|
||||
await self._site.start()
|
||||
logger.info("REST API & Web Remote listening on http://%s:%d", self.config.host, self.config.port)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
@@ -1,117 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from src.player.models import MediaMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
YOUTUBE_REGEX = re.compile(
|
||||
r"(?:https?://)?(?:www\.|m\.)?(?:youtube\.com/(?:watch\?.*v=|embed/|v/|shorts/)|youtu\.be/)([\w-]{11})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
KODI_PLUGIN_YOUTUBE_REGEX = re.compile(
|
||||
r"plugin://plugin\.video\.youtube/(?:play/)?\?.*video_id=([\w-]{11})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_youtube_video_id(url_or_text: str) -> Optional[str]:
|
||||
"""Extract an 11-character YouTube video ID from various URL formats or text."""
|
||||
if not url_or_text:
|
||||
return None
|
||||
|
||||
url_clean = url_or_text.strip()
|
||||
|
||||
# Check for Kodi YouTube plugin URL: plugin://plugin.video.youtube/play/?video_id=...
|
||||
match = KODI_PLUGIN_YOUTUBE_REGEX.search(url_clean)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Check for standard YouTube URL
|
||||
match = YOUTUBE_REGEX.search(url_clean)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Check query string params (e.g., if inside a redirect or wrapped URL)
|
||||
try:
|
||||
parsed = urlparse(url_clean)
|
||||
qs = parse_qs(parsed.query)
|
||||
if "v" in qs and qs["v"]:
|
||||
vid = qs["v"][0]
|
||||
if len(vid) == 11:
|
||||
return vid
|
||||
if "video_id" in qs and qs["video_id"]:
|
||||
vid = qs["video_id"][0]
|
||||
if len(vid) == 11:
|
||||
return vid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_youtube_url(url_or_text: str) -> bool:
|
||||
return extract_youtube_video_id(url_or_text) is not None
|
||||
|
||||
|
||||
def get_canonical_youtube_url(video_id: str) -> str:
|
||||
return f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
|
||||
async def fetch_youtube_metadata(video_url_or_id: str) -> Optional[MediaMetadata]:
|
||||
"""Fetch video title, channel, duration, and thumbnail using yt-dlp."""
|
||||
video_id = extract_youtube_video_id(video_url_or_id)
|
||||
canonical_url = get_canonical_youtube_url(video_id) if video_id else video_url_or_id
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"yt-dlp",
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
"--skip-download",
|
||||
canonical_url,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0)
|
||||
|
||||
if proc.returncode != 0:
|
||||
logger.warning("yt-dlp returned code %d: %s", proc.returncode, stderr.decode().strip())
|
||||
return None
|
||||
|
||||
info = json.loads(stdout.decode("utf-8"))
|
||||
title = info.get("title", "YouTube Video")
|
||||
uploader = info.get("uploader") or info.get("channel") or "YouTube"
|
||||
duration = float(info.get("duration") or 0.0)
|
||||
thumbnail = info.get("thumbnail")
|
||||
|
||||
return MediaMetadata(
|
||||
url=canonical_url,
|
||||
title=title,
|
||||
artist=uploader,
|
||||
album="YouTube",
|
||||
duration=duration,
|
||||
thumbnail=thumbnail,
|
||||
source_type="youtube",
|
||||
extra={"youtube_id": video_id, "view_count": info.get("view_count")},
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout fetching metadata with yt-dlp for %s", canonical_url)
|
||||
except Exception as e:
|
||||
logger.warning("Error fetching metadata via yt-dlp: %s", e)
|
||||
|
||||
# Fallback if yt-dlp is unavailable or times out
|
||||
return MediaMetadata(
|
||||
url=canonical_url,
|
||||
title=f"YouTube Video ({video_id})" if video_id else "YouTube Video",
|
||||
artist="YouTube",
|
||||
source_type="youtube",
|
||||
extra={"youtube_id": video_id},
|
||||
)
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local Kodi EventServer (Yatse UDP 9777) test utility and CLI.
|
||||
|
||||
This script allows you to test the Kodi EventServer locally by sending
|
||||
valid Kodi EventServer UDP packets (HELO, PING, BUTTON, ACTION) and
|
||||
verifying handshake responses.
|
||||
|
||||
Usage examples:
|
||||
# 1. Test HELO and PING handshake
|
||||
python3 tests/test_eventserver_client.py ping
|
||||
|
||||
# 2. Send navigation buttons
|
||||
python3 tests/test_eventserver_client.py up
|
||||
python3 tests/test_eventserver_client.py down
|
||||
python3 tests/test_eventserver_client.py left
|
||||
python3 tests/test_eventserver_client.py right
|
||||
python3 tests/test_eventserver_client.py select
|
||||
python3 tests/test_eventserver_client.py back
|
||||
|
||||
# 3. Interactive keyboard control mode:
|
||||
python3 tests/test_eventserver_client.py interactive
|
||||
|
||||
# 4. Run automated test suite against running server:
|
||||
python3 tests/test_eventserver_client.py test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Kodi EventServer Constants
|
||||
HEADER_FORMAT = "!4sBBHIIHI10s"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 32 bytes
|
||||
|
||||
PT_HELO = 0x01
|
||||
PT_BYE = 0x02
|
||||
PT_BUTTON = 0x03
|
||||
PT_PING = 0x05
|
||||
PT_ACTION = 0x0A
|
||||
|
||||
PTB_DOWN = 0x02
|
||||
PTB_UP = 0x04
|
||||
|
||||
|
||||
class EventServerClient:
|
||||
"""UDP Client simulating Yatse / Kodi remote."""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 9777, timeout: float = 2.0):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.token = random.randint(100000, 999999)
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.settimeout(timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self.sock.close()
|
||||
|
||||
def _build_header(self, pkttype: int, paylen: int) -> bytes:
|
||||
return struct.pack(
|
||||
HEADER_FORMAT,
|
||||
b"XBMC", # Signature
|
||||
2, # Major version
|
||||
0, # Minor version
|
||||
pkttype, # Packet type
|
||||
1, # Seq
|
||||
1, # MaxSeq
|
||||
paylen, # Payload length
|
||||
self.token,
|
||||
b"\x00" * 10,
|
||||
)
|
||||
|
||||
def send_helo(self, device_name: str = "Local Yatse Test") -> bool:
|
||||
"""Send HELO packet and wait for PING ACK response."""
|
||||
payload = (
|
||||
device_name.encode("utf-8") + b"\x00" +
|
||||
b"\x00" + # Icon type
|
||||
struct.pack("!H", 0) + # Port
|
||||
b"\x00" * 8 # Reserved
|
||||
)
|
||||
packet = self._build_header(PT_HELO, len(payload)) + payload
|
||||
self.sock.sendto(packet, (self.host, self.port))
|
||||
|
||||
try:
|
||||
data, addr = self.sock.recvfrom(1024)
|
||||
if len(data) >= HEADER_SIZE:
|
||||
sig, maj, minv, pkttype, seq, maxseq, plen, token, _ = struct.unpack(
|
||||
HEADER_FORMAT, data[:HEADER_SIZE]
|
||||
)
|
||||
if sig == b"XBMC" and pkttype == PT_PING and token == self.token:
|
||||
return True
|
||||
except socket.timeout:
|
||||
return False
|
||||
return False
|
||||
|
||||
def send_ping(self) -> bool:
|
||||
"""Send PING packet and wait for PING ACK response."""
|
||||
packet = self._build_header(PT_PING, 0)
|
||||
self.sock.sendto(packet, (self.host, self.port))
|
||||
|
||||
try:
|
||||
data, addr = self.sock.recvfrom(1024)
|
||||
if len(data) >= HEADER_SIZE:
|
||||
sig, _, _, pkttype, _, _, _, token, _ = struct.unpack(
|
||||
HEADER_FORMAT, data[:HEADER_SIZE]
|
||||
)
|
||||
if sig == b"XBMC" and pkttype == PT_PING and token == self.token:
|
||||
return True
|
||||
except socket.timeout:
|
||||
return False
|
||||
return False
|
||||
|
||||
def send_button(self, button_name: str, map_name: str = "R1", is_down: bool = True) -> None:
|
||||
"""Send BUTTON packet (e.g. up, down, left, right, select, back)."""
|
||||
flags = PTB_DOWN if is_down else PTB_UP
|
||||
payload = (
|
||||
struct.pack("!HHH", 0, flags, 0) +
|
||||
map_name.encode("utf-8") + b"\x00" +
|
||||
button_name.encode("utf-8") + b"\x00"
|
||||
)
|
||||
packet = self._build_header(PT_BUTTON, len(payload)) + payload
|
||||
self.sock.sendto(packet, (self.host, self.port))
|
||||
|
||||
def send_action(self, action_str: str) -> None:
|
||||
"""Send ACTION packet (e.g. Action(1), Action(7), Up)."""
|
||||
payload = b"\x01" + action_str.encode("utf-8") + b"\x00"
|
||||
packet = self._build_header(PT_ACTION, len(payload)) + payload
|
||||
self.sock.sendto(packet, (self.host, self.port))
|
||||
|
||||
def send_bye(self) -> None:
|
||||
"""Send BYE disconnect packet."""
|
||||
packet = self._build_header(PT_BYE, 0)
|
||||
self.sock.sendto(packet, (self.host, self.port))
|
||||
|
||||
|
||||
def run_automated_tests(host: str, port: int) -> bool:
|
||||
print(f"Testing Kodi EventServer at {host}:{port}...")
|
||||
client = EventServerClient(host, port, timeout=2.0)
|
||||
|
||||
# Test 1: HELO Handshake
|
||||
print(" [1/4] Testing HELO handshake (client registration)...", end=" ", flush=True)
|
||||
if client.send_helo("EventServer Test Suite"):
|
||||
print("OK (Received PING-ACK)")
|
||||
else:
|
||||
print("FAILED (No PING-ACK response received)")
|
||||
print(f" Ensure jellyfin-kiosk is running and listening on UDP {port}")
|
||||
client.close()
|
||||
return False
|
||||
|
||||
# Test 2: PING Heartbeat
|
||||
print(" [2/4] Testing PING heartbeat...", end=" ", flush=True)
|
||||
if client.send_ping():
|
||||
print("OK (Received PING-ACK)")
|
||||
else:
|
||||
print("FAILED")
|
||||
client.close()
|
||||
return False
|
||||
|
||||
# Test 3: Button Events
|
||||
print(" [3/4] Sending navigation buttons (Up, Down, Left, Right, Select, Back)...", end=" ", flush=True)
|
||||
for btn in ["up", "down", "left", "right", "select", "back"]:
|
||||
client.send_button(btn)
|
||||
time.sleep(0.05)
|
||||
print("OK (Packets sent)")
|
||||
|
||||
# Test 4: Action Events
|
||||
print(" [4/4] Sending action strings (Action(1), Action(7))...", end=" ", flush=True)
|
||||
for act in ["Action(1)", "Action(7)"]:
|
||||
client.send_action(act)
|
||||
time.sleep(0.05)
|
||||
print("OK (Packets sent)")
|
||||
|
||||
client.send_bye()
|
||||
client.close()
|
||||
print("\nAll EventServer tests passed successfully!")
|
||||
return True
|
||||
|
||||
|
||||
def run_interactive_mode(client: EventServerClient) -> None:
|
||||
print("\n--- EventServer Interactive Remote Mode ---")
|
||||
print("Controls:")
|
||||
print(" [W] or [Up] : Up")
|
||||
print(" [S] or [Down] : Down")
|
||||
print(" [A] or [Left] : Left")
|
||||
print(" [D] or [Right] : Right")
|
||||
print(" [Enter] / [O] : Select / OK")
|
||||
print(" [B] / [Esc] : Back")
|
||||
print(" [Space] / [P] : Play / Pause")
|
||||
print(" [X] : Stop")
|
||||
print(" [Q] : Quit\n")
|
||||
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
while True:
|
||||
ch = sys.stdin.read(1)
|
||||
if ch == "\x1b": # Escape or arrow sequence
|
||||
# Non-blocking check for arrow keys
|
||||
import select
|
||||
r, _, _ = select.select([sys.stdin], [], [], 0.05)
|
||||
if r:
|
||||
seq = sys.stdin.read(2)
|
||||
if seq == "[A":
|
||||
client.send_button("up")
|
||||
elif seq == "[B":
|
||||
client.send_button("down")
|
||||
elif seq == "[C":
|
||||
client.send_button("right")
|
||||
elif seq == "[D":
|
||||
client.send_button("left")
|
||||
else:
|
||||
client.send_button("back")
|
||||
elif ch in ("q", "Q", "\x03"): # Quit or Ctrl+C
|
||||
break
|
||||
elif ch in ("w", "W"):
|
||||
client.send_button("up")
|
||||
elif ch in ("s", "S"):
|
||||
client.send_button("down")
|
||||
elif ch in ("a", "A"):
|
||||
client.send_button("left")
|
||||
elif ch in ("d", "D"):
|
||||
client.send_button("right")
|
||||
elif ch in ("\r", "\n", "o", "O"):
|
||||
client.send_button("select")
|
||||
elif ch in ("b", "B"):
|
||||
client.send_button("back")
|
||||
elif ch in (" ", "p", "P"):
|
||||
client.send_button("playpause")
|
||||
elif ch in ("x", "X"):
|
||||
client.send_button("stop")
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
print("\nExited interactive mode.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Kodi EventServer Local Test Client")
|
||||
parser.add_argument("command", nargs="?", default="test",
|
||||
help="Command: test, ping, helo, up, down, left, right, select, back, play, stop, interactive")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="EventServer host (default: 127.0.0.1)")
|
||||
parser.add_argument("--port", type=int, default=9777, help="EventServer UDP port (default: 9777)")
|
||||
|
||||
args = parser.parse_args()
|
||||
cmd = args.command.lower()
|
||||
|
||||
if cmd == "test":
|
||||
success = run_automated_tests(args.host, args.port)
|
||||
return 0 if success else 1
|
||||
|
||||
client = EventServerClient(args.host, args.port)
|
||||
try:
|
||||
if cmd == "ping":
|
||||
print(f"Sending PING to {args.host}:{args.port}...")
|
||||
if client.send_ping():
|
||||
print("Success: Received PING-ACK from EventServer!")
|
||||
else:
|
||||
print("Failed: No response (timeout)")
|
||||
return 1
|
||||
elif cmd == "helo":
|
||||
print(f"Sending HELO to {args.host}:{args.port}...")
|
||||
if client.send_helo():
|
||||
print("Success: EventServer accepted HELO and replied with PING-ACK!")
|
||||
else:
|
||||
print("Failed: No response (timeout)")
|
||||
return 1
|
||||
elif cmd in ("up", "down", "left", "right", "select", "back", "play", "pause", "playpause", "stop"):
|
||||
client.send_button(cmd)
|
||||
print(f"Sent button '{cmd}' to {args.host}:{args.port}")
|
||||
elif cmd == "interactive":
|
||||
run_interactive_mode(client)
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
return 1
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user