mark watched and unwatched

This commit is contained in:
2026-02-17 00:20:01 -06:00
parent 426d7c260a
commit 9ce31a6e09
2 changed files with 110 additions and 7 deletions

View File

@@ -143,6 +143,43 @@ impl JellyfinClient {
false false
} }
pub fn mark_played(&self, user_id: &str, item_id: &str) -> Result<(), String> {
let base = self.base_url.trim_end_matches('/');
let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}");
let resp = self
.client
.post(url)
.header("X-Emby-Token", &self.token)
.send()
.map_err(|e| format!("mark_played request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("mark_played failed: HTTP {}", resp.status()));
}
Ok(())
}
pub fn mark_unplayed(&self, user_id: &str, item_id: &str) -> Result<(), String> {
let base = self.base_url.trim_end_matches('/');
let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}");
let resp = self
.client
.delete(url)
.header("X-Emby-Token", &self.token)
.send()
.map_err(|e| format!("mark_unplayed request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("mark_unplayed failed: HTTP {}", resp.status()));
}
Ok(())
}
pub fn list_views(&self, user_id: &str) -> Result<Vec<JfItem>, String> { pub fn list_views(&self, user_id: &str) -> Result<Vec<JfItem>, String> {
let url = format!("{}/Users/{}/Views", self.base_url, user_id); let url = format!("{}/Users/{}/Views", self.base_url, user_id);
@@ -416,19 +453,19 @@ pub struct JfStudio {
pub name: String, pub name: String,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Deserialize, Clone, Debug, Default)]
pub struct JfUserData { pub struct JfUserData {
#[serde(rename = "Played")] #[serde(rename = "Played")]
pub played: Option<bool>, pub played: Option<bool>,
#[serde(rename = "PlaybackPositionTicks")]
pub playback_position_ticks: Option<i64>,
#[serde(rename = "PlayedPercentage")] #[serde(rename = "PlayedPercentage")]
pub played_percentage: Option<f64>, pub played_percentage: Option<f64>,
#[serde(rename = "PlaybackPositionTicks")]
pub playback_position_ticks: Option<i64>,
#[serde(rename = "UnplayedItemCount")] #[serde(rename = "UnplayedItemCount")]
pub unplayed_item_count: Option<i64>, pub unplayed_item_count: Option<i32>,
} }

View File

@@ -668,6 +668,74 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
} }
// Mark watched (w) / Unmark watched (W)
if matches!(code, KeyCode::Char('w') | KeyCode::Char('W')) {
let unmark = matches!(code, KeyCode::Char('W'));
let cfg = match &app.config {
Some(c) => c.clone(),
None => { app.status = "No config loaded".into(); return; }
};
let user_id = match cfg.user_id.as_deref() {
Some(u) => u.to_string(),
None => { app.status = "Missing user_id (login again)".into(); return; }
};
let (item_id, item_type) = match app.current_view() {
View::Library { items, selected, .. } if !items.is_empty() => {
let it = &items[*selected];
(it.id.clone(), it.item_type.clone().unwrap_or_default())
}
_ => { app.status = "Not in a library view".into(); return; }
};
// Only do this for playables (you can loosen this later if you want)
if item_type != "Episode" && item_type != "Movie" {
app.status = "Watched toggle only for Episodes/Movies (for now)".into();
return;
}
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c,
Err(e) => { app.status = e; return; }
};
let r = if unmark {
client.mark_unplayed(&user_id, &item_id)
} else {
client.mark_played(&user_id, &item_id)
};
match r {
Ok(_) => {
// Update local UI state so ✅ disappears/appears immediately
if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() {
if let Some(it) = items.get_mut(*selected) {
let ud = it.user_data.get_or_insert_with(core::jellyfin::JfUserData::default);
if unmark {
ud.played = Some(false);
ud.played_percentage = Some(0.0);
ud.playback_position_ticks = Some(0);
} else {
ud.played = Some(true);
ud.played_percentage = Some(100.0);
ud.playback_position_ticks = Some(0);
}
}
}
app.status = if unmark { "Marked unplayed".into() } else { "Marked played".into() };
}
Err(e) => {
app.status = format!("Watched update failed: {e}");
}
}
return;
}
// mpv controls (only when not typing into search/login) // mpv controls (only when not typing into search/login)
if app.search.is_none() && !matches!(app.current_view(), View::Login) { if app.search.is_none() && !matches!(app.current_view(), View::Login) {
@@ -910,8 +978,6 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
} }
} }
KeyCode::Char('r') => { KeyCode::Char('r') => {
app.autoplay = false; app.autoplay = false;
app.autoplay_next_index = None; app.autoplay_next_index = None;