Added Functionality for Sending Current play position
This commit is contained in:
@@ -13,6 +13,7 @@ Maybe one day when I am better at programming I will refactor the codebase and f
|
|||||||
* Download Queue Management
|
* Download Queue Management
|
||||||
* Local Media Management
|
* Local Media Management
|
||||||
* Autoplay and Resume functionality
|
* Autoplay and Resume functionality
|
||||||
|
* Sends Current Watch Position to the Server
|
||||||
* Playlist and Collection Support
|
* Playlist and Collection Support
|
||||||
* Persistent Auth
|
* Persistent Auth
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,65 @@ struct AuthenticateUserByName<'a> {
|
|||||||
pw: &'a str,
|
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<i64>,
|
||||||
|
|
||||||
|
#[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<i64>,
|
||||||
|
|
||||||
|
#[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<i64>,
|
||||||
|
|
||||||
|
#[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")]
|
||||||
|
media_source_id: Option<&'a str>,
|
||||||
|
|
||||||
|
#[serde(rename = "Failed")]
|
||||||
|
failed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthenticationResult {
|
struct AuthenticationResult {
|
||||||
#[serde(rename = "AccessToken")]
|
#[serde(rename = "AccessToken")]
|
||||||
@@ -52,6 +111,7 @@ struct PlaylistItemsResponse {
|
|||||||
total_record_count: Option<usize>,
|
total_record_count: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl JellyfinClient {
|
impl JellyfinClient {
|
||||||
pub fn from_config(cfg: &Config) -> Result<Self, String> {
|
pub fn from_config(cfg: &Config) -> Result<Self, String> {
|
||||||
let token = cfg
|
let token = cfg
|
||||||
@@ -179,6 +239,87 @@ impl JellyfinClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn report_playback_start(&self, item_id: &str, position_ticks: Option<i64>) -> 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<i64>, 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<i64>, 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<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);
|
||||||
|
|||||||
225
src/main.rs
225
src/main.rs
@@ -67,6 +67,7 @@ struct PlaylistCtx {
|
|||||||
struct MpvState {
|
struct MpvState {
|
||||||
eof: bool,
|
eof: bool,
|
||||||
idle: bool,
|
idle: bool,
|
||||||
|
paused: bool,
|
||||||
time_pos: Option<f64>,
|
time_pos: Option<f64>,
|
||||||
duration: Option<f64>,
|
duration: Option<f64>,
|
||||||
percent: Option<f64>,
|
percent: Option<f64>,
|
||||||
@@ -193,6 +194,11 @@ struct App {
|
|||||||
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
|
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
|
||||||
debug_mpv: bool,
|
debug_mpv: bool,
|
||||||
mpv_now_playing: Option<String>,
|
mpv_now_playing: Option<String>,
|
||||||
|
mpv_now_playing_item_id: Option<String>,
|
||||||
|
mpv_now_playing_is_stream: bool,
|
||||||
|
last_progress_report_at: Instant,
|
||||||
|
last_reported_position_ticks: Option<i64>,
|
||||||
|
marked_watched_this_session: bool,
|
||||||
downloads: Option<downloads::DownloadManager>,
|
downloads: Option<downloads::DownloadManager>,
|
||||||
downloads_focus: bool,
|
downloads_focus: bool,
|
||||||
downloads_selected: usize,
|
downloads_selected: usize,
|
||||||
@@ -261,6 +267,11 @@ impl Default for App {
|
|||||||
mpv_state_rx: None,
|
mpv_state_rx: None,
|
||||||
debug_mpv: false,
|
debug_mpv: false,
|
||||||
mpv_now_playing: None,
|
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: false,
|
||||||
autoplay_view_depth: 0,
|
autoplay_view_depth: 0,
|
||||||
autoplay_next_index: None,
|
autoplay_next_index: None,
|
||||||
@@ -333,6 +344,8 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
|
|||||||
app.status = fmt_mpv_state(&app.mpv_state);
|
app.status = fmt_mpv_state(&app.mpv_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
playback_reporting_tick(&mut app);
|
||||||
|
|
||||||
// --- mpv lifecycle (no borrow issues) ---
|
// --- mpv lifecycle (no borrow issues) ---
|
||||||
let mpv_exited = match app.mpv.as_mut() {
|
let mpv_exited = match app.mpv.as_mut() {
|
||||||
Some(mpv) => match mpv.child.try_wait() {
|
Some(mpv) => match mpv.child.try_wait() {
|
||||||
@@ -343,6 +356,8 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(status) = mpv_exited {
|
if let Some(status) = mpv_exited {
|
||||||
|
jellyfin_report_stopped_best_effort(&mut app);
|
||||||
|
|
||||||
// take ownership so we can clean up
|
// take ownership so we can clean up
|
||||||
if let Some(mpv) = app.mpv.take() {
|
if let Some(mpv) = app.mpv.take() {
|
||||||
let _ = std::fs::remove_file(&mpv.socket_path);
|
let _ = std::fs::remove_file(&mpv.socket_path);
|
||||||
@@ -352,6 +367,10 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
|
|||||||
// Your requirement: window closing disables autoplay
|
// Your requirement: window closing disables autoplay
|
||||||
app.autoplay = false;
|
app.autoplay = false;
|
||||||
app.autoplay_next_index = None;
|
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_now_playing = None;
|
||||||
app.mpv_state_rx = None;
|
app.mpv_state_rx = None;
|
||||||
app.mpv_state = MpvState::default();
|
app.mpv_state = MpvState::default();
|
||||||
@@ -825,6 +844,17 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
KeyCode::Char('x') => {
|
KeyCode::Char('x') => {
|
||||||
app.autoplay = false;
|
app.autoplay = false;
|
||||||
app.autoplay_next_index = None;
|
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() {
|
if let Some(mut mpv) = app.mpv.take() {
|
||||||
mpv_stop(&mut mpv);
|
mpv_stop(&mut mpv);
|
||||||
app.status = "Stopped mpv".into();
|
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 = 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:
|
// Decide URL/path:
|
||||||
// 1) If OfflineLibrary gave us a file path -> play it directly.
|
// 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())
|
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 mpv running -> replace, else spawn
|
||||||
if let Some(mpv) = &app.mpv {
|
if let Some(mpv) = &app.mpv {
|
||||||
match mpv_load_url(&cfg, mpv, &url) {
|
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 = 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 = true;
|
||||||
app.autoplay_prev_time_pos = None;
|
app.autoplay_prev_time_pos = None;
|
||||||
app.autoplay_view_depth = app.view_stack.len();
|
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)
|
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 {
|
if let Some(mpv) = &app.mpv {
|
||||||
let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) };
|
let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) };
|
||||||
if let Err(e) = r {
|
if let Err(e) = r {
|
||||||
@@ -1055,8 +1116,25 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
|
|
||||||
app.mpv_now_playing = Some(item_name);
|
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);
|
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 {
|
if let Some(mpv) = &app.mpv {
|
||||||
match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) {
|
match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -1103,6 +1181,12 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
|
|
||||||
app.mpv_now_playing = Some(item_name);
|
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 = true;
|
||||||
app.autoplay_prev_time_pos = None;
|
app.autoplay_prev_time_pos = None;
|
||||||
app.autoplay_view_depth = app.view_stack.len();
|
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);
|
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 {
|
if let Some(mpv) = &app.mpv {
|
||||||
match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) {
|
match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -2254,6 +2348,13 @@ fn ticks_to_seconds(ticks: u64) -> f64 {
|
|||||||
(ticks as f64) / 10_000_000.0
|
(ticks as f64) / 10_000_000.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seconds_to_ticks(seconds: f64) -> Option<i64> {
|
||||||
|
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> {
|
fn mpv_seek_absolute(socket: &PathBuf, seconds: f64) -> Result<(), String> {
|
||||||
// Wait until mpv has loaded something real (duration becomes available),
|
// Wait until mpv has loaded something real (duration becomes available),
|
||||||
// then issue a seek and REQUIRE a success reply.
|
// 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)
|
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) {
|
if let Err(e) = mpv_load_url(&cfg, mpv, &url) {
|
||||||
app.autoplay = false;
|
app.autoplay = false;
|
||||||
app.autoplay_next_index = None;
|
app.autoplay_next_index = None;
|
||||||
app.status = format!("autoplay load failed: {e}");
|
app.status = format!("autoplay load failed: {e}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NextTarget::Offline { path, .. } => {
|
NextTarget::Offline { path, .. } => {
|
||||||
let url = path.to_string_lossy().to_string();
|
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) {
|
if let Err(e) = mpv_load_local(mpv, &url) {
|
||||||
app.autoplay = false;
|
app.autoplay = false;
|
||||||
app.autoplay_next_index = None;
|
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> {
|
fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> {
|
||||||
// pause is a boolean property in mpv JSON IPC
|
// pause is a boolean property in mpv JSON IPC
|
||||||
let v = if paused { "true" } else { "false" };
|
let v = if paused { "true" } else { "false" };
|
||||||
@@ -2682,6 +2874,9 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver<MpvState> {
|
|||||||
let track_list = mpv_get_prop_jtui(&socket_path, "track-list", request_id).ok();
|
let track_list = mpv_get_prop_jtui(&socket_path, "track-list", request_id).ok();
|
||||||
request_id = request_id.wrapping_add(1);
|
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() {
|
let (audio, subs) = match track_list.as_ref() {
|
||||||
Some(v) => selected_tracks_from_track_list(v),
|
Some(v) => selected_tracks_from_track_list(v),
|
||||||
None => (None, None),
|
None => (None, None),
|
||||||
@@ -2699,6 +2894,7 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver<MpvState> {
|
|||||||
st.media_title = media_title;
|
st.media_title = media_title;
|
||||||
st.audio = audio;
|
st.audio = audio;
|
||||||
st.subs = subs;
|
st.subs = subs;
|
||||||
|
st.paused = paused;
|
||||||
|
|
||||||
// compute percent yourself (jtui style)
|
// compute percent yourself (jtui style)
|
||||||
st.percent = percent_pos
|
st.percent = percent_pos
|
||||||
@@ -3719,3 +3915,32 @@ fn ensure_config_before_tui() -> Result<(), String> {
|
|||||||
|
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user