diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index 733a5e9..042cf38 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -52,6 +52,34 @@ struct PlaylistItemsResponse { total_record_count: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +struct PlaybackInfoDto<'a> { + user_id: Option<&'a str>, + max_streaming_bitrate: Option, + + enable_direct_play: Option, + enable_direct_stream: Option, + enable_transcoding: Option, + + allow_video_stream_copy: Option, + allow_audio_stream_copy: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct PlaybackInfoResponse { + media_sources: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct MediaSourceInfo { + transcoding_url: Option, + // (We don't need more fields.) +} + + impl JellyfinClient { pub fn from_config(cfg: &Config) -> Result { let token = cfg @@ -143,6 +171,54 @@ impl JellyfinClient { false } + pub fn playback_transcoding_url( + &self, + item_id: &str, + user_id: Option<&str>, + max_streaming_bitrate_bps: i32, + force_transcode: bool, + ) -> Result, String> { + let url = format!("{}/Items/{}/PlaybackInfo", self.base_url.trim_end_matches('/'), item_id); + + let body = if force_transcode { + PlaybackInfoDto { + user_id, + max_streaming_bitrate: Some(max_streaming_bitrate_bps), + enable_direct_play: Some(false), + enable_direct_stream: Some(false), + enable_transcoding: Some(true), + allow_video_stream_copy: Some(false), + allow_audio_stream_copy: Some(false), + } + } else { + PlaybackInfoDto { + user_id, + max_streaming_bitrate: Some(max_streaming_bitrate_bps), + enable_direct_play: None, + enable_direct_stream: None, + enable_transcoding: None, + allow_video_stream_copy: None, + allow_audio_stream_copy: None, + } + }; + + let resp: PlaybackInfoResponse = self + .client + .post(url) + // IMPORTANT: authenticated call + .header("X-Emby-Authorization", self.auth_header_value()) + .json(&body) + .send() + .map_err(|e| format!("PlaybackInfo request failed: {e}"))? + .error_for_status() + .map_err(|e| format!("PlaybackInfo HTTP error: {e}"))? + .json() + .map_err(|e| format!("PlaybackInfo json parse failed: {e}"))?; + + Ok(resp.media_sources.into_iter().find_map(|ms| ms.transcoding_url)) + } + + 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 5902695..f47b9e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -793,17 +793,26 @@ fn handle_key(app: &mut App, code: KeyCode) { let url = if let Some(p) = maybe_path.or(local) { p.to_string_lossy().to_string() } else { - build_jellyfin_play_url(&cfg, maybe_item_id.as_deref().unwrap(), app.transcoding) + let id = maybe_item_id.as_deref().unwrap(); + if app.transcoding { + build_jellyfin_play_url_transcode_forced(&cfg, id) + .unwrap_or_else(|| build_jellyfin_play_url(&cfg, id, true)) + } else { + build_jellyfin_play_url(&cfg, id, false) + } }; // If mpv running -> replace, else spawn if let Some(mpv) = &app.mpv { - match mpv_load_url(&cfg, mpv, &url) { + // For local files, use mpv_load_local; otherwise mpv_load_url + let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) }; + match r { Ok(_) => app.status = "mpv: loaded".into(), Err(e) => app.status = format!("mpv load failed: {e}"), } } else { - match spawn_mpv_url(&cfg, &url) { + let r = if url.starts_with('/') { spawn_mpv_local(&url) } else { spawn_mpv_url(&cfg, &url) }; + match r { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); @@ -813,9 +822,9 @@ fn handle_key(app: &mut App, code: KeyCode) { Err(e) => app.status = format!("mpv spawn failed: {e}"), } } + return; } - KeyCode::Char('P') => { let cfg = match &app.config { Some(c) => c.clone(), @@ -848,8 +857,11 @@ fn handle_key(app: &mut App, code: KeyCode) { let url = if let Some(p) = local { p.to_string_lossy().to_string() + } else if app.transcoding { + build_jellyfin_play_url_transcode_forced(&cfg, &item_id) + .unwrap_or_else(|| build_jellyfin_play_url(&cfg, &item_id, true)) } else { - build_jellyfin_play_url(&cfg, &item_id, app.transcoding) + build_jellyfin_play_url(&cfg, &item_id, false) }; if let Some(mpv) = &app.mpv { @@ -928,8 +940,6 @@ fn handle_key(app: &mut App, code: KeyCode) { } } } - - KeyCode::Char('r') => { app.autoplay = false; app.autoplay_next_index = None; @@ -954,7 +964,12 @@ fn handle_key(app: &mut App, code: KeyCode) { app.mpv_now_playing = Some(item_name); - let url = build_jellyfin_play_url(&cfg, &item_id, app.transcoding); + let url = if app.transcoding { + build_jellyfin_play_url_transcode_forced(&cfg, &item_id) + .unwrap_or_else(|| build_jellyfin_play_url(&cfg, &item_id, true)) + } else { + build_jellyfin_play_url(&cfg, &item_id, false) + }; if let Some(mpv) = &app.mpv { match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { @@ -981,11 +996,15 @@ fn handle_key(app: &mut App, code: KeyCode) { Err(e) => app.status = format!("mpv spawn failed: {e}"), } } + return; } KeyCode::Char('R') => { // resume + autoplay - let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config".into(); return; } + }; let (item_id, item_name, selected, len, start_ticks) = match app.current_view() { View::Library { items, selected, .. } if !items.is_empty() => { @@ -1008,7 +1027,12 @@ fn handle_key(app: &mut App, code: KeyCode) { app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; app.autoplay_eof_latched = false; - let url = build_jellyfin_play_url(&cfg, &item_id, app.transcoding); + let url = if app.transcoding { + build_jellyfin_play_url_transcode_forced(&cfg, &item_id) + .unwrap_or_else(|| build_jellyfin_play_url(&cfg, &item_id, true)) + } else { + build_jellyfin_play_url(&cfg, &item_id, false) + }; if let Some(mpv) = &app.mpv { match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { @@ -1035,6 +1059,7 @@ fn handle_key(app: &mut App, code: KeyCode) { Err(e) => app.status = format!("mpv spawn failed: {e}"), } } + return; } _ => {} @@ -2361,7 +2386,7 @@ fn autoplay_tick(app: &mut App) { // Snapshot latest mpv state from poller thread let st = app.mpv_state.clone(); - // Optional: live debug in status (toggle with 'D') + // Optional: live debug in status (toggle with 'M' in your keybinds) if app.debug_mpv { app.status = fmt_mpv_state(&st); } @@ -2417,126 +2442,151 @@ fn autoplay_tick(app: &mut App) { } }; - // Choose next target based on current view - enum NextTarget { - Online { id: String, name: String, len: usize }, - Offline { path: PathBuf, name: String, len: usize }, - } + // Choose next target based on current view + enum NextTarget { + Online { id: String, name: String, len: usize }, + Offline { path: PathBuf, name: String, len: usize }, + } - let next = match app.current_view() { - View::Library { items, .. } if next_i < items.len() => { - NextTarget::Online { - id: items[next_i].id.clone(), - name: items[next_i].name.clone(), - len: items.len(), - } - } - View::OfflineLibrary { entries, .. } if next_i < entries.len() => { - // skip dirs: if next_i lands on a dir, find the next file - let mut j = next_i; - while j < entries.len() && entries[j].is_dir { - j += 1; - } - if j >= entries.len() { - // no more playable files - app.autoplay = false; - app.autoplay_next_index = None; - if !app.debug_mpv { app.status = "$".into(); } - return; - } + let next = match app.current_view() { + View::Library { items, .. } if next_i < items.len() => NextTarget::Online { + id: items[next_i].id.clone(), + name: items[next_i].name.clone(), + len: items.len(), + }, - NextTarget::Offline { - path: entries[j].path.clone(), - name: entries[j].name.clone(), - len: entries.len(), - } + View::OfflineLibrary { entries, .. } if next_i < entries.len() => { + // skip dirs: if next_i lands on a dir, find the next file + let mut j = next_i; + while j < entries.len() && entries[j].is_dir { + j += 1; } - _ => { + if j >= entries.len() { + // no more playable files app.autoplay = false; app.autoplay_next_index = None; - if !app.debug_mpv { app.status = "$".into(); } + if !app.debug_mpv { + app.status = "$".into(); + } return; } - }; - match &next { - NextTarget::Online { name, .. } => app.mpv_now_playing = Some(name.clone()), - NextTarget::Offline { name, .. } => app.mpv_now_playing = Some(name.clone()), + NextTarget::Offline { + path: entries[j].path.clone(), + name: entries[j].name.clone(), + len: entries.len(), + } } - // Move selection highlight in UI + compute following index - match app.view_stack.last_mut() { - Some(View::Library { selected, .. }) => { - *selected = next_i; + _ => { + app.autoplay = false; + app.autoplay_next_index = None; + if !app.debug_mpv { + app.status = "$".into(); } - Some(View::OfflineLibrary { selected, entries, .. }) => { - // if we skipped dirs, move selection to actual file index - let mut j = next_i; - while j < entries.len() && entries[j].is_dir { j += 1; } - *selected = j; - } - _ => {} + return; } + }; - // compute next index (offline should skip dirs too later) - let len = match &next { - NextTarget::Online { len, .. } => *len, - NextTarget::Offline { len, .. } => *len, - }; - app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; + // Update "now playing" + match &next { + NextTarget::Online { name, .. } => app.mpv_now_playing = Some(name.clone()), + NextTarget::Offline { name, .. } => app.mpv_now_playing = Some(name.clone()), + } - // Load next - match next { - NextTarget::Online { id: next_id, .. } => { - let cfg = match app.config.clone() { - Some(c) => c, - None => { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = "No config".into(); - return; - } - }; + // Move selection highlight in UI + match app.view_stack.last_mut() { + Some(View::Library { selected, .. }) => { + *selected = next_i; + } + Some(View::OfflineLibrary { + selected, + entries, + .. + }) => { + // if we skipped dirs, move selection to actual file index + let mut j = next_i; + while j < entries.len() && entries[j].is_dir { + j += 1; + } + *selected = j; + } + _ => {} + } - // If offline, MUST be local - let local = crate::core::downloads::local_media_path_for_item_id(&next_id); - if app.offline && local.is_none() { + // compute next index (offline skip-dirs happens on next tick too) + let len = match &next { + NextTarget::Online { len, .. } => *len, + NextTarget::Offline { len, .. } => *len, + }; + app.autoplay_next_index = if next_i + 1 < len { + Some(next_i + 1) + } else { + None + }; + + // Load next + match next { + NextTarget::Online { id: next_id, .. } => { + let cfg = match app.config.clone() { + Some(c) => c, + None => { app.autoplay = false; app.autoplay_next_index = None; - app.status = "Offline: next item not downloaded".into(); + app.status = "No config".into(); return; } + }; - let url = if let Some(p) = local { - p.to_string_lossy().to_string() - } else { - build_jellyfin_play_url(&cfg, &next_id, app.transcoding) - }; - - 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; - } + // If offline, MUST be local + let local = crate::core::downloads::local_media_path_for_item_id(&next_id); + if app.offline && local.is_none() { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "Offline: next item not downloaded".into(); + return; } - NextTarget::Offline { path, .. } => { - let url = path.to_string_lossy().to_string(); - if let Err(e) = mpv_load_local(mpv, &url) { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("offline autoplay load failed: {e}"); - return; - } + let url = if let Some(p) = local { + p.to_string_lossy().to_string() + } else if app.transcoding { + build_jellyfin_play_url_transcode_forced(&cfg, &next_id) + .unwrap_or_else(|| build_jellyfin_play_url(&cfg, &next_id, true)) + } else { + build_jellyfin_play_url(&cfg, &next_id, false) + }; + + // Local vs remote loader + let r = if url.starts_with('/') { + mpv_load_local(mpv, &url) + } else { + mpv_load_url(&cfg, mpv, &url) + }; + + if let Err(e) = r { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("autoplay load failed: {e}"); + return; } } - let _ = mpv_set_pause(mpv, false); - app.autoplay_prev_time_pos = None; - if !app.debug_mpv { - app.status = "$".into(); + NextTarget::Offline { path, .. } => { + let url = path.to_string_lossy().to_string(); + if let Err(e) = mpv_load_local(mpv, &url) { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("offline autoplay load failed: {e}"); + return; + } } + } + + let _ = mpv_set_pause(mpv, false); + app.autoplay_prev_time_pos = None; + if !app.debug_mpv { + app.status = "$".into(); + } } fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> { @@ -3542,3 +3592,23 @@ fn render_offline_library_dir( right, ); } + +fn build_jellyfin_play_url_transcode_forced( + cfg: &core::config::Config, + item_id: &str, +) -> Option { + let user_id = cfg.user_id.as_deref()?; + let client = core::jellyfin::JellyfinClient::from_config(cfg).ok()?; + + let max_bps: i32 = (cfg.transcode_mbps.max(0.01) * 1_000_000.0) as i32; + + let turl = client + .playback_transcoding_url(item_id, Some(user_id), max_bps, true) + .ok()??; + + if turl.starts_with("http://") || turl.starts_with("https://") { + Some(turl) + } else { + Some(format!("{}{}", cfg.base_url.trim_end_matches('/'), turl)) + } +}