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>, id: Option<String>,
} }
#[derive(Debug, Clone, Deserialize)]
pub struct ViewsResponse {
#[serde(rename = "Items", default)]
pub items: Vec<JfItem>,
}
impl JellyfinClient { impl JellyfinClient {
pub fn from_config(cfg: &Config) -> Result<Self, String> { pub fn from_config(cfg: &Config) -> Result<Self, String> {
@@ -97,8 +102,30 @@ impl JellyfinClient {
access_token: token, access_token: token,
user_id, user_id,
}) })
} }
pub fn list_views(&self, user_id: &str) -> Result<Vec<JfItem>, String> {
let url = format!("{}/Users/{}/Views", self.base_url, user_id);
let resp = self
.client
.get(url)
.header("X-Emby-Authorization", self.auth_header_value())
.send()
.map_err(|e| format!("Views request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(format!("Views failed: {status} {text}"));
}
let parsed: ViewsResponse = resp
.json()
.map_err(|e| format!("Views parse failed: {e}"))?;
Ok(parsed.items)
}
fn auth_header_value(&self) -> String { fn auth_header_value(&self) -> String {
// Identify as a client and include the token. // 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 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", "true"), ("Recursive", recursive),
("StartIndex", &start_index.to_string()), ("StartIndex", &start_index.to_string()),
("Limit", &limit.to_string()), ("Limit", &limit.to_string()),
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber"), ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber"),
@@ -154,16 +183,6 @@ impl JellyfinClient {
req = req.query(&[("SearchTerm", term)]); 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}"))?; let resp = req.send().map_err(|e| format!("request failed: {e}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {

View File

@@ -193,6 +193,7 @@ impl Default for App {
"Music".into(), "Music".into(),
"Playlists".into(), "Playlists".into(),
"Collections".into(), "Collections".into(),
"Libraries".into(),
"Continue Watching".into(), "Continue Watching".into(),
"Recently Added".into(), "Recently Added".into(),
"Downloads".into(), "Downloads".into(),
@@ -519,6 +520,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
match code { match code {
KeyCode::Char('q') => app.should_exit = true, KeyCode::Char('q') => app.should_exit = true,
//Enter code Block
KeyCode::Enter => { KeyCode::Enter => {
let view = app.current_view().clone(); let view = app.current_view().clone();
@@ -526,38 +528,50 @@ fn handle_key(app: &mut App, code: KeyCode) {
View::Categories => { View::Categories => {
let cat = app.items[app.selected].clone(); 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 { let cfg = match &app.config {
Some(c) => c.clone(), Some(c) => c.clone(),
None => { None => { app.status = "No config loaded".into(); return; }
app.status = "No config loaded".to_string();
return;
}
}; };
let user_id = match cfg.user_id.as_deref() { let user_id = match cfg.user_id.as_deref() {
Some(u) => u, Some(u) => u,
None => { None => { app.status = "Missing user_id in config (login again)".into(); return; }
app.status = "Missing user_id in config (login again)".to_string();
return;
}
}; };
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => { app.status = e; return; }
app.status = e; };
// 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; 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); app.status = format!("Fetching {}...", cat);
@@ -574,11 +588,9 @@ fn handle_key(app: &mut App, code: KeyCode) {
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
loading: false, loading: false,
}); });
app.status = "Loaded".to_string(); app.status = "Loaded".into();
}
Err(e) => {
app.status = format!("Fetch failed: {e}");
} }
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(""); 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 // Shows -> Seasons -> Episodes
if ty == "Series" { if ty == "Series" {
let q = core::jellyfin::LibraryQuery::all() 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); open_children(app, format!("{} > {}", title, it.name), q);
return; 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 -> mpv
// Playables: do nothing on Enter (use 'p' to play) // Playables: do nothing on Enter (use 'p' to play)
if ty == "Episode" || ty == "Audio" || ty == "Movie" { if ty == "Episode" || ty == "Audio" || ty == "Movie" {
@@ -1005,15 +1028,24 @@ fn render_library(
let list_items: Vec<ListItem> = items[start..end] let list_items: Vec<ListItem> = items[start..end]
.iter() .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| { .map(|it| {
let prefix = left_number_prefix(it, show_audio_track_numbers); let prefix = left_number_prefix(it, show_audio_track_numbers);
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!("{}{}{}", prefix, it.name, year)) ListItem::new(format!("{}{}{}", prefix, it.name, year))
}) })
.collect(); .collect();