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:
janisslsmandClaude Opus 4.7 committed 2026-05-16 21:38:22 +03:00
commit af43b21f5d
11 files changed
+3712

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
use anyhow::Result;
use ratatui::widgets::ListState;
use crate::emby::{Client, Item};
use crate::player::Player;
pub const RESUME_VIRTUAL_ID: &str = "__resume__";
pub const RESUME_TITLE: &str = "Continue Watching";
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum View {
Libraries,
Items,
}
#[derive(Debug, Clone)]
pub enum Action {
Play {
id: String,
/// Display title (e.g. "Series — S01E02 — Episode Title").
title: String,
/// Resume position from server-side UserData, in Emby ticks (100 ns units).
start_ticks: i64,
},
}
pub struct App {
pub client: Client,
pub view: View,
pub libraries: Vec<Item>,
pub resume: Vec<Item>,
/// Libraries view list = optional virtual "Continue Watching" entry + libraries.
pub home_entries: Vec<Item>,
pub items: Vec<Item>,
pub list_state: ListState,
pub current_title: Option<String>,
pub breadcrumbs: Vec<(String, String)>, // (id, name)
pub status: String,
}
impl App {
pub async fn new(client: Client) -> Result<Self> {
let libraries = client.views().await?;
// Best-effort: don't fail startup if the resume endpoint hiccups.
let resume = client.resume_items(20).await.unwrap_or_default();
let home_entries = build_home_entries(&libraries, &resume);
let mut list_state = ListState::default();
if !home_entries.is_empty() {
list_state.select(Some(0));
}
let status = format!(
"Connected as {} · {} libraries · {} in progress",
client.user_name,
libraries.len(),
resume.len()
);
Ok(Self {
client,
view: View::Libraries,
libraries,
resume,
home_entries,
items: vec![],
list_state,
current_title: None,
breadcrumbs: vec![],
status,
})
}
pub fn current_list(&self) -> &[Item] {
match self.view {
View::Libraries => &self.home_entries,
View::Items => &self.items,
}
}
/// Refresh the Continue Watching list (after playback ends, for example).
pub async fn refresh_resume(&mut self) {
if let Ok(resume) = self.client.resume_items(20).await {
self.resume = resume;
self.home_entries = build_home_entries(&self.libraries, &self.resume);
}
}
pub fn move_down(&mut self) {
let len = self.current_list().len();
if len == 0 {
return;
}
let i = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some((i + 1).min(len - 1)));
}
pub fn move_up(&mut self) {
let i = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some(i.saturating_sub(1)));
}
pub fn page_down(&mut self) {
let len = self.current_list().len();
if len == 0 {
return;
}
let i = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some((i + 10).min(len - 1)));
}
pub fn page_up(&mut self) {
let i = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some(i.saturating_sub(10)));
}
pub async fn activate(&mut self) -> Result<Option<Action>> {
let Some(i) = self.list_state.selected() else {
return Ok(None);
};
match self.view {
View::Libraries => {
let Some(entry) = self.home_entries.get(i).cloned() else {
return Ok(None);
};
if entry.id == RESUME_VIRTUAL_ID {
self.items = self.resume.clone();
self.view = View::Items;
self.breadcrumbs.clear();
self.breadcrumbs
.push((RESUME_VIRTUAL_ID.into(), RESUME_TITLE.into()));
self.current_title = Some(RESUME_TITLE.into());
self.list_state
.select(if self.items.is_empty() { None } else { Some(0) });
self.status = format!("{}: {} items", RESUME_TITLE, self.items.len());
return Ok(None);
}
self.status = format!("Loading {}...", entry.name);
self.items = self.client.items(&entry.id).await?;
self.view = View::Items;
self.breadcrumbs.clear();
self.breadcrumbs
.push((entry.id.clone(), entry.name.clone()));
self.current_title = Some(entry.name.clone());
self.list_state
.select(if self.items.is_empty() { None } else { Some(0) });
self.status = format!("{}: {} items", entry.name, self.items.len());
Ok(None)
}
View::Items => {
let Some(item) = self.items.get(i).cloned() else {
return Ok(None);
};
if item.is_folder {
self.status = format!("Loading {}...", item.name);
self.items = self.client.items(&item.id).await?;
self.breadcrumbs.push((item.id.clone(), item.name.clone()));
self.current_title = Some(item.name.clone());
self.list_state
.select(if self.items.is_empty() { None } else { Some(0) });
self.status = format!("{}: {} items", item.name, self.items.len());
Ok(None)
} else {
let start_ticks = item
.user_data
.as_ref()
.map(|ud| ud.position_ticks)
.unwrap_or(0);
let title = item.display_title();
Ok(Some(Action::Play {
id: item.id,
title,
start_ticks,
}))
}
}
}
}
pub async fn back(&mut self) -> Result<()> {
if self.view != View::Items {
return Ok(());
}
// Pop the current breadcrumb (current folder)
self.breadcrumbs.pop();
if let Some((parent_id, parent_name)) = self.breadcrumbs.last().cloned() {
if parent_id == RESUME_VIRTUAL_ID {
self.items = self.resume.clone();
} else {
self.items = self.client.items(&parent_id).await?;
}
self.current_title = Some(parent_name.clone());
self.list_state
.select(if self.items.is_empty() { None } else { Some(0) });
self.status = format!("{}: {} items", parent_name, self.items.len());
} else {
self.view = View::Libraries;
self.current_title = None;
self.items.clear();
self.list_state.select(if self.home_entries.is_empty() {
None
} else {
Some(0)
});
self.status = format!(
"{} libraries · {} in progress",
self.libraries.len(),
self.resume.len()
);
}
Ok(())
}
pub fn play(&mut self, action: &Action) -> Result<()> {
match action {
Action::Play {
id,
title,
start_ticks,
} => {
let url = self.client.stream_url(id);
self.status = format!("Playing {title}...");
let start_seconds = (*start_ticks as f64) / 10_000_000.0;
let reporter = self.client.reporter(id.clone());
let mut player = Player::new()?;
let r = player.play_blocking(&url, title, start_seconds, Some(&reporter));
self.status = match &r {
Ok(_) => format!("Finished: {title}"),
Err(e) => format!("Playback error: {e}"),
};
r
}
}
}
pub fn breadcrumb_path(&self) -> String {
if self.breadcrumbs.is_empty() {
"Libraries".into()
} else {
self.breadcrumbs
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(" / ")
}
}
}
fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec<Item> {
let mut entries: Vec<Item> = Vec::with_capacity(libraries.len() + 1);
if !resume.is_empty() {
entries.push(Item {
id: RESUME_VIRTUAL_ID.into(),
name: format!("{RESUME_TITLE} ({})", resume.len()),
item_type: "Collection".into(),
is_folder: true,
..Item::default()
});
}
entries.extend(libraries.iter().cloned());
entries
}
+111
View File
@@ -0,0 +1,111 @@
use anyhow::{Context, Result};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Parser, Debug, Clone)]
#[command(name = "emby-client", about = "TUI Emby client with libmpv playback")]
pub struct CliArgs {
/// Emby server URL (e.g. http://emby.home.local)
#[arg(long, env = "EMBY_SERVER")]
pub server: Option<String>,
/// Emby username
#[arg(short, long, env = "EMBY_USER")]
pub user: Option<String>,
/// Emby password
#[arg(short, long, env = "EMBY_PASSWORD")]
pub password: Option<String>,
/// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml
#[arg(short, long)]
pub config: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FileConfig {
pub server: Option<String>,
pub user: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub server: String,
pub user: String,
pub password: String,
}
/// Returns `(Some(config), save_target)` when all credentials are available,
/// or `(None, save_target)` when at least one is missing — the caller can then
/// prompt the user. `save_target` is the path where new credentials should
/// be persisted if the user chooses to save them.
pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> {
let (file_cfg, save_target) = read_file_config(args.config.as_deref())?;
let server = args.server.clone().or(file_cfg.server);
let user = args.user.clone().or(file_cfg.user);
let password = args.password.clone().or(file_cfg.password);
let cfg = match (server, user, password) {
(Some(server), Some(user), Some(password)) => Some(Config {
server: server.trim_end_matches('/').to_string(),
user,
password,
}),
_ => None,
};
Ok((cfg, Some(save_target)))
}
fn read_file_config(explicit: Option<&Path>) -> Result<(FileConfig, PathBuf)> {
let default_target = default_save_path();
let candidates: Vec<PathBuf> = if let Some(p) = explicit {
vec![p.to_path_buf()]
} else {
let mut v = vec![PathBuf::from("config.toml")];
if let Some(dirs) = directories::ProjectDirs::from("", "", "emby-client") {
v.push(dirs.config_dir().join("config.toml"));
}
v
};
let mut save_target = explicit
.map(|p| p.to_path_buf())
.unwrap_or(default_target);
for path in &candidates {
if path.exists() {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config {}", path.display()))?;
let cfg: FileConfig = toml::from_str(&text)
.with_context(|| format!("parsing config {}", path.display()))?;
save_target = path.clone();
return Ok((cfg, save_target));
}
}
Ok((FileConfig::default(), save_target))
}
fn default_save_path() -> PathBuf {
if let Some(dirs) = directories::ProjectDirs::from("", "", "emby-client") {
dirs.config_dir().join("config.toml")
} else {
PathBuf::from("config.toml")
}
}
pub fn save(path: &Path, cfg: &Config) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let file_cfg = FileConfig {
server: Some(cfg.server.clone()),
user: Some(cfg.user.clone()),
password: Some(cfg.password.clone()),
};
let text = toml::to_string_pretty(&file_cfg).context("serializing config")?;
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
+369
View File
@@ -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,
},
)
}
}
+263
View File
@@ -0,0 +1,263 @@
use anyhow::{Result, anyhow};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use std::io::stdout;
use std::path::Path;
use std::time::Duration;
use crate::config::{self, Config};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Field {
Server = 0,
User = 1,
Password = 2,
SaveToggle = 3,
}
impl Field {
fn next(self) -> Self {
match self {
Field::Server => Field::User,
Field::User => Field::Password,
Field::Password => Field::SaveToggle,
Field::SaveToggle => Field::Server,
}
}
fn prev(self) -> Self {
match self {
Field::Server => Field::SaveToggle,
Field::User => Field::Server,
Field::Password => Field::User,
Field::SaveToggle => Field::Password,
}
}
}
struct Form {
server: String,
user: String,
password: String,
save_to_disk: bool,
focus: Field,
error: Option<String>,
}
pub fn prompt(save_target: Option<&Path>) -> Result<Config> {
enable_raw_mode()?;
let mut out = stdout();
execute!(out, EnterAlternateScreen)?;
let mut term = Terminal::new(CrosstermBackend::new(out))?;
let mut form = Form {
server: String::new(),
user: String::new(),
password: String::new(),
save_to_disk: save_target.is_some(),
focus: Field::Server,
error: None,
};
let result = run(&mut term, &mut form);
disable_raw_mode().ok();
execute!(term.backend_mut(), LeaveAlternateScreen).ok();
term.show_cursor().ok();
let cfg = result?;
if form.save_to_disk
&& let Some(path) = save_target
{
if let Err(e) = config::save(path, &cfg) {
eprintln!("warning: failed to save config to {}: {e}", path.display());
} else {
eprintln!("saved credentials to {}", path.display());
}
}
Ok(cfg)
}
fn run<B: ratatui::backend::Backend>(
term: &mut Terminal<B>,
form: &mut Form,
) -> Result<Config> {
loop {
term.draw(|f| draw(f, form))?;
if !event::poll(Duration::from_millis(200))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
{
return Err(anyhow!("login canceled by user"));
}
match key.code {
KeyCode::Esc => return Err(anyhow!("login canceled by user")),
KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(),
KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(),
KeyCode::Enter => {
if form.focus == Field::SaveToggle {
form.save_to_disk = !form.save_to_disk;
continue;
}
// Validate
let server = form.server.trim().trim_end_matches('/').to_string();
let user = form.user.trim().to_string();
if server.is_empty() {
form.error = Some("Server URL required".into());
form.focus = Field::Server;
continue;
}
if !(server.starts_with("http://") || server.starts_with("https://")) {
form.error = Some("Server URL must start with http:// or https://".into());
form.focus = Field::Server;
continue;
}
if user.is_empty() {
form.error = Some("Username required".into());
form.focus = Field::User;
continue;
}
return Ok(Config {
server,
user,
password: form.password.clone(),
});
}
KeyCode::Char(' ') if form.focus == Field::SaveToggle => {
form.save_to_disk = !form.save_to_disk;
}
KeyCode::Char(c) => match form.focus {
Field::Server => form.server.push(c),
Field::User => form.user.push(c),
Field::Password => form.password.push(c),
Field::SaveToggle => {}
},
KeyCode::Backspace => match form.focus {
Field::Server => {
form.server.pop();
}
Field::User => {
form.user.pop();
}
Field::Password => {
form.password.pop();
}
Field::SaveToggle => {}
},
_ => {}
}
}
}
fn draw(f: &mut ratatui::Frame, form: &Form) {
let area = centered_rect(70, 60, f.area());
let block = Block::default()
.borders(Borders::ALL)
.title(" Emby Login ")
.border_style(Style::default().fg(Color::Cyan));
f.render_widget(block.clone(), area);
let inner = block.inner(area);
let rows = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(1), // hint
Constraint::Length(3), // server
Constraint::Length(3), // user
Constraint::Length(3), // password
Constraint::Length(2), // save toggle
Constraint::Min(1), // error / help
])
.split(inner);
let hint = Paragraph::new(Span::styled(
"No credentials found — enter them below.",
Style::default().fg(Color::Gray),
));
f.render_widget(hint, rows[0]);
f.render_widget(field_widget("Server", &form.server, form.focus == Field::Server, false), rows[1]);
f.render_widget(field_widget("User", &form.user, form.focus == Field::User, false), rows[2]);
f.render_widget(field_widget("Password", &form.password, form.focus == Field::Password, true), rows[3]);
let toggle_marker = if form.save_to_disk { "[x]" } else { "[ ]" };
let toggle_style = if form.focus == Field::SaveToggle {
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let toggle = Paragraph::new(Line::from(vec![
Span::styled(format!(" {toggle_marker} "), toggle_style),
Span::raw("Save to config.toml (Enter/Space to toggle)"),
]));
f.render_widget(toggle, rows[4]);
let footer = if let Some(err) = &form.error {
Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red)))
} else {
Line::from(Span::styled(
"Tab/↓ next · Shift+Tab/↑ prev · Enter submit · Esc cancel",
Style::default().fg(Color::DarkGray),
))
};
f.render_widget(Paragraph::new(footer), rows[5]);
}
fn field_widget<'a>(label: &'a str, value: &'a str, focused: bool, mask: bool) -> Paragraph<'a> {
let display: String = if mask {
"*".repeat(value.chars().count())
} else {
value.to_string()
};
let cursor = if focused { "_" } else { "" };
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
Paragraph::new(format!("{display}{cursor}"))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(format!(" {label} ")),
)
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
+28
View File
@@ -0,0 +1,28 @@
mod app;
mod config;
mod emby;
mod login;
mod player;
mod ui;
use anyhow::Result;
use clap::Parser;
#[tokio::main]
async fn main() -> Result<()> {
let args = config::CliArgs::parse();
let (cfg, save_target) = config::load(&args)?;
let cfg = match cfg {
Some(c) => c,
None => {
// Interactive credential prompt in the TUI
login::prompt(save_target.as_deref())?
}
};
let client = emby::Client::new(&cfg).await?;
let mut app = app::App::new(client).await?;
ui::run(&mut app).await?;
Ok(())
}
+131
View File
@@ -0,0 +1,131 @@
use anyhow::{Result, anyhow};
use libmpv2::{Mpv, events::Event};
use std::time::{Duration, Instant};
use crate::emby::Reporter;
pub struct Player {
mpv: Mpv,
}
impl Player {
pub fn new() -> Result<Self> {
let mpv = Mpv::new().map_err(|e| anyhow!("mpv init failed: {e}"))?;
for (k, v) in [
("force-window", "yes"),
("input-default-bindings", "yes"),
("input-vo-keyboard", "yes"),
("osc", "yes"),
("keep-open", "no"),
("terminal", "no"),
] {
let _ = mpv.set_property(k, v);
}
Ok(Self { mpv })
}
/// Loads `url` and blocks until playback ends or the player shuts down.
/// `title` is displayed in mpv's OSC/window title (instead of the raw URL).
/// `start_seconds` resumes from a saved position when > 0.
/// `reporter`, if present, posts Start/Progress/Stopped events to Emby.
pub fn play_blocking(
&mut self,
url: &str,
title: &str,
start_seconds: f64,
reporter: Option<&Reporter>,
) -> Result<()> {
let seek_target = format!("{:.3}", start_seconds.max(0.0));
// mpv's `start` option is consumed by the next loadfile call. It's a time
// spec parsed as a string — passing an f64 fails with PROPERTY_FORMAT and
// is silently dropped, so always go through the string path.
if start_seconds > 0.5 {
let _ = self.mpv.set_property("start", seek_target.as_str());
}
// Overrides the title mpv would otherwise compute from the URL.
let _ = self.mpv.set_property("force-media-title", title);
self.mpv
.command("loadfile", &[url])
.map_err(|e| anyhow!("mpv loadfile failed: {e}"))?;
let start_ticks = seconds_to_ticks(start_seconds);
if let Some(r) = reporter {
let _ = r.started(start_ticks);
}
// Fallback: if mpv's `start` was somehow not honored, seek once on
// FileLoaded. Some libmpv versions / option types reject the early
// set_property and we want the resume to be reliable.
let mut needs_seek = start_seconds > 0.5;
let mut last_pos_ticks = start_ticks;
let mut last_report = Instant::now();
let report_every = Duration::from_secs(10);
loop {
// Borrow event_context only for the wait, so we can re-borrow `self.mpv`
// for property reads / commands after each tick.
let mut do_seek = false;
let break_now;
{
let ctx = self.mpv.event_context_mut();
match ctx.wait_event(1.0) {
Some(Ok(Event::EndFile(_))) | Some(Ok(Event::Shutdown)) => {
break_now = true;
}
Some(Ok(Event::FileLoaded)) => {
break_now = false;
if needs_seek {
do_seek = true;
needs_seek = false;
}
}
Some(Err(e)) => {
if let Some(r) = reporter {
let _ = r.stopped(last_pos_ticks);
}
return Err(anyhow!("mpv event error: {e}"));
}
_ => {
break_now = false;
}
}
}
if break_now {
break;
}
if do_seek {
let _ = self
.mpv
.command("seek", &[seek_target.as_str(), "absolute"]);
}
// Update position from mpv. time-pos is only valid once playback started.
if let Ok(pos) = self.mpv.get_property::<f64>("time-pos") {
last_pos_ticks = seconds_to_ticks(pos);
}
if let Some(r) = reporter
&& last_report.elapsed() >= report_every
{
let paused = self.mpv.get_property::<bool>("pause").unwrap_or(false);
let _ = r.progress(last_pos_ticks, paused);
last_report = Instant::now();
}
}
if let Some(r) = reporter {
let _ = r.stopped(last_pos_ticks);
}
Ok(())
}
}
fn seconds_to_ticks(seconds: f64) -> i64 {
if seconds <= 0.0 || !seconds.is_finite() {
0
} else {
(seconds * 10_000_000.0) as i64
}
}
+213
View File
@@ -0,0 +1,213 @@
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use std::io::{Stdout, stdout};
use std::time::Duration;
use crate::app::{App, RESUME_VIRTUAL_ID, View};
use crate::emby::Item;
type Term = Terminal<CrosstermBackend<Stdout>>;
pub async fn run(app: &mut App) -> Result<()> {
let mut term = setup_terminal()?;
let res = run_loop(&mut term, app).await;
let _ = restore_terminal(&mut term);
res
}
fn setup_terminal() -> Result<Term> {
enable_raw_mode()?;
let mut out = stdout();
execute!(out, EnterAlternateScreen)?;
Ok(Terminal::new(CrosstermBackend::new(out))?)
}
fn restore_terminal(term: &mut Term) -> Result<()> {
disable_raw_mode()?;
execute!(term.backend_mut(), LeaveAlternateScreen)?;
term.show_cursor()?;
Ok(())
}
async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> {
loop {
term.draw(|f| draw(f, app))?;
if !event::poll(Duration::from_millis(200))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('q') => match app.view {
View::Items => app.back().await?,
View::Libraries => break,
},
KeyCode::Esc => match app.view {
View::Items => app.back().await?,
View::Libraries => break,
},
KeyCode::Char('Q') => break,
KeyCode::Down | KeyCode::Char('j') => app.move_down(),
KeyCode::Up | KeyCode::Char('k') => app.move_up(),
KeyCode::PageDown => app.page_down(),
KeyCode::PageUp => app.page_up(),
KeyCode::Home => app.list_state.select(Some(0)),
KeyCode::End => {
let len = app.current_list().len();
if len > 0 {
app.list_state.select(Some(len - 1));
}
}
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
let action = app.activate().await?;
if let Some(action) = action {
// Tear down TUI before mpv takes over (it spawns its own window
// but we still want a clean terminal in case of -vo tct etc.).
restore_terminal(term)?;
let r = app.play(&action);
*term = setup_terminal()?;
term.clear()?;
if let Err(e) = r {
app.status = format!("playback error: {e}");
}
// Resume positions likely changed; refresh in the background view.
app.refresh_resume().await;
}
}
KeyCode::Backspace | KeyCode::Char('h') | KeyCode::Left => app.back().await?,
_ => {}
}
}
Ok(())
}
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(f.area());
// Header
let header_text = format!(
" {} @ {} ",
app.client.user_name,
app.client.server.trim_start_matches("http://").trim_start_matches("https://"),
);
let header = Paragraph::new(Line::from(vec![
Span::styled(header_text, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(app.breadcrumb_path(), Style::default().fg(Color::Gray)),
]))
.block(Block::default().borders(Borders::ALL).title(" Emby "));
f.render_widget(header, chunks[0]);
// List
let title = match app.view {
View::Libraries => "Libraries".to_string(),
View::Items => app
.current_title
.clone()
.unwrap_or_else(|| "Items".into()),
};
let items: Vec<ListItem> = app
.current_list()
.iter()
.map(format_item)
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(format!(" {title} ")))
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(list, chunks[1], &mut app.list_state);
// Status bar
let help = "↑/↓ move · Enter open/play · Backspace back · q quit (Q force)";
let status = Paragraph::new(vec![
Line::from(Span::styled(
app.status.clone(),
Style::default().fg(Color::Green),
)),
Line::from(Span::styled(help, Style::default().fg(Color::DarkGray))),
])
.block(Block::default().borders(Borders::ALL));
f.render_widget(status, chunks[2]);
}
fn format_item(it: &Item) -> ListItem<'static> {
if it.id == RESUME_VIRTUAL_ID {
let style = Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD);
return ListItem::new(Line::from(vec![
Span::styled("[>>] ", style),
Span::styled(it.name.clone(), style),
]));
}
let icon = if it.is_folder {
"[D]"
} else {
match it.item_type.as_str() {
"Movie" => "[M]",
"Episode" => "[E]",
"Series" => "[S]",
"Season" => "[s]",
"Audio" => "[A]",
_ => "[*]",
}
};
// Episode lines: "Series — SxxEyy — Title"; movies include "(Year)".
let mut line = format!("{icon} {}", it.display_title());
if let Some(ticks) = it.runtime_ticks {
let mins = ticks / 10_000_000 / 60;
if mins > 0 {
line.push_str(&format!(" [{mins}m]"));
}
}
// Progress badge for in-progress items.
if let Some(ud) = &it.user_data {
if let Some(pct) = ud.played_percentage {
if pct > 0.0 && pct < 100.0 {
line.push_str(&format!(" ({:.0}%)", pct));
}
} else if ud.position_ticks > 0
&& let Some(rt) = it.runtime_ticks
&& rt > 0
{
let pct = (ud.position_ticks as f64 / rt as f64) * 100.0;
if pct > 0.0 && pct < 100.0 {
line.push_str(&format!(" ({:.0}%)", pct));
}
}
}
ListItem::new(line)
}