use anyhow::{Context, Result, anyhow}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::config::Config; const CLIENT_NAME: &str = "EmbyTuiClient"; const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const DEVICE_NAME: &str = "emby-client-tui"; #[derive(Debug, Clone)] pub struct Client { http: reqwest::Client, pub server: String, pub user_id: String, pub user_name: String, pub token: String, pub device_id: String, } #[derive(Debug, Serialize)] struct AuthRequest<'a> { #[serde(rename = "Username")] username: &'a str, #[serde(rename = "Pw")] pw: &'a str, } #[derive(Debug, Deserialize)] struct AuthResponse { #[serde(rename = "AccessToken")] access_token: String, #[serde(rename = "User")] user: AuthUser, } #[derive(Debug, Deserialize)] struct AuthUser { #[serde(rename = "Id")] id: String, #[serde(rename = "Name")] name: String, } #[derive(Debug, Clone, Deserialize, Default)] pub struct Item { #[serde(rename = "Id")] pub id: String, #[serde(rename = "Name")] pub name: String, #[serde(rename = "Type", default)] pub item_type: String, #[serde(rename = "IsFolder", default)] pub is_folder: bool, #[serde(rename = "ProductionYear", default)] pub year: Option, #[serde(rename = "RunTimeTicks", default)] pub runtime_ticks: Option, #[serde(rename = "SeriesName", default)] pub series_name: Option, /// Set for Episode items. Lets us fetch the full series episode list so a /// queue started mid-show can continue past the current season. #[serde(rename = "SeriesId", default)] pub series_id: Option, #[serde(rename = "IndexNumber", default)] pub index_number: Option, #[serde(rename = "ParentIndexNumber", default)] pub parent_index_number: Option, #[serde(rename = "UserData", default)] pub user_data: Option, } impl Item { /// Human-readable label suitable for status lines and mpv's force-media-title. /// Episodes get "Series — SxxEyy — Title"; movies get "Name (Year)". pub fn display_title(&self) -> String { if self.item_type == "Episode" { let series = self.series_name.as_deref().unwrap_or(""); let code = match (self.parent_index_number, self.index_number) { (Some(s), Some(e)) => format!("S{s:02}E{e:02}"), (None, Some(e)) => format!("E{e:02}"), _ => String::new(), }; let mut parts: Vec = Vec::new(); if !series.is_empty() { parts.push(series.to_string()); } if !code.is_empty() { parts.push(code); } parts.push(self.name.clone()); parts.join(" — ") } else if let Some(year) = self.year { format!("{} ({year})", self.name) } else { self.name.clone() } } } #[derive(Debug, Clone, Deserialize, Default)] pub struct UserData { #[serde(rename = "PlaybackPositionTicks", default)] pub position_ticks: i64, #[serde(rename = "PlayedPercentage", default)] pub played_percentage: Option, } #[derive(Debug, Deserialize)] struct ItemsResponse { #[serde(rename = "Items", default)] items: Vec, } impl Client { pub async fn new(cfg: &Config) -> Result { let http = reqwest::Client::builder() .user_agent(format!("{CLIENT_NAME}/{CLIENT_VERSION}")) .build()?; let device_id = Uuid::new_v4().to_string(); let auth_header = format!( "MediaBrowser Client=\"{CLIENT_NAME}\", Device=\"{DEVICE_NAME}\", DeviceId=\"{device_id}\", Version=\"{CLIENT_VERSION}\"" ); let url = format!("{}/Users/AuthenticateByName", cfg.server); let resp = http .post(&url) .header("X-Emby-Authorization", &auth_header) .json(&AuthRequest { username: &cfg.user, pw: &cfg.password, }) .send() .await .with_context(|| format!("POST {url}"))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(anyhow!("authentication failed: HTTP {status}: {body}")); } let parsed: AuthResponse = resp.json().await.context("parsing auth response")?; Ok(Self { http, server: cfg.server.clone(), user_id: parsed.user.id, user_name: parsed.user.name, token: parsed.access_token, device_id, }) } fn headers(&self) -> HeaderMap { let mut h = HeaderMap::new(); h.insert( HeaderName::from_static("x-emby-token"), HeaderValue::from_str(&self.token).expect("token is ascii"), ); h } pub async fn views(&self) -> Result> { let url = format!("{}/Users/{}/Views", self.server, self.user_id); let resp: ItemsResponse = self .http .get(&url) .headers(self.headers()) .send() .await? .error_for_status()? .json() .await?; Ok(resp.items) } pub async fn items(&self, parent_id: &str) -> Result> { let url = format!("{}/Users/{}/Items", self.server, self.user_id); let resp: ItemsResponse = self .http .get(&url) .headers(self.headers()) .query(&[ ("ParentId", parent_id), // ParentIndexNumber+IndexNumber gives correct season/episode order // (and season order inside a series); SortName is the fallback for // movies, libraries, etc. where index numbers are absent. ("SortBy", "ParentIndexNumber,IndexNumber,SortName"), ("SortOrder", "Ascending"), ("Fields", "ProductionYear,RunTimeTicks,SeriesName,UserData"), ]) .send() .await? .error_for_status()? .json() .await?; Ok(resp.items) } /// In-progress items across all libraries (Emby "Continue Watching"). pub async fn resume_items(&self, limit: u32) -> Result> { let url = format!("{}/Users/{}/Items/Resume", self.server, self.user_id); let limit_s = limit.to_string(); let resp: ItemsResponse = self .http .get(&url) .headers(self.headers()) .query(&[ ("Limit", limit_s.as_str()), ("Recursive", "true"), ("MediaTypes", "Video"), ("Fields", "ProductionYear,RunTimeTicks,SeriesName,UserData"), ]) .send() .await? .error_for_status()? .json() .await?; Ok(resp.items) } /// All episodes for a series, in playback order (season, then episode). /// Used to build a multi-season playback queue starting from any episode. pub async fn series_episodes(&self, series_id: &str) -> Result> { let url = format!("{}/Shows/{}/Episodes", self.server, series_id); let resp: ItemsResponse = self .http .get(&url) .headers(self.headers()) .query(&[ ("UserId", self.user_id.as_str()), ("Fields", "ProductionYear,RunTimeTicks,SeriesName,UserData"), ]) .send() .await? .error_for_status()? .json() .await?; Ok(resp.items) } /// Ask Emby to rescan/refresh a library folder or media folder. pub async fn refresh_item(&self, item_id: &str) -> Result<()> { let url = format!("{}/Items/{item_id}/Refresh", self.server); let resp = self .http .post(&url) .headers(self.headers()) .query(&[ ("Recursive", "true"), ("ImageRefreshMode", "Default"), ("MetadataRefreshMode", "Default"), ("ReplaceAllImages", "false"), ("ReplaceAllMetadata", "false"), ]) .send() .await .with_context(|| format!("POST {url}"))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return Err(anyhow!("refresh failed: HTTP {status}: {body}")); } Ok(()) } pub fn stream_url(&self, item_id: &str) -> String { format!( "{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}", self.server, item_id, self.token, self.device_id ) } /// Build a playback reporter for a specific item. Cheap; reuses the /// async HTTP client. Methods are sync — they bridge into the tokio /// runtime internally so they can be called from the libmpv event loop. pub fn reporter(&self, item_id: String) -> Reporter { Reporter { http: self.http.clone(), server: self.server.clone(), token: self.token.clone(), item_id, play_session_id: Uuid::new_v4().to_string(), } } } /// Reports playback progress back to Emby so Continue Watching reflects /// what was played in this TUI. Cheap to construct per playback session. pub struct Reporter { http: reqwest::Client, server: String, token: String, item_id: String, play_session_id: String, } #[derive(Debug, Serialize)] struct StartBody<'a> { #[serde(rename = "ItemId")] item_id: &'a str, #[serde(rename = "MediaSourceId")] media_source_id: &'a str, #[serde(rename = "PlaySessionId")] play_session_id: &'a str, #[serde(rename = "PlayMethod")] play_method: &'static str, #[serde(rename = "CanSeek")] can_seek: bool, #[serde(rename = "PositionTicks")] position_ticks: i64, } #[derive(Debug, Serialize)] struct ProgressBody<'a> { #[serde(rename = "ItemId")] item_id: &'a str, #[serde(rename = "MediaSourceId")] media_source_id: &'a str, #[serde(rename = "PlaySessionId")] play_session_id: &'a str, #[serde(rename = "PlayMethod")] play_method: &'static str, #[serde(rename = "PositionTicks")] position_ticks: i64, #[serde(rename = "IsPaused")] is_paused: bool, #[serde(rename = "EventName")] event_name: &'static str, } #[derive(Debug, Serialize)] struct StoppedBody<'a> { #[serde(rename = "ItemId")] item_id: &'a str, #[serde(rename = "MediaSourceId")] media_source_id: &'a str, #[serde(rename = "PlaySessionId")] play_session_id: &'a str, #[serde(rename = "PositionTicks")] position_ticks: i64, } impl Reporter { async fn post_async(&self, path: &str, body: &T) -> Result<()> { let url = format!("{}{path}", self.server); let resp = self .http .post(&url) .header("X-Emby-Token", &self.token) .json(body) .send() .await .with_context(|| format!("POST {url}"))?; if !resp.status().is_success() { return Err(anyhow!("Emby reporting failed: HTTP {}", resp.status())); } Ok(()) } /// Bridge from the sync libmpv event loop into the async runtime. /// Requires being called from a thread that participates in a /// multi-thread tokio runtime (true when invoked from `#[tokio::main]`). fn post(&self, path: &str, body: &T) -> Result<()> { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(self.post_async(path, body)) }) } pub fn started(&self, position_ticks: i64) -> Result<()> { self.post( "/Sessions/Playing", &StartBody { item_id: &self.item_id, media_source_id: &self.item_id, play_session_id: &self.play_session_id, play_method: "DirectStream", can_seek: true, position_ticks, }, ) } pub fn progress(&self, position_ticks: i64, is_paused: bool) -> Result<()> { self.post( "/Sessions/Playing/Progress", &ProgressBody { item_id: &self.item_id, media_source_id: &self.item_id, play_session_id: &self.play_session_id, play_method: "DirectStream", position_ticks, is_paused, event_name: "TimeUpdate", }, ) } pub fn stopped(&self, position_ticks: i64) -> Result<()> { self.post( "/Sessions/Playing/Stopped", &StoppedBody { item_id: &self.item_id, media_source_id: &self.item_id, play_session_id: &self.play_session_id, position_ticks, }, ) } }