library integration

This commit is contained in:
2026-02-14 17:26:34 -06:00
parent 3ae77d34d8
commit 56ba176615
2 changed files with 120 additions and 69 deletions

View File

@@ -36,6 +36,11 @@ struct UserDto {
id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ViewsResponse {
#[serde(rename = "Items", default)]
pub items: Vec<JfItem>,
}
impl JellyfinClient {
pub fn from_config(cfg: &Config) -> Result<Self, String> {
@@ -60,45 +65,67 @@ impl JellyfinClient {
}
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);
let auth_header = format!(
"MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\"",
env!("CARGO_PKG_VERSION"),
);
let auth_header = format!(
"MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\"",
env!("CARGO_PKG_VERSION"),
);
let body = AuthenticateUserByName { username, pw: password };
let body = AuthenticateUserByName { username, pw: password };
let resp = self
.client
.post(url)
.header("X-Emby-Authorization", auth_header)
.json(&body)
.send()
.map_err(|e| format!("request failed: {e}"))?;
let resp = self
.client
.post(url)
.header("X-Emby-Authorization", auth_header)
.json(&body)
.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!("login failed: {status} {text}"));
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(format!("login failed: {status} {text}"));
}
let parsed: AuthenticationResult = resp
.json()
.map_err(|e| format!("bad response json: {e}"))?;
let token = parsed
.access_token
.ok_or_else(|| "missing AccessToken in response".to_string())?;
let user_id = parsed.user.and_then(|u| u.id);
Ok(LoginResult {
access_token: token,
user_id,
})
}
let parsed: AuthenticationResult = resp
.json()
.map_err(|e| format!("bad response json: {e}"))?;
pub fn list_views(&self, user_id: &str) -> Result<Vec<JfItem>, String> {
let url = format!("{}/Users/{}/Views", self.base_url, user_id);
let token = parsed
.access_token
.ok_or_else(|| "missing AccessToken in response".to_string())?;
let resp = self
.client
.get(url)
.header("X-Emby-Authorization", self.auth_header_value())
.send()
.map_err(|e| format!("Views request failed: {e}"))?;
let user_id = parsed.user.and_then(|u| u.id);
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(format!("Views failed: {status} {text}"));
}
Ok(LoginResult {
access_token: token,
user_id,
})
}
let parsed: ViewsResponse = resp
.json()
.map_err(|e| format!("Views parse failed: {e}"))?;
Ok(parsed.items)
}
fn auth_header_value(&self) -> String {
// Identify as a client and include the token.
@@ -128,12 +155,14 @@ impl JellyfinClient {
}
}
let recursive = if query.recursive { "true" } else { "false" };
let mut req = self
.client
.get(url)
.header("X-Emby-Authorization", self.auth_header_value())
.query(&[
("Recursive", "true"),
("Recursive", recursive),
("StartIndex", &start_index.to_string()),
("Limit", &limit.to_string()),
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber"),
@@ -154,16 +183,6 @@ impl JellyfinClient {
req = req.query(&[("SearchTerm", term)]);
}
// apply query (movie/series/etc)
if let Some(types) = query.include_item_types.as_deref() {
req = req.query(&[("IncludeItemTypes", types)]);
}
if let Some(term) = query.search_term.as_deref() {
req = req.query(&[("SearchTerm", term)]);
}
let resp = req.send().map_err(|e| format!("request failed: {e}"))?;
if !resp.status().is_success() {

View File

@@ -193,6 +193,7 @@ impl Default for App {
"Music".into(),
"Playlists".into(),
"Collections".into(),
"Libraries".into(),
"Continue Watching".into(),
"Recently Added".into(),
"Downloads".into(),
@@ -519,45 +520,58 @@ fn handle_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Char('q') => app.should_exit = true,
//Enter code Block
KeyCode::Enter => {
let view = app.current_view().clone();
let view = app.current_view().clone();
match view {
View::Categories => {
let cat = app.items[app.selected].clone();
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;
}
None => { app.status = "No config loaded".into(); 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;
}
None => { app.status = "Missing user_id in config (login again)".into(); return; }
};
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c,
Err(e) => {
app.status = e;
return;
Err(e) => { app.status = e; return; }
};
// Special case: Libraries uses /Views, not /Items
if cat == "Libraries" {
app.status = "Fetching Libraries...".into();
match client.list_views(user_id) {
Ok(items) => {
let total = items.len();
app.view_stack.push(View::Library {
title: "Libraries".into(),
query: core::jellyfin::LibraryQuery::all(), // placeholder
items,
selected: 0,
start_index: 0,
total, // cheap total for UI
page_size: PAGE_SIZE,
loading: false,
});
app.status = "Loaded".into();
}
Err(e) => app.status = format!("Views failed: {e}"),
}
return;
}
// Normal categories use /Items
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; }
};
app.status = format!("Fetching {}...", cat);
@@ -574,11 +588,9 @@ fn handle_key(app: &mut App, code: KeyCode) {
page_size: PAGE_SIZE,
loading: false,
});
app.status = "Loaded".to_string();
}
Err(e) => {
app.status = format!("Fetch failed: {e}");
app.status = "Loaded".into();
}
Err(e) => app.status = format!("Fetch failed: {e}"),
}
}
@@ -588,6 +600,13 @@ fn handle_key(app: &mut App, code: KeyCode) {
let ty = it.item_type.as_deref().unwrap_or("");
// Libraries (/Views) usually return CollectionFolder. Entering it should list its contents.
if ty == "CollectionFolder" {
let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone());
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
// Shows -> Seasons -> Episodes
if ty == "Series" {
let q = core::jellyfin::LibraryQuery::all()
@@ -619,7 +638,11 @@ fn handle_key(app: &mut App, code: KeyCode) {
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
if ty == "BoxSet" {
let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone());
open_children(app, format!("{} > {}", title, it.name), q);
return;
}
// Playables -> mpv
// Playables: do nothing on Enter (use 'p' to play)
if ty == "Episode" || ty == "Audio" || ty == "Movie" {
@@ -1005,15 +1028,24 @@ fn render_library(
let list_items: Vec<ListItem> = items[start..end]
.iter()
.filter(|it| {
// Hide the redundant "collections" folder inside the Collections library
if title.ends_with("Collections") {
if it.item_type.as_deref() == Some("Folder")
&& it.name.eq_ignore_ascii_case("collections")
{
return false;
}
}
true
})
.map(|it| {
let prefix = left_number_prefix(it, show_audio_track_numbers);
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!("{}{}{}", prefix, it.name, year))
})
.collect();