V1 mpv playback

This commit is contained in:
2026-02-14 04:05:03 -06:00
parent 2dfbc7bbad
commit 5144af1cfb
2 changed files with 234 additions and 103 deletions

View File

@@ -118,21 +118,34 @@ impl JellyfinClient {
) -> Result<PagedItems, String> { ) -> Result<PagedItems, String> {
let url = format!("{}/Users/{}/Items", self.base_url, user_id); let url = format!("{}/Users/{}/Items", self.base_url, user_id);
let mut sort_by = "SortName";
if let Some(types) = query.include_item_types.as_deref() {
if types.contains("Episode") {
sort_by = "ParentIndexNumber,IndexNumber,SortName";
} else if types.contains("Audio") {
sort_by = "IndexNumber,SortName";
}
}
let mut req = self let mut req = self
.client .client
.get(url) .get(url)
.header("X-Emby-Authorization", self.auth_header_value()) .header("X-Emby-Authorization", self.auth_header_value())
.query(&[ .query(&[
("Recursive", if query.recursive { "true" } else { "false" }), ("Recursive", "true"),
("StartIndex", &start_index.to_string()), ("StartIndex", &start_index.to_string()),
("Limit", &limit.to_string()), ("Limit", &limit.to_string()),
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear"), ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber"),
("SortBy", "SortName"), ("SortBy", sort_by),
("SortOrder", "Ascending"), ("SortOrder", "Ascending"),
]); ]);
if let Some(types) = query.include_item_types.as_deref() { if let Some(types) = query.include_item_types.as_deref() {
req = req.query(&[("IncludeItemTypes", types)]); // Jellyfin expects IncludeItemTypes as repeated query params, not CSV
for t in types.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
req = req.query(&[("IncludeItemTypes", t)]);
}
} }
if let Some(parent) = query.parent_id.as_deref() { if let Some(parent) = query.parent_id.as_deref() {
req = req.query(&[("ParentId", parent)]); req = req.query(&[("ParentId", parent)]);
@@ -248,6 +261,11 @@ pub struct JfItem {
pub item_type: Option<String>, pub item_type: Option<String>,
#[serde(rename = "ProductionYear")] #[serde(rename = "ProductionYear")]
pub production_year: Option<i32>, pub production_year: Option<i32>,
#[serde(rename = "IndexNumber")]
pub index_number: Option<i32>, // track # OR episode #
#[serde(rename = "ParentIndexNumber")]
pub parent_index_number: Option<i32>, // season # (for episodes)
} }
#[derive(Deserialize)] #[derive(Deserialize)]

View File

@@ -5,6 +5,7 @@ const PAGE_SIZE: usize = 50;
use std::{ use std::{
io, io,
time::{Duration, Instant}, time::{Duration, Instant},
process::Command,
}; };
use crossterm::{ use crossterm::{
@@ -131,8 +132,17 @@ struct App {
login_error: Option<String>, login_error: Option<String>,
search: Option<SearchState>, search: Option<SearchState>,
pending_play: Option<PlayRequest>,
} }
#[derive(Clone, Debug)]
struct PlayRequest {
item_id: String,
item_type: Option<String>,
name: String,
}
impl Default for App { impl Default for App {
fn default() -> Self { fn default() -> Self {
let loaded = core::config::load_config().ok(); let loaded = core::config::load_config().ok();
@@ -178,6 +188,8 @@ impl Default for App {
login_focus: LoginFocus::User, login_focus: LoginFocus::User,
login_error: None, login_error: None,
search: None, search: None,
pending_play: None,
} }
} }
} }
@@ -224,6 +236,31 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
} }
} }
// If we need to play something, suspend the TUI and run mpv
if let Some(req) = app.pending_play.take() {
// Restore terminal to normal before launching mpv
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
// Run mpv (blocking until it exits)
let result = match &app.config {
Some(cfg) => play_in_mpv(cfg, &req).map_err(|e| e),
None => Err("No config loaded".to_string()),
};
// Re-enter TUI mode after mpv closes
enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
terminal.clear()?;
// Update status
match result {
Ok(_) => app.status = format!("Finished: {}", req.name),
Err(e) => app.status = format!("Play failed: {}", e),
}
}
if last_tick.elapsed() >= tick_rate { if last_tick.elapsed() >= tick_rate {
// tick-based updates go here later (smooth nav, download polling, etc.) // tick-based updates go here later (smooth nav, download polling, etc.)
last_tick = Instant::now(); last_tick = Instant::now();
@@ -233,6 +270,15 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
} }
fn handle_key(app: &mut App, code: KeyCode) { fn handle_key(app: &mut App, code: KeyCode) {
// Normalize arrow navigation
let code = match code {
KeyCode::Right => KeyCode::Enter,
KeyCode::Left => KeyCode::Esc,
KeyCode::Char('l') => KeyCode::Enter,
KeyCode::Char('h') => KeyCode::Esc,
other => other,
};
// Special input handling in Login view // Special input handling in Login view
if matches!(app.current_view(), View::Login) { if matches!(app.current_view(), View::Login) {
match code { match code {
@@ -372,110 +418,126 @@ fn handle_key(app: &mut App, code: KeyCode) {
KeyCode::Char('q') => app.should_exit = true, KeyCode::Char('q') => app.should_exit = true,
KeyCode::Enter => { KeyCode::Enter => {
match app.current_view() { let view = app.current_view().clone();
View::Categories => {
let cat = app.items[app.selected].clone();
// Map your categories to Jellyfin queries (expand later) match view {
let query = match cat.as_str() { View::Categories => {
"Movies" => core::jellyfin::LibraryQuery::movies(), let cat = app.items[app.selected].clone();
"Shows" => core::jellyfin::LibraryQuery::shows(),
"Music" => core::jellyfin::LibraryQuery::music(),
_ => {
app.status = format!("Not wired yet: {}", cat);
return;
}
};
let cfg = match &app.config { let query = match cat.as_str() {
Some(c) => c.clone(), "Movies" => core::jellyfin::LibraryQuery::movies(),
None => { "Shows" => core::jellyfin::LibraryQuery::shows(),
app.status = "No config loaded".to_string(); "Music" => core::jellyfin::LibraryQuery::music(),
return; _ => {
} app.status = format!("Not wired yet: {}", cat);
}; return;
let user_id = match cfg.user_id.as_deref() {
Some(u) => u,
None => {
app.status = "Missing user_id in config (login again)".to_string();
return;
}
};
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c,
Err(e) => {
app.status = e;
return;
}
};
app.status = format!("Fetching {}...", cat);
match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) {
Ok(page) => {
app.view_stack.push(View::Library {
title: cat,
query,
items: page.items,
selected: 0,
start_index: 0,
total: page.total,
page_size: PAGE_SIZE,
loading: false,
});
app.status = "Loaded".to_string();
}
Err(e) => {
app.status = format!("Fetch failed: {e}");
}
}
} }
View::Library { title, items, selected, .. } => { };
if items.is_empty() { return; }
let it = &items[*selected];
let ty = it.item_type.as_deref().unwrap_or(""); let cfg = match &app.config {
Some(c) => c.clone(),
// Shows -> Seasons -> Episodes None => {
if ty == "Series" { app.status = "No config loaded".to_string();
let q = core::jellyfin::LibraryQuery::all() return;
.with_parent(it.id.clone()) }
.with_item_types("Season"); };
open_children(app, format!("{} > {}", title, it.name), q);
return; let user_id = match cfg.user_id.as_deref() {
} Some(u) => u,
if ty == "Season" { None => {
let q = core::jellyfin::LibraryQuery::all() app.status = "Missing user_id in config (login again)".to_string();
.with_parent(it.id.clone()) return;
.with_item_types("Episode"); }
open_children(app, format!("{} > {}", title, it.name), q); };
return;
} let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c,
// MusicArtist -> MusicAlbum -> Audio Err(e) => {
if ty == "MusicArtist" { app.status = e;
let q = core::jellyfin::LibraryQuery::all() return;
.with_parent(it.id.clone()) }
.with_item_types("MusicAlbum"); };
open_children(app, format!("{} > {}", title, it.name), q);
return; app.status = format!("Fetching {}...", cat);
}
if ty == "MusicAlbum" { match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) {
let q = core::jellyfin::LibraryQuery::all() Ok(page) => {
.with_parent(it.id.clone()) app.view_stack.push(View::Library {
.with_item_types("Audio"); title: cat,
open_children(app, format!("{} > {}", title, it.name), q); query,
return; items: page.items,
} selected: 0,
start_index: 0,
// Otherwise: later we can make Enter “play”, “details”, etc. total: page.total,
app.status = format!("No drilldown for type: {}", ty); page_size: PAGE_SIZE,
loading: false,
});
app.status = "Loaded".to_string();
}
Err(e) => {
app.status = format!("Fetch failed: {e}");
} }
_ => {}
} }
} }
View::Library { title, items, selected, .. } => {
if items.is_empty() { return; }
let it = &items[selected];
let ty = it.item_type.as_deref().unwrap_or("");
// Shows -> Seasons -> Episodes
if ty == "Series" {
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("Season");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
if ty == "Season" {
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("Episode");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
// MusicArtist -> MusicAlbum -> Audio
if ty == "MusicArtist" {
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("MusicAlbum");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
if ty == "MusicAlbum" {
let q = core::jellyfin::LibraryQuery::all()
.with_parent(it.id.clone())
.with_item_types("Audio");
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
// Playables -> mpv
if ty == "Episode" || ty == "Audio" || ty == "Movie" {
let name = it.name.clone();
app.pending_play = Some(PlayRequest {
item_id: it.id.clone(),
item_type: it.item_type.clone(),
name: name.clone(),
});
app.status = format!("Launching mpv: {}", name);
return;
}
app.status = format!("No drilldown for type: {}", ty);
}
_ => {}
}
}
KeyCode::Esc | KeyCode::Backspace => { KeyCode::Esc | KeyCode::Backspace => {
if app.view_stack.len() > 1 { if app.view_stack.len() > 1 {
app.view_stack.pop(); app.view_stack.pop();
@@ -803,19 +865,42 @@ fn render_library(
matches!(item_type, Some("Movie" | "Series" | "MusicAlbum")) matches!(item_type, Some("Movie" | "Series" | "MusicAlbum"))
} }
fn left_number_prefix(it: &core::jellyfin::JfItem) -> String {
match it.item_type.as_deref() {
Some("Episode") => {
// season x episode (01x02)
match (it.parent_index_number, it.index_number) {
(Some(season), Some(ep)) => format!("{:02}x{:02} ", season, ep),
(_, Some(ep)) => format!("{:02} ", ep),
_ => String::new(),
}
}
Some("Audio") => {
// track number
match it.index_number {
Some(n) => format!("{:02}. ", n),
None => String::new(),
}
}
_ => String::new(),
}
}
let list_items: Vec<ListItem> = items[start..end] let list_items: Vec<ListItem> = items[start..end]
.iter() .iter()
.map(|it| { .map(|it| {
let prefix = left_number_prefix(it);
let year = if show_year_for(it.item_type.as_deref()) { let year = if show_year_for(it.item_type.as_deref()) {
it.production_year.map(|y| format!(" ({y})")).unwrap_or_default() it.production_year.map(|y| format!(" ({y})")).unwrap_or_default()
} else { } else {
String::new() String::new()
}; };
ListItem::new(format!("{}{}", it.name, year)) ListItem::new(format!("{}{}{}", prefix, it.name, year))
}) })
.collect(); .collect();
let list = List::new(list_items) let list = List::new(list_items)
.block( .block(
Block::bordered() Block::bordered()
@@ -881,6 +966,34 @@ fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQue
} }
} }
fn play_in_mpv(cfg: &core::config::Config, req: &PlayRequest) -> Result<(), String> {
let token = cfg
.access_token
.as_deref()
.ok_or_else(|| "Missing access_token (login again)".to_string())?;
// Simple “download” endpoint works well for mpv.
// (Jellyfin supports this route; it returns the media file/stream.)
let base = cfg.base_url.trim_end_matches('/');
let url = format!("{}/Items/{}/Download", base, req.item_id);
// mpv can send HTTP headers; Jellyfin accepts X-Emby-Token
let header = format!("X-Emby-Token: {}", token);
let status = Command::new("mpv")
.arg(url)
.arg("--force-window=yes")
.arg(format!("--http-header-fields={}", header))
.status()
.map_err(|e| format!("Failed to launch mpv: {e}"))?;
if !status.success() {
return Err(format!("mpv exited with status: {status}"));
}
Ok(())
}
struct Panes { struct Panes {
top: Rect, top: Rect,
left_main: Rect, left_main: Rect,