From af17ffd2b6b921b122757e6f464a07f79c658516 Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Tue, 17 Feb 2026 13:43:28 -0600 Subject: [PATCH] Added Functionality for Sending Current play position --- README.md | 1 + src/core/jellyfin.rs | 141 +++++++++++++++++++++++++++ src/main.rs | 225 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 367 insertions(+) diff --git a/README.md b/README.md index bffc203..ad65104 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Maybe one day when I am better at programming I will refactor the codebase and f * Download Queue Management * Local Media Management * Autoplay and Resume functionality +* Sends Current Watch Position to the Server * Playlist and Collection Support * Persistent Auth diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index e8ce691..77fda8d 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -24,6 +24,65 @@ struct AuthenticateUserByName<'a> { pw: &'a str, } +#[derive(Serialize)] +struct PlaybackStartInfo<'a> { + #[serde(rename = "ItemId")] + item_id: &'a str, + + #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] + position_ticks: Option, + + #[serde(rename = "CanSeek")] + can_seek: bool, + + #[serde(rename = "IsPaused")] + is_paused: bool, + + #[serde(rename = "IsMuted")] + is_muted: bool, + + // Optional but harmless to include; some servers like seeing it present. + #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] + media_source_id: Option<&'a str>, +} + +#[derive(Serialize)] +struct PlaybackProgressInfo<'a> { + #[serde(rename = "ItemId")] + item_id: &'a str, + + #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] + position_ticks: Option, + + #[serde(rename = "CanSeek")] + can_seek: bool, + + #[serde(rename = "IsPaused")] + is_paused: bool, + + #[serde(rename = "IsMuted")] + is_muted: bool, + + #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] + media_source_id: Option<&'a str>, +} + +#[derive(Serialize)] +struct PlaybackStopInfo<'a> { + #[serde(rename = "ItemId")] + item_id: &'a str, + + #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] + position_ticks: Option, + + #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] + media_source_id: Option<&'a str>, + + #[serde(rename = "Failed")] + failed: bool, +} + + #[derive(Deserialize)] struct AuthenticationResult { #[serde(rename = "AccessToken")] @@ -52,6 +111,7 @@ struct PlaylistItemsResponse { total_record_count: Option, } + impl JellyfinClient { pub fn from_config(cfg: &Config) -> Result { let token = cfg @@ -179,6 +239,87 @@ impl JellyfinClient { Ok(()) } + pub fn report_playback_start(&self, item_id: &str, position_ticks: Option) -> Result<(), String> { + let base = self.base_url.trim_end_matches('/'); + let url = format!("{base}/Sessions/Playing"); + + let body = PlaybackStartInfo { + item_id, + position_ticks, + can_seek: true, + is_paused: false, + is_muted: false, + media_source_id: Some(item_id), + }; + + let resp = self + .client + .post(url) + .header("X-Emby-Token", &self.token) + .json(&body) + .send() + .map_err(|e| format!("report_playback_start request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("report_playback_start HTTP {}", resp.status())); + } + + Ok(()) +} + + pub fn report_playback_progress(&self, item_id: &str, position_ticks: Option, is_paused: bool) -> Result<(), String> { + let base = self.base_url.trim_end_matches('/'); + let url = format!("{base}/Sessions/Playing/Progress"); + + let body = PlaybackProgressInfo { + item_id, + position_ticks, + can_seek: true, + is_paused, + is_muted: false, + media_source_id: Some(item_id), + }; + + let resp = self + .client + .post(url) + .header("X-Emby-Token", &self.token) + .json(&body) + .send() + .map_err(|e| format!("report_playback_progress request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("report_playback_progress HTTP {}", resp.status())); + } + + Ok(()) + } + + pub fn report_playback_stopped(&self, item_id: &str, position_ticks: Option, failed: bool) -> Result<(), String> { + let base = self.base_url.trim_end_matches('/'); + let url = format!("{base}/Sessions/Playing/Stopped"); + + let body = PlaybackStopInfo { + item_id, + position_ticks, + media_source_id: Some(item_id), + failed, + }; + + let resp = self + .client + .post(url) + .header("X-Emby-Token", &self.token) + .json(&body) + .send() + .map_err(|e| format!("report_playback_stopped request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("report_playback_stopped HTTP {}", resp.status())); + } + + Ok(()) + } pub fn list_views(&self, user_id: &str) -> Result, String> { let url = format!("{}/Users/{}/Views", self.base_url, user_id); diff --git a/src/main.rs b/src/main.rs index 3cf2c36..9059d67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,7 @@ struct PlaylistCtx { struct MpvState { eof: bool, idle: bool, + paused: bool, time_pos: Option, duration: Option, percent: Option, @@ -193,6 +194,11 @@ struct App { mpv_state_rx: Option>, debug_mpv: bool, mpv_now_playing: Option, + mpv_now_playing_item_id: Option, + mpv_now_playing_is_stream: bool, + last_progress_report_at: Instant, + last_reported_position_ticks: Option, + marked_watched_this_session: bool, downloads: Option, downloads_focus: bool, downloads_selected: usize, @@ -261,6 +267,11 @@ impl Default for App { mpv_state_rx: None, debug_mpv: false, mpv_now_playing: None, + mpv_now_playing_item_id: None, + mpv_now_playing_is_stream: false, + last_progress_report_at: Instant::now(), + last_reported_position_ticks: None, + marked_watched_this_session: false, autoplay: false, autoplay_view_depth: 0, autoplay_next_index: None, @@ -333,6 +344,8 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< app.status = fmt_mpv_state(&app.mpv_state); } + playback_reporting_tick(&mut app); + // --- mpv lifecycle (no borrow issues) --- let mpv_exited = match app.mpv.as_mut() { Some(mpv) => match mpv.child.try_wait() { @@ -343,6 +356,8 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< }; if let Some(status) = mpv_exited { + jellyfin_report_stopped_best_effort(&mut app); + // take ownership so we can clean up if let Some(mpv) = app.mpv.take() { let _ = std::fs::remove_file(&mpv.socket_path); @@ -352,6 +367,10 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< // Your requirement: window closing disables autoplay app.autoplay = false; app.autoplay_next_index = None; + app.mpv_now_playing_item_id = None; + app.mpv_now_playing_is_stream = false; + app.last_reported_position_ticks = None; + app.marked_watched_this_session = false; app.mpv_now_playing = None; app.mpv_state_rx = None; app.mpv_state = MpvState::default(); @@ -825,6 +844,17 @@ fn handle_key(app: &mut App, code: KeyCode) { KeyCode::Char('x') => { app.autoplay = false; app.autoplay_next_index = None; + + jellyfin_report_stopped_best_effort(app); + + app.mpv_now_playing_item_id = None; + app.mpv_now_playing_is_stream = false; + app.last_reported_position_ticks = None; + app.marked_watched_this_session = false; + app.mpv_now_playing = None; + app.mpv_state_rx = None; + app.mpv_state = MpvState::default(); + if let Some(mut mpv) = app.mpv.take() { mpv_stop(&mut mpv); app.status = "Stopped mpv".into(); @@ -880,6 +910,10 @@ fn handle_key(app: &mut App, code: KeyCode) { }; app.mpv_now_playing = Some(item_name); + app.mpv_now_playing_item_id = maybe_item_id.clone(); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); // Decide URL/path: // 1) If OfflineLibrary gave us a file path -> play it directly. @@ -899,6 +933,18 @@ fn handle_key(app: &mut App, code: KeyCode) { build_jellyfin_play_url(&cfg, maybe_item_id.as_deref().unwrap()) }; + // Only report watch progress when actually streaming (http(s) URL). + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + // Notify Jellyfin that playback started (best effort). + if app.mpv_now_playing_is_stream { + if let (Some(cfg), Some(item_id)) = (app.config.clone(), app.mpv_now_playing_item_id.clone()) { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let _ = client.report_playback_start(&item_id, None); + } + } + } + // If mpv running -> replace, else spawn if let Some(mpv) = &app.mpv { match mpv_load_url(&cfg, mpv, &url) { @@ -935,6 +981,11 @@ fn handle_key(app: &mut App, code: KeyCode) { app.mpv_now_playing = Some(item_name); + app.mpv_now_playing_item_id = Some(item_id.clone()); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + app.autoplay = true; app.autoplay_prev_time_pos = None; app.autoplay_view_depth = app.view_stack.len(); @@ -955,6 +1006,16 @@ fn handle_key(app: &mut App, code: KeyCode) { build_jellyfin_play_url(&cfg, &item_id) }; + // Only report watch progress when actually streaming (http(s) URL). + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + // Notify Jellyfin that playback started (best effort). + if app.mpv_now_playing_is_stream { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let _ = client.report_playback_start(&item_id, None); + } + } + if let Some(mpv) = &app.mpv { let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) }; if let Err(e) = r { @@ -1055,8 +1116,25 @@ fn handle_key(app: &mut App, code: KeyCode) { app.mpv_now_playing = Some(item_name); + // --- progress reporting state for resume start --- + app.mpv_now_playing_item_id = Some(item_id.clone()); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + let url = build_jellyfin_play_url(&cfg, &item_id); + // resume is always a stream URL here + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + // Notify Jellyfin that playback started (best effort). For resume, include the start ticks if present. + if app.mpv_now_playing_is_stream { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let start_i64 = start_ticks.map(|t| t as i64); + let _ = client.report_playback_start(&item_id, start_i64); + } + } + if let Some(mpv) = &app.mpv { match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { Ok(_) => { @@ -1103,6 +1181,12 @@ fn handle_key(app: &mut App, code: KeyCode) { app.mpv_now_playing = Some(item_name); + // --- progress reporting state for resume+autoplay start --- + app.mpv_now_playing_item_id = Some(item_id.clone()); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + app.autoplay = true; app.autoplay_prev_time_pos = None; app.autoplay_view_depth = app.view_stack.len(); @@ -1111,6 +1195,16 @@ fn handle_key(app: &mut App, code: KeyCode) { let url = build_jellyfin_play_url(&cfg, &item_id); + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + // Notify Jellyfin that playback started (best effort). Include resume ticks if present. + if app.mpv_now_playing_is_stream { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let start_i64 = start_ticks.map(|t| t as i64); + let _ = client.report_playback_start(&item_id, start_i64); + } + } + if let Some(mpv) = &app.mpv { match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { Ok(_) => { @@ -2254,6 +2348,13 @@ fn ticks_to_seconds(ticks: u64) -> f64 { (ticks as f64) / 10_000_000.0 } +fn seconds_to_ticks(seconds: f64) -> Option { + if !seconds.is_finite() || seconds < 0.0 { + return None; + } + Some((seconds * 10_000_000.0).round() as i64) +} + fn mpv_seek_absolute(socket: &PathBuf, seconds: f64) -> Result<(), String> { // Wait until mpv has loaded something real (duration becomes available), // then issue a seek and REQUIRE a success reply. @@ -2601,16 +2702,36 @@ fn autoplay_tick(app: &mut App) { build_jellyfin_play_url(&cfg, &next_id) }; + // --- progress reporting state for autoplay transitions --- + app.mpv_now_playing_item_id = Some(next_id.clone()); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + if app.mpv_now_playing_is_stream { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let _ = client.report_playback_start(&next_id, None); + } + } + if let Err(e) = mpv_load_url(&cfg, mpv, &url) { app.autoplay = false; app.autoplay_next_index = None; app.status = format!("autoplay load failed: {e}"); return; } + } NextTarget::Offline { path, .. } => { let url = path.to_string_lossy().to_string(); + app.mpv_now_playing_item_id = None; + app.mpv_now_playing_is_stream = false; + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); if let Err(e) = mpv_load_local(mpv, &url) { app.autoplay = false; app.autoplay_next_index = None; @@ -2627,6 +2748,77 @@ fn autoplay_tick(app: &mut App) { } } +fn playback_reporting_tick(app: &mut App) { + // Only report for streaming items with a known Jellyfin item id + if !app.mpv_now_playing_is_stream { + return; + } + let item_id = match app.mpv_now_playing_item_id.clone() { + Some(id) => id, + None => return, + }; + + // Need config + user id + let cfg = match app.config.clone() { + Some(c) => c, + None => return, + }; + let user_id = match cfg.user_id.clone() { + Some(u) => u, + None => return, + }; + + // Need current playback time + let secs = match app.mpv_state.time_pos { + Some(s) if s.is_finite() && s >= 0.0 => s, + _ => return, + }; + let pos_ticks = match seconds_to_ticks(secs) { + Some(t) => t, + None => return, + }; + + // Throttle (every 10s) and avoid duplicate sends + let now = Instant::now(); + if now.duration_since(app.last_progress_report_at) < Duration::from_secs(10) { + return; + } + if let Some(last) = app.last_reported_position_ticks { + // if time didn't meaningfully move, don't spam + if (pos_ticks - last).abs() < 5 * 10_000_000 { + app.last_progress_report_at = now; + return; + } + } + + // Send progress (best effort) + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + // NOTE: adjust this call name/signature to whatever you implemented in core. + // Common pattern: report_playback_progress(item_id, position_ticks) + let _ = client.report_playback_progress(&item_id, Some(pos_ticks), app.mpv_state.paused); + + app.last_reported_position_ticks = Some(pos_ticks); + app.last_progress_report_at = now; + + // Mark watched once when >=95% + let pct = app.mpv_state.percent.unwrap_or(0.0); + if !app.marked_watched_this_session && pct.is_finite() && pct >= 95.0 { + let _ = client.mark_played(&user_id, &item_id); + app.marked_watched_this_session = true; + + // Optional: update current library row immediately if visible + if let Some(View::Library { items, .. }) = app.view_stack.last_mut() { + if let Some(it) = items.iter_mut().find(|it| it.id == item_id) { + let ud = it.user_data.get_or_insert_with(core::jellyfin::JfUserData::default); + ud.played = Some(true); + ud.played_percentage = Some(100.0); + ud.playback_position_ticks = Some(0); + } + } + } + } +} + fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> { // pause is a boolean property in mpv JSON IPC let v = if paused { "true" } else { "false" }; @@ -2682,6 +2874,9 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { let track_list = mpv_get_prop_jtui(&socket_path, "track-list", request_id).ok(); request_id = request_id.wrapping_add(1); + let paused = mpv_get_bool_jtui(&socket_path, "pause", request_id).unwrap_or(false); + request_id = request_id.wrapping_add(1); + let (audio, subs) = match track_list.as_ref() { Some(v) => selected_tracks_from_track_list(v), None => (None, None), @@ -2699,6 +2894,7 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { st.media_title = media_title; st.audio = audio; st.subs = subs; + st.paused = paused; // compute percent yourself (jtui style) st.percent = percent_pos @@ -3719,3 +3915,32 @@ fn ensure_config_before_tui() -> Result<(), String> { Ok(()) } + +fn jellyfin_report_stopped_best_effort(app: &mut App) { + // Only report for streaming items with a known Jellyfin item id + if !app.mpv_now_playing_is_stream { + return; + } + let item_id = match app.mpv_now_playing_item_id.clone() { + Some(id) => id, + None => return, + }; + + let cfg = match app.config.clone() { + Some(c) => c, + None => return, + }; + + // position ticks (best effort) + let secs = app.mpv_state.time_pos.unwrap_or(0.0); + let pos_ticks = seconds_to_ticks(secs).unwrap_or(0); + + // IMPORTANT: + // Jellyfin's "Failed" means playback error, not "not completed". + // Completion is handled by your >=95% mark_played logic elsewhere. + let failed = false; + + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let _ = client.report_playback_stopped(&item_id, Some(pos_ticks), failed); + } +}