V1 mpv playback
This commit is contained in:
@@ -118,21 +118,34 @@ impl JellyfinClient {
|
||||
) -> Result<PagedItems, String> {
|
||||
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
|
||||
.client
|
||||
.get(url)
|
||||
.header("X-Emby-Authorization", self.auth_header_value())
|
||||
.query(&[
|
||||
("Recursive", if query.recursive { "true" } else { "false" }),
|
||||
("Recursive", "true"),
|
||||
("StartIndex", &start_index.to_string()),
|
||||
("Limit", &limit.to_string()),
|
||||
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear"),
|
||||
("SortBy", "SortName"),
|
||||
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber"),
|
||||
("SortBy", sort_by),
|
||||
("SortOrder", "Ascending"),
|
||||
]);
|
||||
|
||||
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() {
|
||||
req = req.query(&[("ParentId", parent)]);
|
||||
@@ -248,6 +261,11 @@ pub struct JfItem {
|
||||
pub item_type: Option<String>,
|
||||
#[serde(rename = "ProductionYear")]
|
||||
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)]
|
||||
|
||||
311
src/main.rs
311
src/main.rs
@@ -5,6 +5,7 @@ const PAGE_SIZE: usize = 50;
|
||||
use std::{
|
||||
io,
|
||||
time::{Duration, Instant},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
@@ -131,8 +132,17 @@ struct App {
|
||||
login_error: Option<String>,
|
||||
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
let loaded = core::config::load_config().ok();
|
||||
@@ -178,6 +188,8 @@ impl Default for App {
|
||||
login_focus: LoginFocus::User,
|
||||
login_error: 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 {
|
||||
// tick-based updates go here later (smooth nav, download polling, etc.)
|
||||
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) {
|
||||
// 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
|
||||
if matches!(app.current_view(), View::Login) {
|
||||
match code {
|
||||
@@ -372,110 +418,126 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
||||
KeyCode::Char('q') => app.should_exit = true,
|
||||
|
||||
KeyCode::Enter => {
|
||||
match app.current_view() {
|
||||
View::Categories => {
|
||||
let cat = app.items[app.selected].clone();
|
||||
let view = app.current_view().clone();
|
||||
|
||||
// Map your categories to Jellyfin queries (expand later)
|
||||
let query = match cat.as_str() {
|
||||
"Movies" => core::jellyfin::LibraryQuery::movies(),
|
||||
"Shows" => core::jellyfin::LibraryQuery::shows(),
|
||||
"Music" => core::jellyfin::LibraryQuery::music(),
|
||||
_ => {
|
||||
app.status = format!("Not wired yet: {}", cat);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match view {
|
||||
View::Categories => {
|
||||
let cat = app.items[app.selected].clone();
|
||||
|
||||
let cfg = match &app.config {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
app.status = "No config loaded".to_string();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
let query = match cat.as_str() {
|
||||
"Movies" => core::jellyfin::LibraryQuery::movies(),
|
||||
"Shows" => core::jellyfin::LibraryQuery::shows(),
|
||||
"Music" => core::jellyfin::LibraryQuery::music(),
|
||||
_ => {
|
||||
app.status = format!("Not wired yet: {}", cat);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Otherwise: later we can make Enter “play”, “details”, etc.
|
||||
app.status = format!("No drilldown for type: {}", ty);
|
||||
let cfg = match &app.config {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
app.status = "No config loaded".to_string();
|
||||
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("");
|
||||
|
||||
// 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 => {
|
||||
if app.view_stack.len() > 1 {
|
||||
app.view_stack.pop();
|
||||
@@ -803,19 +865,42 @@ fn render_library(
|
||||
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]
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let prefix = left_number_prefix(it);
|
||||
|
||||
let year = if show_year_for(it.item_type.as_deref()) {
|
||||
it.production_year.map(|y| format!(" ({y})")).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
ListItem::new(format!("{}{}", it.name, year))
|
||||
ListItem::new(format!("{}{}{}", prefix, it.name, year))
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
let list = List::new(list_items)
|
||||
.block(
|
||||
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 {
|
||||
top: Rect,
|
||||
left_main: Rect,
|
||||
|
||||
Reference in New Issue
Block a user