A ratatui-based terminal browser for an Emby server with mpv as the player
backend. Authenticates via /Users/AuthenticateByName, lists libraries and
items, streams via /Videos/{id}/stream, and reports playback progress so
"Continue Watching" stays in sync across clients.
Credentials are read in priority order: CLI flags, env vars
(EMBY_SERVER/EMBY_USER/EMBY_PASSWORD), ./config.toml or
$XDG_CONFIG_HOME/emby-client/config.toml, then an in-TUI login prompt with
an option to persist to config.toml.
Modules: config (CLI + TOML loader), login (TUI prompt), emby (HTTP client
+ progress Reporter), player (libmpv2 wrapper, blocks until file ends),
app (browse state, Continue Watching virtual entry), ui (ratatui frontend).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
370 lines
11 KiB
Rust
370 lines
11 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>,
|
|
#[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)
|
|
}
|
|
|
|
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,
|
|
},
|
|
)
|
|
}
|
|
}
|