loads all shows

This commit is contained in:
2026-02-14 00:51:30 -06:00
parent b5a0790cb6
commit da6bda9772
2 changed files with 411 additions and 77 deletions

View File

@@ -6,6 +6,7 @@ use super::config::Config;
pub struct JellyfinClient { pub struct JellyfinClient {
base_url: String, base_url: String,
client: reqwest::blocking::Client, client: reqwest::blocking::Client,
token: String,
} }
pub struct LoginResult { pub struct LoginResult {
@@ -13,25 +14,60 @@ pub struct LoginResult {
pub user_id: Option<String>, pub user_id: Option<String>,
} }
#[derive(Serialize)]
struct AuthenticateUserByName<'a> {
#[serde(rename = "Username")]
username: &'a str,
#[serde(rename = "Pw")]
pw: &'a str,
}
#[derive(Deserialize)]
struct AuthenticationResult {
#[serde(rename = "AccessToken")]
access_token: Option<String>,
#[serde(rename = "User")]
user: Option<UserDto>,
}
#[derive(Deserialize)]
struct UserDto {
#[serde(rename = "Id")]
id: Option<String>,
}
impl JellyfinClient { impl JellyfinClient {
pub fn from_config(cfg: &Config) -> Self { pub fn from_config(cfg: &Config) -> Result<Self, String> {
Self { let token = cfg
.access_token
.clone()
.ok_or_else(|| "missing access_token (need login)".to_string())?;
Ok(Self {
base_url: cfg.base_url.trim_end_matches('/').to_string(), base_url: cfg.base_url.trim_end_matches('/').to_string(),
client: reqwest::blocking::Client::new(), client: reqwest::blocking::Client::new(),
token,
})
}
pub fn unauthenticated(base_url: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
client: reqwest::blocking::Client::new(),
token: String::new(), // unused for login
} }
} }
pub fn login_by_name(&self, username: &str, password: &str) -> Result<LoginResult, String> { pub fn login_by_name(&self, username: &str, password: &str) -> Result<LoginResult, String> {
let url = format!("{}/Users/AuthenticateByName", self.base_url); let url = format!("{}/Users/AuthenticateByName", self.base_url);
// Jellyfin expects this format. :contentReference[oaicite:0]{index=0} let auth_header = format!(
let body = AuthenticateUserByName { "MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\"",
Username: username, env!("CARGO_PKG_VERSION"),
Pw: password, );
};
// This header identifies the client/device. :contentReference[oaicite:1]{index=1} let body = AuthenticateUserByName { username, pw: password };
let auth_header = r#"MediaBrowser Client="cj-jftui", Device="kitty", DeviceId="cj-jftui", Version="0.1.0""#;
let resp = self let resp = self
.client .client
@@ -52,31 +88,112 @@ impl JellyfinClient {
.map_err(|e| format!("bad response json: {e}"))?; .map_err(|e| format!("bad response json: {e}"))?;
let token = parsed let token = parsed
.AccessToken .access_token
.ok_or_else(|| "missing AccessToken in response".to_string())?; .ok_or_else(|| "missing AccessToken in response".to_string())?;
let user_id = parsed.User.and_then(|u| u.Id); let user_id = parsed.user.and_then(|u| u.id);
Ok(LoginResult { Ok(LoginResult {
access_token: token, access_token: token,
user_id, user_id,
}) })
} }
fn auth_header_value(&self) -> String {
// Identify as a client and include the token.
format!(
"MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\", Token=\"{}\"",
env!("CARGO_PKG_VERSION"),
self.token
)
} }
#[derive(Serialize)] pub fn list_items(
struct AuthenticateUserByName<'a> { &self,
Username: &'a str, user_id: &str,
Pw: &'a str, query: LibraryQuery,
start_index: usize,
limit: usize,
) -> Result<PagedItems, String> {
let url = format!("{}/Users/{}/Items", self.base_url, user_id);
let mut req = self
.client
.get(url)
.header("X-Emby-Authorization", self.auth_header_value())
.query(&[
("Recursive", "true"),
("StartIndex", &start_index.to_string()),
("Limit", &limit.to_string()),
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear"),
("SortBy", "SortName"),
("SortOrder", "Ascending"),
]);
// apply query (movie/series/etc)
if let Some(types) = query.include_item_types.as_deref() {
req = req.query(&[("IncludeItemTypes", types)]);
}
let resp = req.send().map_err(|e| format!("request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(format!("list_items failed: {status} {text}"));
}
let parsed: ItemsResponse = resp
.json()
.map_err(|e| format!("bad response json: {e}"))?;
Ok(PagedItems {
items: parsed.items,
total: parsed.total_record_count.unwrap_or(0),
})
}
}
/// What kind of library list we want.
#[derive(Clone)]
pub struct LibraryQuery {
pub include_item_types: Option<String>, // e.g. Some("Movie".into())
}
impl LibraryQuery {
pub fn movies() -> Self {
Self { include_item_types: Some("Movie".into()) }
}
pub fn shows() -> Self {
Self { include_item_types: Some("Series".into()) }
}
pub fn music() -> Self {
Self { include_item_types: Some("Audio".into()) }
}
}
pub struct PagedItems {
pub items: Vec<JfItem>,
pub total: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct JfItem {
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Type")]
pub item_type: Option<String>,
#[serde(rename = "ProductionYear")]
pub production_year: Option<i32>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthenticationResult { struct ItemsResponse {
AccessToken: Option<String>, #[serde(rename = "Items")]
User: Option<UserDto>, items: Vec<JfItem>,
} #[serde(rename = "TotalRecordCount")]
total_record_count: Option<usize>,
#[derive(Deserialize)]
struct UserDto {
Id: Option<String>,
} }

View File

@@ -1,5 +1,7 @@
mod core; mod core;
const PAGE_SIZE: usize = 50;
use std::{ use std::{
io, io,
time::{Duration, Instant}, time::{Duration, Instant},
@@ -22,7 +24,14 @@ use ratatui::backend::CrosstermBackend;
#[derive(Clone)] #[derive(Clone)]
enum View { enum View {
Categories, Categories,
Placeholder(String), // temporary for testing navigation Library {
title: String,
query: core::jellyfin::LibraryQuery,
items: Vec<core::jellyfin::JfItem>,
selected: usize,
start_index: usize,
total: usize,
},
Login, Login,
} }
@@ -180,7 +189,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
}; };
let client = core::jellyfin::JellyfinClient::from_config(&cfg); let client = core::jellyfin::JellyfinClient::unauthenticated(&cfg.base_url);
match client.login_by_name(&app.login_user, &app.login_pass) { match client.login_by_name(&app.login_user, &app.login_pass) {
Ok(res) => { Ok(res) => {
let mut new_cfg = cfg; let mut new_cfg = cfg;
@@ -224,10 +233,61 @@ fn handle_key(app: &mut App, code: KeyCode) {
KeyCode::Enter => { KeyCode::Enter => {
match app.current_view() { match app.current_view() {
View::Categories => { View::Categories => {
let selected = app.items[app.selected].clone(); let cat = app.items[app.selected].clone();
app.view_stack.push(View::Placeholder(selected.clone()));
app.status = format!("Entered {}", selected); // Map your categories to Jellyfin queries (expand later)
app.selected = 0; 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;
}
};
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,
});
app.status = "Loaded".to_string();
}
Err(e) => {
app.status = format!("Fetch failed: {e}");
}
}
} }
_ => {} _ => {}
} }
@@ -242,28 +302,128 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
if app.items.is_empty() { match app.view_stack.last_mut().unwrap() {
return; View::Categories => {
} if app.items.is_empty() { return; }
if app.selected == 0 { if app.selected == 0 { app.selected = app.items.len() - 1; }
app.selected = app.items.len() - 1; else { app.selected -= 1; }
} else {
app.selected -= 1;
}
app.status = format!("Selected: {}", app.items[app.selected]); app.status = format!("Selected: {}", app.items[app.selected]);
} }
View::Library { items, selected, .. } => {
KeyCode::Down | KeyCode::Char('j') => { if items.is_empty() { return; }
if app.items.is_empty() { if *selected == 0 { *selected = items.len() - 1; }
return; else { *selected -= 1; }
}
app.selected = (app.selected + 1) % app.items.len();
app.status = format!("Selected: {}", app.items[app.selected]);
} }
_ => {} _ => {}
} }
} }
KeyCode::Down | KeyCode::Char('j') => {
match app.view_stack.last_mut().unwrap() {
View::Categories => {
if app.items.is_empty() { return; }
app.selected = (app.selected + 1) % app.items.len();
app.status = format!("Selected: {}", app.items[app.selected]);
}
View::Library { items, selected, total, .. } => {
if items.is_empty() { return; }
// If we're at the end of what we have loaded…
if *selected + 1 >= items.len() {
// …and theres more available on the server, fetch next page.
if items.len() < *total {
load_next_library_page(app);
// After loading more, move down if possible.
if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() {
if !items.is_empty() && *selected + 1 < items.len() {
*selected += 1;
}
}
} else {
// wrap or stop—choose behavior:
// stop at end:
// do nothing
// OR wrap:
// *selected = 0;
}
} else {
*selected += 1;
}
}
_ => {}
}
}
_ => {}
}
}
fn load_next_library_page(app: &mut App) {
// Only meaningful if the current view is Library
let (query, next_start, total) = match app.view_stack.last() {
Some(View::Library { query, items, total, start_index, .. }) => {
let loaded = items.len();
let next_start = start_index + loaded;
(query.clone(), next_start, *total)
}
_ => return,
};
// If we already loaded everything, do nothing
let loaded = match app.view_stack.last() {
Some(View::Library { items, .. }) => items.len(),
_ => 0,
};
if loaded >= total {
return;
}
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!("Loading more... ({}/{})", loaded, total);
let page = match client.list_items(user_id, query.clone(), next_start, PAGE_SIZE) {
Ok(p) => p,
Err(e) => {
app.status = format!("Fetch failed: {e}");
return;
}
};
// Append the new items into the current library view
if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() {
items.extend(page.items);
*total = page.total; // keep total fresh in case server changes
}
app.status = "Loaded more".to_string();
}
fn ui(frame: &mut Frame, app: &App) { fn ui(frame: &mut Frame, app: &App) {
let area = frame.area(); let area = frame.area();
let panes = layout(area); let panes = layout(area);
@@ -277,8 +437,8 @@ fn ui(frame: &mut Frame, app: &App) {
View::Categories => { View::Categories => {
render_categories(frame, app, panes.left_main, panes.right_main); render_categories(frame, app, panes.left_main, panes.right_main);
} }
View::Placeholder(name) => { View::Library { title, items, selected, start_index, total, .. } => {
render_placeholder(frame, name, panes.left_main, panes.right_main); render_library(frame, title, *start_index, *total, items, *selected, panes.left_main, panes.right_main);
} }
View::Login => { View::Login => {
render_login(frame, app, panes.left_main, panes.right_main); render_login(frame, app, panes.left_main, panes.right_main);
@@ -368,6 +528,63 @@ fn render_placeholder(frame: &mut Frame, name: &str, left: Rect, right: Rect) {
); );
} }
fn render_library(
frame: &mut Frame,
title: &str,
start_index: usize,
total: usize,
items: &[core::jellyfin::JfItem],
selected: usize,
left: Rect,
right: Rect,
) {
let viewport = left.height.saturating_sub(2) as usize;
let len = items.len();
let selected = selected.min(len.saturating_sub(1));
let (start, end) = compute_window(len, selected, viewport);
let list_items: Vec<ListItem> = items[start..end]
.iter()
.map(|it| {
let year = it.production_year.map(|y| format!(" ({y})")).unwrap_or_default();
ListItem::new(format!("{}{}", it.name, year))
})
.collect();
let list = List::new(list_items)
.block(
Block::bordered()
.title(format!("{title} [{}/{}]", items.len(), total))
.merge_borders(MergeStrategy::Exact),
)
.highlight_symbol("> ")
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default();
state.select(if len > 0 { Some(selected - start) } else { None });
frame.render_stateful_widget(list, left, &mut state);
let detail = if len > 0 {
let it = &items[selected];
format!(
"Name: {}\nId: {}\nType: {:?}\nYear: {:?}",
it.name, it.id, it.item_type, it.production_year
)
} else {
"No items returned".to_string()
};
frame.render_widget(
Paragraph::new(detail)
.block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)),
right,
);
}
struct Panes { struct Panes {
top: Rect, top: Rect,
left_main: Rect, left_main: Rect,
@@ -388,7 +605,7 @@ fn layout(area: Rect) -> Panes {
.areas(bottom); .areas(bottom);
let [left_main, right_main] = let [left_main, right_main] =
Layout::horizontal([Fill(2), Fill(5)]) Layout::horizontal([Fill(3), Fill(5)])
.spacing(Spacing::Overlap(1)) .spacing(Spacing::Overlap(1))
.areas(main_area); .areas(main_area);