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_audio_track_switch_strategy, determine_video_seek_strategy, AudioTrackSwitchStrategy,
29 MediaItem, MediaSessionManager, MediaSource, MediaType, PlayerController, PlayerState,
30 PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
31};
32use crate::repository::{
33 types::{GetItemsOptions, ImageOptions, ImageType},
34 MediaRepository, StreamSelection,
35};
36use crate::settings::VideoSettings;
37use crate::storage::db_service::{DatabaseService, Query, QueryParam};
38
39pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
41
42pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
50
51pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
55
56pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
60
61#[derive(specta::Type, Debug, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct PlayerStatus {
65 pub state: PlayerState,
66 pub position: f64,
67 pub duration: Option<f64>,
68 pub volume: f32,
69 pub muted: bool,
70 pub shuffle: bool,
71 pub repeat: RepeatMode,
72
73 pub merged_media: Option<MergedMediaItem>,
76 pub merged_is_playing: bool,
78 pub merged_volume: f32,
80}
81
82#[derive(specta::Type, Debug, Serialize, Clone)]
85#[serde(rename_all = "camelCase")]
86pub struct MergedMediaItem {
87 pub id: String,
88 pub title: String,
89 pub artist: Option<String>,
90 pub album: Option<String>,
91 pub album_id: Option<String>,
92 pub duration: Option<f64>,
93 pub primary_image_tag: Option<String>,
94 pub image_id: Option<String>,
96 pub media_type: String,
97}
98
99impl From<&crate::player::MediaItem> for MergedMediaItem {
101 fn from(item: &crate::player::MediaItem) -> Self {
102 Self {
103 id: item.id.clone(),
104 title: item.title.clone(),
105 artist: item.artist.clone(),
106 album: item.album.clone(),
107 album_id: item.album_id.clone(),
108 duration: item.duration,
109 primary_image_tag: item.primary_image_tag.clone(),
110 image_id: item.primary_image_tag.clone(),
111 media_type: match item.media_type {
112 crate::player::MediaType::Audio => "audio".to_string(),
113 crate::player::MediaType::Video => "video".to_string(),
114 },
115 }
116 }
117}
118
119impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
121 fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
122 Self {
123 id: item.id.clone().unwrap_or_default(),
124 title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
125 artist: item
126 .album_artist
127 .clone()
128 .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
129 album: item.album.clone(),
130 album_id: item.album_id.clone(),
131 duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
132 primary_image_tag: item.primary_image_tag.clone(),
133 image_id: item.primary_image_tag.clone(),
134 media_type: item
135 .item_type
136 .clone()
137 .unwrap_or_else(|| "audio".to_string())
138 .to_lowercase(),
139 }
140 }
141}
142
143#[derive(specta::Type, Debug, Serialize)]
145#[serde(rename_all = "camelCase")]
146pub struct QueueStatus {
147 pub items: Vec<MediaItem>,
148 pub current_index: Option<usize>,
149 pub shuffle: bool,
150 pub repeat: RepeatMode,
151 pub has_next: bool,
152 pub has_previous: bool,
153}
154
155#[derive(specta::Type, Debug, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct PlayItemRequest {
162 pub id: String,
163 pub title: String,
164 pub stream_url: String,
165 pub video_codec: String,
167 pub needs_transcoding: bool,
169 #[serde(default)]
179 pub transport: Option<crate::repository::Transport>,
180
181 #[serde(default)]
185 pub artist: Option<String>,
186 #[serde(default)]
187 pub primary_image_tag: Option<String>,
188 #[serde(default)]
189 pub server_id: Option<String>,
190 #[serde(default)]
194 pub duration_seconds: Option<f64>,
195 #[serde(default)]
199 pub item_type: Option<String>,
200 #[serde(default)]
203 pub series_id: Option<String>,
204 #[serde(default)]
223 pub subtitles: Vec<crate::player::SubtitleTrack>,
224}
225
226#[derive(specta::Type, Debug, Deserialize)]
228#[serde(tag = "type", rename_all = "lowercase")]
229pub enum PlayQueueContext {
230 Album {
232 #[serde(rename = "albumId")]
233 album_id: String,
234 #[serde(rename = "albumName")]
235 album_name: String,
236 },
237 Playlist {
239 #[serde(rename = "playlistId")]
240 playlist_id: String,
241 #[serde(rename = "playlistName")]
242 playlist_name: String,
243 },
244 Custom,
246}
247
248#[derive(specta::Type, Debug, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct PlayQueueRequest {
252 pub items: Vec<PlayItemRequest>,
253 pub start_index: usize,
254 pub shuffle: bool,
255 #[serde(default)]
258 pub context: Option<PlayQueueContext>,
259}
260
261#[derive(specta::Type, Debug, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct PlayAlbumTrackRequest {
265 pub album_id: String,
266 pub album_name: String,
267 pub track_id: String,
268 pub shuffle: bool,
269}
270
271#[derive(specta::Type, Debug, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct PlayTracksRequest {
275 pub track_ids: Vec<String>,
276 pub start_index: usize,
277 pub shuffle: bool,
278 pub context: PlayTracksContext,
279 #[serde(default)]
282 pub start_position: Option<f64>,
283}
284
285#[derive(specta::Type, Debug, Deserialize)]
287#[serde(tag = "type", rename_all = "lowercase")]
288pub enum PlayTracksContext {
289 Playlist {
290 #[serde(rename = "playlistId")]
291 playlist_id: String,
292 #[serde(rename = "playlistName")]
293 playlist_name: String,
294 },
295 Search {
296 #[serde(rename = "searchQuery")]
297 #[allow(dead_code)] search_query: String,
299 },
300 Custom {
301 #[serde(rename = "label")]
302 #[allow(dead_code)] label: Option<String>,
304 },
305}
306
307#[derive(specta::Type, Debug, Serialize)]
309#[serde(tag = "strategy", rename_all = "camelCase")]
310pub enum VideoSeekResponse {
311 Native {
313 position: f64,
315 },
316}
317
318#[derive(specta::Type, Debug, Serialize)]
320#[serde(tag = "strategy", rename_all = "camelCase")]
321pub enum AudioTrackSwitchResponse {
322 Native {
324 success: bool,
326 },
327}
328
329#[derive(specta::Type, Debug, Serialize)]
336#[serde(tag = "strategy", rename_all = "camelCase")]
337pub enum StreamQualityResponse {
338 Native {
350 selection: StreamSelection,
353 position: f64,
355 },
356}
357
358pub(super) async fn create_media_item(
363 req: PlayItemRequest,
364 db: Option<&DatabaseWrapper>,
365) -> Result<MediaItem, String> {
366 let jellyfin_id = req.id.clone();
368
369 let local_path = if let Some(db_wrapper) = db {
371 check_for_local_download(db_wrapper, &jellyfin_id).await?
372 } else {
373 None
374 };
375
376 let source = if let Some(path) = local_path {
377 MediaSource::Local {
378 file_path: PathBuf::from(path),
379 jellyfin_item_id: Some(jellyfin_id.clone()),
380 }
381 } else {
382 MediaSource::Remote {
383 stream_url: req.stream_url,
384 jellyfin_item_id: jellyfin_id.clone(),
385 }
386 };
387
388 Ok(MediaItem {
389 id: req.id.clone(),
390 title: req.title.clone(),
391 name: Some(req.title.clone()),
392 artist: None, album: None, album_name: None, album_id: None, artist_items: None, artists: None, primary_image_tag: None, image_id: None,
400 item_type: None, playlist_id: None, duration: None, artwork_url: None, media_type: crate::player::MediaType::Video, source,
406 video_codec: Some(req.video_codec),
407 needs_transcoding: req.needs_transcoding,
408 transport: req.transport,
410 video_width: None, video_height: None, subtitles: req.subtitles,
416 series_id: None, server_id: None, })
419}
420
421pub(super) fn background_audio_source(
433 local_path: Option<String>,
434 stream_url: String,
435 item_id: &str,
436) -> MediaSource {
437 match local_path {
438 Some(path) => MediaSource::Local {
439 file_path: PathBuf::from(path),
440 jellyfin_item_id: Some(item_id.to_string()),
441 },
442 None => MediaSource::Remote {
443 stream_url,
444 jellyfin_item_id: item_id.to_string(),
445 },
446 }
447}
448
449pub(super) struct BackgroundAudioPlan {
454 pub base_seconds: f64,
458 pub seek_to: Option<f64>,
460}
461
462pub(super) fn background_audio_plan(
474 is_local_file: bool,
475 position_seconds: f64,
476) -> BackgroundAudioPlan {
477 let position = position_seconds.max(0.0);
478
479 if is_local_file {
480 BackgroundAudioPlan {
481 base_seconds: 0.0,
482 seek_to: (position > 0.0).then_some(position),
483 }
484 } else {
485 BackgroundAudioPlan {
486 base_seconds: position,
487 seek_to: None,
488 }
489 }
490}
491
492pub(super) async fn resolve_local_media_path<S: DatabaseService>(
504 db_service: &Arc<S>,
505 item_id: &str,
506) -> Result<Option<String>, String> {
507 let query = Query::with_params(
508 "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
509 vec![QueryParam::String(item_id.to_string())],
510 );
511
512 let path: Option<String> = db_service
513 .query_optional(query, |row| row.get(0))
514 .await
515 .map_err(|e| e.to_string())?;
516
517 match path {
518 Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
519 Some(file_path) => {
520 warn!(
521 "[Player] Download entry exists in DB but file not found: {}",
522 file_path
523 );
524 Ok(None)
525 }
526 None => Ok(None),
527 }
528}
529
530pub(super) async fn check_for_local_download(
532 db: &DatabaseWrapper,
533 item_id: &str,
534) -> Result<Option<String>, String> {
535 let db_service = {
536 let database = db.0.lock().map_err(|e| e.to_string())?;
537 Arc::new(database.service())
538 };
539
540 resolve_local_media_path(&db_service, item_id).await
541}
542
543#[tauri::command]
556#[specta::specta]
557pub async fn player_local_media_path(
558 db: State<'_, DatabaseWrapper>,
559 item_id: String,
560) -> Result<Option<String>, String> {
561 let db_service = {
562 let database = db.0.lock().map_err(|e| e.to_string())?;
563 Arc::new(database.service())
564 };
565
566 resolve_local_media_path(&db_service, &item_id).await
567}
568
569pub(super) async fn refresh_queue_local_sources(
578 controller: &PlayerController,
579 db: &DatabaseWrapper,
580) -> Result<usize, String> {
581 let remote_ids: Vec<String> = {
583 let queue = controller.queue();
584 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
585 queue_lock
586 .items()
587 .iter()
588 .filter_map(|item| match &item.source {
589 MediaSource::Remote {
590 jellyfin_item_id, ..
591 } => Some(jellyfin_item_id.clone()),
592 _ => None,
593 })
594 .collect()
595 };
596
597 if remote_ids.is_empty() {
598 return Ok(0);
599 }
600
601 let mut local_paths: Vec<(String, String)> = Vec::new();
602 for id in remote_ids {
603 if let Some(path) = check_for_local_download(db, &id).await? {
604 local_paths.push((id, path));
605 }
606 }
607
608 if local_paths.is_empty() {
609 return Ok(0);
610 }
611
612 let queue = controller.queue();
613 let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
614 let mut switched = 0;
615 for item in queue_lock.items_mut() {
616 if let MediaSource::Remote {
617 jellyfin_item_id, ..
618 } = &item.source
619 {
620 if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
621 info!(
622 "[Player] Switching queued track {} to local download: {}",
623 id, path
624 );
625 item.source = MediaSource::Local {
626 file_path: PathBuf::from(path),
627 jellyfin_item_id: Some(id.clone()),
628 };
629 switched += 1;
630 }
631 }
632 }
633 Ok(switched)
634}
635
636#[tauri::command]
646#[specta::specta]
647pub async fn player_play_item(
648 player: State<'_, PlayerStateWrapper>,
649 session: State<'_, MediaSessionManagerWrapper>,
650 db: State<'_, DatabaseWrapper>,
651 item: PlayItemRequest,
652) -> Result<PlayerStatus, String> {
653 info!(
654 "player_play_item called: {} - {}",
655 item.title, item.stream_url
656 );
657
658 crate::repository::online::clear_playback_quality_override();
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 controller
694 .play_item(media_item)
695 .map_err(|e| e.to_string())?;
696
697 controller.emit_queue_changed();
699
700 if let Some(emitter) = controller.event_emitter() {
702 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
703 emitter.emit(PlayerStatusEvent::SessionChanged {
704 session: session_mgr.current().clone(),
705 });
706 }
707
708 Ok(get_player_status(&controller))
709}
710
711#[tauri::command]
728#[specta::specta]
729pub async fn player_enter_background_audio(
730 player: State<'_, PlayerStateWrapper>,
731 session: State<'_, MediaSessionManagerWrapper>,
732 db: State<'_, DatabaseWrapper>,
733 item: PlayItemRequest,
734 position_seconds: f64,
735) -> Result<PlayerStatus, String> {
736 info!(
737 "player_enter_background_audio: {} @ {:.1}s",
738 item.title, position_seconds
739 );
740
741 let local_path = check_for_local_download(&db, &item.id).await?;
746 if local_path.is_some() {
747 info!(
748 "player_enter_background_audio: using downloaded file for {}",
749 item.id
750 );
751 }
752 let plan = background_audio_plan(local_path.is_some(), position_seconds);
755 let source = background_audio_source(local_path, item.stream_url, &item.id);
756
757 let media_item = MediaItem {
761 transport: None,
763 id: item.id.clone(),
764 title: item.title.clone(),
765 name: Some(item.title.clone()),
766 artist: item.artist.clone(),
767 album: None,
768 album_name: None,
769 album_id: None,
770 artist_items: None,
771 artists: None,
772 primary_image_tag: item.primary_image_tag.clone(),
773 image_id: item.primary_image_tag.clone(),
774 item_type: item.item_type.clone(),
777 playlist_id: None,
778 duration: item.duration_seconds,
780 artwork_url: None,
781 media_type: MediaType::Audio,
782 source,
783 video_codec: None,
784 needs_transcoding: false,
785 video_width: None,
786 video_height: None,
787 subtitles: vec![],
788 series_id: item.series_id.clone(),
789 server_id: item.server_id.clone(),
790 };
791
792 {
793 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
794 session_mgr.start_audio_session(media_item.clone());
795 }
796
797 let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
801
802 let controller = player.0.lock().await;
803 controller.enter_background_audio(plan.base_seconds);
809 controller
810 .play_item(media_item)
811 .map_err(|e| e.to_string())?;
812 if let Some(seek_to) = plan.seek_to {
816 info!(
817 "player_enter_background_audio: seeking the downloaded file to {:.1}s",
818 seek_to
819 );
820 controller.seek(seek_to).map_err(|e| e.to_string())?;
821 }
822
823 controller.emit_queue_changed();
824 if let Some(emitter) = controller.event_emitter() {
825 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
826 emitter.emit(PlayerStatusEvent::SessionChanged {
827 session: session_mgr.current().clone(),
828 });
829 }
830
831 Ok(get_player_status(&controller))
832}
833
834#[tauri::command]
855#[specta::specta]
856pub async fn player_background_action(
857 player: State<'_, PlayerStateWrapper>,
858 background_audio_armed: bool,
859 in_picture_in_picture: bool,
860) -> Result<crate::player::background_policy::BackgroundAction, String> {
861 use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
862
863 let is_video = {
864 let controller = player.0.lock().await;
865 let queue_arc = controller.queue();
866 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
867 match queue.current() {
868 Some(item) => is_video_media(item.media_type),
869 None => return Ok(BackgroundAction::KeepPlaying),
870 }
871 };
872
873 let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
874 info!(
875 "[player_background_action] video={} armed={} pip={} -> {:?}",
876 is_video, background_audio_armed, in_picture_in_picture, action
877 );
878 Ok(action)
879}
880
881#[tauri::command]
888#[specta::specta]
889pub async fn player_exit_background_audio(
890 player: State<'_, PlayerStateWrapper>,
891) -> Result<crate::player::BackgroundAudioResume, String> {
892 let controller = player.0.lock().await;
893
894 let resume = controller.background_audio_resume();
908
909 let _ = crate::player::set_lockscreen_position_offset(0.0);
911 controller.exit_background_audio();
912 controller.stop().map_err(|e| e.to_string())?;
913 info!(
914 "player_exit_background_audio: resuming {:?} at {:.1}s",
915 resume.item_id, resume.position_seconds
916 );
917 Ok(resume)
918}
919
920#[tauri::command]
927#[specta::specta]
928pub async fn player_play_queue(
929 player: State<'_, PlayerStateWrapper>,
930 session: State<'_, MediaSessionManagerWrapper>,
931 db: State<'_, DatabaseWrapper>,
932 request: PlayQueueRequest,
933) -> Result<PlayerStatus, String> {
934 info!(
935 "player_play_queue called: {} items, start_index: {}, shuffle: {}",
936 request.items.len(),
937 request.start_index,
938 request.shuffle
939 );
940
941 crate::repository::online::clear_playback_quality_override();
948
949 if request.shuffle {
951 let controller = player.0.lock().await;
952 controller.toggle_shuffle();
953 }
954
955 let queue_context = match request.context {
957 Some(PlayQueueContext::Album {
958 album_id,
959 album_name,
960 }) => QueueContext::Album {
961 album_id,
962 album_name,
963 },
964 Some(PlayQueueContext::Playlist {
965 playlist_id,
966 playlist_name,
967 }) => QueueContext::Playlist {
968 playlist_id,
969 playlist_name,
970 },
971 Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
972 };
973
974 let mut items: Vec<MediaItem> = Vec::new();
976 for req in request.items {
977 items.push(create_media_item(req, Some(&db)).await?);
978 }
979
980 if let Some(first_item) = items.get(request.start_index) {
982 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
983 match first_item.media_type {
984 MediaType::Audio => {
985 session_mgr.start_audio_session(first_item.clone());
986 }
987 MediaType::Video => {
988 session_mgr.start_movie_session(first_item.clone());
990 }
991 }
992 }
993
994 let controller = player.0.lock().await;
996 info!(
997 "player_play_queue: Calling controller.play_queue with {} items at index {}",
998 items.len(),
999 request.start_index
1000 );
1001 controller
1002 .play_queue(items, request.start_index)
1003 .map_err(|e| e.to_string())?;
1004
1005 {
1007 let queue_arc = controller.queue();
1008 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1009 queue.set_context(queue_context);
1010 }
1011
1012 controller.emit_queue_changed();
1014
1015 if let Some(emitter) = controller.event_emitter() {
1017 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1018 emitter.emit(PlayerStatusEvent::SessionChanged {
1019 session: session_mgr.current().clone(),
1020 });
1021 }
1022
1023 Ok(get_player_status(&controller))
1024}
1025
1026#[tauri::command]
1027#[specta::specta]
1028pub async fn player_play(
1029 player: State<'_, PlayerStateWrapper>,
1030 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1031) -> Result<PlayerStatus, String> {
1032 let mode = playback_mode.0.get_mode();
1034
1035 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1036 let client = {
1038 let controller = player.0.lock().await;
1039 let client_arc = controller.jellyfin_client();
1040 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1041 client_opt
1042 .as_ref()
1043 .ok_or("Jellyfin client not configured")?
1044 .clone()
1045 };
1046 client.send_session_command(session_id, "Unpause").await?;
1047 } else {
1048 let controller = player.0.lock().await;
1050 controller.play().map_err(|e| e.to_string())?;
1051 }
1052
1053 let controller = player.0.lock().await;
1054 Ok(get_player_status(&controller))
1055}
1056
1057#[tauri::command]
1058#[specta::specta]
1059pub async fn player_pause(
1060 player: State<'_, PlayerStateWrapper>,
1061 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1062) -> Result<PlayerStatus, String> {
1063 let mode = playback_mode.0.get_mode();
1065
1066 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1067 let client = {
1069 let controller = player.0.lock().await;
1070 let client_arc = controller.jellyfin_client();
1071 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1072 client_opt
1073 .as_ref()
1074 .ok_or("Jellyfin client not configured")?
1075 .clone()
1076 };
1077 client.send_session_command(session_id, "Pause").await?;
1078 } else {
1079 let controller = player.0.lock().await;
1081 controller.pause().map_err(|e| e.to_string())?;
1082 }
1083
1084 let controller = player.0.lock().await;
1085 Ok(get_player_status(&controller))
1086}
1087
1088#[tauri::command]
1089#[specta::specta]
1090pub async fn player_toggle(
1091 player: State<'_, PlayerStateWrapper>,
1092 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1093) -> Result<PlayerStatus, String> {
1094 let mode = playback_mode.0.get_mode();
1096
1097 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1098 let client = {
1100 let controller = player.0.lock().await;
1101 let client_arc = controller.jellyfin_client();
1102 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1103 client_opt
1104 .as_ref()
1105 .ok_or("Jellyfin client not configured")?
1106 .clone()
1107 };
1108 client.send_session_command(session_id, "PlayPause").await?;
1109 } else {
1110 let controller = player.0.lock().await;
1112 controller.toggle_playback().map_err(|e| e.to_string())?;
1113 }
1114
1115 let controller = player.0.lock().await;
1116 Ok(get_player_status(&controller))
1117}
1118
1119#[tauri::command]
1120#[specta::specta]
1121pub async fn player_stop(
1122 player: State<'_, PlayerStateWrapper>,
1123 session: State<'_, MediaSessionManagerWrapper>,
1124 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1125) -> Result<PlayerStatus, String> {
1126 let mode = playback_mode.0.get_mode();
1128
1129 info!("[player_stop] called (mode: {:?})", mode);
1136
1137 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1138 let client = {
1140 let controller = player.0.lock().await;
1141 let client_arc = controller.jellyfin_client();
1142 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1143 client_opt
1144 .as_ref()
1145 .ok_or("Jellyfin client not configured")?
1146 .clone()
1147 };
1148 client.send_session_command(session_id, "Stop").await?;
1149
1150 playback_mode
1157 .0
1158 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1159 } else {
1160 let controller = player.0.lock().await;
1162 controller.stop().map_err(|e| e.to_string())?;
1163
1164 playback_mode
1168 .0
1169 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1170
1171 {
1173 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1174 let current_session = session_mgr.current().clone();
1175
1176 match current_session {
1177 crate::player::MediaSessionType::Movie { .. } => {
1178 session_mgr.movie_session_ended();
1180 }
1181 crate::player::MediaSessionType::Audio { .. } => {
1182 session_mgr.audio_session_inactive();
1184 }
1185 crate::player::MediaSessionType::TvShow { .. } => {
1186 session_mgr.tv_session_episode_ended();
1188 }
1189 crate::player::MediaSessionType::Idle => {
1190 }
1192 }
1193
1194 if let Some(emitter) = controller.event_emitter() {
1196 emitter.emit(PlayerStatusEvent::SessionChanged {
1197 session: session_mgr.current().clone(),
1198 });
1199 }
1200 }
1201 }
1202
1203 let controller = player.0.lock().await;
1204 Ok(get_player_status(&controller))
1205}
1206
1207#[tauri::command]
1208#[specta::specta]
1209pub async fn player_next(
1210 player: State<'_, PlayerStateWrapper>,
1211 session: State<'_, MediaSessionManagerWrapper>,
1212 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1213 db: State<'_, DatabaseWrapper>,
1214) -> Result<PlayerStatus, String> {
1215 debug!("[player_next] Command called from frontend");
1216
1217 let mode = playback_mode.0.get_mode();
1219
1220 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1221 let client = {
1223 let controller = player.0.lock().await;
1224 let client_arc = controller.jellyfin_client();
1225 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1226 client_opt
1227 .as_ref()
1228 .ok_or("Jellyfin client not configured")?
1229 .clone()
1230 };
1231 client.send_session_command(session_id, "NextTrack").await?;
1232 } else {
1233 let controller = player.0.lock().await;
1235 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1237 warn!("[player_next] Failed to refresh local sources: {}", e);
1238 }
1239 controller.next().map_err(|e| e.to_string())?;
1240 controller.emit_queue_changed();
1241
1242 let current_item = {
1244 let queue = controller.queue();
1245 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1246 queue_lock.current().cloned()
1247 };
1248
1249 if let Some(item) = current_item {
1250 if item.media_type == MediaType::Audio {
1251 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1252 session_mgr.update_audio_track(item);
1253
1254 if let Some(emitter) = controller.event_emitter() {
1256 emitter.emit(PlayerStatusEvent::SessionChanged {
1257 session: session_mgr.current().clone(),
1258 });
1259 }
1260 }
1261 }
1262 }
1263
1264 let controller = player.0.lock().await;
1265 Ok(get_player_status(&controller))
1266}
1267
1268#[tauri::command]
1269#[specta::specta]
1270pub async fn player_previous(
1271 player: State<'_, PlayerStateWrapper>,
1272 session: State<'_, MediaSessionManagerWrapper>,
1273 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1274 db: State<'_, DatabaseWrapper>,
1275) -> Result<PlayerStatus, String> {
1276 let mode = playback_mode.0.get_mode();
1278
1279 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1280 let client = {
1282 let controller = player.0.lock().await;
1283 let client_arc = controller.jellyfin_client();
1284 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1285 client_opt
1286 .as_ref()
1287 .ok_or("Jellyfin client not configured")?
1288 .clone()
1289 };
1290 client
1291 .send_session_command(session_id, "PreviousTrack")
1292 .await?;
1293 } else {
1294 let controller = player.0.lock().await;
1296 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1298 warn!("[player_previous] Failed to refresh local sources: {}", e);
1299 }
1300 controller.previous().map_err(|e| e.to_string())?;
1301 controller.emit_queue_changed();
1302
1303 let current_item = {
1305 let queue = controller.queue();
1306 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1307 queue_lock.current().cloned()
1308 };
1309
1310 if let Some(item) = current_item {
1311 if item.media_type == MediaType::Audio {
1312 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1313 session_mgr.update_audio_track(item);
1314
1315 if let Some(emitter) = controller.event_emitter() {
1317 emitter.emit(PlayerStatusEvent::SessionChanged {
1318 session: session_mgr.current().clone(),
1319 });
1320 }
1321 }
1322 }
1323 }
1324
1325 let controller = player.0.lock().await;
1326 Ok(get_player_status(&controller))
1327}
1328
1329#[tauri::command]
1330#[specta::specta]
1331pub async fn player_seek(
1332 player: State<'_, PlayerStateWrapper>,
1333 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1334 position: f64,
1335) -> Result<PlayerStatus, String> {
1336 let mode = playback_mode.0.get_mode();
1338
1339 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1340 let client = {
1342 let controller = player.0.lock().await;
1343 let client_arc = controller.jellyfin_client();
1344 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1345 client_opt
1346 .as_ref()
1347 .ok_or("Jellyfin client not configured")?
1348 .clone()
1349 };
1350 let position_ticks = (position * 10_000_000.0) as i64;
1351 client.session_seek(session_id, position_ticks).await?;
1352 } else {
1353 let controller = player.0.lock().await;
1358 controller.seek_absolute(position).await?;
1359 }
1360
1361 let controller = player.0.lock().await;
1362 Ok(get_player_status(&controller))
1363}
1364
1365#[tauri::command]
1376#[specta::specta]
1377pub async fn player_seek_video(
1378 player: State<'_, PlayerStateWrapper>,
1379 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1380 repository_handle: String,
1381 position: f64,
1382 media_source_id: Option<String>,
1383 audio_stream_index: Option<i32>,
1384) -> Result<VideoSeekResponse, String> {
1385 info!("[player_seek_video] Seeking to {} seconds", position);
1386
1387 let repository = repository_manager
1389 .0
1390 .get(&repository_handle)
1391 .ok_or("Repository not found - user may need to log in")?;
1392
1393 let (needs_transcoding, jellyfin_item_id, is_local) = {
1396 let controller = player.0.lock().await;
1397 let queue_arc = controller.queue();
1398 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1399
1400 let current_item = queue.current().ok_or("No item currently playing")?;
1401
1402 if current_item.media_type != MediaType::Video {
1403 return Err("Current item is not a video".to_string());
1404 }
1405
1406 let jellyfin_id = current_item
1407 .jellyfin_id()
1408 .ok_or("Current video has no Jellyfin ID")?
1409 .to_string();
1410
1411 let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
1416
1417 (current_item.needs_transcoding, jellyfin_id, is_local_file)
1418 }; let seeks_transcoded_in_place = {
1424 let controller = player.0.lock().await;
1425 controller.capabilities().seeks_transcoded_in_place
1426 };
1427 let strategy =
1428 determine_video_seek_strategy(is_local, seeks_transcoded_in_place, needs_transcoding);
1429
1430 info!(
1431 "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
1432 needs_transcoding={}, strategy={:?}",
1433 is_local, seeks_transcoded_in_place, needs_transcoding, strategy
1434 );
1435
1436 match strategy {
1437 VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
1438 info!("[player_seek_video] Using backend native seek");
1440 let controller = player.0.lock().await;
1441 controller.seek(position).map_err(|e| e.to_string())?;
1442 Ok(VideoSeekResponse::Native { position })
1443 }
1444 VideoSeekStrategy::BackendReloadStream => {
1445 info!("[player_seek_video] Backend reload stream - requesting new stream URL");
1447
1448 let selection = repository
1449 .get_stream_selection(
1450 &jellyfin_item_id,
1451 media_source_id.as_deref(),
1452 audio_stream_index,
1453 )
1454 .await
1455 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1456 let new_url = selection.url.clone();
1457
1458 info!("[player_seek_video] Got new selection, handling reload internally");
1459
1460 {
1462 let controller = player.0.lock().await;
1463 controller.stop().map_err(|e| e.to_string())?;
1464 }
1465
1466 {
1468 let controller = player.0.lock().await;
1469 let queue_arc = controller.queue();
1470 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1471
1472 if !queue.update_current_stream_url(new_url.clone()) {
1473 return Err("Failed to update stream URL in queue".to_string());
1474 }
1475 }
1476
1477 {
1479 let controller = player.0.lock().await;
1480 let queue_arc = controller.queue();
1481 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1482
1483 if let Some(updated_item) = queue.current() {
1484 controller
1485 .load_and_play(updated_item)
1486 .map_err(|e| e.to_string())?;
1487 } else {
1488 return Err("No current item after URL update".to_string());
1489 }
1490
1491 controller.seek(position).map_err(|e| e.to_string())?;
1495 }
1496
1497 info!(
1498 "[player_seek_video] Stream reloaded successfully at position {}",
1499 position
1500 );
1501
1502 Ok(VideoSeekResponse::Native { position })
1503 }
1504 }
1505}
1506
1507#[tauri::command]
1534#[specta::specta]
1535#[allow(clippy::too_many_arguments)]
1539pub async fn player_switch_audio_track(
1540 player: State<'_, PlayerStateWrapper>,
1541 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1542 repository_handle: String,
1543 stream_index: i32,
1544 array_index: i32,
1545 current_position: Option<f64>,
1546 media_source_id: Option<String>,
1547) -> Result<AudioTrackSwitchResponse, String> {
1548 info!(
1549 "[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}",
1550 stream_index, array_index
1551 );
1552
1553 let (jellyfin_item_id, needs_transcoding, engine_position) = {
1558 let controller = player.0.lock().await;
1559 let engine_position = controller.absolute_position();
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 (
1566 current_item
1567 .jellyfin_id()
1568 .ok_or("Current item has no Jellyfin ID")?
1569 .to_string(),
1570 current_item.needs_transcoding,
1571 engine_position,
1572 )
1573 };
1574
1575 let strategy = determine_audio_track_switch_strategy(needs_transcoding);
1576
1577 info!(
1578 "[player_switch_audio_track] needs_transcoding={}, strategy={:?}",
1579 needs_transcoding, strategy
1580 );
1581
1582 if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
1583 let controller = player.0.lock().await;
1585 controller
1586 .set_audio_track(array_index)
1587 .map_err(|e| e.to_string())?;
1588
1589 return Ok(AudioTrackSwitchResponse::Native { success: true });
1590 }
1591
1592 let repository = repository_manager
1594 .0
1595 .get(&repository_handle)
1596 .ok_or("Repository not found - user may need to log in")?;
1597
1598 let selection = repository
1607 .get_stream_selection(
1608 &jellyfin_item_id,
1609 media_source_id.as_deref(),
1610 Some(stream_index),
1611 )
1612 .await
1613 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1614
1615 let position = crate::player::track_switch::resume_position(current_position, engine_position);
1619
1620 match strategy {
1621 AudioTrackSwitchStrategy::BackendReloadStream => {
1622 let new_url = selection.url.clone();
1627
1628 {
1629 let controller = player.0.lock().await;
1630 controller.stop().map_err(|e| e.to_string())?;
1631 }
1632
1633 {
1634 let controller = player.0.lock().await;
1635 let queue_arc = controller.queue();
1636 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1637
1638 if !queue.update_current_stream_url(new_url) {
1639 return Err("Failed to update stream URL in queue".to_string());
1640 }
1641 }
1642
1643 {
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 updated_item = queue
1649 .current()
1650 .ok_or("No current item after URL update")?
1651 .clone();
1652 drop(queue);
1653
1654 controller
1655 .load_and_play(&updated_item)
1656 .map_err(|e| e.to_string())?;
1657 controller.seek(position).map_err(|e| e.to_string())?;
1658 }
1659
1660 info!(
1661 "[player_switch_audio_track] Re-opened the stream on audio stream {} and resumed at {}",
1662 stream_index, position
1663 );
1664
1665 Ok(AudioTrackSwitchResponse::Native { success: true })
1666 }
1667 AudioTrackSwitchStrategy::BackendSelectInPlace => {
1669 Ok(AudioTrackSwitchResponse::Native { success: true })
1670 }
1671 }
1672}
1673
1674#[tauri::command]
1693#[specta::specta]
1694#[allow(clippy::too_many_arguments)]
1698pub async fn player_set_stream_quality(
1699 player: State<'_, PlayerStateWrapper>,
1700 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1701 repository_handle: String,
1702 quality: crate::settings::StreamingQuality,
1703 current_position: Option<f64>,
1704 media_source_id: Option<String>,
1705 audio_stream_index: Option<i32>,
1706) -> Result<StreamQualityResponse, String> {
1707 info!(
1708 "[player_set_stream_quality] Switching to {} (position: {:?})",
1709 quality.label(),
1710 current_position
1711 );
1712
1713 let repository = repository_manager
1714 .0
1715 .get(&repository_handle)
1716 .ok_or("Repository not found - user may need to log in")?;
1717
1718 let jellyfin_item_id = {
1719 let controller = player.0.lock().await;
1720 let queue_arc = controller.queue();
1721 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1722
1723 let current_item = queue.current().ok_or("No item currently playing")?;
1724
1725 if current_item.media_type != MediaType::Video {
1726 return Err("Current item is not a video".to_string());
1727 }
1728
1729 current_item
1730 .jellyfin_id()
1731 .ok_or("Current item has no Jellyfin ID")?
1732 .to_string()
1733 };
1734
1735 crate::repository::online::set_playback_quality_override(quality);
1743
1744 let position = match current_position {
1758 Some(p) => p,
1759 None => {
1760 let controller = player.0.lock().await;
1761 controller.absolute_position()
1762 }
1763 };
1764 let selection = repository
1765 .get_stream_selection(
1766 &jellyfin_item_id,
1767 media_source_id.as_deref(),
1768 audio_stream_index,
1769 )
1770 .await
1771 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1772 let new_url = selection.url.clone();
1773
1774 {
1780 let controller = player.0.lock().await;
1781 controller.stop().map_err(|e| e.to_string())?;
1782
1783 let queue_arc = controller.queue();
1784 {
1785 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1786 if !queue.update_current_stream_url(new_url.clone()) {
1787 return Err("Failed to update stream URL in queue".to_string());
1788 }
1789 }
1790
1791 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1792 let updated_item = queue.current().ok_or("No current item after URL update")?;
1793 controller
1794 .load_and_play(updated_item)
1795 .map_err(|e| e.to_string())?;
1796 if position > 0.0 {
1797 controller.seek(position).map_err(|e| e.to_string())?;
1798 }
1799 }
1800
1801 Ok(StreamQualityResponse::Native {
1802 selection,
1803 position,
1804 })
1805}
1806
1807#[tauri::command]
1811#[specta::specta]
1812pub async fn player_set_audio_track(
1813 player: State<'_, PlayerStateWrapper>,
1814 stream_index: i32,
1815) -> Result<PlayerStatus, String> {
1816 let controller = player.0.lock().await;
1817 controller
1818 .set_audio_track(stream_index)
1819 .map_err(|e| e.to_string())?;
1820 Ok(get_player_status(&controller))
1821}
1822
1823#[tauri::command]
1832#[specta::specta]
1833pub async fn player_set_subtitle_track(
1834 player: State<'_, PlayerStateWrapper>,
1835 stream_index: Option<i32>,
1836) -> Result<PlayerStatus, String> {
1837 let controller = player.0.lock().await;
1838 controller
1839 .set_subtitle_track(stream_index)
1840 .map_err(|e| e.to_string())?;
1841 Ok(get_player_status(&controller))
1842}
1843
1844fn normalize_volume(volume: f32) -> f32 {
1854 if volume.is_nan() {
1855 0.0
1856 } else {
1857 volume.clamp(0.0, 1.0)
1858 }
1859}
1860
1861#[tauri::command]
1862#[specta::specta]
1863pub async fn player_set_volume(
1864 player: State<'_, PlayerStateWrapper>,
1865 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1866 volume: f32,
1867) -> Result<PlayerStatus, String> {
1868 let volume = normalize_volume(volume);
1873
1874 let mode = playback_mode.0.get_mode();
1876
1877 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1878 let client = {
1880 let controller = player.0.lock().await;
1881 let client_arc = controller.jellyfin_client();
1882 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1883 client_opt
1884 .as_ref()
1885 .ok_or("Jellyfin client not configured")?
1886 .clone()
1887 };
1888 let volume_percent = (volume * 100.0) as i32;
1890 client
1891 .session_set_volume(session_id, volume_percent)
1892 .await?;
1893 } else {
1894 let controller = player.0.lock().await;
1896 controller.set_volume(volume).map_err(|e| e.to_string())?;
1897 }
1898
1899 let controller = player.0.lock().await;
1900 Ok(get_player_status(&controller))
1901}
1902
1903#[tauri::command]
1904#[specta::specta]
1905pub async fn player_toggle_mute(
1906 player: State<'_, PlayerStateWrapper>,
1907 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1908) -> Result<PlayerStatus, String> {
1909 let mode = playback_mode.0.get_mode();
1911
1912 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1913 let client = {
1915 let controller = player.0.lock().await;
1916 let client_arc = controller.jellyfin_client();
1917 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1918 client_opt
1919 .as_ref()
1920 .ok_or("Jellyfin client not configured")?
1921 .clone()
1922 };
1923 client
1924 .send_session_command(session_id, "ToggleMute")
1925 .await?;
1926 } else {
1927 }
1932
1933 let controller = player.0.lock().await;
1934 Ok(get_player_status(&controller))
1935}
1936
1937#[tauri::command]
1938#[specta::specta]
1939pub async fn player_toggle_shuffle(
1940 player: State<'_, PlayerStateWrapper>,
1941) -> Result<QueueStatus, String> {
1942 let controller = player.0.lock().await;
1943 controller.toggle_shuffle();
1944 controller.emit_queue_changed();
1945 Ok(get_queue_status(&controller))
1946}
1947
1948#[tauri::command]
1949#[specta::specta]
1950pub async fn player_cycle_repeat(
1951 player: State<'_, PlayerStateWrapper>,
1952) -> Result<QueueStatus, String> {
1953 let controller = player.0.lock().await;
1954 controller.cycle_repeat();
1955 controller.emit_queue_changed();
1956 Ok(get_queue_status(&controller))
1957}
1958
1959#[tauri::command]
1960#[specta::specta]
1961pub async fn player_get_status(
1962 player: State<'_, PlayerStateWrapper>,
1963 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1964) -> Result<PlayerStatus, String> {
1965 let mode = playback_mode.0.get_mode();
1966
1967 let controller = player.0.lock().await;
1969 let mut status = get_player_status(&controller);
1970
1971 let local_media = {
1973 let queue_arc = controller.queue();
1974 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1975 queue.current().map(MergedMediaItem::from)
1976 };
1977
1978 let local_is_playing = status.state.is_playing();
1979 let local_volume = status.volume;
1980
1981 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1983 let client = {
1984 let client_arc = controller.jellyfin_client();
1985 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1986 client_opt
1987 .as_ref()
1988 .ok_or("Jellyfin client not configured")?
1989 .clone()
1990 };
1991
1992 drop(controller); match client.get_session(&session_id).await {
1995 Ok(Some(session)) => {
1996 log::info!("[PlayerCommands] Merging remote session state");
1997
1998 status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
2000
2001 status.merged_is_playing = session
2003 .play_state
2004 .as_ref()
2005 .map(|ps| !ps.is_paused.unwrap_or(true))
2006 .unwrap_or(false);
2007
2008 status.position = session
2010 .play_state
2011 .as_ref()
2012 .and_then(|ps| ps.position_ticks)
2013 .map(|ticks| ticks as f64 / 10_000_000.0)
2014 .unwrap_or(0.0);
2015
2016 status.duration = session
2018 .now_playing_item
2019 .as_ref()
2020 .and_then(|item| item.run_time_ticks)
2021 .map(|ticks| ticks as f64 / 10_000_000.0);
2022
2023 status.merged_volume = session
2025 .play_state
2026 .as_ref()
2027 .and_then(|ps| ps.volume_level)
2028 .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
2029 .unwrap_or(1.0);
2030
2031 return Ok(status);
2032 }
2033 Ok(None) => {
2034 log::warn!("[PlayerCommands] Remote session not found, using local state");
2035 }
2036 Err(e) => {
2037 log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
2038 }
2039 }
2040 }
2041
2042 status.merged_media = local_media;
2044 status.merged_is_playing = local_is_playing;
2045 status.merged_volume = local_volume;
2046
2047 Ok(status)
2048}
2049
2050#[tauri::command]
2051#[specta::specta]
2052pub async fn player_get_queue(
2053 player: State<'_, PlayerStateWrapper>,
2054) -> Result<QueueStatus, String> {
2055 let controller = player.0.lock().await;
2056 Ok(get_queue_status(&controller))
2057}
2058
2059#[derive(specta::Type, Debug, Serialize)]
2068#[serde(rename_all = "camelCase")]
2069pub struct PlaybackCapabilities {
2070 pub uses_webview_audio: bool,
2078}
2079
2080#[tauri::command]
2084#[specta::specta]
2085pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
2086 let native_audio = cfg!(any(
2089 target_os = "android",
2090 target_os = "linux",
2091 target_os = "windows"
2092 ));
2093
2094 Ok(PlaybackCapabilities {
2095 uses_webview_audio: !native_audio,
2096 })
2097}
2098
2099pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2100 PlayerStatus {
2101 state: controller.state(),
2102 position: controller.absolute_position(),
2106 duration: controller.duration(),
2107 volume: controller.volume(),
2108 muted: controller.muted(),
2109 shuffle: controller.is_shuffle(),
2110 repeat: controller.repeat_mode(),
2111
2112 merged_media: None,
2114 merged_is_playing: false,
2115 merged_volume: controller.volume(),
2116 }
2117}
2118
2119pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2120 let queue = controller.queue();
2121 let queue_lock = queue.lock_safe();
2122
2123 QueueStatus {
2124 items: queue_lock.items().to_vec(),
2125 current_index: queue_lock.current_index(),
2126 shuffle: queue_lock.is_shuffle(),
2127 repeat: queue_lock.repeat_mode(),
2128 has_next: queue_lock.has_next(),
2129 has_previous: queue_lock.has_previous(),
2130 }
2131}
2132
2133async fn play_selection_on_remote(
2145 controller: &PlayerController,
2146 session_id: &str,
2147 media_items: &[MediaItem],
2148 start_index: usize,
2149) -> Result<(), String> {
2150 let mut jellyfin_ids: Vec<String> = Vec::new();
2153 let mut adjusted_index: Option<usize> = None;
2154 for (i, item) in media_items.iter().enumerate() {
2155 if let Some(id) = item.jellyfin_id() {
2156 if i == start_index {
2157 adjusted_index = Some(jellyfin_ids.len());
2158 }
2159 jellyfin_ids.push(id.to_string());
2160 }
2161 }
2162
2163 let start_index =
2164 adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2165
2166 if jellyfin_ids.is_empty() {
2167 return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2168 }
2169
2170 let client = {
2171 let client_arc = controller.jellyfin_client();
2172 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2173 client_opt
2174 .as_ref()
2175 .ok_or("Jellyfin client not configured")?
2176 .clone()
2177 };
2178
2179 client
2181 .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2182 .await
2183 .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2184}
2185
2186#[tauri::command]
2188#[specta::specta]
2189pub async fn player_play_album_track(
2190 player: State<'_, PlayerStateWrapper>,
2191 session: State<'_, MediaSessionManagerWrapper>,
2192 db: State<'_, DatabaseWrapper>,
2193 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2194 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2195 repository_handle: String,
2196 request: PlayAlbumTrackRequest,
2197) -> Result<PlayerStatus, String> {
2198 info!(
2199 "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2200 request.album_id, request.track_id, request.shuffle
2201 );
2202
2203 let repository = repository_manager
2205 .0
2206 .get(&repository_handle)
2207 .ok_or("Repository not found - user may need to log in")?;
2208
2209 info!(
2211 "Fetching tracks for album {} via repository",
2212 request.album_id
2213 );
2214 let album_items = repository
2215 .get_items(
2216 &request.album_id,
2217 Some(GetItemsOptions {
2218 limit: Some(1000),
2219 fields: Some(vec![
2220 "PrimaryImageAspectRatio".to_string(),
2221 "Overview".to_string(),
2222 "MediaStreams".to_string(),
2223 ]),
2224 ..Default::default()
2225 }),
2226 )
2227 .await
2228 .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2229
2230 info!("Found {} items in album", album_items.items.len());
2231
2232 let mut tracks: Vec<_> = album_items
2234 .items
2235 .into_iter()
2236 .filter(|item| item.item_type == "Audio")
2237 .collect();
2238 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2239
2240 info!("Found {} audio tracks in album", tracks.len());
2241
2242 if tracks.is_empty() {
2243 return Err("No audio tracks found in album".to_string());
2244 }
2245
2246 info!("Album has {} tracks after sorting:", tracks.len());
2248 for (idx, track) in tracks.iter().enumerate() {
2249 info!(" [{}] {} (ID: {})", idx, track.name, track.id);
2250 }
2251
2252 info!("Looking for track_id: {}", request.track_id);
2255 let album_index = tracks
2256 .iter()
2257 .position(|t| t.id == request.track_id)
2258 .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2259
2260 info!(
2261 "Track {} is at index {} in album",
2262 request.track_id, album_index
2263 );
2264
2265 let mut media_items = Vec::new();
2267 for track in tracks {
2268 let jellyfin_id = &track.id;
2270 let local_path = check_for_local_download(&db, jellyfin_id).await?;
2271
2272 let source = if let Some(path) = local_path {
2273 MediaSource::Local {
2274 file_path: PathBuf::from(path),
2275 jellyfin_item_id: Some(jellyfin_id.clone()),
2276 }
2277 } else {
2278 match repository.get_audio_stream_url(&track.id).await {
2282 Ok(stream_url) => MediaSource::Remote {
2283 stream_url,
2284 jellyfin_item_id: jellyfin_id.clone(),
2285 },
2286 Err(e) => {
2287 warn!(
2288 "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2289 track.name, track.id, e
2290 );
2291 continue;
2292 }
2293 }
2294 };
2295
2296 let primary_image_tag_for_url = track.primary_image_tag.clone();
2297 let media_item = MediaItem {
2298 transport: None,
2300 id: track.id.clone(),
2301 title: track.name.clone(),
2302 name: Some(track.name.clone()), artist: track
2304 .album_artist
2305 .clone()
2306 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2307 album: Some(request.album_name.clone()),
2308 album_name: Some(request.album_name.clone()), album_id: Some(request.album_id.clone()),
2310 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(),
2314 item_type: Some(track.item_type.clone()), playlist_id: None,
2316 duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2317 artwork_url: primary_image_tag_for_url.map(|tag| {
2318 repository.get_image_url(
2319 &request.album_id,
2320 ImageType::Primary,
2321 Some(ImageOptions {
2322 max_width: Some(300),
2323 tag: Some(tag),
2324 ..Default::default()
2325 }),
2326 )
2327 }),
2328 media_type: MediaType::Audio,
2329 source,
2330 video_codec: None,
2331 needs_transcoding: false,
2332 video_width: None,
2333 video_height: None,
2334 subtitles: vec![],
2335 series_id: None,
2336 server_id: None,
2337 };
2338
2339 media_items.push(media_item);
2340 }
2341
2342 if media_items.is_empty() {
2343 return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2344 }
2345
2346 let start_index = media_items
2350 .iter()
2351 .position(|item| item.id == request.track_id)
2352 .unwrap_or(0);
2353
2354 info!(
2355 "Built queue with {} media items, starting at index {}",
2356 media_items.len(),
2357 start_index
2358 );
2359
2360 if request.shuffle {
2362 let controller = player.0.lock().await;
2363 controller.toggle_shuffle();
2364 }
2365
2366 if let Some(first_item) = media_items.get(start_index) {
2368 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2369 session_mgr.start_audio_session(first_item.clone());
2370 }
2371
2372 let controller = player.0.lock().await;
2373
2374 let remote_session = match playback_mode.0.get_mode() {
2378 crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2379 _ => None,
2380 };
2381
2382 if let Some(session_id) = &remote_session {
2383 play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2384 controller
2385 .set_queue(media_items, start_index)
2386 .map_err(|e| e.to_string())?;
2387 } else {
2388 playback_mode
2391 .0
2392 .set_mode(crate::playback_mode::PlaybackMode::Local);
2393 controller
2394 .play_queue(media_items, start_index)
2395 .map_err(|e| e.to_string())?;
2396 }
2397
2398 {
2400 let queue_arc = controller.queue();
2401 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2402 queue.set_context(QueueContext::Album {
2403 album_id: request.album_id.clone(),
2404 album_name: request.album_name.clone(),
2405 });
2406 }
2407
2408 controller.emit_queue_changed();
2410
2411 {
2413 let queue_arc = controller.queue();
2414 let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2415 info!(
2416 "player_play_album_track: Queue now has {} items, current_index: {:?}",
2417 queue_lock.items().len(),
2418 queue_lock.current_index()
2419 );
2420 }
2421
2422 if let Some(emitter) = controller.event_emitter() {
2424 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2425 emitter.emit(PlayerStatusEvent::SessionChanged {
2426 session: session_mgr.current().clone(),
2427 });
2428 }
2429
2430 Ok(get_player_status(&controller))
2431}
2432
2433#[tauri::command]
2435#[specta::specta]
2436pub async fn player_play_tracks(
2437 player: State<'_, PlayerStateWrapper>,
2438 session: State<'_, MediaSessionManagerWrapper>,
2439 db: State<'_, DatabaseWrapper>,
2440 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2441 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2442 repository_handle: String,
2443 request: PlayTracksRequest,
2444) -> Result<PlayerStatus, String> {
2445 crate::repository::online::clear_playback_quality_override();
2452
2453 info!(
2454 "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2455 request.track_ids.len(),
2456 request.start_index,
2457 request.shuffle
2458 );
2459
2460 if request.track_ids.is_empty() {
2462 return Err("No tracks provided".to_string());
2463 }
2464
2465 let repository = repository_manager
2467 .0
2468 .get(&repository_handle)
2469 .ok_or("Repository not found - user may need to log in")?;
2470
2471 let mut media_items = Vec::new();
2473 for track_id in &request.track_ids {
2474 let track = repository
2476 .get_item(track_id)
2477 .await
2478 .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2479
2480 let local_path = check_for_local_download(&db, track_id).await?;
2482
2483 let source = if let Some(path) = local_path {
2485 MediaSource::Local {
2486 file_path: PathBuf::from(path),
2487 jellyfin_item_id: Some(track.id.clone()),
2488 }
2489 } else {
2490 let stream_url = repository
2491 .get_audio_stream_url(track_id)
2492 .await
2493 .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2494
2495 MediaSource::Remote {
2496 stream_url,
2497 jellyfin_item_id: track.id.clone(),
2498 }
2499 };
2500
2501 let primary_image_tag_for_url = track.primary_image_tag.clone();
2503 let media_item = MediaItem {
2504 transport: None,
2506 id: track.id.clone(),
2507 title: track.name.clone(),
2508 name: Some(track.name.clone()), artist: track
2510 .album_artist
2511 .clone()
2512 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2513 album: track.album_name.clone(),
2514 album_name: track.album_name.clone(), album_id: track.album_id.clone(),
2516 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(),
2520 item_type: Some(track.item_type.clone()), playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2523 artwork_url: primary_image_tag_for_url.and_then(|tag| {
2524 track.album_id.as_ref().map(|album_id| {
2525 repository.get_image_url(
2526 album_id,
2527 ImageType::Primary,
2528 Some(ImageOptions {
2529 max_width: Some(300),
2530 tag: Some(tag),
2531 ..Default::default()
2532 }),
2533 )
2534 })
2535 }),
2536 media_type: MediaType::Audio,
2537 source,
2538 video_codec: None,
2539 needs_transcoding: false,
2540 video_width: None,
2541 video_height: None,
2542 subtitles: vec![],
2543 series_id: None,
2544 server_id: None,
2545 };
2546
2547 media_items.push(media_item);
2548 }
2549
2550 info!("Built queue with {} media items", media_items.len());
2551
2552 let queue_context = match request.context {
2554 PlayTracksContext::Playlist {
2555 playlist_id,
2556 playlist_name,
2557 } => {
2558 for item in &mut media_items {
2559 item.playlist_id = Some(playlist_id.clone());
2560 }
2561 QueueContext::Playlist {
2562 playlist_id,
2563 playlist_name,
2564 }
2565 }
2566 PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2567 };
2568
2569 if request.shuffle {
2571 let controller = player.0.lock().await;
2572 controller.toggle_shuffle();
2573 }
2574
2575 if let Some(first_item) = media_items.get(request.start_index) {
2577 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2578 session_mgr.start_audio_session(first_item.clone());
2579 }
2580
2581 let controller = player.0.lock().await;
2582
2583 let remote_session = match playback_mode.0.get_mode() {
2589 crate::playback_mode::PlaybackMode::Remote { session_id }
2590 if !playback_mode.0.is_transferring() =>
2591 {
2592 Some(session_id)
2593 }
2594 _ => None,
2595 };
2596
2597 if let Some(session_id) = &remote_session {
2598 play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2599 .await?;
2600 controller
2601 .set_queue(media_items, request.start_index)
2602 .map_err(|e| e.to_string())?;
2603 } else {
2604 if !playback_mode.0.is_transferring() {
2612 playback_mode
2613 .0
2614 .set_mode(crate::playback_mode::PlaybackMode::Local);
2615 }
2616 controller
2617 .play_queue_from(media_items, request.start_index, request.start_position)
2618 .map_err(|e| e.to_string())?;
2619 }
2620
2621 {
2623 let queue_arc = controller.queue();
2624 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2625 queue.set_context(queue_context);
2626 }
2627
2628 controller.emit_queue_changed();
2630 if let Some(emitter) = controller.event_emitter() {
2631 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2632 emitter.emit(PlayerStatusEvent::SessionChanged {
2633 session: session_mgr.current().clone(),
2634 });
2635 }
2636
2637 info!("player_play_tracks completed successfully");
2638 Ok(get_player_status(&controller))
2639}
2640
2641#[derive(specta::Type, Debug, Serialize)]
2643#[serde(rename_all = "camelCase")]
2644pub struct PreloadResult {
2645 pub queued_count: usize,
2647 pub already_downloaded: usize,
2649 pub skipped: usize,
2651}
2652
2653#[tauri::command]
2656#[specta::specta]
2657pub async fn player_preload_upcoming(
2658 player: State<'_, PlayerStateWrapper>,
2659 db: State<'_, DatabaseWrapper>,
2660 smart_cache: State<'_, SmartCacheWrapper>,
2661 download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2662 app: tauri::AppHandle,
2663 user_id: String,
2664 _download_base_path: String,
2665) -> Result<PreloadResult, String> {
2666 let (db_service, target_dir) = {
2670 let database = db.0.lock().map_err(|e| e.to_string())?;
2671 let target_dir = database
2672 .path()
2673 .parent()
2674 .ok_or_else(|| "Database path has no parent directory".to_string())?
2675 .to_string_lossy()
2676 .to_string();
2677 (Arc::new(database.service()), target_dir)
2678 };
2679
2680 let precache_count = {
2682 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2683 if !cache.should_precache_queue() {
2684 return Ok(PreloadResult {
2685 queued_count: 0,
2686 already_downloaded: 0,
2687 skipped: 0,
2688 });
2689 }
2690 cache.queue_precache_count()
2691 };
2692
2693 let upcoming_items: Vec<MediaItem> = {
2695 let controller = player.0.lock().await;
2696 let queue = controller.queue();
2697 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2698 queue_lock
2699 .get_upcoming(precache_count)
2700 .into_iter()
2701 .cloned()
2702 .collect()
2703 };
2704
2705 if upcoming_items.is_empty() {
2706 return Ok(PreloadResult {
2707 queued_count: 0,
2708 already_downloaded: 0,
2709 skipped: 0,
2710 });
2711 }
2712
2713 let mut queued_count = 0;
2714 let mut already_downloaded = 0;
2715 let mut skipped = 0;
2716
2717 for item in upcoming_items {
2719 let (jellyfin_id, stream_url) = match &item.source {
2723 MediaSource::Remote {
2724 jellyfin_item_id,
2725 stream_url,
2726 } => (jellyfin_item_id.clone(), stream_url.clone()),
2727 MediaSource::Local { .. } => {
2728 already_downloaded += 1;
2729 continue;
2730 }
2731 MediaSource::DirectUrl { .. } => {
2732 skipped += 1;
2733 continue;
2734 }
2735 };
2736
2737 let query = Query::with_params(
2741 "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2742 AND (status IN ('completed', 'downloading')
2743 OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2744 vec![
2745 QueryParam::String(jellyfin_id.clone()),
2746 QueryParam::String(user_id.clone()),
2747 ],
2748 );
2749
2750 let is_downloaded: Option<String> = db_service
2751 .query_optional(query, |row| row.get(0))
2752 .await
2753 .map_err(|e| e.to_string())?;
2754
2755 if is_downloaded.is_some() {
2756 already_downloaded += 1;
2757 continue;
2758 }
2759
2760 let album_dir = item
2763 .album
2764 .as_deref()
2765 .filter(|a| !a.is_empty())
2766 .unwrap_or("Unknown Album");
2767 let file_path = format!(
2768 "downloads/{}/{}.mp3",
2769 sanitize_filename(album_dir),
2770 sanitize_filename(&item.title)
2771 );
2772
2773 let insert_query = Query::with_params(
2777 "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)
2778 VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2779 ON CONFLICT(item_id, user_id) DO UPDATE SET
2780 stream_url = excluded.stream_url,
2781 target_dir = excluded.target_dir
2782 WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2783 vec![
2784 QueryParam::String(jellyfin_id),
2785 QueryParam::String(user_id.clone()),
2786 QueryParam::String(file_path),
2787 QueryParam::String(item.title.clone()),
2788 item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2789 item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2790 QueryParam::String(stream_url),
2791 QueryParam::String(target_dir.clone()),
2792 ],
2793 );
2794
2795 match db_service.execute(insert_query).await {
2796 Ok(rows) if rows > 0 => {
2797 info!(
2798 "[Preload] Queued download for: {} - {}",
2799 item.artist.as_deref().unwrap_or("Unknown"),
2800 item.title
2801 );
2802 queued_count += 1;
2803 }
2804 Ok(_) => {
2805 already_downloaded += 1;
2807 }
2808 Err(e) => {
2809 error!("[Preload] Failed to queue {}: {}", item.title, e);
2810 skipped += 1;
2811 }
2812 }
2813 }
2814
2815 if queued_count > 0 {
2818 let active_downloads = {
2819 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2820 manager.get_active_downloads()
2821 };
2822 crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2823 }
2824
2825 info!(
2826 "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2827 queued_count, already_downloaded, skipped
2828 );
2829
2830 Ok(PreloadResult {
2831 queued_count,
2832 already_downloaded,
2833 skipped,
2834 })
2835}
2836
2837fn sanitize_filename(name: &str) -> String {
2839 name.chars()
2840 .map(|c| match c {
2841 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2842 _ => c,
2843 })
2844 .collect()
2845}
2846
2847#[tauri::command]
2849#[specta::specta]
2850pub async fn player_set_cache_config(
2851 smart_cache: State<'_, SmartCacheWrapper>,
2852 config: CacheConfig,
2853) -> Result<(), String> {
2854 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2855 cache.update_config(config);
2856 Ok(())
2857}
2858
2859#[tauri::command]
2861#[specta::specta]
2862pub async fn player_get_cache_config(
2863 smart_cache: State<'_, SmartCacheWrapper>,
2864) -> Result<CacheConfig, String> {
2865 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2866 Ok(cache.get_config().unwrap_or_default())
2867}
2868
2869#[tauri::command]
2871#[specta::specta]
2872pub async fn player_configure_jellyfin(
2873 player: State<'_, PlayerStateWrapper>,
2874 db: State<'_, DatabaseWrapper>,
2875 server_url: String,
2876 access_token: String,
2877 user_id: String,
2878 device_id: String,
2879) -> Result<(), String> {
2880 log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
2881
2882 let config = JellyfinConfig {
2883 server_url,
2884 access_token,
2885 device_id,
2886 };
2887
2888 let client = JellyfinClient::new(config.clone())?;
2890
2891 let db_service = {
2898 let database = db.0.lock().map_err(|e| e.to_string())?;
2899 Arc::new(database.service())
2900 };
2901 let reporter_client = JellyfinClient::new(config)?;
2902 let reporter = crate::playback_reporting::PlaybackReporter::new(
2903 db_service,
2904 Arc::new(TokioMutex::new(Some(reporter_client))),
2905 user_id,
2906 );
2907
2908 let controller = player.0.lock().await;
2909 controller.set_jellyfin_client(Some(client));
2910 controller.set_playback_reporter(Some(reporter)).await;
2911
2912 log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
2913 Ok(())
2914}
2915
2916#[tauri::command]
2918#[specta::specta]
2919pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
2920 log::info!("[PlayerCommand] Disabling Jellyfin client");
2921
2922 let controller = player.0.lock().await;
2923 controller.set_jellyfin_client(None);
2924 controller.set_playback_reporter(None).await;
2925
2926 log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
2927 Ok(())
2928}
2929
2930#[cfg(test)]
2931mod tests {
2932 use crate::utils::lock::MutexSafe;
2933
2934 #[test]
2943 fn test_video_always_goes_to_the_backend() {
2944 let src = include_str!("mod.rs");
2945 let play_item = src
2946 .split("pub async fn player_play_item(")
2947 .nth(1)
2948 .and_then(|rest| rest.split("\n}\n").next())
2949 .expect("player_play_item exists");
2950 assert!(play_item.contains(".play_item(media_item)"));
2951 assert!(
2952 !play_item.contains(".set_current_item("),
2953 "player_play_item must not keep video from the backend"
2954 );
2955 }
2956
2957 #[tokio::test]
2961 async fn test_every_shipped_platform_plays_audio_natively() {
2962 let caps = super::player_get_capabilities().await.unwrap();
2963 if cfg!(any(
2964 target_os = "linux",
2965 target_os = "windows",
2966 target_os = "android"
2967 )) {
2968 assert!(!caps.uses_webview_audio);
2969 }
2970 }
2971
2972 #[test]
2983 fn test_normalize_volume_clamps_and_rejects_nan() {
2984 use super::normalize_volume;
2985
2986 assert_eq!(normalize_volume(0.0), 0.0);
2988 assert_eq!(normalize_volume(0.5), 0.5);
2989 assert_eq!(normalize_volume(1.0), 1.0);
2990
2991 assert_eq!(normalize_volume(-0.5), 0.0);
2993 assert_eq!(normalize_volume(42.0), 1.0);
2994 assert_eq!(normalize_volume(f32::INFINITY), 1.0);
2995 assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
2996
2997 let from_nan = normalize_volume(f32::NAN);
3000 assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
3001 assert_eq!(from_nan, 0.0);
3002
3003 for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
3005 let percent = (normalize_volume(input) * 100.0) as i32;
3006 assert!((0..=100).contains(&percent), "input {input} gave {percent}");
3007 }
3008 }
3009
3010 #[tokio::test]
3027 async fn test_play_item_request_carries_subtitles_into_media_item() {
3028 use super::{create_media_item, PlayItemRequest};
3029
3030 let payload = serde_json::json!({
3031 "id": "ep-1",
3032 "title": "Pilot",
3033 "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
3034 "videoCodec": "h264",
3035 "needsTranscoding": false,
3036 "subtitles": [
3037 {
3038 "index": 2,
3039 "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
3040 "language": "eng",
3041 "label": "English (SRT)",
3042 "mime_type": "text/vtt"
3043 },
3044 {
3045 "index": 3,
3046 "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
3047 "language": null,
3048 "label": null,
3049 "mime_type": "text/vtt"
3050 }
3051 ]
3052 });
3053
3054 let req: PlayItemRequest =
3055 serde_json::from_value(payload).expect("frontend payload must deserialize");
3056 assert_eq!(
3057 req.subtitles.len(),
3058 2,
3059 "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
3060 );
3061
3062 let media = create_media_item(req, None).await.unwrap();
3063 assert_eq!(
3064 media.subtitles.len(),
3065 2,
3066 "create_media_item must thread the tracks onto the MediaItem the backend loads"
3067 );
3068 assert_eq!(media.subtitles[0].index, 2);
3069 assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
3070 assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
3071 assert_eq!(media.subtitles[0].mime_type, "text/vtt");
3072 assert_eq!(media.subtitles[1].index, 3);
3075 assert!(media.subtitles[1].language.is_none());
3076 }
3077
3078 #[tokio::test]
3084 async fn test_play_item_request_without_subtitles_defaults_to_empty() {
3085 use super::{create_media_item, PlayItemRequest};
3086
3087 let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
3088 "id": "movie-1",
3089 "title": "Movie",
3090 "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
3091 "videoCodec": "h264",
3092 "needsTranscoding": false
3093 }))
3094 .expect("a subtitle-less payload must still deserialize");
3095
3096 assert!(req.subtitles.is_empty());
3097 assert!(create_media_item(req, None)
3098 .await
3099 .unwrap()
3100 .subtitles
3101 .is_empty());
3102 }
3103
3104 #[test]
3116 fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
3117 use crate::player::media::SubtitleTrack;
3118
3119 let subtitles = vec![SubtitleTrack {
3120 index: 2,
3121 url: "https://jelly.example/subs.vtt".to_string(),
3122 language: Some("eng".to_string()),
3123 label: Some("English".to_string()),
3124 mime_type: "text/vtt".to_string(),
3125 }];
3126
3127 let json = serde_json::to_string(&subtitles).unwrap();
3129 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
3130 let obj = parsed[0].as_object().unwrap();
3131
3132 for key in ["url", "language", "label", "mime_type"] {
3133 assert!(
3134 obj.contains_key(key),
3135 "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
3136 obj.keys().collect::<Vec<_>>()
3137 );
3138 }
3139 assert!(
3140 !obj.contains_key("mimeType"),
3141 "camelCasing mime_type silently drops every track's MIME type on Android"
3142 );
3143 }
3144
3145 #[test]
3150 fn test_background_audio_source_prefers_local_file() {
3151 use super::background_audio_source;
3152 use crate::player::MediaSource;
3153 use std::path::PathBuf;
3154
3155 let local = background_audio_source(
3156 Some("/downloads/ep1.mkv".to_string()),
3157 "https://server/audio-only".to_string(),
3158 "ep-1",
3159 );
3160 match local {
3161 MediaSource::Local {
3162 file_path,
3163 jellyfin_item_id,
3164 } => {
3165 assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3166 assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3168 }
3169 other => panic!("expected a local source, got {:?}", other),
3170 }
3171
3172 let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3173 match remote {
3174 MediaSource::Remote {
3175 stream_url,
3176 jellyfin_item_id,
3177 } => {
3178 assert_eq!(stream_url, "https://server/audio-only");
3179 assert_eq!(jellyfin_item_id, "ep-1");
3180 }
3181 other => panic!("expected a remote source, got {:?}", other),
3182 }
3183 }
3184
3185 #[test]
3198 fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3199 use super::background_audio_plan;
3200
3201 let local = background_audio_plan(true, 1104.0);
3202 assert_eq!(local.base_seconds, 0.0);
3203 assert_eq!(local.seek_to, Some(1104.0));
3204
3205 let streamed = background_audio_plan(false, 1104.0);
3206 assert_eq!(streamed.base_seconds, 1104.0);
3207 assert_eq!(
3208 streamed.seek_to, None,
3209 "the URL already starts at the handoff point; seeking again skips past it"
3210 );
3211 }
3212
3213 #[test]
3218 fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3219 use super::background_audio_plan;
3220
3221 for local in [true, false] {
3222 let plan = background_audio_plan(local, 0.0);
3223 assert_eq!(plan.base_seconds, 0.0);
3224 assert_eq!(plan.seek_to, None);
3225 }
3226 }
3227
3228 #[tokio::test]
3234 async fn test_resolve_local_media_path() {
3235 use super::resolve_local_media_path;
3236 use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3237 use rusqlite::Connection;
3238 use std::sync::{Arc, Mutex};
3239
3240 let conn = Connection::open_in_memory().unwrap();
3241 conn.execute(
3242 "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3243 [],
3244 )
3245 .unwrap();
3246 let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3247
3248 let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3250 std::fs::write(&present, b"x").unwrap();
3251 let present_str = present.to_string_lossy().to_string();
3252
3253 for (item, status, path) in [
3254 ("downloaded", "completed", present_str.as_str()),
3255 ("still-going", "downloading", present_str.as_str()),
3256 (
3257 "file-gone",
3258 "completed",
3259 "/nonexistent/jellytau/missing.mp4",
3260 ),
3261 ] {
3262 db_service
3263 .execute(Query::with_params(
3264 "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3265 vec![
3266 crate::storage::db_service::QueryParam::String(item.to_string()),
3267 crate::storage::db_service::QueryParam::String(status.to_string()),
3268 crate::storage::db_service::QueryParam::String(path.to_string()),
3269 ],
3270 ))
3271 .await
3272 .unwrap();
3273 }
3274
3275 assert_eq!(
3276 resolve_local_media_path(&db_service, "downloaded")
3277 .await
3278 .unwrap()
3279 .as_deref(),
3280 Some(present_str.as_str()),
3281 "a completed download with its file present must resolve"
3282 );
3283
3284 assert_eq!(
3285 resolve_local_media_path(&db_service, "still-going")
3286 .await
3287 .unwrap(),
3288 None,
3289 "an in-progress download is not playable from disk"
3290 );
3291
3292 assert_eq!(
3293 resolve_local_media_path(&db_service, "file-gone")
3294 .await
3295 .unwrap(),
3296 None,
3297 "a row whose file has gone must fall back to streaming, not hand over a dead path"
3298 );
3299
3300 assert_eq!(
3301 resolve_local_media_path(&db_service, "never-heard-of-it")
3302 .await
3303 .unwrap(),
3304 None
3305 );
3306
3307 let _ = std::fs::remove_file(&present);
3308 }
3309
3310 #[tokio::test]
3314 async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3315 use super::{refresh_queue_local_sources, DatabaseWrapper};
3316 use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3317 use crate::storage::Database;
3318 use std::sync::Mutex;
3319
3320 let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3323 std::fs::create_dir_all(&dir).unwrap();
3324 let existing = dir.join("track-a.mp3");
3325 std::fs::write(&existing, b"audio").unwrap();
3326 let missing = dir.join("track-b-missing.mp3");
3327 let _ = std::fs::remove_file(&missing);
3328
3329 let database = Database::open_in_memory().unwrap();
3330 {
3331 let conn = database.connection();
3332 let conn = conn.lock_safe();
3333 conn.execute_batch(&format!(
3334 r#"
3335 INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3336 INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3337 INSERT INTO downloads (item_id, user_id, file_path, status)
3338 VALUES ('track-a', 'user1', '{}', 'completed');
3339 INSERT INTO downloads (item_id, user_id, file_path, status)
3340 VALUES ('track-b', 'user1', '{}', 'completed');
3341 "#,
3342 existing.display(),
3343 missing.display()
3344 ))
3345 .unwrap();
3346 }
3347 let db = DatabaseWrapper(Mutex::new(database));
3348
3349 let make_item = |id: &str| MediaItem {
3350 transport: None,
3352 id: id.to_string(),
3353 title: id.to_string(),
3354 name: None,
3355 artist: None,
3356 album: None,
3357 album_name: None,
3358 album_id: None,
3359 artist_items: None,
3360 artists: None,
3361 primary_image_tag: None,
3362 image_id: None,
3363 item_type: None,
3364 playlist_id: None,
3365 duration: None,
3366 artwork_url: None,
3367 media_type: MediaType::Audio,
3368 source: MediaSource::Remote {
3369 stream_url: format!("http://test/Audio/{}/stream", id),
3370 jellyfin_item_id: id.to_string(),
3371 },
3372 video_codec: None,
3373 needs_transcoding: false,
3374 video_width: None,
3375 video_height: None,
3376 subtitles: vec![],
3377 series_id: None,
3378 server_id: None,
3379 };
3380
3381 let controller = PlayerController::default();
3382 controller
3383 .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3384 .unwrap();
3385
3386 let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3387 assert_eq!(switched, 1, "only the download whose file exists switches");
3388
3389 let queue = controller.queue();
3390 let queue_lock = queue.lock_safe();
3391 match &queue_lock.items()[0].source {
3392 MediaSource::Local {
3393 file_path,
3394 jellyfin_item_id,
3395 } => {
3396 assert_eq!(file_path, &existing);
3397 assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3398 }
3399 other => panic!("track-a should be local, got {:?}", other),
3400 }
3401 assert!(
3402 matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3403 "track-b's file is missing, it must stay remote"
3404 );
3405 }
3406
3407 #[test]
3410 fn test_find_track_index_in_album() {
3411 #[derive(Clone)]
3413 struct MockTrack {
3414 id: String,
3415 name: String,
3416 index_number: Option<i32>,
3417 }
3418
3419 let mut tracks = [
3420 MockTrack {
3421 id: "track1".to_string(),
3422 name: "Song 1".to_string(),
3423 index_number: Some(1),
3424 },
3425 MockTrack {
3426 id: "track2".to_string(),
3427 name: "Song 2".to_string(),
3428 index_number: Some(2),
3429 },
3430 MockTrack {
3431 id: "track3".to_string(),
3432 name: "Song 3".to_string(),
3433 index_number: Some(3),
3434 },
3435 MockTrack {
3436 id: "track4".to_string(),
3437 name: "Song 4".to_string(),
3438 index_number: Some(4),
3439 },
3440 MockTrack {
3441 id: "track5".to_string(),
3442 name: "Song 5".to_string(),
3443 index_number: Some(5),
3444 },
3445 ];
3446
3447 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3449
3450 let index1 = tracks.iter().position(|t| t.id == "track1");
3452 assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3453 assert_eq!(
3454 tracks[0].name, "Song 1",
3455 "Track at index 0 should carry its name"
3456 );
3457
3458 let index3 = tracks.iter().position(|t| t.id == "track3");
3460 assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3461
3462 let index5 = tracks.iter().position(|t| t.id == "track5");
3464 assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3465
3466 let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3468 assert_eq!(index_none, None, "Non-existent track should return None");
3469 }
3470
3471 #[test]
3473 fn test_track_iteration_order() {
3474 let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3476
3477 let target_index = track_ids.iter().position(|&id| id == "id3");
3479 assert_eq!(
3480 target_index,
3481 Some(2),
3482 "id3 should be at index 2 in original list"
3483 );
3484
3485 let mut media_items = Vec::new();
3487 for id in &track_ids {
3488 media_items.push(id.to_string());
3489 }
3490
3491 assert_eq!(media_items.len(), 5);
3493 assert_eq!(media_items[0], "id1");
3494 assert_eq!(media_items[2], "id3");
3495 assert_eq!(media_items[4], "id5");
3496
3497 assert_eq!(media_items[target_index.unwrap()], "id3");
3499 }
3500
3501 #[test]
3503 fn test_album_track_sorting() {
3504 #[derive(Clone, Debug)]
3505 struct MockTrack {
3506 id: String,
3507 name: String,
3508 index_number: Option<i32>,
3509 }
3510
3511 let mut tracks = [
3513 MockTrack {
3514 id: "id5".to_string(),
3515 name: "Track 5".to_string(),
3516 index_number: Some(5),
3517 },
3518 MockTrack {
3519 id: "id1".to_string(),
3520 name: "Track 1".to_string(),
3521 index_number: Some(1),
3522 },
3523 MockTrack {
3524 id: "id3".to_string(),
3525 name: "Track 3".to_string(),
3526 index_number: Some(3),
3527 },
3528 MockTrack {
3529 id: "id2".to_string(),
3530 name: "Track 2".to_string(),
3531 index_number: Some(2),
3532 },
3533 MockTrack {
3534 id: "id4".to_string(),
3535 name: "Track 4".to_string(),
3536 index_number: Some(4),
3537 },
3538 ];
3539
3540 let requested_track_id = "id1";
3542
3543 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3545
3546 let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3548
3549 assert_eq!(
3551 start_index,
3552 Some(0),
3553 "After sorting, track id1 should be at index 0"
3554 );
3555
3556 assert_eq!(tracks[0].id, "id1");
3558 assert_eq!(
3559 tracks[0].name, "Track 1",
3560 "Sorted track should retain its name"
3561 );
3562 assert_eq!(tracks[1].id, "id2");
3563 assert_eq!(tracks[2].id, "id3");
3564 assert_eq!(tracks[3].id, "id4");
3565 assert_eq!(tracks[4].id, "id5");
3566 }
3567}