V1 mpv playback
This commit is contained in:
@@ -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)]
|
||||||
|
|||||||
309
src/main.rs
309
src/main.rs
@@ -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,16 +865,39 @@ 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();
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user