Files
emby-tui-client/src/emby.rs
T
2026-06-12 17:45:45 +03:00

414 lines
13 KiB
Rust

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<i32>,
#[serde(rename = "RunTimeTicks", default)]
pub runtime_ticks: Option<i64>,
#[serde(rename = "SeriesName", default)]
pub series_name: Option<String>,
/// 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<String>,
#[serde(rename = "IndexNumber", default)]
pub index_number: Option<i32>,
#[serde(rename = "ParentIndexNumber", default)]
pub parent_index_number: Option<i32>,
#[serde(rename = "UserData", default)]
pub user_data: Option<UserData>,
}
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<String> = 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<f64>,
}
#[derive(Debug, Deserialize)]
struct ItemsResponse {
#[serde(rename = "Items", default)]
items: Vec<Item>,
}
impl Client {
pub async fn new(cfg: &Config) -> Result<Self> {
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<Vec<Item>> {
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<Vec<Item>> {
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<Vec<Item>> {
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<Vec<Item>> {
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<T: Serialize>(&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<T: Serialize>(&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,
},
)
}
}