Initial commit: Emby TUI client with libmpv playback
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]>
This commit is contained in:
commit
af43b21f5d
11 files changed
+3712
No files matched your search
+369
@@ -0,0 +1,369 @@
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user