1mod queue;
6mod remote;
7mod session;
8mod settings;
9mod timers;
10pub use queue::*;
11pub use remote::*;
12pub use session::*;
13pub use settings::*;
14pub use timers::*;
15
16use crate::utils::lock::MutexSafe;
17use log::{debug, error, info, warn};
18use serde::{Deserialize, Serialize};
19use std::path::PathBuf;
20use std::sync::{Arc, Mutex};
21use tauri::State;
22use tokio::sync::Mutex as TokioMutex;
23
24use super::DatabaseWrapper;
25use crate::download::cache::{CacheConfig, SmartCache};
26use crate::jellyfin::{JellyfinClient, JellyfinConfig};
27use crate::player::{
28 determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
29 PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
30};
31use crate::repository::{
32 types::{GetItemsOptions, ImageOptions, ImageType},
33 MediaRepository,
34};
35use crate::settings::VideoSettings;
36use crate::storage::db_service::{DatabaseService, Query, QueryParam};
37
38pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
40
41pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
49
50pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
54
55pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
59
60#[derive(specta::Type, Debug, Serialize)]
62#[serde(rename_all = "camelCase")]
63pub struct PlayerStatus {
64 pub state: PlayerState,
65 pub position: f64,
66 pub duration: Option<f64>,
67 pub volume: f32,
68 pub muted: bool,
69 pub shuffle: bool,
70 pub repeat: RepeatMode,
71 pub backend: VideoBackend,
73 pub use_html5_element: bool,
75
76 pub merged_media: Option<MergedMediaItem>,
79 pub merged_is_playing: bool,
81 pub merged_volume: f32,
83}
84
85#[derive(specta::Type, Debug, Serialize, Clone)]
88#[serde(rename_all = "camelCase")]
89pub struct MergedMediaItem {
90 pub id: String,
91 pub title: String,
92 pub artist: Option<String>,
93 pub album: Option<String>,
94 pub album_id: Option<String>,
95 pub duration: Option<f64>,
96 pub primary_image_tag: Option<String>,
97 pub image_id: Option<String>,
99 pub media_type: String,
100}
101
102impl From<&crate::player::MediaItem> for MergedMediaItem {
104 fn from(item: &crate::player::MediaItem) -> Self {
105 Self {
106 id: item.id.clone(),
107 title: item.title.clone(),
108 artist: item.artist.clone(),
109 album: item.album.clone(),
110 album_id: item.album_id.clone(),
111 duration: item.duration,
112 primary_image_tag: item.primary_image_tag.clone(),
113 image_id: item.primary_image_tag.clone(),
114 media_type: match item.media_type {
115 crate::player::MediaType::Audio => "audio".to_string(),
116 crate::player::MediaType::Video => "video".to_string(),
117 },
118 }
119 }
120}
121
122impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
124 fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
125 Self {
126 id: item.id.clone().unwrap_or_default(),
127 title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
128 artist: item
129 .album_artist
130 .clone()
131 .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
132 album: item.album.clone(),
133 album_id: item.album_id.clone(),
134 duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
135 primary_image_tag: item.primary_image_tag.clone(),
136 image_id: item.primary_image_tag.clone(),
137 media_type: item
138 .item_type
139 .clone()
140 .unwrap_or_else(|| "audio".to_string())
141 .to_lowercase(),
142 }
143 }
144}
145
146#[derive(specta::Type, Debug, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct QueueStatus {
150 pub items: Vec<MediaItem>,
151 pub current_index: Option<usize>,
152 pub shuffle: bool,
153 pub repeat: RepeatMode,
154 pub has_next: bool,
155 pub has_previous: bool,
156}
157
158#[derive(specta::Type, Debug, Serialize)]
160#[serde(rename_all = "lowercase")]
161pub enum VideoBackend {
162 Native,
164 Html5,
166}
167
168#[derive(specta::Type, Debug, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct PlayItemRequest {
175 pub id: String,
176 pub title: String,
177 pub stream_url: String,
178 pub video_codec: String,
180 pub needs_transcoding: bool,
182 #[serde(default)]
186 pub artist: Option<String>,
187 #[serde(default)]
188 pub primary_image_tag: Option<String>,
189 #[serde(default)]
190 pub server_id: Option<String>,
191 #[serde(default)]
195 pub duration_seconds: Option<f64>,
196 #[serde(default)]
200 pub item_type: Option<String>,
201 #[serde(default)]
204 pub series_id: Option<String>,
205 #[serde(default)]
225 pub subtitles: Vec<crate::player::SubtitleTrack>,
226}
227
228#[derive(specta::Type, Debug, Deserialize)]
230#[serde(tag = "type", rename_all = "lowercase")]
231pub enum PlayQueueContext {
232 Album {
234 #[serde(rename = "albumId")]
235 album_id: String,
236 #[serde(rename = "albumName")]
237 album_name: String,
238 },
239 Playlist {
241 #[serde(rename = "playlistId")]
242 playlist_id: String,
243 #[serde(rename = "playlistName")]
244 playlist_name: String,
245 },
246 Custom,
248}
249
250#[derive(specta::Type, Debug, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub struct PlayQueueRequest {
254 pub items: Vec<PlayItemRequest>,
255 pub start_index: usize,
256 pub shuffle: bool,
257 #[serde(default)]
260 pub context: Option<PlayQueueContext>,
261}
262
263#[derive(specta::Type, Debug, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct PlayAlbumTrackRequest {
267 pub album_id: String,
268 pub album_name: String,
269 pub track_id: String,
270 pub shuffle: bool,
271}
272
273#[derive(specta::Type, Debug, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct PlayTracksRequest {
277 pub track_ids: Vec<String>,
278 pub start_index: usize,
279 pub shuffle: bool,
280 pub context: PlayTracksContext,
281 #[serde(default)]
284 pub start_position: Option<f64>,
285}
286
287#[derive(specta::Type, Debug, Deserialize)]
289#[serde(tag = "type", rename_all = "lowercase")]
290pub enum PlayTracksContext {
291 Playlist {
292 #[serde(rename = "playlistId")]
293 playlist_id: String,
294 #[serde(rename = "playlistName")]
295 playlist_name: String,
296 },
297 Search {
298 #[serde(rename = "searchQuery")]
299 #[allow(dead_code)] search_query: String,
301 },
302 Custom {
303 #[serde(rename = "label")]
304 #[allow(dead_code)] label: Option<String>,
306 },
307}
308
309#[derive(specta::Type, Debug, Serialize)]
311#[serde(tag = "strategy", rename_all = "camelCase")]
312pub enum VideoSeekResponse {
313 Native {
315 position: f64,
317 },
318 ReloadStream {
320 new_url: String,
322 seek_offset: f64,
324 },
325}
326
327#[derive(specta::Type, Debug, Serialize)]
329#[serde(tag = "strategy", rename_all = "camelCase")]
330pub enum AudioTrackSwitchResponse {
331 Native {
333 success: bool,
335 },
336 ReloadStream {
338 new_url: String,
340 position: f64,
342 },
343}
344
345#[derive(specta::Type, Debug, Serialize)]
352#[serde(tag = "strategy", rename_all = "camelCase")]
353pub enum StreamQualityResponse {
354 Native {
356 position: f64,
358 },
359 ReloadStream {
361 new_url: String,
363 position: f64,
365 },
366}
367
368pub(super) async fn create_media_item(
373 req: PlayItemRequest,
374 db: Option<&DatabaseWrapper>,
375) -> Result<MediaItem, String> {
376 let jellyfin_id = req.id.clone();
378
379 let local_path = if let Some(db_wrapper) = db {
381 check_for_local_download(db_wrapper, &jellyfin_id).await?
382 } else {
383 None
384 };
385
386 let source = if let Some(path) = local_path {
387 MediaSource::Local {
388 file_path: PathBuf::from(path),
389 jellyfin_item_id: Some(jellyfin_id.clone()),
390 }
391 } else {
392 MediaSource::Remote {
393 stream_url: req.stream_url,
394 jellyfin_item_id: jellyfin_id.clone(),
395 }
396 };
397
398 Ok(MediaItem {
399 id: req.id.clone(),
400 title: req.title.clone(),
401 name: Some(req.title.clone()),
402 artist: None, album: None, album_name: None, album_id: None, artist_items: None, artists: None, primary_image_tag: None, image_id: None,
410 item_type: None, playlist_id: None, duration: None, artwork_url: None, media_type: crate::player::MediaType::Video, source,
416 video_codec: Some(req.video_codec),
417 needs_transcoding: req.needs_transcoding,
418 video_width: None, video_height: None, subtitles: req.subtitles,
424 series_id: None, server_id: None, })
427}
428
429pub(super) fn background_audio_source(
441 local_path: Option<String>,
442 stream_url: String,
443 item_id: &str,
444) -> MediaSource {
445 match local_path {
446 Some(path) => MediaSource::Local {
447 file_path: PathBuf::from(path),
448 jellyfin_item_id: Some(item_id.to_string()),
449 },
450 None => MediaSource::Remote {
451 stream_url,
452 jellyfin_item_id: item_id.to_string(),
453 },
454 }
455}
456
457pub(super) struct BackgroundAudioPlan {
462 pub base_seconds: f64,
466 pub seek_to: Option<f64>,
468}
469
470pub(super) fn background_audio_plan(
482 is_local_file: bool,
483 position_seconds: f64,
484) -> BackgroundAudioPlan {
485 let position = position_seconds.max(0.0);
486
487 if is_local_file {
488 BackgroundAudioPlan {
489 base_seconds: 0.0,
490 seek_to: (position > 0.0).then_some(position),
491 }
492 } else {
493 BackgroundAudioPlan {
494 base_seconds: position,
495 seek_to: None,
496 }
497 }
498}
499
500pub(super) async fn resolve_local_media_path<S: DatabaseService>(
512 db_service: &Arc<S>,
513 item_id: &str,
514) -> Result<Option<String>, String> {
515 let query = Query::with_params(
516 "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
517 vec![QueryParam::String(item_id.to_string())],
518 );
519
520 let path: Option<String> = db_service
521 .query_optional(query, |row| row.get(0))
522 .await
523 .map_err(|e| e.to_string())?;
524
525 match path {
526 Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
527 Some(file_path) => {
528 warn!(
529 "[Player] Download entry exists in DB but file not found: {}",
530 file_path
531 );
532 Ok(None)
533 }
534 None => Ok(None),
535 }
536}
537
538pub(super) async fn check_for_local_download(
540 db: &DatabaseWrapper,
541 item_id: &str,
542) -> Result<Option<String>, String> {
543 let db_service = {
544 let database = db.0.lock().map_err(|e| e.to_string())?;
545 Arc::new(database.service())
546 };
547
548 resolve_local_media_path(&db_service, item_id).await
549}
550
551#[tauri::command]
564#[specta::specta]
565pub async fn player_local_media_path(
566 db: State<'_, DatabaseWrapper>,
567 item_id: String,
568) -> Result<Option<String>, String> {
569 let db_service = {
570 let database = db.0.lock().map_err(|e| e.to_string())?;
571 Arc::new(database.service())
572 };
573
574 resolve_local_media_path(&db_service, &item_id).await
575}
576
577pub(super) async fn refresh_queue_local_sources(
586 controller: &PlayerController,
587 db: &DatabaseWrapper,
588) -> Result<usize, String> {
589 let remote_ids: Vec<String> = {
591 let queue = controller.queue();
592 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
593 queue_lock
594 .items()
595 .iter()
596 .filter_map(|item| match &item.source {
597 MediaSource::Remote {
598 jellyfin_item_id, ..
599 } => Some(jellyfin_item_id.clone()),
600 _ => None,
601 })
602 .collect()
603 };
604
605 if remote_ids.is_empty() {
606 return Ok(0);
607 }
608
609 let mut local_paths: Vec<(String, String)> = Vec::new();
610 for id in remote_ids {
611 if let Some(path) = check_for_local_download(db, &id).await? {
612 local_paths.push((id, path));
613 }
614 }
615
616 if local_paths.is_empty() {
617 return Ok(0);
618 }
619
620 let queue = controller.queue();
621 let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
622 let mut switched = 0;
623 for item in queue_lock.items_mut() {
624 if let MediaSource::Remote {
625 jellyfin_item_id, ..
626 } = &item.source
627 {
628 if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
629 info!(
630 "[Player] Switching queued track {} to local download: {}",
631 id, path
632 );
633 item.source = MediaSource::Local {
634 file_path: PathBuf::from(path),
635 jellyfin_item_id: Some(id.clone()),
636 };
637 switched += 1;
638 }
639 }
640 }
641 Ok(switched)
642}
643
644#[tauri::command]
654#[specta::specta]
655pub async fn player_play_item(
656 player: State<'_, PlayerStateWrapper>,
657 session: State<'_, MediaSessionManagerWrapper>,
658 db: State<'_, DatabaseWrapper>,
659 item: PlayItemRequest,
660) -> Result<PlayerStatus, String> {
661 info!(
662 "player_play_item called: {} - {}",
663 item.title, item.stream_url
664 );
665
666 let media_item = create_media_item(item, Some(&db)).await?;
668
669 {
671 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
672 match media_item.media_type {
673 MediaType::Audio => {
674 session_mgr.start_audio_session(media_item.clone());
675 }
676 MediaType::Video => {
677 session_mgr.start_movie_session(media_item.clone());
679 }
680 }
681 }
682
683 let controller = player.0.lock().await;
684 #[cfg(not(target_os = "linux"))]
690 controller
691 .play_item(media_item)
692 .map_err(|e| e.to_string())?;
693 #[cfg(target_os = "linux")]
694 {
695 controller
697 .set_current_item(media_item)
698 .map_err(|e| e.to_string())?;
699 }
700
701 controller.emit_queue_changed();
703
704 if let Some(emitter) = controller.event_emitter() {
706 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
707 emitter.emit(PlayerStatusEvent::SessionChanged {
708 session: session_mgr.current().clone(),
709 });
710 }
711
712 Ok(get_player_status(&controller))
713}
714
715#[tauri::command]
732#[specta::specta]
733pub async fn player_enter_background_audio(
734 player: State<'_, PlayerStateWrapper>,
735 session: State<'_, MediaSessionManagerWrapper>,
736 db: State<'_, DatabaseWrapper>,
737 item: PlayItemRequest,
738 position_seconds: f64,
739) -> Result<PlayerStatus, String> {
740 info!(
741 "player_enter_background_audio: {} @ {:.1}s",
742 item.title, position_seconds
743 );
744
745 let local_path = check_for_local_download(&db, &item.id).await?;
750 if local_path.is_some() {
751 info!(
752 "player_enter_background_audio: using downloaded file for {}",
753 item.id
754 );
755 }
756 let plan = background_audio_plan(local_path.is_some(), position_seconds);
759 let source = background_audio_source(local_path, item.stream_url, &item.id);
760
761 let media_item = MediaItem {
765 id: item.id.clone(),
766 title: item.title.clone(),
767 name: Some(item.title.clone()),
768 artist: item.artist.clone(),
769 album: None,
770 album_name: None,
771 album_id: None,
772 artist_items: None,
773 artists: None,
774 primary_image_tag: item.primary_image_tag.clone(),
775 image_id: item.primary_image_tag.clone(),
776 item_type: item.item_type.clone(),
779 playlist_id: None,
780 duration: item.duration_seconds,
782 artwork_url: None,
783 media_type: MediaType::Audio,
784 source,
785 video_codec: None,
786 needs_transcoding: false,
787 video_width: None,
788 video_height: None,
789 subtitles: vec![],
790 series_id: item.series_id.clone(),
791 server_id: item.server_id.clone(),
792 };
793
794 {
795 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
796 session_mgr.start_audio_session(media_item.clone());
797 }
798
799 let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
803
804 let controller = player.0.lock().await;
805 controller.enter_background_audio(plan.base_seconds);
811 controller
812 .play_item(media_item)
813 .map_err(|e| e.to_string())?;
814 if let Some(seek_to) = plan.seek_to {
818 info!(
819 "player_enter_background_audio: seeking the downloaded file to {:.1}s",
820 seek_to
821 );
822 controller.seek(seek_to).map_err(|e| e.to_string())?;
823 }
824
825 controller.emit_queue_changed();
826 if let Some(emitter) = controller.event_emitter() {
827 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
828 emitter.emit(PlayerStatusEvent::SessionChanged {
829 session: session_mgr.current().clone(),
830 });
831 }
832
833 Ok(get_player_status(&controller))
834}
835
836#[tauri::command]
857#[specta::specta]
858pub async fn player_background_action(
859 player: State<'_, PlayerStateWrapper>,
860 background_audio_armed: bool,
861 in_picture_in_picture: bool,
862) -> Result<crate::player::background_policy::BackgroundAction, String> {
863 use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
864
865 let is_video = {
866 let controller = player.0.lock().await;
867 let queue_arc = controller.queue();
868 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
869 match queue.current() {
870 Some(item) => is_video_media(item.media_type),
871 None => return Ok(BackgroundAction::KeepPlaying),
872 }
873 };
874
875 let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
876 info!(
877 "[player_background_action] video={} armed={} pip={} -> {:?}",
878 is_video, background_audio_armed, in_picture_in_picture, action
879 );
880 Ok(action)
881}
882
883#[tauri::command]
885#[specta::specta]
886pub async fn player_exit_background_audio(
887 player: State<'_, PlayerStateWrapper>,
888) -> Result<f64, String> {
889 let controller = player.0.lock().await;
890
891 let absolute = controller.absolute_position();
905
906 let _ = crate::player::set_lockscreen_position_offset(0.0);
908 controller.exit_background_audio();
909 controller.stop().map_err(|e| e.to_string())?;
910 info!(
911 "player_exit_background_audio: resuming the video at {:.1}s",
912 absolute
913 );
914 Ok(absolute)
915}
916
917#[tauri::command]
924#[specta::specta]
925pub async fn player_play_queue(
926 player: State<'_, PlayerStateWrapper>,
927 session: State<'_, MediaSessionManagerWrapper>,
928 db: State<'_, DatabaseWrapper>,
929 request: PlayQueueRequest,
930) -> Result<PlayerStatus, String> {
931 info!(
932 "player_play_queue called: {} items, start_index: {}, shuffle: {}",
933 request.items.len(),
934 request.start_index,
935 request.shuffle
936 );
937
938 if request.shuffle {
940 let controller = player.0.lock().await;
941 controller.toggle_shuffle();
942 }
943
944 let queue_context = match request.context {
946 Some(PlayQueueContext::Album {
947 album_id,
948 album_name,
949 }) => QueueContext::Album {
950 album_id,
951 album_name,
952 },
953 Some(PlayQueueContext::Playlist {
954 playlist_id,
955 playlist_name,
956 }) => QueueContext::Playlist {
957 playlist_id,
958 playlist_name,
959 },
960 Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
961 };
962
963 let mut items: Vec<MediaItem> = Vec::new();
965 for req in request.items {
966 items.push(create_media_item(req, Some(&db)).await?);
967 }
968
969 if let Some(first_item) = items.get(request.start_index) {
971 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
972 match first_item.media_type {
973 MediaType::Audio => {
974 session_mgr.start_audio_session(first_item.clone());
975 }
976 MediaType::Video => {
977 session_mgr.start_movie_session(first_item.clone());
979 }
980 }
981 }
982
983 let controller = player.0.lock().await;
985 info!(
986 "player_play_queue: Calling controller.play_queue with {} items at index {}",
987 items.len(),
988 request.start_index
989 );
990 controller
991 .play_queue(items, request.start_index)
992 .map_err(|e| e.to_string())?;
993
994 {
996 let queue_arc = controller.queue();
997 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
998 queue.set_context(queue_context);
999 }
1000
1001 controller.emit_queue_changed();
1003
1004 if let Some(emitter) = controller.event_emitter() {
1006 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1007 emitter.emit(PlayerStatusEvent::SessionChanged {
1008 session: session_mgr.current().clone(),
1009 });
1010 }
1011
1012 Ok(get_player_status(&controller))
1013}
1014
1015#[tauri::command]
1016#[specta::specta]
1017pub async fn player_play(
1018 player: State<'_, PlayerStateWrapper>,
1019 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1020) -> Result<PlayerStatus, String> {
1021 let mode = playback_mode.0.get_mode();
1023
1024 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1025 let client = {
1027 let controller = player.0.lock().await;
1028 let client_arc = controller.jellyfin_client();
1029 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1030 client_opt
1031 .as_ref()
1032 .ok_or("Jellyfin client not configured")?
1033 .clone()
1034 };
1035 client.send_session_command(session_id, "Unpause").await?;
1036 } else {
1037 let controller = player.0.lock().await;
1039 controller.play().map_err(|e| e.to_string())?;
1040 }
1041
1042 let controller = player.0.lock().await;
1043 Ok(get_player_status(&controller))
1044}
1045
1046#[tauri::command]
1047#[specta::specta]
1048pub async fn player_pause(
1049 player: State<'_, PlayerStateWrapper>,
1050 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1051) -> Result<PlayerStatus, String> {
1052 let mode = playback_mode.0.get_mode();
1054
1055 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1056 let client = {
1058 let controller = player.0.lock().await;
1059 let client_arc = controller.jellyfin_client();
1060 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1061 client_opt
1062 .as_ref()
1063 .ok_or("Jellyfin client not configured")?
1064 .clone()
1065 };
1066 client.send_session_command(session_id, "Pause").await?;
1067 } else {
1068 let controller = player.0.lock().await;
1070 controller.pause().map_err(|e| e.to_string())?;
1071 }
1072
1073 let controller = player.0.lock().await;
1074 Ok(get_player_status(&controller))
1075}
1076
1077#[tauri::command]
1078#[specta::specta]
1079pub async fn player_toggle(
1080 player: State<'_, PlayerStateWrapper>,
1081 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1082) -> Result<PlayerStatus, String> {
1083 let mode = playback_mode.0.get_mode();
1085
1086 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1087 let client = {
1089 let controller = player.0.lock().await;
1090 let client_arc = controller.jellyfin_client();
1091 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1092 client_opt
1093 .as_ref()
1094 .ok_or("Jellyfin client not configured")?
1095 .clone()
1096 };
1097 client.send_session_command(session_id, "PlayPause").await?;
1098 } else {
1099 let controller = player.0.lock().await;
1101 controller.toggle_playback().map_err(|e| e.to_string())?;
1102 }
1103
1104 let controller = player.0.lock().await;
1105 Ok(get_player_status(&controller))
1106}
1107
1108#[tauri::command]
1109#[specta::specta]
1110pub async fn player_stop(
1111 player: State<'_, PlayerStateWrapper>,
1112 session: State<'_, MediaSessionManagerWrapper>,
1113 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1114) -> Result<PlayerStatus, String> {
1115 let mode = playback_mode.0.get_mode();
1117
1118 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1119 let client = {
1121 let controller = player.0.lock().await;
1122 let client_arc = controller.jellyfin_client();
1123 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1124 client_opt
1125 .as_ref()
1126 .ok_or("Jellyfin client not configured")?
1127 .clone()
1128 };
1129 client.send_session_command(session_id, "Stop").await?;
1130
1131 playback_mode
1138 .0
1139 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1140 } else {
1141 let controller = player.0.lock().await;
1143 controller.stop().map_err(|e| e.to_string())?;
1144
1145 playback_mode
1149 .0
1150 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1151
1152 {
1154 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1155 let current_session = session_mgr.current().clone();
1156
1157 match current_session {
1158 crate::player::MediaSessionType::Movie { .. } => {
1159 session_mgr.movie_session_ended();
1161 }
1162 crate::player::MediaSessionType::Audio { .. } => {
1163 session_mgr.audio_session_inactive();
1165 }
1166 crate::player::MediaSessionType::TvShow { .. } => {
1167 session_mgr.tv_session_episode_ended();
1169 }
1170 crate::player::MediaSessionType::Idle => {
1171 }
1173 }
1174
1175 if let Some(emitter) = controller.event_emitter() {
1177 emitter.emit(PlayerStatusEvent::SessionChanged {
1178 session: session_mgr.current().clone(),
1179 });
1180 }
1181 }
1182 }
1183
1184 let controller = player.0.lock().await;
1185 Ok(get_player_status(&controller))
1186}
1187
1188#[tauri::command]
1189#[specta::specta]
1190pub async fn player_next(
1191 player: State<'_, PlayerStateWrapper>,
1192 session: State<'_, MediaSessionManagerWrapper>,
1193 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1194 db: State<'_, DatabaseWrapper>,
1195) -> Result<PlayerStatus, String> {
1196 debug!("[player_next] Command called from frontend");
1197
1198 let mode = playback_mode.0.get_mode();
1200
1201 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1202 let client = {
1204 let controller = player.0.lock().await;
1205 let client_arc = controller.jellyfin_client();
1206 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1207 client_opt
1208 .as_ref()
1209 .ok_or("Jellyfin client not configured")?
1210 .clone()
1211 };
1212 client.send_session_command(session_id, "NextTrack").await?;
1213 } else {
1214 let controller = player.0.lock().await;
1216 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1218 warn!("[player_next] Failed to refresh local sources: {}", e);
1219 }
1220 controller.next().map_err(|e| e.to_string())?;
1221 controller.emit_queue_changed();
1222
1223 let current_item = {
1225 let queue = controller.queue();
1226 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1227 queue_lock.current().cloned()
1228 };
1229
1230 if let Some(item) = current_item {
1231 if item.media_type == MediaType::Audio {
1232 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1233 session_mgr.update_audio_track(item);
1234
1235 if let Some(emitter) = controller.event_emitter() {
1237 emitter.emit(PlayerStatusEvent::SessionChanged {
1238 session: session_mgr.current().clone(),
1239 });
1240 }
1241 }
1242 }
1243 }
1244
1245 let controller = player.0.lock().await;
1246 Ok(get_player_status(&controller))
1247}
1248
1249#[tauri::command]
1250#[specta::specta]
1251pub async fn player_previous(
1252 player: State<'_, PlayerStateWrapper>,
1253 session: State<'_, MediaSessionManagerWrapper>,
1254 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1255 db: State<'_, DatabaseWrapper>,
1256) -> Result<PlayerStatus, String> {
1257 let mode = playback_mode.0.get_mode();
1259
1260 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1261 let client = {
1263 let controller = player.0.lock().await;
1264 let client_arc = controller.jellyfin_client();
1265 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1266 client_opt
1267 .as_ref()
1268 .ok_or("Jellyfin client not configured")?
1269 .clone()
1270 };
1271 client
1272 .send_session_command(session_id, "PreviousTrack")
1273 .await?;
1274 } else {
1275 let controller = player.0.lock().await;
1277 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1279 warn!("[player_previous] Failed to refresh local sources: {}", e);
1280 }
1281 controller.previous().map_err(|e| e.to_string())?;
1282 controller.emit_queue_changed();
1283
1284 let current_item = {
1286 let queue = controller.queue();
1287 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1288 queue_lock.current().cloned()
1289 };
1290
1291 if let Some(item) = current_item {
1292 if item.media_type == MediaType::Audio {
1293 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1294 session_mgr.update_audio_track(item);
1295
1296 if let Some(emitter) = controller.event_emitter() {
1298 emitter.emit(PlayerStatusEvent::SessionChanged {
1299 session: session_mgr.current().clone(),
1300 });
1301 }
1302 }
1303 }
1304 }
1305
1306 let controller = player.0.lock().await;
1307 Ok(get_player_status(&controller))
1308}
1309
1310#[tauri::command]
1311#[specta::specta]
1312pub async fn player_seek(
1313 player: State<'_, PlayerStateWrapper>,
1314 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1315 position: f64,
1316) -> Result<PlayerStatus, String> {
1317 let mode = playback_mode.0.get_mode();
1319
1320 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1321 let client = {
1323 let controller = player.0.lock().await;
1324 let client_arc = controller.jellyfin_client();
1325 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1326 client_opt
1327 .as_ref()
1328 .ok_or("Jellyfin client not configured")?
1329 .clone()
1330 };
1331 let position_ticks = (position * 10_000_000.0) as i64;
1332 client.session_seek(session_id, position_ticks).await?;
1333 } else {
1334 let controller = player.0.lock().await;
1339 controller.seek_absolute(position).await?;
1340 }
1341
1342 let controller = player.0.lock().await;
1343 Ok(get_player_status(&controller))
1344}
1345
1346#[tauri::command]
1357#[specta::specta]
1358pub async fn player_seek_video(
1359 player: State<'_, PlayerStateWrapper>,
1360 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1361 repository_handle: String,
1362 position: f64,
1363 media_source_id: Option<String>,
1364 audio_stream_index: Option<i32>,
1365 use_html5: bool,
1366) -> Result<VideoSeekResponse, String> {
1367 info!(
1368 "[player_seek_video] Seeking to {} seconds (use_html5: {})",
1369 position, use_html5
1370 );
1371
1372 let repository = repository_manager
1374 .0
1375 .get(&repository_handle)
1376 .ok_or("Repository not found - user may need to log in")?;
1377
1378 let (needs_transcoding, jellyfin_item_id, stream_url, is_local) = {
1381 let controller = player.0.lock().await;
1382 let queue_arc = controller.queue();
1383 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1384
1385 let current_item = queue.current().ok_or("No item currently playing")?;
1386
1387 if current_item.media_type != MediaType::Video {
1388 return Err("Current item is not a video".to_string());
1389 }
1390
1391 let jellyfin_id = current_item
1392 .jellyfin_id()
1393 .ok_or("Current video has no Jellyfin ID")?
1394 .to_string();
1395
1396 let (stream_url, is_local_file) = match ¤t_item.source {
1397 MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false),
1398 MediaSource::Local { .. } => (String::new(), true),
1399 MediaSource::DirectUrl { url } => (url.clone(), false),
1400 };
1401
1402 let needs_trans = current_item.needs_transcoding;
1403 (needs_trans, jellyfin_id, stream_url, is_local_file)
1404 }; let is_hls = stream_url.contains(".m3u8");
1408 let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
1409
1410 info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
1411 is_local, is_hls, needs_transcoding, use_html5, strategy);
1412
1413 match strategy {
1414 VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
1415 info!("[player_seek_video] Using backend native seek");
1417 let controller = player.0.lock().await;
1418 controller.seek(position).map_err(|e| e.to_string())?;
1419 Ok(VideoSeekResponse::Native { position })
1420 }
1421 VideoSeekStrategy::Html5NativeSeek => {
1422 info!("[player_seek_video] HTML5 native seek - returning position for frontend");
1425 Ok(VideoSeekResponse::Native { position })
1426 }
1427 VideoSeekStrategy::Html5ReloadStream => {
1428 info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
1430
1431 let new_url = repository
1432 .get_video_stream_url(
1433 &jellyfin_item_id,
1434 media_source_id.as_deref(),
1435 audio_stream_index,
1436 )
1437 .await
1438 .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
1439
1440 info!(
1441 "[player_seek_video] Got new stream URL for position {}",
1442 position
1443 );
1444
1445 Ok(VideoSeekResponse::ReloadStream {
1453 new_url,
1454 seek_offset: position,
1455 })
1456 }
1457 VideoSeekStrategy::BackendReloadStream => {
1458 info!("[player_seek_video] Backend reload stream - requesting new stream URL");
1460
1461 let new_url = repository
1462 .get_video_stream_url(
1463 &jellyfin_item_id,
1464 media_source_id.as_deref(),
1465 audio_stream_index,
1466 )
1467 .await
1468 .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
1469
1470 info!("[player_seek_video] Got new stream URL, handling reload internally");
1471
1472 {
1474 let controller = player.0.lock().await;
1475 controller.stop().map_err(|e| e.to_string())?;
1476 }
1477
1478 {
1480 let controller = player.0.lock().await;
1481 let queue_arc = controller.queue();
1482 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1483
1484 if !queue.update_current_stream_url(new_url.clone()) {
1485 return Err("Failed to update stream URL in queue".to_string());
1486 }
1487 }
1488
1489 {
1491 let controller = player.0.lock().await;
1492 let queue_arc = controller.queue();
1493 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1494
1495 if let Some(updated_item) = queue.current() {
1496 controller
1497 .load_and_play(updated_item)
1498 .map_err(|e| e.to_string())?;
1499 } else {
1500 return Err("No current item after URL update".to_string());
1501 }
1502
1503 controller.seek(position).map_err(|e| e.to_string())?;
1507 }
1508
1509 info!(
1510 "[player_seek_video] Stream reloaded successfully at position {}",
1511 position
1512 );
1513
1514 Ok(VideoSeekResponse::Native { position })
1515 }
1516 }
1517}
1518
1519#[tauri::command]
1532#[specta::specta]
1533#[allow(clippy::too_many_arguments)]
1537pub async fn player_switch_audio_track(
1538 player: State<'_, PlayerStateWrapper>,
1539 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1540 repository_handle: String,
1541 stream_index: i32,
1542 array_index: i32,
1543 use_html5: bool,
1544 current_position: Option<f64>,
1545 media_source_id: Option<String>,
1546) -> Result<AudioTrackSwitchResponse, String> {
1547 info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
1548 stream_index, array_index, use_html5);
1549
1550 if use_html5 {
1551 let repository = repository_manager
1553 .0
1554 .get(&repository_handle)
1555 .ok_or("Repository not found - user may need to log in")?;
1556
1557 let jellyfin_item_id = {
1559 let controller = player.0.lock().await;
1560 let queue_arc = controller.queue();
1561 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1562
1563 let current_item = queue.current().ok_or("No item currently playing")?;
1564
1565 current_item
1566 .jellyfin_id()
1567 .ok_or("Current item has no Jellyfin ID")?
1568 .to_string()
1569 };
1570
1571 let new_url = repository
1575 .get_video_stream_url(
1576 &jellyfin_item_id,
1577 media_source_id.as_deref(),
1578 Some(stream_index),
1579 )
1580 .await
1581 .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
1582
1583 Ok(AudioTrackSwitchResponse::ReloadStream {
1584 new_url,
1585 position: current_position.unwrap_or(0.0),
1586 })
1587 } else {
1588 let controller = player.0.lock().await;
1590 controller
1591 .set_audio_track(array_index)
1592 .map_err(|e| e.to_string())?;
1593
1594 Ok(AudioTrackSwitchResponse::Native { success: true })
1595 }
1596}
1597
1598#[tauri::command]
1615#[specta::specta]
1616#[allow(clippy::too_many_arguments)]
1620pub async fn player_set_stream_quality(
1621 player: State<'_, PlayerStateWrapper>,
1622 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1623 video_settings: State<'_, VideoSettingsWrapper>,
1624 repository_handle: String,
1625 quality: crate::settings::StreamingQuality,
1626 use_html5: bool,
1627 current_position: Option<f64>,
1628 media_source_id: Option<String>,
1629 audio_stream_index: Option<i32>,
1630) -> Result<StreamQualityResponse, String> {
1631 info!(
1632 "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
1633 quality.label(),
1634 use_html5,
1635 current_position
1636 );
1637
1638 let repository = repository_manager
1639 .0
1640 .get(&repository_handle)
1641 .ok_or("Repository not found - user may need to log in")?;
1642
1643 let jellyfin_item_id = {
1644 let controller = player.0.lock().await;
1645 let queue_arc = controller.queue();
1646 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1647
1648 let current_item = queue.current().ok_or("No item currently playing")?;
1649
1650 if current_item.media_type != MediaType::Video {
1651 return Err("Current item is not a video".to_string());
1652 }
1653
1654 current_item
1655 .jellyfin_id()
1656 .ok_or("Current item has no Jellyfin ID")?
1657 .to_string()
1658 };
1659
1660 crate::repository::online::set_streaming_quality(quality);
1662 {
1663 let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?;
1664 settings.streaming_quality = quality;
1665 }
1666
1667 let position = current_position.unwrap_or(0.0);
1668 let new_url = repository
1669 .get_video_stream_url(
1670 &jellyfin_item_id,
1671 media_source_id.as_deref(),
1672 audio_stream_index,
1673 )
1674 .await
1675 .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
1676
1677 if use_html5 {
1678 return Ok(StreamQualityResponse::ReloadStream { new_url, position });
1679 }
1680
1681 {
1687 let controller = player.0.lock().await;
1688 controller.stop().map_err(|e| e.to_string())?;
1689
1690 let queue_arc = controller.queue();
1691 {
1692 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1693 if !queue.update_current_stream_url(new_url.clone()) {
1694 return Err("Failed to update stream URL in queue".to_string());
1695 }
1696 }
1697
1698 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1699 let updated_item = queue.current().ok_or("No current item after URL update")?;
1700 controller
1701 .load_and_play(updated_item)
1702 .map_err(|e| e.to_string())?;
1703 if position > 0.0 {
1704 controller.seek(position).map_err(|e| e.to_string())?;
1705 }
1706 }
1707
1708 Ok(StreamQualityResponse::Native { position })
1709}
1710
1711#[tauri::command]
1715#[specta::specta]
1716pub async fn player_set_audio_track(
1717 player: State<'_, PlayerStateWrapper>,
1718 stream_index: i32,
1719) -> Result<PlayerStatus, String> {
1720 let controller = player.0.lock().await;
1721 controller
1722 .set_audio_track(stream_index)
1723 .map_err(|e| e.to_string())?;
1724 Ok(get_player_status(&controller))
1725}
1726
1727#[tauri::command]
1736#[specta::specta]
1737pub async fn player_set_subtitle_track(
1738 player: State<'_, PlayerStateWrapper>,
1739 stream_index: Option<i32>,
1740) -> Result<PlayerStatus, String> {
1741 let controller = player.0.lock().await;
1742 controller
1743 .set_subtitle_track(stream_index)
1744 .map_err(|e| e.to_string())?;
1745 Ok(get_player_status(&controller))
1746}
1747
1748fn normalize_volume(volume: f32) -> f32 {
1758 if volume.is_nan() {
1759 0.0
1760 } else {
1761 volume.clamp(0.0, 1.0)
1762 }
1763}
1764
1765#[tauri::command]
1766#[specta::specta]
1767pub async fn player_set_volume(
1768 player: State<'_, PlayerStateWrapper>,
1769 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1770 volume: f32,
1771) -> Result<PlayerStatus, String> {
1772 let volume = normalize_volume(volume);
1777
1778 let mode = playback_mode.0.get_mode();
1780
1781 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1782 let client = {
1784 let controller = player.0.lock().await;
1785 let client_arc = controller.jellyfin_client();
1786 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1787 client_opt
1788 .as_ref()
1789 .ok_or("Jellyfin client not configured")?
1790 .clone()
1791 };
1792 let volume_percent = (volume * 100.0) as i32;
1794 client
1795 .session_set_volume(session_id, volume_percent)
1796 .await?;
1797 } else {
1798 let controller = player.0.lock().await;
1800 controller.set_volume(volume).map_err(|e| e.to_string())?;
1801 }
1802
1803 let controller = player.0.lock().await;
1804 Ok(get_player_status(&controller))
1805}
1806
1807#[tauri::command]
1808#[specta::specta]
1809pub async fn player_toggle_mute(
1810 player: State<'_, PlayerStateWrapper>,
1811 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1812) -> Result<PlayerStatus, String> {
1813 let mode = playback_mode.0.get_mode();
1815
1816 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1817 let client = {
1819 let controller = player.0.lock().await;
1820 let client_arc = controller.jellyfin_client();
1821 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1822 client_opt
1823 .as_ref()
1824 .ok_or("Jellyfin client not configured")?
1825 .clone()
1826 };
1827 client
1828 .send_session_command(session_id, "ToggleMute")
1829 .await?;
1830 } else {
1831 }
1836
1837 let controller = player.0.lock().await;
1838 Ok(get_player_status(&controller))
1839}
1840
1841#[tauri::command]
1842#[specta::specta]
1843pub async fn player_toggle_shuffle(
1844 player: State<'_, PlayerStateWrapper>,
1845) -> Result<QueueStatus, String> {
1846 let controller = player.0.lock().await;
1847 controller.toggle_shuffle();
1848 controller.emit_queue_changed();
1849 Ok(get_queue_status(&controller))
1850}
1851
1852#[tauri::command]
1853#[specta::specta]
1854pub async fn player_cycle_repeat(
1855 player: State<'_, PlayerStateWrapper>,
1856) -> Result<QueueStatus, String> {
1857 let controller = player.0.lock().await;
1858 controller.cycle_repeat();
1859 controller.emit_queue_changed();
1860 Ok(get_queue_status(&controller))
1861}
1862
1863#[tauri::command]
1864#[specta::specta]
1865pub async fn player_get_status(
1866 player: State<'_, PlayerStateWrapper>,
1867 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1868) -> Result<PlayerStatus, String> {
1869 let mode = playback_mode.0.get_mode();
1870
1871 let controller = player.0.lock().await;
1873 let mut status = get_player_status(&controller);
1874
1875 let local_media = {
1877 let queue_arc = controller.queue();
1878 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1879 queue.current().map(MergedMediaItem::from)
1880 };
1881
1882 let local_is_playing = status.state.is_playing();
1883 let local_volume = status.volume;
1884
1885 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1887 let client = {
1888 let client_arc = controller.jellyfin_client();
1889 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1890 client_opt
1891 .as_ref()
1892 .ok_or("Jellyfin client not configured")?
1893 .clone()
1894 };
1895
1896 drop(controller); match client.get_session(&session_id).await {
1899 Ok(Some(session)) => {
1900 log::info!("[PlayerCommands] Merging remote session state");
1901
1902 status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
1904
1905 status.merged_is_playing = session
1907 .play_state
1908 .as_ref()
1909 .map(|ps| !ps.is_paused.unwrap_or(true))
1910 .unwrap_or(false);
1911
1912 status.position = session
1914 .play_state
1915 .as_ref()
1916 .and_then(|ps| ps.position_ticks)
1917 .map(|ticks| ticks as f64 / 10_000_000.0)
1918 .unwrap_or(0.0);
1919
1920 status.duration = session
1922 .now_playing_item
1923 .as_ref()
1924 .and_then(|item| item.run_time_ticks)
1925 .map(|ticks| ticks as f64 / 10_000_000.0);
1926
1927 status.merged_volume = session
1929 .play_state
1930 .as_ref()
1931 .and_then(|ps| ps.volume_level)
1932 .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
1933 .unwrap_or(1.0);
1934
1935 return Ok(status);
1936 }
1937 Ok(None) => {
1938 log::warn!("[PlayerCommands] Remote session not found, using local state");
1939 }
1940 Err(e) => {
1941 log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
1942 }
1943 }
1944 }
1945
1946 status.merged_media = local_media;
1948 status.merged_is_playing = local_is_playing;
1949 status.merged_volume = local_volume;
1950
1951 Ok(status)
1952}
1953
1954#[tauri::command]
1955#[specta::specta]
1956pub async fn player_get_queue(
1957 player: State<'_, PlayerStateWrapper>,
1958) -> Result<QueueStatus, String> {
1959 let controller = player.0.lock().await;
1960 Ok(get_queue_status(&controller))
1961}
1962
1963#[derive(specta::Type, Debug, Serialize)]
1972#[serde(rename_all = "camelCase")]
1973pub struct PlaybackCapabilities {
1974 pub uses_webview_audio: bool,
1978 pub supports_native_video: bool,
1983}
1984
1985#[tauri::command]
1989#[specta::specta]
1990pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
1991 let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
1993
1994 Ok(PlaybackCapabilities {
1995 uses_webview_audio: !native_audio,
1996 supports_native_video: cfg!(target_os = "android"),
1997 })
1998}
1999
2000pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2001 let (backend, use_html5_element) = if cfg!(target_os = "android") {
2003 (VideoBackend::Native, false)
2005 } else {
2006 (VideoBackend::Html5, true)
2008 };
2009
2010 PlayerStatus {
2011 state: controller.state(),
2012 position: controller.absolute_position(),
2017 duration: controller.duration(),
2018 volume: controller.volume(),
2019 muted: controller.muted(),
2020 shuffle: controller.is_shuffle(),
2021 repeat: controller.repeat_mode(),
2022 backend,
2023 use_html5_element,
2024
2025 merged_media: None,
2027 merged_is_playing: false,
2028 merged_volume: controller.volume(),
2029 }
2030}
2031
2032pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2033 let queue = controller.queue();
2034 let queue_lock = queue.lock_safe();
2035
2036 QueueStatus {
2037 items: queue_lock.items().to_vec(),
2038 current_index: queue_lock.current_index(),
2039 shuffle: queue_lock.is_shuffle(),
2040 repeat: queue_lock.repeat_mode(),
2041 has_next: queue_lock.has_next(),
2042 has_previous: queue_lock.has_previous(),
2043 }
2044}
2045
2046async fn play_selection_on_remote(
2058 controller: &PlayerController,
2059 session_id: &str,
2060 media_items: &[MediaItem],
2061 start_index: usize,
2062) -> Result<(), String> {
2063 let mut jellyfin_ids: Vec<String> = Vec::new();
2066 let mut adjusted_index: Option<usize> = None;
2067 for (i, item) in media_items.iter().enumerate() {
2068 if let Some(id) = item.jellyfin_id() {
2069 if i == start_index {
2070 adjusted_index = Some(jellyfin_ids.len());
2071 }
2072 jellyfin_ids.push(id.to_string());
2073 }
2074 }
2075
2076 let start_index =
2077 adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2078
2079 if jellyfin_ids.is_empty() {
2080 return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2081 }
2082
2083 let client = {
2084 let client_arc = controller.jellyfin_client();
2085 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2086 client_opt
2087 .as_ref()
2088 .ok_or("Jellyfin client not configured")?
2089 .clone()
2090 };
2091
2092 client
2094 .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2095 .await
2096 .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2097}
2098
2099#[tauri::command]
2101#[specta::specta]
2102pub async fn player_play_album_track(
2103 player: State<'_, PlayerStateWrapper>,
2104 session: State<'_, MediaSessionManagerWrapper>,
2105 db: State<'_, DatabaseWrapper>,
2106 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2107 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2108 repository_handle: String,
2109 request: PlayAlbumTrackRequest,
2110) -> Result<PlayerStatus, String> {
2111 info!(
2112 "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2113 request.album_id, request.track_id, request.shuffle
2114 );
2115
2116 let repository = repository_manager
2118 .0
2119 .get(&repository_handle)
2120 .ok_or("Repository not found - user may need to log in")?;
2121
2122 info!(
2124 "Fetching tracks for album {} via repository",
2125 request.album_id
2126 );
2127 let album_items = repository
2128 .get_items(
2129 &request.album_id,
2130 Some(GetItemsOptions {
2131 limit: Some(1000),
2132 fields: Some(vec![
2133 "PrimaryImageAspectRatio".to_string(),
2134 "Overview".to_string(),
2135 "MediaStreams".to_string(),
2136 ]),
2137 ..Default::default()
2138 }),
2139 )
2140 .await
2141 .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2142
2143 info!("Found {} items in album", album_items.items.len());
2144
2145 let mut tracks: Vec<_> = album_items
2147 .items
2148 .into_iter()
2149 .filter(|item| item.item_type == "Audio")
2150 .collect();
2151 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2152
2153 info!("Found {} audio tracks in album", tracks.len());
2154
2155 if tracks.is_empty() {
2156 return Err("No audio tracks found in album".to_string());
2157 }
2158
2159 info!("Album has {} tracks after sorting:", tracks.len());
2161 for (idx, track) in tracks.iter().enumerate() {
2162 info!(" [{}] {} (ID: {})", idx, track.name, track.id);
2163 }
2164
2165 info!("Looking for track_id: {}", request.track_id);
2168 let album_index = tracks
2169 .iter()
2170 .position(|t| t.id == request.track_id)
2171 .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2172
2173 info!(
2174 "Track {} is at index {} in album",
2175 request.track_id, album_index
2176 );
2177
2178 let mut media_items = Vec::new();
2180 for track in tracks {
2181 let jellyfin_id = &track.id;
2183 let local_path = check_for_local_download(&db, jellyfin_id).await?;
2184
2185 let source = if let Some(path) = local_path {
2186 MediaSource::Local {
2187 file_path: PathBuf::from(path),
2188 jellyfin_item_id: Some(jellyfin_id.clone()),
2189 }
2190 } else {
2191 match repository.get_audio_stream_url(&track.id).await {
2195 Ok(stream_url) => MediaSource::Remote {
2196 stream_url,
2197 jellyfin_item_id: jellyfin_id.clone(),
2198 },
2199 Err(e) => {
2200 warn!(
2201 "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2202 track.name, track.id, e
2203 );
2204 continue;
2205 }
2206 }
2207 };
2208
2209 let primary_image_tag_for_url = track.primary_image_tag.clone();
2210 let media_item = MediaItem {
2211 id: track.id.clone(),
2212 title: track.name.clone(),
2213 name: Some(track.name.clone()), artist: track
2215 .album_artist
2216 .clone()
2217 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2218 album: Some(request.album_name.clone()),
2219 album_name: Some(request.album_name.clone()), album_id: Some(request.album_id.clone()),
2221 artist_items: track.artist_items.clone(), artists: track.artists.clone(), primary_image_tag: track.primary_image_tag.clone(), image_id: track.primary_image_tag.clone(),
2225 item_type: Some(track.item_type.clone()), playlist_id: None,
2227 duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2228 artwork_url: primary_image_tag_for_url.map(|tag| {
2229 repository.get_image_url(
2230 &request.album_id,
2231 ImageType::Primary,
2232 Some(ImageOptions {
2233 max_width: Some(300),
2234 tag: Some(tag),
2235 ..Default::default()
2236 }),
2237 )
2238 }),
2239 media_type: MediaType::Audio,
2240 source,
2241 video_codec: None,
2242 needs_transcoding: false,
2243 video_width: None,
2244 video_height: None,
2245 subtitles: vec![],
2246 series_id: None,
2247 server_id: None,
2248 };
2249
2250 media_items.push(media_item);
2251 }
2252
2253 if media_items.is_empty() {
2254 return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2255 }
2256
2257 let start_index = media_items
2261 .iter()
2262 .position(|item| item.id == request.track_id)
2263 .unwrap_or(0);
2264
2265 info!(
2266 "Built queue with {} media items, starting at index {}",
2267 media_items.len(),
2268 start_index
2269 );
2270
2271 if request.shuffle {
2273 let controller = player.0.lock().await;
2274 controller.toggle_shuffle();
2275 }
2276
2277 if let Some(first_item) = media_items.get(start_index) {
2279 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2280 session_mgr.start_audio_session(first_item.clone());
2281 }
2282
2283 let controller = player.0.lock().await;
2284
2285 let remote_session = match playback_mode.0.get_mode() {
2289 crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2290 _ => None,
2291 };
2292
2293 if let Some(session_id) = &remote_session {
2294 play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2295 controller
2296 .set_queue(media_items, start_index)
2297 .map_err(|e| e.to_string())?;
2298 } else {
2299 playback_mode
2302 .0
2303 .set_mode(crate::playback_mode::PlaybackMode::Local);
2304 controller
2305 .play_queue(media_items, start_index)
2306 .map_err(|e| e.to_string())?;
2307 }
2308
2309 {
2311 let queue_arc = controller.queue();
2312 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2313 queue.set_context(QueueContext::Album {
2314 album_id: request.album_id.clone(),
2315 album_name: request.album_name.clone(),
2316 });
2317 }
2318
2319 controller.emit_queue_changed();
2321
2322 {
2324 let queue_arc = controller.queue();
2325 let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2326 info!(
2327 "player_play_album_track: Queue now has {} items, current_index: {:?}",
2328 queue_lock.items().len(),
2329 queue_lock.current_index()
2330 );
2331 }
2332
2333 if let Some(emitter) = controller.event_emitter() {
2335 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2336 emitter.emit(PlayerStatusEvent::SessionChanged {
2337 session: session_mgr.current().clone(),
2338 });
2339 }
2340
2341 Ok(get_player_status(&controller))
2342}
2343
2344#[tauri::command]
2346#[specta::specta]
2347pub async fn player_play_tracks(
2348 player: State<'_, PlayerStateWrapper>,
2349 session: State<'_, MediaSessionManagerWrapper>,
2350 db: State<'_, DatabaseWrapper>,
2351 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2352 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2353 repository_handle: String,
2354 request: PlayTracksRequest,
2355) -> Result<PlayerStatus, String> {
2356 info!(
2357 "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2358 request.track_ids.len(),
2359 request.start_index,
2360 request.shuffle
2361 );
2362
2363 if request.track_ids.is_empty() {
2365 return Err("No tracks provided".to_string());
2366 }
2367
2368 let repository = repository_manager
2370 .0
2371 .get(&repository_handle)
2372 .ok_or("Repository not found - user may need to log in")?;
2373
2374 let mut media_items = Vec::new();
2376 for track_id in &request.track_ids {
2377 let track = repository
2379 .get_item(track_id)
2380 .await
2381 .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2382
2383 let local_path = check_for_local_download(&db, track_id).await?;
2385
2386 let source = if let Some(path) = local_path {
2388 MediaSource::Local {
2389 file_path: PathBuf::from(path),
2390 jellyfin_item_id: Some(track.id.clone()),
2391 }
2392 } else {
2393 let stream_url = repository
2394 .get_audio_stream_url(track_id)
2395 .await
2396 .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2397
2398 MediaSource::Remote {
2399 stream_url,
2400 jellyfin_item_id: track.id.clone(),
2401 }
2402 };
2403
2404 let primary_image_tag_for_url = track.primary_image_tag.clone();
2406 let media_item = MediaItem {
2407 id: track.id.clone(),
2408 title: track.name.clone(),
2409 name: Some(track.name.clone()), artist: track
2411 .album_artist
2412 .clone()
2413 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2414 album: track.album_name.clone(),
2415 album_name: track.album_name.clone(), album_id: track.album_id.clone(),
2417 artist_items: track.artist_items.clone(), artists: track.artists.clone(), primary_image_tag: track.primary_image_tag.clone(), image_id: track.primary_image_tag.clone(),
2421 item_type: Some(track.item_type.clone()), playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2424 artwork_url: primary_image_tag_for_url.and_then(|tag| {
2425 track.album_id.as_ref().map(|album_id| {
2426 repository.get_image_url(
2427 album_id,
2428 ImageType::Primary,
2429 Some(ImageOptions {
2430 max_width: Some(300),
2431 tag: Some(tag),
2432 ..Default::default()
2433 }),
2434 )
2435 })
2436 }),
2437 media_type: MediaType::Audio,
2438 source,
2439 video_codec: None,
2440 needs_transcoding: false,
2441 video_width: None,
2442 video_height: None,
2443 subtitles: vec![],
2444 series_id: None,
2445 server_id: None,
2446 };
2447
2448 media_items.push(media_item);
2449 }
2450
2451 info!("Built queue with {} media items", media_items.len());
2452
2453 let queue_context = match request.context {
2455 PlayTracksContext::Playlist {
2456 playlist_id,
2457 playlist_name,
2458 } => {
2459 for item in &mut media_items {
2460 item.playlist_id = Some(playlist_id.clone());
2461 }
2462 QueueContext::Playlist {
2463 playlist_id,
2464 playlist_name,
2465 }
2466 }
2467 PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2468 };
2469
2470 if request.shuffle {
2472 let controller = player.0.lock().await;
2473 controller.toggle_shuffle();
2474 }
2475
2476 if let Some(first_item) = media_items.get(request.start_index) {
2478 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2479 session_mgr.start_audio_session(first_item.clone());
2480 }
2481
2482 let controller = player.0.lock().await;
2483
2484 let remote_session = match playback_mode.0.get_mode() {
2490 crate::playback_mode::PlaybackMode::Remote { session_id }
2491 if !playback_mode.0.is_transferring() =>
2492 {
2493 Some(session_id)
2494 }
2495 _ => None,
2496 };
2497
2498 if let Some(session_id) = &remote_session {
2499 play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2500 .await?;
2501 controller
2502 .set_queue(media_items, request.start_index)
2503 .map_err(|e| e.to_string())?;
2504 } else {
2505 if !playback_mode.0.is_transferring() {
2513 playback_mode
2514 .0
2515 .set_mode(crate::playback_mode::PlaybackMode::Local);
2516 }
2517 controller
2518 .play_queue_from(media_items, request.start_index, request.start_position)
2519 .map_err(|e| e.to_string())?;
2520 }
2521
2522 {
2524 let queue_arc = controller.queue();
2525 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2526 queue.set_context(queue_context);
2527 }
2528
2529 controller.emit_queue_changed();
2531 if let Some(emitter) = controller.event_emitter() {
2532 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2533 emitter.emit(PlayerStatusEvent::SessionChanged {
2534 session: session_mgr.current().clone(),
2535 });
2536 }
2537
2538 info!("player_play_tracks completed successfully");
2539 Ok(get_player_status(&controller))
2540}
2541
2542#[derive(specta::Type, Debug, Serialize)]
2544#[serde(rename_all = "camelCase")]
2545pub struct PreloadResult {
2546 pub queued_count: usize,
2548 pub already_downloaded: usize,
2550 pub skipped: usize,
2552}
2553
2554#[tauri::command]
2557#[specta::specta]
2558pub async fn player_preload_upcoming(
2559 player: State<'_, PlayerStateWrapper>,
2560 db: State<'_, DatabaseWrapper>,
2561 smart_cache: State<'_, SmartCacheWrapper>,
2562 download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2563 app: tauri::AppHandle,
2564 user_id: String,
2565 _download_base_path: String,
2566) -> Result<PreloadResult, String> {
2567 let (db_service, target_dir) = {
2571 let database = db.0.lock().map_err(|e| e.to_string())?;
2572 let target_dir = database
2573 .path()
2574 .parent()
2575 .ok_or_else(|| "Database path has no parent directory".to_string())?
2576 .to_string_lossy()
2577 .to_string();
2578 (Arc::new(database.service()), target_dir)
2579 };
2580
2581 let precache_count = {
2583 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2584 if !cache.should_precache_queue() {
2585 return Ok(PreloadResult {
2586 queued_count: 0,
2587 already_downloaded: 0,
2588 skipped: 0,
2589 });
2590 }
2591 cache.queue_precache_count()
2592 };
2593
2594 let upcoming_items: Vec<MediaItem> = {
2596 let controller = player.0.lock().await;
2597 let queue = controller.queue();
2598 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2599 queue_lock
2600 .get_upcoming(precache_count)
2601 .into_iter()
2602 .cloned()
2603 .collect()
2604 };
2605
2606 if upcoming_items.is_empty() {
2607 return Ok(PreloadResult {
2608 queued_count: 0,
2609 already_downloaded: 0,
2610 skipped: 0,
2611 });
2612 }
2613
2614 let mut queued_count = 0;
2615 let mut already_downloaded = 0;
2616 let mut skipped = 0;
2617
2618 for item in upcoming_items {
2620 let (jellyfin_id, stream_url) = match &item.source {
2624 MediaSource::Remote {
2625 jellyfin_item_id,
2626 stream_url,
2627 } => (jellyfin_item_id.clone(), stream_url.clone()),
2628 MediaSource::Local { .. } => {
2629 already_downloaded += 1;
2630 continue;
2631 }
2632 MediaSource::DirectUrl { .. } => {
2633 skipped += 1;
2634 continue;
2635 }
2636 };
2637
2638 let query = Query::with_params(
2642 "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2643 AND (status IN ('completed', 'downloading')
2644 OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2645 vec![
2646 QueryParam::String(jellyfin_id.clone()),
2647 QueryParam::String(user_id.clone()),
2648 ],
2649 );
2650
2651 let is_downloaded: Option<String> = db_service
2652 .query_optional(query, |row| row.get(0))
2653 .await
2654 .map_err(|e| e.to_string())?;
2655
2656 if is_downloaded.is_some() {
2657 already_downloaded += 1;
2658 continue;
2659 }
2660
2661 let album_dir = item
2664 .album
2665 .as_deref()
2666 .filter(|a| !a.is_empty())
2667 .unwrap_or("Unknown Album");
2668 let file_path = format!(
2669 "downloads/{}/{}.mp3",
2670 sanitize_filename(album_dir),
2671 sanitize_filename(&item.title)
2672 );
2673
2674 let insert_query = Query::with_params(
2678 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
2679 VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2680 ON CONFLICT(item_id, user_id) DO UPDATE SET
2681 stream_url = excluded.stream_url,
2682 target_dir = excluded.target_dir
2683 WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2684 vec![
2685 QueryParam::String(jellyfin_id),
2686 QueryParam::String(user_id.clone()),
2687 QueryParam::String(file_path),
2688 QueryParam::String(item.title.clone()),
2689 item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2690 item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2691 QueryParam::String(stream_url),
2692 QueryParam::String(target_dir.clone()),
2693 ],
2694 );
2695
2696 match db_service.execute(insert_query).await {
2697 Ok(rows) if rows > 0 => {
2698 info!(
2699 "[Preload] Queued download for: {} - {}",
2700 item.artist.as_deref().unwrap_or("Unknown"),
2701 item.title
2702 );
2703 queued_count += 1;
2704 }
2705 Ok(_) => {
2706 already_downloaded += 1;
2708 }
2709 Err(e) => {
2710 error!("[Preload] Failed to queue {}: {}", item.title, e);
2711 skipped += 1;
2712 }
2713 }
2714 }
2715
2716 if queued_count > 0 {
2719 let active_downloads = {
2720 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2721 manager.get_active_downloads()
2722 };
2723 crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2724 }
2725
2726 info!(
2727 "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2728 queued_count, already_downloaded, skipped
2729 );
2730
2731 Ok(PreloadResult {
2732 queued_count,
2733 already_downloaded,
2734 skipped,
2735 })
2736}
2737
2738fn sanitize_filename(name: &str) -> String {
2740 name.chars()
2741 .map(|c| match c {
2742 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2743 _ => c,
2744 })
2745 .collect()
2746}
2747
2748#[tauri::command]
2750#[specta::specta]
2751pub async fn player_set_cache_config(
2752 smart_cache: State<'_, SmartCacheWrapper>,
2753 config: CacheConfig,
2754) -> Result<(), String> {
2755 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2756 cache.update_config(config);
2757 Ok(())
2758}
2759
2760#[tauri::command]
2762#[specta::specta]
2763pub async fn player_get_cache_config(
2764 smart_cache: State<'_, SmartCacheWrapper>,
2765) -> Result<CacheConfig, String> {
2766 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2767 Ok(cache.get_config().unwrap_or_default())
2768}
2769
2770#[tauri::command]
2772#[specta::specta]
2773pub async fn player_configure_jellyfin(
2774 player: State<'_, PlayerStateWrapper>,
2775 db: State<'_, DatabaseWrapper>,
2776 server_url: String,
2777 access_token: String,
2778 user_id: String,
2779 device_id: String,
2780) -> Result<(), String> {
2781 log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
2782
2783 let config = JellyfinConfig {
2784 server_url,
2785 access_token,
2786 device_id,
2787 };
2788
2789 let client = JellyfinClient::new(config.clone())?;
2791
2792 let db_service = {
2799 let database = db.0.lock().map_err(|e| e.to_string())?;
2800 Arc::new(database.service())
2801 };
2802 let reporter_client = JellyfinClient::new(config)?;
2803 let reporter = crate::playback_reporting::PlaybackReporter::new(
2804 db_service,
2805 Arc::new(TokioMutex::new(Some(reporter_client))),
2806 user_id,
2807 );
2808
2809 let controller = player.0.lock().await;
2810 controller.set_jellyfin_client(Some(client));
2811 controller.set_playback_reporter(Some(reporter)).await;
2812
2813 log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
2814 Ok(())
2815}
2816
2817#[tauri::command]
2819#[specta::specta]
2820pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
2821 log::info!("[PlayerCommand] Disabling Jellyfin client");
2822
2823 let controller = player.0.lock().await;
2824 controller.set_jellyfin_client(None);
2825 controller.set_playback_reporter(None).await;
2826
2827 log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
2828 Ok(())
2829}
2830
2831#[cfg(test)]
2832mod tests {
2833 use crate::utils::lock::MutexSafe;
2834
2835 #[test]
2846 fn test_normalize_volume_clamps_and_rejects_nan() {
2847 use super::normalize_volume;
2848
2849 assert_eq!(normalize_volume(0.0), 0.0);
2851 assert_eq!(normalize_volume(0.5), 0.5);
2852 assert_eq!(normalize_volume(1.0), 1.0);
2853
2854 assert_eq!(normalize_volume(-0.5), 0.0);
2856 assert_eq!(normalize_volume(42.0), 1.0);
2857 assert_eq!(normalize_volume(f32::INFINITY), 1.0);
2858 assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
2859
2860 let from_nan = normalize_volume(f32::NAN);
2863 assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
2864 assert_eq!(from_nan, 0.0);
2865
2866 for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
2868 let percent = (normalize_volume(input) * 100.0) as i32;
2869 assert!((0..=100).contains(&percent), "input {input} gave {percent}");
2870 }
2871 }
2872
2873 #[tokio::test]
2890 async fn test_play_item_request_carries_subtitles_into_media_item() {
2891 use super::{create_media_item, PlayItemRequest};
2892
2893 let payload = serde_json::json!({
2894 "id": "ep-1",
2895 "title": "Pilot",
2896 "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
2897 "videoCodec": "h264",
2898 "needsTranscoding": false,
2899 "subtitles": [
2900 {
2901 "index": 2,
2902 "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
2903 "language": "eng",
2904 "label": "English (SRT)",
2905 "mime_type": "text/vtt"
2906 },
2907 {
2908 "index": 3,
2909 "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
2910 "language": null,
2911 "label": null,
2912 "mime_type": "text/vtt"
2913 }
2914 ]
2915 });
2916
2917 let req: PlayItemRequest =
2918 serde_json::from_value(payload).expect("frontend payload must deserialize");
2919 assert_eq!(
2920 req.subtitles.len(),
2921 2,
2922 "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
2923 );
2924
2925 let media = create_media_item(req, None).await.unwrap();
2926 assert_eq!(
2927 media.subtitles.len(),
2928 2,
2929 "create_media_item must thread the tracks onto the MediaItem the backend loads"
2930 );
2931 assert_eq!(media.subtitles[0].index, 2);
2932 assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
2933 assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
2934 assert_eq!(media.subtitles[0].mime_type, "text/vtt");
2935 assert_eq!(media.subtitles[1].index, 3);
2938 assert!(media.subtitles[1].language.is_none());
2939 }
2940
2941 #[tokio::test]
2947 async fn test_play_item_request_without_subtitles_defaults_to_empty() {
2948 use super::{create_media_item, PlayItemRequest};
2949
2950 let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
2951 "id": "movie-1",
2952 "title": "Movie",
2953 "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
2954 "videoCodec": "h264",
2955 "needsTranscoding": false
2956 }))
2957 .expect("a subtitle-less payload must still deserialize");
2958
2959 assert!(req.subtitles.is_empty());
2960 assert!(create_media_item(req, None)
2961 .await
2962 .unwrap()
2963 .subtitles
2964 .is_empty());
2965 }
2966
2967 #[test]
2979 fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
2980 use crate::player::media::SubtitleTrack;
2981
2982 let subtitles = vec![SubtitleTrack {
2983 index: 2,
2984 url: "https://jelly.example/subs.vtt".to_string(),
2985 language: Some("eng".to_string()),
2986 label: Some("English".to_string()),
2987 mime_type: "text/vtt".to_string(),
2988 }];
2989
2990 let json = serde_json::to_string(&subtitles).unwrap();
2992 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
2993 let obj = parsed[0].as_object().unwrap();
2994
2995 for key in ["url", "language", "label", "mime_type"] {
2996 assert!(
2997 obj.contains_key(key),
2998 "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
2999 obj.keys().collect::<Vec<_>>()
3000 );
3001 }
3002 assert!(
3003 !obj.contains_key("mimeType"),
3004 "camelCasing mime_type silently drops every track's MIME type on Android"
3005 );
3006 }
3007
3008 #[test]
3013 fn test_background_audio_source_prefers_local_file() {
3014 use super::background_audio_source;
3015 use crate::player::MediaSource;
3016 use std::path::PathBuf;
3017
3018 let local = background_audio_source(
3019 Some("/downloads/ep1.mkv".to_string()),
3020 "https://server/audio-only".to_string(),
3021 "ep-1",
3022 );
3023 match local {
3024 MediaSource::Local {
3025 file_path,
3026 jellyfin_item_id,
3027 } => {
3028 assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3029 assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3031 }
3032 other => panic!("expected a local source, got {:?}", other),
3033 }
3034
3035 let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3036 match remote {
3037 MediaSource::Remote {
3038 stream_url,
3039 jellyfin_item_id,
3040 } => {
3041 assert_eq!(stream_url, "https://server/audio-only");
3042 assert_eq!(jellyfin_item_id, "ep-1");
3043 }
3044 other => panic!("expected a remote source, got {:?}", other),
3045 }
3046 }
3047
3048 #[test]
3061 fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3062 use super::background_audio_plan;
3063
3064 let local = background_audio_plan(true, 1104.0);
3065 assert_eq!(local.base_seconds, 0.0);
3066 assert_eq!(local.seek_to, Some(1104.0));
3067
3068 let streamed = background_audio_plan(false, 1104.0);
3069 assert_eq!(streamed.base_seconds, 1104.0);
3070 assert_eq!(
3071 streamed.seek_to, None,
3072 "the URL already starts at the handoff point; seeking again skips past it"
3073 );
3074 }
3075
3076 #[test]
3081 fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3082 use super::background_audio_plan;
3083
3084 for local in [true, false] {
3085 let plan = background_audio_plan(local, 0.0);
3086 assert_eq!(plan.base_seconds, 0.0);
3087 assert_eq!(plan.seek_to, None);
3088 }
3089 }
3090
3091 #[tokio::test]
3097 async fn test_resolve_local_media_path() {
3098 use super::resolve_local_media_path;
3099 use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3100 use rusqlite::Connection;
3101 use std::sync::{Arc, Mutex};
3102
3103 let conn = Connection::open_in_memory().unwrap();
3104 conn.execute(
3105 "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3106 [],
3107 )
3108 .unwrap();
3109 let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3110
3111 let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3113 std::fs::write(&present, b"x").unwrap();
3114 let present_str = present.to_string_lossy().to_string();
3115
3116 for (item, status, path) in [
3117 ("downloaded", "completed", present_str.as_str()),
3118 ("still-going", "downloading", present_str.as_str()),
3119 (
3120 "file-gone",
3121 "completed",
3122 "/nonexistent/jellytau/missing.mp4",
3123 ),
3124 ] {
3125 db_service
3126 .execute(Query::with_params(
3127 "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3128 vec![
3129 crate::storage::db_service::QueryParam::String(item.to_string()),
3130 crate::storage::db_service::QueryParam::String(status.to_string()),
3131 crate::storage::db_service::QueryParam::String(path.to_string()),
3132 ],
3133 ))
3134 .await
3135 .unwrap();
3136 }
3137
3138 assert_eq!(
3139 resolve_local_media_path(&db_service, "downloaded")
3140 .await
3141 .unwrap()
3142 .as_deref(),
3143 Some(present_str.as_str()),
3144 "a completed download with its file present must resolve"
3145 );
3146
3147 assert_eq!(
3148 resolve_local_media_path(&db_service, "still-going")
3149 .await
3150 .unwrap(),
3151 None,
3152 "an in-progress download is not playable from disk"
3153 );
3154
3155 assert_eq!(
3156 resolve_local_media_path(&db_service, "file-gone")
3157 .await
3158 .unwrap(),
3159 None,
3160 "a row whose file has gone must fall back to streaming, not hand over a dead path"
3161 );
3162
3163 assert_eq!(
3164 resolve_local_media_path(&db_service, "never-heard-of-it")
3165 .await
3166 .unwrap(),
3167 None
3168 );
3169
3170 let _ = std::fs::remove_file(&present);
3171 }
3172
3173 #[tokio::test]
3177 async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3178 use super::{refresh_queue_local_sources, DatabaseWrapper};
3179 use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3180 use crate::storage::Database;
3181 use std::sync::Mutex;
3182
3183 let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3186 std::fs::create_dir_all(&dir).unwrap();
3187 let existing = dir.join("track-a.mp3");
3188 std::fs::write(&existing, b"audio").unwrap();
3189 let missing = dir.join("track-b-missing.mp3");
3190 let _ = std::fs::remove_file(&missing);
3191
3192 let database = Database::open_in_memory().unwrap();
3193 {
3194 let conn = database.connection();
3195 let conn = conn.lock_safe();
3196 conn.execute_batch(&format!(
3197 r#"
3198 INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3199 INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3200 INSERT INTO downloads (item_id, user_id, file_path, status)
3201 VALUES ('track-a', 'user1', '{}', 'completed');
3202 INSERT INTO downloads (item_id, user_id, file_path, status)
3203 VALUES ('track-b', 'user1', '{}', 'completed');
3204 "#,
3205 existing.display(),
3206 missing.display()
3207 ))
3208 .unwrap();
3209 }
3210 let db = DatabaseWrapper(Mutex::new(database));
3211
3212 let make_item = |id: &str| MediaItem {
3213 id: id.to_string(),
3214 title: id.to_string(),
3215 name: None,
3216 artist: None,
3217 album: None,
3218 album_name: None,
3219 album_id: None,
3220 artist_items: None,
3221 artists: None,
3222 primary_image_tag: None,
3223 image_id: None,
3224 item_type: None,
3225 playlist_id: None,
3226 duration: None,
3227 artwork_url: None,
3228 media_type: MediaType::Audio,
3229 source: MediaSource::Remote {
3230 stream_url: format!("http://test/Audio/{}/stream", id),
3231 jellyfin_item_id: id.to_string(),
3232 },
3233 video_codec: None,
3234 needs_transcoding: false,
3235 video_width: None,
3236 video_height: None,
3237 subtitles: vec![],
3238 series_id: None,
3239 server_id: None,
3240 };
3241
3242 let controller = PlayerController::default();
3243 controller
3244 .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3245 .unwrap();
3246
3247 let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3248 assert_eq!(switched, 1, "only the download whose file exists switches");
3249
3250 let queue = controller.queue();
3251 let queue_lock = queue.lock_safe();
3252 match &queue_lock.items()[0].source {
3253 MediaSource::Local {
3254 file_path,
3255 jellyfin_item_id,
3256 } => {
3257 assert_eq!(file_path, &existing);
3258 assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3259 }
3260 other => panic!("track-a should be local, got {:?}", other),
3261 }
3262 assert!(
3263 matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3264 "track-b's file is missing, it must stay remote"
3265 );
3266 }
3267
3268 #[test]
3271 fn test_find_track_index_in_album() {
3272 #[derive(Clone)]
3274 struct MockTrack {
3275 id: String,
3276 name: String,
3277 index_number: Option<i32>,
3278 }
3279
3280 let mut tracks = [
3281 MockTrack {
3282 id: "track1".to_string(),
3283 name: "Song 1".to_string(),
3284 index_number: Some(1),
3285 },
3286 MockTrack {
3287 id: "track2".to_string(),
3288 name: "Song 2".to_string(),
3289 index_number: Some(2),
3290 },
3291 MockTrack {
3292 id: "track3".to_string(),
3293 name: "Song 3".to_string(),
3294 index_number: Some(3),
3295 },
3296 MockTrack {
3297 id: "track4".to_string(),
3298 name: "Song 4".to_string(),
3299 index_number: Some(4),
3300 },
3301 MockTrack {
3302 id: "track5".to_string(),
3303 name: "Song 5".to_string(),
3304 index_number: Some(5),
3305 },
3306 ];
3307
3308 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3310
3311 let index1 = tracks.iter().position(|t| t.id == "track1");
3313 assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3314 assert_eq!(
3315 tracks[0].name, "Song 1",
3316 "Track at index 0 should carry its name"
3317 );
3318
3319 let index3 = tracks.iter().position(|t| t.id == "track3");
3321 assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3322
3323 let index5 = tracks.iter().position(|t| t.id == "track5");
3325 assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3326
3327 let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3329 assert_eq!(index_none, None, "Non-existent track should return None");
3330 }
3331
3332 #[test]
3334 fn test_track_iteration_order() {
3335 let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3337
3338 let target_index = track_ids.iter().position(|&id| id == "id3");
3340 assert_eq!(
3341 target_index,
3342 Some(2),
3343 "id3 should be at index 2 in original list"
3344 );
3345
3346 let mut media_items = Vec::new();
3348 for id in &track_ids {
3349 media_items.push(id.to_string());
3350 }
3351
3352 assert_eq!(media_items.len(), 5);
3354 assert_eq!(media_items[0], "id1");
3355 assert_eq!(media_items[2], "id3");
3356 assert_eq!(media_items[4], "id5");
3357
3358 assert_eq!(media_items[target_index.unwrap()], "id3");
3360 }
3361
3362 #[test]
3364 fn test_album_track_sorting() {
3365 #[derive(Clone, Debug)]
3366 struct MockTrack {
3367 id: String,
3368 name: String,
3369 index_number: Option<i32>,
3370 }
3371
3372 let mut tracks = [
3374 MockTrack {
3375 id: "id5".to_string(),
3376 name: "Track 5".to_string(),
3377 index_number: Some(5),
3378 },
3379 MockTrack {
3380 id: "id1".to_string(),
3381 name: "Track 1".to_string(),
3382 index_number: Some(1),
3383 },
3384 MockTrack {
3385 id: "id3".to_string(),
3386 name: "Track 3".to_string(),
3387 index_number: Some(3),
3388 },
3389 MockTrack {
3390 id: "id2".to_string(),
3391 name: "Track 2".to_string(),
3392 index_number: Some(2),
3393 },
3394 MockTrack {
3395 id: "id4".to_string(),
3396 name: "Track 4".to_string(),
3397 index_number: Some(4),
3398 },
3399 ];
3400
3401 let requested_track_id = "id1";
3403
3404 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3406
3407 let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3409
3410 assert_eq!(
3412 start_index,
3413 Some(0),
3414 "After sorting, track id1 should be at index 0"
3415 );
3416
3417 assert_eq!(tracks[0].id, "id1");
3419 assert_eq!(
3420 tracks[0].name, "Track 1",
3421 "Sorted track should retain its name"
3422 );
3423 assert_eq!(tracks[1].id, "id2");
3424 assert_eq!(tracks[2].id, "id3");
3425 assert_eq!(tracks[3].id, "id4");
3426 assert_eq!(tracks[4].id, "id5");
3427 }
3428}