diff --git a/src/app.rs b/src/app.rs index 4b159a0..e41caec 100644 --- a/src/app.rs +++ b/src/app.rs @@ -261,6 +261,32 @@ impl App { } } + pub async fn rescan_selected(&mut self) -> Result<()> { + let Some(i) = self.list_state.selected() else { + self.status = "Select a library, series, or season to rescan".into(); + return Ok(()); + }; + + let target = match self.view { + View::Libraries => self + .home_entries + .get(i) + .filter(|item| item.id != RESUME_VIRTUAL_ID) + .cloned(), + View::Items => self.items.get(i).filter(|item| can_rescan(item)).cloned(), + }; + + let Some(item) = target else { + self.status = "Select a library, series, or season to rescan".into(); + return Ok(()); + }; + + self.status = format!("Requesting rescan for {}...", item.name); + self.client.refresh_item(&item.id).await?; + self.status = format!("Rescan started for {}", item.name); + Ok(()) + } + pub async fn back(&mut self) -> Result<()> { if self.view != View::Items { return Ok(()); @@ -496,6 +522,10 @@ fn queue_item_from(item: &Item) -> QueueItem { } } +fn can_rescan(item: &Item) -> bool { + item.is_folder && matches!(item.item_type.as_str(), "Series" | "Season") +} + pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec { let mut entries: Vec = Vec::with_capacity(libraries.len() + 1); if !resume.is_empty() { diff --git a/src/emby.rs b/src/emby.rs index 3c406c9..62e1ae8 100644 --- a/src/emby.rs +++ b/src/emby.rs @@ -250,6 +250,32 @@ impl Client { Ok(resp.items) } + /// Ask Emby to rescan/refresh a library folder or media folder. + pub async fn refresh_item(&self, item_id: &str) -> Result<()> { + let url = format!("{}/Items/{item_id}/Refresh", self.server); + let resp = self + .http + .post(&url) + .headers(self.headers()) + .query(&[ + ("Recursive", "true"), + ("ImageRefreshMode", "Default"), + ("MetadataRefreshMode", "Default"), + ("ReplaceAllImages", "false"), + ("ReplaceAllMetadata", "false"), + ]) + .send() + .await + .with_context(|| format!("POST {url}"))?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("refresh failed: HTTP {status}: {body}")); + } + Ok(()) + } + pub fn stream_url(&self, item_id: &str) -> String { format!( "{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}", diff --git a/src/ui.rs b/src/ui.rs index cc71d3a..5c7e534 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -96,6 +96,11 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> { } } KeyCode::Backspace | KeyCode::Char('h') | KeyCode::Left => app.back().await?, + KeyCode::Char('r') => { + if let Err(e) = app.rescan_selected().await { + app.status = format!("rescan error: {e}"); + } + } KeyCode::Char('s') => settings::run(term, app).await?, _ => {} } @@ -196,7 +201,7 @@ fn draw(f: &mut ratatui::Frame, app: &mut App) { let help = if app.picker.is_some() { "↑/↓ select · Enter confirm · [E]pisode [S]eason [W]hole show · Esc cancel" } else { - "↑/↓ move · Enter open/play · Backspace back · s settings · q quit (Q force)" + "↑/↓ move · Enter open/play · r rescan · Backspace back · s settings · q quit (Q force)" }; let status = Paragraph::new(vec![ Line::from(Span::styled(