diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index b1aa701..11390cf 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -136,6 +136,10 @@ impl JellyfinClient { 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() { @@ -155,21 +159,29 @@ impl JellyfinClient { } } -/// What kind of library list we want. #[derive(Clone)] pub struct LibraryQuery { - pub include_item_types: Option, // e.g. Some("Movie".into()) + pub include_item_types: Option, + pub search_term: Option, } impl LibraryQuery { pub fn movies() -> Self { - Self { include_item_types: Some("Movie".into()) } + Self { include_item_types: Some("Movie".into()), search_term: None } } pub fn shows() -> Self { - Self { include_item_types: Some("Series".into()) } + Self { include_item_types: Some("Series".into()), search_term: None } } pub fn music() -> Self { - Self { include_item_types: Some("Audio".into()) } + Self { include_item_types: Some("Audio".into()), search_term: None } + } + pub fn all() -> Self { + Self { include_item_types: None, search_term: None } + } + + pub fn with_search(mut self, term: String) -> Self { + self.search_term = Some(term); + self } } diff --git a/src/main.rs b/src/main.rs index b51a859..83a2c42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,20 @@ enum LoginFocus { Pass, } +#[derive(Clone)] +enum SearchScope { + All, + Library { + title: String, + query: core::jellyfin::LibraryQuery, // includes item types + }, +} + +struct SearchState { + scope: SearchScope, + input: String, +} + struct App { should_exit: bool, status: String, @@ -58,6 +72,8 @@ struct App { login_pass: String, login_focus: LoginFocus, login_error: Option, + + search: Option, } impl Default for App { @@ -104,6 +120,7 @@ impl Default for App { login_pass: String::new(), login_focus: LoginFocus::User, login_error: None, + search: None, } } } @@ -228,6 +245,63 @@ fn handle_key(app: &mut App, code: KeyCode) { } } + // If search mode is active, it captures input first + if let Some(search) = &mut app.search { + match code { + KeyCode::Esc => { app.search = None; return; } + KeyCode::Backspace => { search.input.pop(); return; } + KeyCode::Enter => { + let term = search.input.trim().to_string(); + if term.is_empty() { + app.search = None; + return; + } + + // build query based on scope + let (title, query) = match &search.scope { + SearchScope::All => ( + format!("Search: {}", term), + core::jellyfin::LibraryQuery::all().with_search(term.clone()), + ), + SearchScope::Library { title, query } => ( + format!("{}: {}", title, term), + query.clone().with_search(term.clone()), + ), + }; + + app.search = None; + + // run search (same as your library fetch) + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config".into(); return; } }; + let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "Missing user_id".into(); return; } }; + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = e; return; } }; + + app.status = format!("Searching… {}", term); + + match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title, + query, + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + page_size: PAGE_SIZE, + loading: false, + }); + app.status = "Search loaded".into(); + } + Err(e) => app.status = format!("Search failed: {e}"), + } + + return; + } + KeyCode::Char(c) => { search.input.push(c); return; } + _ => return, + } + } + match code { KeyCode::Char('q') => app.should_exit = true, @@ -371,6 +445,19 @@ fn handle_key(app: &mut App, code: KeyCode) { } } + KeyCode::Char('/') => { + let scope = match app.current_view() { + View::Categories => SearchScope::All, + View::Library { title, query, .. } => SearchScope::Library { + title: title.clone(), + query: query.clone(), + }, + _ => SearchScope::All, + }; + + app.search = Some(SearchState { scope, input: String::new() }); + } + _ => {} } } @@ -481,8 +568,14 @@ fn ui(frame: &mut Frame, app: &App) { } // Bottom Left + let status_text = if let Some(s) = &app.search { + format!("/{}", s.input) + } else { + app.status.clone() + }; + frame.render_widget( - Paragraph::new(app.status.as_str()) + Paragraph::new(status_text) .block(Block::bordered().title("Status").merge_borders(MergeStrategy::Exact)), panes.left_output, );