From 7698e27d419dba8903e25c19c1397395ccc18a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=81nis=20L?= Date: Fri, 12 Jun 2026 19:04:19 +0300 Subject: [PATCH] feat: mark items watched Co-Authored-By: codex --- src/app.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/emby.rs | 24 ++++++++++++++++ src/ui.rs | 11 +++++-- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4977284..e03d31e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -280,6 +280,33 @@ impl App { Ok(()) } + pub async fn mark_selected_watched(&mut self) -> Result<()> { + let Some(i) = self.list_state.selected() else { + self.status = "Select a movie, episode, series, or season to mark watched".into(); + return Ok(()); + }; + + let Some(item) = self + .items + .get(i) + .filter(|item| can_mark_watched(item)) + .cloned() + else { + self.status = "Select a movie, episode, series, or season to mark watched".into(); + return Ok(()); + }; + + let title = item.display_title(); + self.status = format!("Marking watched: {title}..."); + self.client.mark_watched(&item.id).await?; + self.reload_current_items(Some(i)).await?; + if !self.is_resume_view() { + self.refresh_resume().await; + } + self.status = format!("Marked watched: {title}"); + Ok(()) + } + pub async fn back(&mut self) -> Result<()> { if self.view != View::Items { return Ok(()); @@ -312,6 +339,33 @@ impl App { Ok(()) } + async fn reload_current_items(&mut self, restore: Option) -> Result<()> { + if self.view != View::Items { + return Ok(()); + } + let Some((parent_id, parent_name)) = self.breadcrumbs.last().cloned() else { + return Ok(()); + }; + + if parent_id == RESUME_VIRTUAL_ID { + self.resume = self.client.resume_items(20).await?; + self.home_entries = build_home_entries(&self.libraries, &self.resume); + self.items = self.resume.clone(); + } else { + self.items = self.client.items(&parent_id).await?; + } + self.current_title = Some(parent_name); + self.list_state + .select(restore_selection(restore, self.items.len())); + Ok(()) + } + + fn is_resume_view(&self) -> bool { + self.breadcrumbs + .last() + .is_some_and(|(id, _)| id == RESUME_VIRTUAL_ID) + } + pub fn picker_move_up(&mut self) { if let Some(p) = self.picker.as_mut() { p.cursor = match p.cursor { @@ -520,6 +574,13 @@ fn can_rescan(item: &Item) -> bool { item.is_folder && matches!(item.item_type.as_str(), "Series" | "Season") } +fn can_mark_watched(item: &Item) -> bool { + matches!( + item.item_type.as_str(), + "Episode" | "Movie" | "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() { @@ -571,6 +632,28 @@ mod tests { } } + #[test] + fn mark_watched_targets_playable_and_show_folders() { + let series = Item { + id: "show".into(), + name: "Show".into(), + item_type: "Series".into(), + is_folder: true, + ..Item::default() + }; + assert!(can_mark_watched(&movie("m1", "Movie One"))); + assert!(can_mark_watched(&ep("e1", "Show", 1, 1))); + assert!(can_mark_watched(&series)); + assert!(can_mark_watched(&folder("s1", "Season 1"))); + assert!(!can_mark_watched(&Item { + id: "collection".into(), + name: "Collection".into(), + item_type: "CollectionFolder".into(), + is_folder: true, + ..Item::default() + })); + } + #[test] fn queue_for_episode_includes_rest_of_same_series() { let items = vec![ diff --git a/src/emby.rs b/src/emby.rs index 11ab931..1c39821 100644 --- a/src/emby.rs +++ b/src/emby.rs @@ -105,6 +105,8 @@ pub struct UserData { pub position_ticks: i64, #[serde(rename = "PlayedPercentage", default)] pub played_percentage: Option, + #[serde(rename = "Played", default)] + pub played: bool, } #[derive(Debug, Deserialize)] @@ -267,6 +269,28 @@ impl Client { Ok(()) } + /// Mark an item watched for the current user. + pub async fn mark_watched(&self, item_id: &str) -> Result<()> { + let url = format!( + "{}/Users/{}/PlayedItems/{item_id}", + self.server, self.user_id + ); + let resp = self + .http + .post(&url) + .headers(self.headers()) + .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!("mark watched 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 9bfce15..d03b96f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -101,6 +101,11 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> { app.status = format!("rescan error: {e}"); } } + KeyCode::Char('w') => { + if let Err(e) = app.mark_selected_watched().await { + app.status = format!("mark watched error: {e}"); + } + } KeyCode::Char('s') => settings::run(term, app).await?, _ => {} } @@ -206,7 +211,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 · r rescan · Backspace back · s settings · q quit (Q force)" + "↑/↓ move · Enter open/play · w watched · r rescan · Backspace back · s settings · q quit (Q force)" }; let status = Paragraph::new(vec![ Line::from(Span::styled( @@ -322,7 +327,9 @@ fn format_item(it: &Item) -> ListItem<'static> { // Progress badge for in-progress items. if let Some(ud) = &it.user_data { - if let Some(pct) = ud.played_percentage { + if ud.played { + line.push_str(" (watched)"); + } else if let Some(pct) = ud.played_percentage { if pct > 0.0 && pct < 100.0 { line.push_str(&format!(" ({:.0}%)", pct)); }