feat: mark items watched

Co-Authored-By: codex <[email protected]>
This commit is contained in:
janisslsmandcodex committed 2026-06-12 19:04:19 +03:00
1 parent 3945d6f3c9
commit 7698e27d41
3 files changed
+116 -2

No files matched your search

+83
View File
@@ -280,6 +280,33 @@ impl App {
Ok(()) 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<()> { pub async fn back(&mut self) -> Result<()> {
if self.view != View::Items { if self.view != View::Items {
return Ok(()); return Ok(());
@@ -312,6 +339,33 @@ impl App {
Ok(()) Ok(())
} }
async fn reload_current_items(&mut self, restore: Option<usize>) -> 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) { pub fn picker_move_up(&mut self) {
if let Some(p) = self.picker.as_mut() { if let Some(p) = self.picker.as_mut() {
p.cursor = match p.cursor { 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") 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<Item> { pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec<Item> {
let mut entries: Vec<Item> = Vec::with_capacity(libraries.len() + 1); let mut entries: Vec<Item> = Vec::with_capacity(libraries.len() + 1);
if !resume.is_empty() { 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] #[test]
fn queue_for_episode_includes_rest_of_same_series() { fn queue_for_episode_includes_rest_of_same_series() {
let items = vec![ let items = vec![
+24
View File
@@ -105,6 +105,8 @@ pub struct UserData {
pub position_ticks: i64, pub position_ticks: i64,
#[serde(rename = "PlayedPercentage", default)] #[serde(rename = "PlayedPercentage", default)]
pub played_percentage: Option<f64>, pub played_percentage: Option<f64>,
#[serde(rename = "Played", default)]
pub played: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -267,6 +269,28 @@ impl Client {
Ok(()) 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 { pub fn stream_url(&self, item_id: &str) -> String {
format!( format!(
"{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}", "{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}",
+9 -2
View File
@@ -101,6 +101,11 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> {
app.status = format!("rescan error: {e}"); 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?, 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() { let help = if app.picker.is_some() {
"↑/↓ select · Enter confirm · [E]pisode [S]eason [W]hole show · Esc cancel" "↑/↓ select · Enter confirm · [E]pisode [S]eason [W]hole show · Esc cancel"
} else { } 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![ let status = Paragraph::new(vec![
Line::from(Span::styled( Line::from(Span::styled(
@@ -322,7 +327,9 @@ fn format_item(it: &Item) -> ListItem<'static> {
// Progress badge for in-progress items. // Progress badge for in-progress items.
if let Some(ud) = &it.user_data { 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 { if pct > 0.0 && pct < 100.0 {
line.push_str(&format!(" ({:.0}%)", pct)); line.push_str(&format!(" ({:.0}%)", pct));
} }