1 parent
3945d6f3c9
commit
7698e27d41
3 files changed
+116
-2
No files matched your search
+83
@@ -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
@@ -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={}",
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user