From 8545f361c47b764739ebc4925c4bd33416baa91b Mon Sep 17 00:00:00 2001 From: JasonLG1979 Date: Tue, 23 Aug 2022 15:23:37 -0500 Subject: [PATCH] Major Events Overhaul Special thanks to @eladyn for all of their help and suggestions. * Add all player events to `player_event_handler.rs` * Move event handler code to `player_event_handler.rs` * Add session events * Clean up and de-noise events and event firing * Added metadata support via a TrackChanged event * Add `event_handler_example.py` * Handle invalid track start positions by just starting the track from the beginning * Add repeat support to `spirc.rs` * Add `disconnect`, `set_position_ms` and `set_volume` to `spirc.rs` * Set `PlayStatus` to the correct value when Player is loading to avoid blanking out the controls when `self.play_status` is `LoadingPlay` or `LoadingPause` in `spirc.rs` * Handle attempts to play local files better by basically ignoring attempts to load them in `handle_remote_update` in `spirc.rs` * Add an event worker thread that runs async to the main thread(s) but sync to itself to prevent potential data races for event consumers. * Get rid of (probably harmless) `.unwrap()` in `main.rs` * Ensure that events are emited in a logical order and at logical times * Handle invalid and disappearing devices better * Ignore SpircCommands unless we're active with the exception of ShutDown --- CHANGELOG.md | 11 + connect/src/spirc.rs | 301 +++++++++++------ contrib/event_handler_example.py | 77 +++++ core/src/authentication.rs | 2 +- core/src/session.rs | 41 +++ examples/play.rs | 2 +- metadata/src/album.rs | 2 +- metadata/src/audio/item.rs | 314 +++++++++++++----- metadata/src/audio/mod.rs | 2 +- metadata/src/episode.rs | 32 +- metadata/src/error.rs | 4 + metadata/src/image.rs | 7 + metadata/src/track.rs | 43 +-- playback/src/audio_backend/alsa.rs | 44 ++- playback/src/player.rs | 501 ++++++++++++++++++----------- src/main.rs | 72 +---- src/player_event_handler.rs | 423 ++++++++++++++++-------- 17 files changed, 1239 insertions(+), 639 deletions(-) create mode 100644 contrib/event_handler_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ad245b1..a1412875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,9 +79,20 @@ https://github.com/librespot-org/librespot disabled such content. Applications that use librespot as a library without Connect should use the 'filter-explicit-content' user attribute in the session. - [metadata] All metadata fields in the protobufs are now exposed (breaking) +- [connect] Add session events +- [playback] Add metadata support via a `TrackChanged` event +- [main] Add all player events to `player_event_handler.rs` +- [contrib] Add `event_handler_example.py` +- [connect] Add `repeat`, `set_position_ms` and `set_volume` to `spirc.rs` +- [main] Add an event worker thread that runs async to the main thread(s) but sync to itself to prevent potential data races for event consumers ### Fixed +- [connect] Set `PlayStatus` to the correct value when Player is loading to avoid blanking out the controls when `self.play_status` is `LoadingPlay` or `LoadingPause` in `spirc.rs` +- [connect] Handle attempts to play local files better by basically ignoring attempts to load them in `handle_remote_update` in `spirc.rs` +- [playback] Handle invalid track start positions by just starting the track from the beginning +- [playback, connect] Clean up and de-noise events and event firing +- [playback] Handle disappearing and invalid devices better ### Removed - [main] `autoplay` is no longer a command-line option. Instead, librespot now diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9105ffdf..616a44e5 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -44,6 +44,8 @@ use crate::{ pub enum SpircError { #[error("response payload empty")] NoData, + #[error("playback of local files is not supported")] + UnsupportedLocalPlayBack, #[error("message addressed at another ident: {0}")] Ident(String), #[error("message pushed for another URI")] @@ -52,10 +54,10 @@ pub enum SpircError { impl From for Error { fn from(err: SpircError) -> Self { + use SpircError::*; match err { - SpircError::NoData => Error::unavailable(err), - SpircError::Ident(_) => Error::aborted(err), - SpircError::InvalidUri(_) => Error::aborted(err), + NoData | UnsupportedLocalPlayBack => Error::unavailable(err), + Ident(_) | InvalidUri(_) => Error::aborted(err), } } } @@ -113,6 +115,7 @@ struct SpircTask { static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0); +#[derive(Debug)] pub enum SpircCommand { Play, PlayPause, @@ -122,7 +125,11 @@ pub enum SpircCommand { VolumeUp, VolumeDown, Shutdown, - Shuffle, + Shuffle(bool), + Repeat(bool), + Disconnect, + SetPosition(u32), + SetVolume(u16), } const CONTEXT_TRACKS_HISTORY: usize = 10; @@ -243,10 +250,8 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState { msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes); { let repeated = msg.mut_stringValue(); - repeated.push(::std::convert::Into::into("audio/local")); repeated.push(::std::convert::Into::into("audio/track")); repeated.push(::std::convert::Into::into("audio/episode")); - repeated.push(::std::convert::Into::into("local")); repeated.push(::std::convert::Into::into("track")) }; msg @@ -416,8 +421,20 @@ impl Spirc { pub fn shutdown(&self) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::Shutdown)?) } - pub fn shuffle(&self) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::Shuffle)?) + pub fn shuffle(&self, shuffle: bool) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Shuffle(shuffle))?) + } + pub fn repeat(&self, repeat: bool) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Repeat(repeat))?) + } + pub fn set_volume(&self, volume: u16) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::SetVolume(volume))?) + } + pub fn set_position_ms(&self, position_ms: u32) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::SetPosition(position_ms))?) + } + pub fn disconnect(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Disconnect)?) } } @@ -552,76 +569,71 @@ impl SpircTask { } fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> { - let active = self.device.get_is_active(); - match cmd { - SpircCommand::Play => { - if active { + if matches!(cmd, SpircCommand::Shutdown) { + trace!("Received SpircCommand::Shutdown"); + CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?; + self.handle_disconnect(); + self.shutdown = true; + if let Some(rx) = self.commands.as_mut() { + rx.close() + } + Ok(()) + } else if self.device.get_is_active() { + trace!("Received SpircCommand::{:?}", cmd); + match cmd { + SpircCommand::Play => { self.handle_play(); self.notify(None) - } else { - CommandSender::new(self, MessageType::kMessageTypePlay).send() } - } - SpircCommand::PlayPause => { - if active { + SpircCommand::PlayPause => { self.handle_play_pause(); self.notify(None) - } else { - CommandSender::new(self, MessageType::kMessageTypePlayPause).send() } - } - SpircCommand::Pause => { - if active { + SpircCommand::Pause => { self.handle_pause(); self.notify(None) - } else { - CommandSender::new(self, MessageType::kMessageTypePause).send() } - } - SpircCommand::Prev => { - if active { + SpircCommand::Prev => { self.handle_prev(); self.notify(None) - } else { - CommandSender::new(self, MessageType::kMessageTypePrev).send() } - } - SpircCommand::Next => { - if active { + SpircCommand::Next => { self.handle_next(); self.notify(None) - } else { - CommandSender::new(self, MessageType::kMessageTypeNext).send() } - } - SpircCommand::VolumeUp => { - if active { + SpircCommand::VolumeUp => { self.handle_volume_up(); self.notify(None) - } else { - CommandSender::new(self, MessageType::kMessageTypeVolumeUp).send() } - } - SpircCommand::VolumeDown => { - if active { + SpircCommand::VolumeDown => { self.handle_volume_down(); self.notify(None) - } else { - CommandSender::new(self, MessageType::kMessageTypeVolumeDown).send() } - } - SpircCommand::Shutdown => { - CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?; - self.player.stop(); - self.shutdown = true; - if let Some(rx) = self.commands.as_mut() { - rx.close() + SpircCommand::Disconnect => { + self.handle_disconnect(); + self.notify(None) } - Ok(()) - } - SpircCommand::Shuffle => { - CommandSender::new(self, MessageType::kMessageTypeShuffle).send() + SpircCommand::Shuffle(shuffle) => { + self.state.set_shuffle(shuffle); + self.notify(None) + } + SpircCommand::Repeat(repeat) => { + self.state.set_repeat(repeat); + self.notify(None) + } + SpircCommand::SetPosition(position) => { + self.handle_seek(position); + self.notify(None) + } + SpircCommand::SetVolume(volume) => { + self.set_volume(volume); + self.notify(None) + } + _ => Ok(()), } + } else { + warn!("SpircCommand::{:?} will be ignored while Not Active", cmd); + Ok(()) } } @@ -635,11 +647,28 @@ impl SpircTask { match event { PlayerEvent::EndOfTrack { .. } => self.handle_end_of_track(), PlayerEvent::Loading { .. } => { - trace!("==> kPlayStatusLoading"); - self.state.set_status(PlayStatus::kPlayStatusLoading); + match self.play_status { + SpircPlayStatus::LoadingPlay { position_ms } => { + self.update_state_position(position_ms); + self.state.set_status(PlayStatus::kPlayStatusPlay); + trace!("==> kPlayStatusPlay"); + } + SpircPlayStatus::LoadingPause { position_ms } => { + self.update_state_position(position_ms); + self.state.set_status(PlayStatus::kPlayStatusPause); + trace!("==> kPlayStatusPause"); + } + _ => { + self.state.set_status(PlayStatus::kPlayStatusLoading); + self.update_state_position(0); + trace!("==> kPlayStatusLoading"); + } + } self.notify(None) } - PlayerEvent::Playing { position_ms, .. } => { + PlayerEvent::Playing { position_ms, .. } + | PlayerEvent::PositionCorrection { position_ms, .. } + | PlayerEvent::Seeked { position_ms, .. } => { trace!("==> kPlayStatusPlay"); let new_nominal_start_time = self.now_ms() - position_ms as i64; match self.play_status { @@ -674,17 +703,14 @@ impl SpircTask { } => { trace!("==> kPlayStatusPause"); match self.play_status { - SpircPlayStatus::Paused { - ref mut position_ms, - .. - } => { - if *position_ms != new_position_ms { - *position_ms = new_position_ms; - self.update_state_position(new_position_ms); - self.notify(None) - } else { - Ok(()) - } + SpircPlayStatus::Paused { .. } | SpircPlayStatus::Playing { .. } => { + self.state.set_status(PlayStatus::kPlayStatusPause); + self.update_state_position(new_position_ms); + self.play_status = SpircPlayStatus::Paused { + position_ms: new_position_ms, + preloading_of_next_track_triggered: false, + }; + self.notify(None) } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { @@ -762,7 +788,13 @@ impl SpircTask { ); if key == "filter-explicit-content" && new_value == "1" { - self.player.skip_explicit_content(); + self.player + .emit_filter_explicit_content_changed_event(matches!(new_value, "1")); + } + + if key == "autoplay" && old_value != new_value { + self.player + .emit_auto_play_changed_event(matches!(new_value, "1")); } } else { trace!( @@ -785,13 +817,31 @@ impl SpircTask { return Err(SpircError::Ident(ident.to_string()).into()); } + let old_client_id = self.session.client_id(); + for entry in update.get_device_state().get_metadata().iter() { - if entry.get_field_type() == "client_id" { - self.session.set_client_id(entry.get_metadata()); - break; + match entry.get_field_type() { + "client-id" => self.session.set_client_id(entry.get_metadata()), + "brand_display_name" => self.session.set_client_brand_name(entry.get_metadata()), + "model_display_name" => self.session.set_client_model_name(entry.get_metadata()), + _ => (), } } + self.session + .set_client_name(update.get_device_state().get_name()); + + let new_client_id = self.session.client_id(); + + if self.device.get_is_active() && new_client_id != old_client_id { + self.player.emit_session_client_changed_event( + new_client_id, + self.session.client_name(), + self.session.client_brand_name(), + self.session.client_model_name(), + ); + } + match update.get_typ() { MessageType::kMessageTypeHello => self.notify(Some(ident)), @@ -800,6 +850,40 @@ impl SpircTask { let now = self.now_ms(); self.device.set_is_active(true); self.device.set_became_active_at(now); + self.player.emit_session_connected_event( + self.session.connection_id(), + self.session.username(), + ); + self.player.emit_session_client_changed_event( + self.session.client_id(), + self.session.client_name(), + self.session.client_brand_name(), + self.session.client_model_name(), + ); + + self.player + .emit_volume_changed_event(self.device.get_volume() as u16); + + self.player + .emit_auto_play_changed_event(self.session.autoplay()); + + self.player.emit_filter_explicit_content_changed_event( + self.session.filter_explicit_content(), + ); + + self.player + .emit_shuffle_changed_event(self.state.get_shuffle()); + + self.player + .emit_repeat_changed_event(self.state.get_repeat()); + } + + let context_uri = update.get_state().get_context_uri().to_owned(); + + // completely ignore local playback. + if context_uri.starts_with("spotify:local-files") { + self.notify(None)?; + return Err(SpircError::UnsupportedLocalPlayBack.into()); } self.update_tracks(&update); @@ -852,12 +936,17 @@ impl SpircTask { } MessageType::kMessageTypeRepeat => { - self.state.set_repeat(update.get_state().get_repeat()); + let repeat = update.get_state().get_repeat(); + self.state.set_repeat(repeat); + + self.player.emit_repeat_changed_event(repeat); + self.notify(None) } MessageType::kMessageTypeShuffle => { - self.state.set_shuffle(update.get_state().get_shuffle()); + let shuffle = update.get_state().get_shuffle(); + self.state.set_shuffle(shuffle); if self.state.get_shuffle() { let current_index = self.state.get_playing_track_index(); let tracks = self.state.mut_track(); @@ -873,6 +962,9 @@ impl SpircTask { let context = self.state.get_context_uri(); debug!("{:?}", context); } + + self.player.emit_shuffle_changed_event(shuffle); + self.notify(None) } @@ -882,6 +974,14 @@ impl SpircTask { } MessageType::kMessageTypeReplace => { + let context_uri = update.get_state().get_context_uri().to_owned(); + + // completely ignore local playback. + if context_uri.starts_with("spotify:local-files") { + self.notify(None)?; + return Err(SpircError::UnsupportedLocalPlayBack.into()); + } + self.update_tracks(&update); if let SpircPlayStatus::Playing { @@ -915,16 +1015,23 @@ impl SpircTask { && self.device.get_became_active_at() <= update.get_device_state().get_became_active_at() { - self.device.set_is_active(false); - self.handle_stop(); + self.handle_disconnect(); } - Ok(()) + self.notify(None) } _ => Ok(()), } } + fn handle_disconnect(&mut self) { + self.device.set_is_active(false); + self.handle_stop(); + + self.player + .emit_session_disconnected_event(self.session.connection_id(), self.session.username()); + } + fn handle_stop(&mut self) { self.player.stop(); } @@ -1100,17 +1207,7 @@ impl SpircTask { } if new_index >= tracks_len { - let autoplay = self - .session - .get_user_attribute("autoplay") - .unwrap_or_else(|| { - warn!( - "Unable to get autoplay user attribute. Continuing with autoplay disabled." - ); - "0".into() - }); - - if autoplay == "1" { + if self.session.autoplay() { // Extend the playlist debug!("Extending playlist <{}>", context_uri); self.update_tracks_from_context(); @@ -1282,12 +1379,10 @@ impl SpircTask { || context_uri.starts_with("spotify:dailymix:") { self.context_fut = self.resolve_station(&context_uri); - } else if let Some(autoplay) = self.session.get_user_attribute("autoplay") { - if &autoplay == "1" { - info!("Fetching autoplay context uri"); - // Get autoplay_station_uri for regular playlists - self.autoplay_fut = self.resolve_autoplay_uri(&context_uri); - } + } else if self.session.autoplay() { + info!("Fetching autoplay context uri"); + // Get autoplay_station_uri for regular playlists + self.autoplay_fut = self.resolve_autoplay_uri(&context_uri); } self.player @@ -1422,12 +1517,18 @@ impl SpircTask { } fn set_volume(&mut self, volume: u16) { - self.device.set_volume(volume as u32); - self.mixer.set_volume(volume); - if let Some(cache) = self.session.cache() { - cache.save_volume(volume) + let old_volume = self.device.get_volume(); + let new_volume = volume as u32; + if old_volume != new_volume { + self.device.set_volume(new_volume); + self.mixer.set_volume(volume); + if let Some(cache) = self.session.cache() { + cache.save_volume(volume) + } + if self.device.get_is_active() { + self.player.emit_volume_changed_event(volume); + } } - self.player.emit_volume_set_event(volume); } } diff --git a/contrib/event_handler_example.py b/contrib/event_handler_example.py new file mode 100644 index 00000000..f419e7d6 --- /dev/null +++ b/contrib/event_handler_example.py @@ -0,0 +1,77 @@ +#!/usr/bin/python3 +import os +import json +from datetime import datetime + +player_event = os.getenv('PLAYER_EVENT') + +json_dict = { + 'event_time': str(datetime.now()), + 'event': player_event, +} + +if player_event in ('session_connected', 'session_disconnected'): + json_dict['user_name'] = os.environ['USER_NAME'] + json_dict['connection_id'] = os.environ['CONNECTION_ID'] + +elif player_event == 'session_client_changed': + json_dict['client_id'] = os.environ['CLIENT_ID'] + json_dict['client_name'] = os.environ['CLIENT_NAME'] + json_dict['client_brand_name'] = os.environ['CLIENT_BRAND_NAME'] + json_dict['client_model_name'] = os.environ['CLIENT_MODEL_NAME'] + +elif player_event == 'shuffle_changed': + json_dict['shuffle'] = os.environ['SHUFFLE'] + +elif player_event == 'repeat_changed': + json_dict['repeat'] = os.environ['REPEAT'] + +elif player_event == 'auto_play_changed': + json_dict['auto_play'] = os.environ['AUTO_PLAY'] + +elif player_event == 'filter_explicit_content_changed': + json_dict['filter'] = os.environ['FILTER'] + +elif player_event == 'volume_changed': + json_dict['volume'] = os.environ['VOLUME'] + +elif player_event in ('seeked', 'position_correction', 'playing', 'paused'): + json_dict['track_id'] = os.environ['TRACK_ID'] + json_dict['position_ms'] = os.environ['POSITION_MS'] + +elif player_event in ('unavailable', 'end_of_track', 'preload_next', 'preloading', 'loading', 'stopped'): + json_dict['track_id'] = os.environ['TRACK_ID'] + +elif player_event == 'track_changed': + common_metadata_fields = {} + item_type = os.environ['ITEM_TYPE'] + common_metadata_fields['item_type'] = item_type + common_metadata_fields['track_id'] = os.environ['TRACK_ID'] + common_metadata_fields['uri'] = os.environ['URI'] + common_metadata_fields['name'] = os.environ['NAME'] + common_metadata_fields['duration_ms'] = os.environ['DURATION_MS'] + common_metadata_fields['is_explicit'] = os.environ['IS_EXPLICIT'] + common_metadata_fields['language'] = os.environ['LANGUAGE'].split('\n') + common_metadata_fields['covers'] = os.environ['COVERS'].split('\n') + json_dict['common_metadata_fields'] = common_metadata_fields + + + if item_type == 'Track': + track_metadata_fields = {} + track_metadata_fields['number'] = os.environ['NUMBER'] + track_metadata_fields['disc_number'] = os.environ['DISC_NUMBER'] + track_metadata_fields['popularity'] = os.environ['POPULARITY'] + track_metadata_fields['album'] = os.environ['ALBUM'] + track_metadata_fields['artists'] = os.environ['ARTISTS'].split('\n') + track_metadata_fields['album_artists'] = os.environ['ALBUM_ARTISTS'].split('\n') + json_dict['track_metadata_fields'] = track_metadata_fields + + elif item_type == 'Episode': + episode_metadata_fields = {} + episode_metadata_fields['show_name'] = os.environ['SHOW_NAME'] + publish_time = datetime.utcfromtimestamp(int(os.environ['PUBLISH_TIME'])).strftime('%Y-%m-%d') + episode_metadata_fields['publish_time'] = publish_time + episode_metadata_fields['description'] = os.environ['DESCRIPTION'] + json_dict['episode_metadata_fields'] = episode_metadata_fields + +print(json.dumps(json_dict, indent = 4)) diff --git a/core/src/authentication.rs b/core/src/authentication.rs index dad514b0..b2bcad94 100644 --- a/core/src/authentication.rs +++ b/core/src/authentication.rs @@ -26,7 +26,7 @@ impl From for Error { } /// The credentials are used to log into the Spotify API. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Credentials { pub username: String, diff --git a/core/src/session.rs b/core/src/session.rs index d719748c..82c9e4a8 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -73,6 +73,9 @@ pub struct UserData { #[derive(Debug, Clone, Default)] struct SessionData { client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, connection_id: String, time_delta: i64, invalid: bool, @@ -383,6 +386,30 @@ impl Session { self.0.data.write().client_id = client_id.to_owned(); } + pub fn client_name(&self) -> String { + self.0.data.read().client_name.clone() + } + + pub fn set_client_name(&self, client_name: &str) { + self.0.data.write().client_name = client_name.to_owned(); + } + + pub fn client_brand_name(&self) -> String { + self.0.data.read().client_brand_name.clone() + } + + pub fn set_client_brand_name(&self, client_brand_name: &str) { + self.0.data.write().client_brand_name = client_brand_name.to_owned(); + } + + pub fn client_model_name(&self) -> String { + self.0.data.read().client_model_name.clone() + } + + pub fn set_client_model_name(&self, client_model_name: &str) { + self.0.data.write().client_model_name = client_model_name.to_owned(); + } + pub fn connection_id(&self) -> String { self.0.data.read().connection_id.clone() } @@ -403,6 +430,20 @@ impl Session { self.0.data.read().user_data.country.clone() } + pub fn filter_explicit_content(&self) -> bool { + match self.get_user_attribute("filter-explicit-content") { + Some(value) => matches!(&*value, "1"), + None => false, + } + } + + pub fn autoplay(&self) -> bool { + match self.get_user_attribute("autoplay") { + Some(value) => matches!(&*value, "1"), + None => false, + } + } + pub fn set_user_attribute(&self, key: &str, value: &str) -> Option { let mut dummy_attributes = UserAttributes::new(); dummy_attributes.insert(key.to_owned(), value.to_owned()); diff --git a/examples/play.rs b/examples/play.rs index dc22103b..1209bf95 100644 --- a/examples/play.rs +++ b/examples/play.rs @@ -36,7 +36,7 @@ async fn main() { exit(1); } - let (mut player, _) = Player::new(player_config, session, Box::new(NoOpVolume), move || { + let mut player = Player::new(player_config, session, Box::new(NoOpVolume), move || { backend(None, audio_format) }); diff --git a/metadata/src/album.rs b/metadata/src/album.rs index 45db39ea..11f13331 100644 --- a/metadata/src/album.rs +++ b/metadata/src/album.rs @@ -97,7 +97,7 @@ impl TryFrom<&::Message> for Album { date: album.get_date().try_into()?, popularity: album.get_popularity(), genres: album.get_genre().to_vec(), - covers: album.get_cover().into(), + covers: album.get_cover_group().into(), external_ids: album.get_external_id().into(), discs: album.get_disc().try_into()?, reviews: album.get_review().to_vec(), diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs index c3df12e0..2a672075 100644 --- a/metadata/src/audio/item.rs +++ b/metadata/src/audio/item.rs @@ -1,11 +1,14 @@ use std::fmt::Debug; use crate::{ + artist::ArtistsWithRole, availability::{AudioItemAvailability, Availabilities, UnavailabilityReason}, episode::Episode, error::MetadataError, + image::{ImageSize, Images}, restriction::Restrictions, track::{Track, Tracks}, + Metadata, }; use super::file::AudioFiles; @@ -16,98 +19,259 @@ use librespot_core::{ pub type AudioItemResult = Result; -// A wrapper with fields the player needs +#[derive(Debug, Clone)] +pub struct CoverImage { + pub url: String, + pub size: ImageSize, + pub width: i32, + pub height: i32, +} + #[derive(Debug, Clone)] pub struct AudioItem { - pub id: SpotifyId, - pub spotify_uri: String, + pub track_id: SpotifyId, + pub uri: String, pub files: AudioFiles, pub name: String, - pub duration: i32, + pub covers: Vec, + pub language: Vec, + pub duration_ms: u32, + pub is_explicit: bool, pub availability: AudioItemAvailability, pub alternatives: Option, - pub is_explicit: bool, + pub unique_fields: UniqueFields, +} + +#[derive(Debug, Clone)] +pub enum UniqueFields { + Track { + artists: ArtistsWithRole, + album: String, + album_artists: Vec, + popularity: u8, + number: u32, + disc_number: u32, + }, + Episode { + description: String, + publish_time: Date, + show_name: String, + }, } impl AudioItem { pub async fn get_file(session: &Session, id: SpotifyId) -> AudioItemResult { + let image_url = session + .get_user_attribute("image-url") + .unwrap_or_else(|| String::from("https://i.scdn.co/image/{file_id}")); + match id.item_type { - SpotifyItemType::Track => Track::get_audio_item(session, id).await, - SpotifyItemType::Episode => Episode::get_audio_item(session, id).await, + SpotifyItemType::Track => { + let track = Track::get(session, &id).await?; + + if track.duration <= 0 { + return Err(Error::unavailable(MetadataError::InvalidDuration( + track.duration, + ))); + } + + if track.is_explicit && session.filter_explicit_content() { + return Err(Error::unavailable(MetadataError::ExplicitContentFiltered)); + } + + let track_id = track.id; + let uri = track_id.to_uri()?; + let album = track.album.name; + + let album_artists = track + .album + .artists + .0 + .into_iter() + .map(|a| a.name) + .collect::>(); + + let covers = get_covers(track.album.covers, image_url); + + let alternatives = if track.alternatives.is_empty() { + None + } else { + Some(track.alternatives) + }; + + let availability = if Date::now_utc() < track.earliest_live_timestamp { + Err(UnavailabilityReason::Embargo) + } else { + available_for_user( + &session.user_data(), + &track.availability, + &track.restrictions, + ) + }; + + let popularity = track.popularity.max(0).min(100) as u8; + let number = track.number.max(0) as u32; + let disc_number = track.disc_number.max(0) as u32; + + let unique_fields = UniqueFields::Track { + artists: track.artists_with_role, + album, + album_artists, + popularity, + number, + disc_number, + }; + + Ok(Self { + track_id, + uri, + files: track.files, + name: track.name, + covers, + language: track.language_of_performance, + duration_ms: track.duration as u32, + is_explicit: track.is_explicit, + availability, + alternatives, + unique_fields, + }) + } + SpotifyItemType::Episode => { + let episode = Episode::get(session, &id).await?; + + if episode.duration <= 0 { + return Err(Error::unavailable(MetadataError::InvalidDuration( + episode.duration, + ))); + } + + if episode.is_explicit && session.filter_explicit_content() { + return Err(Error::unavailable(MetadataError::ExplicitContentFiltered)); + } + + let track_id = episode.id; + let uri = track_id.to_uri()?; + + let covers = get_covers(episode.covers, image_url); + + let availability = available_for_user( + &session.user_data(), + &episode.availability, + &episode.restrictions, + ); + + let unique_fields = UniqueFields::Episode { + description: episode.description, + publish_time: episode.publish_time, + show_name: episode.show_name, + }; + + Ok(Self { + track_id, + uri, + files: episode.audio, + name: episode.name, + covers, + language: vec![episode.language], + duration_ms: episode.duration as u32, + is_explicit: episode.is_explicit, + availability, + alternatives: None, + unique_fields, + }) + } _ => Err(Error::unavailable(MetadataError::NonPlayable)), } } } -#[async_trait] -pub trait InnerAudioItem { - async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult; +fn get_covers(covers: Images, image_url: String) -> Vec { + let mut covers = covers; - fn allowed_for_user( - user_data: &UserData, - restrictions: &Restrictions, - ) -> AudioItemAvailability { - let country = &user_data.country; - let user_catalogue = match user_data.attributes.get("catalogue") { - Some(catalogue) => catalogue, - None => "premium", - }; + covers.sort_by(|a, b| b.width.cmp(&a.width)); - for premium_restriction in restrictions.iter().filter(|restriction| { - restriction - .catalogue_strs - .iter() - .any(|restricted_catalogue| restricted_catalogue == user_catalogue) - }) { - if let Some(allowed_countries) = &premium_restriction.countries_allowed { - // A restriction will specify either a whitelast *or* a blacklist, - // but not both. So restrict availability if there is a whitelist - // and the country isn't on it. - if allowed_countries.iter().any(|allowed| country == allowed) { - return Ok(()); - } else { - return Err(UnavailabilityReason::NotWhitelisted); - } + covers + .iter() + .filter_map(|cover| { + let cover_id = cover.id.to_string(); + + if !cover_id.is_empty() { + let cover_image = CoverImage { + url: image_url.replace("{file_id}", &cover_id), + size: cover.size, + width: cover.width, + height: cover.height, + }; + + Some(cover_image) + } else { + None } - - if let Some(forbidden_countries) = &premium_restriction.countries_forbidden { - if forbidden_countries - .iter() - .any(|forbidden| country == forbidden) - { - return Err(UnavailabilityReason::Blacklisted); - } else { - return Ok(()); - } - } - } - - Ok(()) // no restrictions in place - } - - fn available(availability: &Availabilities) -> AudioItemAvailability { - if availability.is_empty() { - // not all items have availability specified - return Ok(()); - } - - if !(availability - .iter() - .any(|availability| Date::now_utc() >= availability.start)) - { - return Err(UnavailabilityReason::Embargo); - } - - Ok(()) - } - - fn available_for_user( - user_data: &UserData, - availability: &Availabilities, - restrictions: &Restrictions, - ) -> AudioItemAvailability { - Self::available(availability)?; - Self::allowed_for_user(user_data, restrictions)?; - Ok(()) - } + }) + .collect() +} + +fn allowed_for_user(user_data: &UserData, restrictions: &Restrictions) -> AudioItemAvailability { + let country = &user_data.country; + let user_catalogue = match user_data.attributes.get("catalogue") { + Some(catalogue) => catalogue, + None => "premium", + }; + + for premium_restriction in restrictions.iter().filter(|restriction| { + restriction + .catalogue_strs + .iter() + .any(|restricted_catalogue| restricted_catalogue == user_catalogue) + }) { + if let Some(allowed_countries) = &premium_restriction.countries_allowed { + // A restriction will specify either a whitelast *or* a blacklist, + // but not both. So restrict availability if there is a whitelist + // and the country isn't on it. + if allowed_countries.iter().any(|allowed| country == allowed) { + return Ok(()); + } else { + return Err(UnavailabilityReason::NotWhitelisted); + } + } + + if let Some(forbidden_countries) = &premium_restriction.countries_forbidden { + if forbidden_countries + .iter() + .any(|forbidden| country == forbidden) + { + return Err(UnavailabilityReason::Blacklisted); + } else { + return Ok(()); + } + } + } + + Ok(()) // no restrictions in place +} + +fn available(availability: &Availabilities) -> AudioItemAvailability { + if availability.is_empty() { + // not all items have availability specified + return Ok(()); + } + + if !(availability + .iter() + .any(|availability| Date::now_utc() >= availability.start)) + { + return Err(UnavailabilityReason::Embargo); + } + + Ok(()) +} + +fn available_for_user( + user_data: &UserData, + availability: &Availabilities, + restrictions: &Restrictions, +) -> AudioItemAvailability { + available(availability)?; + allowed_for_user(user_data, restrictions)?; + Ok(()) } diff --git a/metadata/src/audio/mod.rs b/metadata/src/audio/mod.rs index 7e31f190..af9ccdc9 100644 --- a/metadata/src/audio/mod.rs +++ b/metadata/src/audio/mod.rs @@ -2,4 +2,4 @@ pub mod file; pub mod item; pub use file::{AudioFileFormat, AudioFiles}; -pub use item::AudioItem; +pub use item::{AudioItem, UniqueFields}; diff --git a/metadata/src/episode.rs b/metadata/src/episode.rs index e65a1045..fe795a25 100644 --- a/metadata/src/episode.rs +++ b/metadata/src/episode.rs @@ -5,10 +5,7 @@ use std::{ }; use crate::{ - audio::{ - file::AudioFiles, - item::{AudioItem, AudioItemResult, InnerAudioItem}, - }, + audio::file::AudioFiles, availability::Availabilities, content_rating::ContentRatings, image::Images, @@ -36,7 +33,7 @@ pub struct Episode { pub covers: Images, pub language: String, pub is_explicit: bool, - pub show: SpotifyId, + pub show_name: String, pub videos: VideoFiles, pub video_previews: VideoFiles, pub audio_previews: AudioFiles, @@ -57,29 +54,6 @@ pub struct Episodes(pub Vec); impl_deref_wrapped!(Episodes, Vec); -#[async_trait] -impl InnerAudioItem for Episode { - async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult { - let episode = Self::get(session, &id).await?; - let availability = Self::available_for_user( - &session.user_data(), - &episode.availability, - &episode.restrictions, - ); - - Ok(AudioItem { - id, - spotify_uri: id.to_uri()?, - files: episode.audio, - name: episode.name, - duration: episode.duration, - availability, - alternatives: None, - is_explicit: episode.is_explicit, - }) - } -} - #[async_trait] impl Metadata for Episode { type Message = protocol::metadata::Episode; @@ -107,7 +81,7 @@ impl TryFrom<&::Message> for Episode { covers: episode.get_cover_image().get_image().into(), language: episode.get_language().to_owned(), is_explicit: episode.get_explicit().to_owned(), - show: episode.get_show().try_into()?, + show_name: episode.get_show().get_name().to_owned(), videos: episode.get_video().into(), video_previews: episode.get_video_preview().into(), audio_previews: episode.get_audio_preview().into(), diff --git a/metadata/src/error.rs b/metadata/src/error.rs index 31c600b0..26f5ce0b 100644 --- a/metadata/src/error.rs +++ b/metadata/src/error.rs @@ -7,4 +7,8 @@ pub enum MetadataError { Empty, #[error("audio item is non-playable when it should be")] NonPlayable, + #[error("audio item duration can not be: {0}")] + InvalidDuration(i32), + #[error("track is marked as explicit, which client setting forbids")] + ExplicitContentFiltered, } diff --git a/metadata/src/image.rs b/metadata/src/image.rs index dd716623..0bbe5010 100644 --- a/metadata/src/image.rs +++ b/metadata/src/image.rs @@ -10,6 +10,7 @@ use librespot_core::{FileId, SpotifyId}; use librespot_protocol as protocol; use protocol::metadata::Image as ImageMessage; +use protocol::metadata::ImageGroup; pub use protocol::metadata::Image_Size as ImageSize; use protocol::playlist4_external::PictureSize as PictureSizeMessage; use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage; @@ -25,6 +26,12 @@ pub struct Image { #[derive(Debug, Clone, Default)] pub struct Images(pub Vec); +impl From<&ImageGroup> for Images { + fn from(image_group: &ImageGroup) -> Self { + Self(image_group.image.iter().map(|i| i.into()).collect()) + } +} + impl_deref_wrapped!(Images, Vec); #[derive(Debug, Clone)] diff --git a/metadata/src/track.rs b/metadata/src/track.rs index f4855d8a..03fab92c 100644 --- a/metadata/src/track.rs +++ b/metadata/src/track.rs @@ -8,11 +8,8 @@ use uuid::Uuid; use crate::{ artist::{Artists, ArtistsWithRole}, - audio::{ - file::AudioFiles, - item::{AudioItem, AudioItemResult, InnerAudioItem}, - }, - availability::{Availabilities, UnavailabilityReason}, + audio::file::AudioFiles, + availability::Availabilities, content_rating::ContentRatings, external_id::ExternalIds, restriction::Restrictions, @@ -58,42 +55,6 @@ pub struct Tracks(pub Vec); impl_deref_wrapped!(Tracks, Vec); -#[async_trait] -impl InnerAudioItem for Track { - async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult { - let track = Self::get(session, &id).await?; - let alternatives = { - if track.alternatives.is_empty() { - None - } else { - Some(track.alternatives.clone()) - } - }; - - // TODO: check meaning of earliest_live_timestamp in - let availability = if Date::now_utc() < track.earliest_live_timestamp { - Err(UnavailabilityReason::Embargo) - } else { - Self::available_for_user( - &session.user_data(), - &track.availability, - &track.restrictions, - ) - }; - - Ok(AudioItem { - id, - spotify_uri: id.to_uri()?, - files: track.files, - name: track.name, - duration: track.duration, - availability, - alternatives, - is_explicit: track.is_explicit, - }) - } -} - #[async_trait] impl Metadata for Track { type Message = protocol::metadata::Track; diff --git a/playback/src/audio_backend/alsa.rs b/playback/src/audio_backend/alsa.rs index e8d9ee05..49cc579e 100644 --- a/playback/src/audio_backend/alsa.rs +++ b/playback/src/audio_backend/alsa.rs @@ -442,14 +442,16 @@ impl Sink for AlsaSink { } fn stop(&mut self) -> SinkResult<()> { - // Zero fill the remainder of the period buffer and - // write any leftover data before draining the actual PCM buffer. - self.period_buffer.resize(self.period_buffer.capacity(), 0); - self.write_buf()?; + if self.pcm.is_some() { + // Zero fill the remainder of the period buffer and + // write any leftover data before draining the actual PCM buffer. + self.period_buffer.resize(self.period_buffer.capacity(), 0); + self.write_buf()?; - let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?; + let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?; - pcm.drain().map_err(AlsaError::DrainFailure)?; + pcm.drain().map_err(AlsaError::DrainFailure)?; + } Ok(()) } @@ -489,17 +491,29 @@ impl AlsaSink { pub const NAME: &'static str = "alsa"; fn write_buf(&mut self) -> SinkResult<()> { - let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?; + if self.pcm.is_some() { + let write_result = { + let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?; - if let Err(e) = pcm.io_bytes().writei(&self.period_buffer) { - // Capture and log the original error as a warning, and then try to recover. - // If recovery fails then forward that error back to player. - warn!( - "Error writing from AlsaSink buffer to PCM, trying to recover, {}", - e - ); + match pcm.io_bytes().writei(&self.period_buffer) { + Ok(_) => Ok(()), + Err(e) => { + // Capture and log the original error as a warning, and then try to recover. + // If recovery fails then forward that error back to player. + warn!( + "Error writing from AlsaSink buffer to PCM, trying to recover, {}", + e + ); - pcm.try_recover(e, false).map_err(AlsaError::OnWrite)? + pcm.try_recover(e, false).map_err(AlsaError::OnWrite) + } + } + }; + + if let Err(e) = write_result { + self.pcm = None; + return Err(e.into()); + } } self.period_buffer.clear(); diff --git a/playback/src/player.rs b/playback/src/player.rs index a7a51762..cd08197c 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -111,9 +111,26 @@ enum PlayerCommand { Seek(u32), AddEventSender(mpsc::UnboundedSender), SetSinkEventCallback(Option), - EmitVolumeSetEvent(u16), + EmitVolumeChangedEvent(u16), SetAutoNormaliseAsAlbum(bool), - SkipExplicitContent(), + EmitSessionDisconnectedEvent { + connection_id: String, + user_name: String, + }, + EmitSessionConnectedEvent { + connection_id: String, + user_name: String, + }, + EmitSessionClientChangedEvent { + client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, + }, + EmitFilterExplicitContentChangedEvent(bool), + EmitShuffleChangedEvent(bool), + EmitRepeatChangedEvent(bool), + EmitAutoPlayChangedEvent(bool), } #[derive(Debug, Clone)] @@ -123,19 +140,6 @@ pub enum PlayerEvent { play_request_id: u64, track_id: SpotifyId, }, - // The player started working on playback of a track while it was in a stopped state. - // This is always immediately followed up by a "Loading" or "Playing" event. - Started { - play_request_id: u64, - track_id: SpotifyId, - position_ms: u32, - }, - // Same as started but in the case that the player already had a track loaded. - // The player was either playing the loaded track or it was paused. - Changed { - old_track_id: SpotifyId, - new_track_id: SpotifyId, - }, // The player is delayed by loading a track. Loading { play_request_id: u64, @@ -157,14 +161,12 @@ pub enum PlayerEvent { play_request_id: u64, track_id: SpotifyId, position_ms: u32, - duration_ms: u32, }, // The player entered a paused state. Paused { play_request_id: u64, track_id: SpotifyId, position_ms: u32, - duration_ms: u32, }, // The player thinks it's a good idea to issue a preload command for the next track now. // This event is intended for use within spirc. @@ -173,8 +175,7 @@ pub enum PlayerEvent { track_id: SpotifyId, }, // The player reached the end of a track. - // This event is intended for use within spirc. Spirc will respond by issuing another command - // which will trigger another event (e.g. Changed or Stopped) + // This event is intended for use within spirc. Spirc will respond by issuing another command. EndOfTrack { play_request_id: u64, track_id: SpotifyId, @@ -185,9 +186,48 @@ pub enum PlayerEvent { track_id: SpotifyId, }, // The mixer volume was set to a new level. - VolumeSet { + VolumeChanged { volume: u16, }, + PositionCorrection { + play_request_id: u64, + track_id: SpotifyId, + position_ms: u32, + }, + Seeked { + play_request_id: u64, + track_id: SpotifyId, + position_ms: u32, + }, + TrackChanged { + audio_item: Box, + }, + SessionConnected { + connection_id: String, + user_name: String, + }, + SessionDisconnected { + connection_id: String, + user_name: String, + }, + SessionClientChanged { + client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, + }, + ShuffleChanged { + shuffle: bool, + }, + RepeatChanged { + repeat: bool, + }, + AutoPlayChanged { + auto_play: bool, + }, + FilterExplicitContentChanged { + filter: bool, + }, } impl PlayerEvent { @@ -200,9 +240,6 @@ impl PlayerEvent { | Unavailable { play_request_id, .. } - | Started { - play_request_id, .. - } | Playing { play_request_id, .. } @@ -217,8 +254,14 @@ impl PlayerEvent { } | Stopped { play_request_id, .. + } + | PositionCorrection { + play_request_id, .. + } + | Seeked { + play_request_id, .. } => Some(*play_request_id), - Changed { .. } | Preloading { .. } | VolumeSet { .. } => None, + _ => None, } } } @@ -370,12 +413,11 @@ impl Player { session: Session, volume_getter: Box, sink_builder: F, - ) -> (Player, PlayerEventChannel) + ) -> Self where F: FnOnce() -> Box + Send + 'static, { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let (event_sender, event_receiver) = mpsc::unbounded_channel(); if config.normalisation { debug!("Normalisation Type: {:?}", config.normalisation_type); @@ -421,7 +463,7 @@ impl Player { sink_status: SinkStatus::Closed, sink_event_callback: None, volume_getter, - event_senders: [event_sender].to_vec(), + event_senders: vec![], converter, normalisation_peak: 0.0, @@ -440,14 +482,11 @@ impl Player { debug!("PlayerInternal thread finished."); }); - ( - Player { - commands: Some(cmd_tx), - thread_handle: Some(handle), - play_request_id_generator: SeqGenerator::new(0), - }, - event_receiver, - ) + Self { + commands: Some(cmd_tx), + thread_handle: Some(handle), + play_request_id_generator: SeqGenerator::new(0), + } } fn command(&self, cmd: PlayerCommand) { @@ -512,16 +551,57 @@ impl Player { self.command(PlayerCommand::SetSinkEventCallback(callback)); } - pub fn emit_volume_set_event(&self, volume: u16) { - self.command(PlayerCommand::EmitVolumeSetEvent(volume)); + pub fn emit_volume_changed_event(&self, volume: u16) { + self.command(PlayerCommand::EmitVolumeChangedEvent(volume)); } pub fn set_auto_normalise_as_album(&self, setting: bool) { self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting)); } - pub fn skip_explicit_content(&self) { - self.command(PlayerCommand::SkipExplicitContent()); + pub fn emit_filter_explicit_content_changed_event(&self, filter: bool) { + self.command(PlayerCommand::EmitFilterExplicitContentChangedEvent(filter)); + } + + pub fn emit_session_connected_event(&self, connection_id: String, user_name: String) { + self.command(PlayerCommand::EmitSessionConnectedEvent { + connection_id, + user_name, + }); + } + + pub fn emit_session_disconnected_event(&self, connection_id: String, user_name: String) { + self.command(PlayerCommand::EmitSessionDisconnectedEvent { + connection_id, + user_name, + }); + } + + pub fn emit_session_client_changed_event( + &self, + client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, + ) { + self.command(PlayerCommand::EmitSessionClientChangedEvent { + client_id, + client_name, + client_brand_name, + client_model_name, + }); + } + + pub fn emit_shuffle_changed_event(&self, shuffle: bool) { + self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle)); + } + + pub fn emit_repeat_changed_event(&self, repeat: bool) { + self.command(PlayerCommand::EmitRepeatChangedEvent(repeat)); + } + + pub fn emit_auto_play_changed_event(&self, auto_play: bool) { + self.command(PlayerCommand::EmitAutoPlayChangedEvent(auto_play)); } } @@ -541,6 +621,7 @@ struct PlayerLoadedTrackData { decoder: Decoder, normalisation_data: NormalisationData, stream_loader_controller: StreamLoaderController, + audio_item: AudioItem, bytes_per_second: usize, duration_ms: u32, stream_position_ms: u32, @@ -573,6 +654,7 @@ enum PlayerState { track_id: SpotifyId, play_request_id: u64, decoder: Decoder, + audio_item: AudioItem, normalisation_data: NormalisationData, normalisation_factor: f64, stream_loader_controller: StreamLoaderController, @@ -587,6 +669,7 @@ enum PlayerState { play_request_id: u64, decoder: Decoder, normalisation_data: NormalisationData, + audio_item: AudioItem, normalisation_factor: f64, stream_loader_controller: StreamLoaderController, bytes_per_second: usize, @@ -660,6 +743,7 @@ impl PlayerState { stream_loader_controller, stream_position_ms, is_explicit, + audio_item, .. } => { *self = EndOfTrack { @@ -669,6 +753,7 @@ impl PlayerState { decoder, normalisation_data, stream_loader_controller, + audio_item, bytes_per_second, duration_ms, stream_position_ms, @@ -694,6 +779,7 @@ impl PlayerState { track_id, play_request_id, decoder, + audio_item, normalisation_data, normalisation_factor, stream_loader_controller, @@ -707,13 +793,15 @@ impl PlayerState { track_id, play_request_id, decoder, + audio_item, normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, bytes_per_second, stream_position_ms, - reported_nominal_start_time: None, + reported_nominal_start_time: Instant::now() + .checked_sub(Duration::from_millis(stream_position_ms as u64)), suggested_to_preload_next_track, is_explicit, }; @@ -736,20 +824,22 @@ impl PlayerState { track_id, play_request_id, decoder, + audio_item, normalisation_data, normalisation_factor, stream_loader_controller, duration_ms, bytes_per_second, stream_position_ms, - reported_nominal_start_time: _, suggested_to_preload_next_track, is_explicit, + .. } => { *self = Paused { track_id, play_request_id, decoder, + audio_item, normalisation_data, normalisation_factor, stream_loader_controller, @@ -777,13 +867,13 @@ struct PlayerTrackLoader { } impl PlayerTrackLoader { - async fn find_available_alternative(&self, audio: AudioItem) -> Option { - if let Err(e) = audio.availability { + async fn find_available_alternative(&self, audio_item: AudioItem) -> Option { + if let Err(e) = audio_item.availability { error!("Track is unavailable: {}", e); None - } else if !audio.files.is_empty() { - Some(audio) - } else if let Some(alternatives) = &audio.alternatives { + } else if !audio_item.files.is_empty() { + Some(audio_item) + } else if let Some(alternatives) = &audio_item.alternatives { let alternatives: FuturesUnordered<_> = alternatives .iter() .map(|alt_id| AudioItem::get_file(&self.session, *alt_id)) @@ -822,7 +912,7 @@ impl PlayerTrackLoader { spotify_id: SpotifyId, position_ms: u32, ) -> Option { - let audio = match AudioItem::get_file(&self.session, spotify_id).await { + let audio_item = match AudioItem::get_file(&self.session, spotify_id).await { Ok(audio) => match self.find_available_alternative(audio).await { Some(audio) => audio, None => { @@ -841,31 +931,9 @@ impl PlayerTrackLoader { info!( "Loading <{}> with Spotify URI <{}>", - audio.name, audio.spotify_uri + audio_item.name, audio_item.uri ); - let is_explicit = audio.is_explicit; - - if is_explicit { - if let Some(value) = self.session.get_user_attribute("filter-explicit-content") { - if &value == "1" { - warn!("Track is marked as explicit, which client setting forbids."); - return None; - } - } - } - - if audio.duration < 0 { - error!( - "Track duration for <{}> cannot be {}", - spotify_id.to_uri().unwrap_or_default(), - audio.duration - ); - return None; - } - - let duration_ms = audio.duration as u32; - // (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it let formats = match self.config.bitrate { Bitrate::Bitrate96 => [ @@ -900,13 +968,16 @@ impl PlayerTrackLoader { let (format, file_id) = match formats .iter() - .find_map(|format| match audio.files.get(format) { + .find_map(|format| match audio_item.files.get(format) { Some(&file_id) => Some((*format, file_id)), _ => None, }) { Some(t) => t, None => { - warn!("<{}> is not available in any supported format", audio.name); + warn!( + "<{}> is not available in any supported format", + audio_item.name + ); return None; } }; @@ -1020,6 +1091,17 @@ impl PlayerTrackLoader { } }; + let duration_ms = audio_item.duration_ms; + // Don't try to seek past the track's duration. + // If the position is invalid just start from + // the beginning of the track. + let position_ms = if position_ms > duration_ms { + warn!("Invalid start position of {}ms exceeds track's duration of {}ms, starting track from the beginning", position_ms, duration_ms); + 0 + } else { + position_ms + }; + // Ensure the starting position. Even when we want to play from the beginning, // the cursor may have been moved by parsing normalisation data. This may not // matter for playback (but won't hurt either), but may be useful for the @@ -1038,12 +1120,15 @@ impl PlayerTrackLoader { // Ensure streaming mode now that we are ready to play from the requested position. stream_loader_controller.set_stream_mode(); - info!("<{}> ({} ms) loaded", audio.name, audio.duration); + let is_explicit = audio_item.is_explicit; + + info!("<{}> ({} ms) loaded", audio_item.name, duration_ms); return Some(PlayerLoadedTrackData { decoder, normalisation_data, stream_loader_controller, + audio_item, bytes_per_second, duration_ms, stream_position_ms, @@ -1164,7 +1249,6 @@ impl Future for PlayerInternal { normalisation_factor, ref mut stream_position_ms, ref mut reported_nominal_start_time, - duration_ms, .. } = self.state { @@ -1226,11 +1310,10 @@ impl Future for PlayerInternal { if notify_about_position { *reported_nominal_start_time = now.checked_sub(new_stream_position); - self.send_event(PlayerEvent::Playing { - track_id, + self.send_event(PlayerEvent::PositionCorrection { play_request_id, + track_id, position_ms: new_stream_position_ms as u32, - duration_ms, }); } } @@ -1315,7 +1398,7 @@ impl PlayerInternal { Ok(()) => self.sink_status = SinkStatus::Running, Err(e) => { error!("{}", e); - exit(1); + self.handle_pause(); } } } @@ -1396,7 +1479,6 @@ impl PlayerInternal { track_id, play_request_id, stream_position_ms, - duration_ms, .. } = self.state { @@ -1405,7 +1487,6 @@ impl PlayerInternal { track_id, play_request_id, position_ms: stream_position_ms, - duration_ms, }); self.ensure_sink_running(); } else { @@ -1414,25 +1495,24 @@ impl PlayerInternal { } fn handle_pause(&mut self) { - if let PlayerState::Playing { - track_id, - play_request_id, - stream_position_ms, - duration_ms, - .. - } = self.state - { - self.state.playing_to_paused(); - - self.ensure_sink_stopped(false); - self.send_event(PlayerEvent::Paused { + match self.state { + PlayerState::Paused { .. } => self.ensure_sink_stopped(false), + PlayerState::Playing { track_id, play_request_id, - position_ms: stream_position_ms, - duration_ms, - }); - } else { - error!("Player::pause called from invalid state: {:?}", self.state); + stream_position_ms, + .. + } => { + self.state.playing_to_paused(); + + self.ensure_sink_stopped(false); + self.send_event(PlayerEvent::Paused { + track_id, + play_request_id, + position_ms: stream_position_ms, + }); + } + _ => error!("Player::pause called from invalid state: {:?}", self.state), } } @@ -1555,7 +1635,7 @@ impl PlayerInternal { if let Err(e) = self.sink.write(packet, &mut self.converter) { error!("{}", e); - exit(1); + self.handle_pause(); } } } @@ -1587,6 +1667,10 @@ impl PlayerInternal { loaded_track: PlayerLoadedTrackData, start_playback: bool, ) { + let audio_item = Box::new(loaded_track.audio_item.clone()); + + self.send_event(PlayerEvent::TrackChanged { audio_item }); + let position_ms = loaded_track.stream_position_ms; let mut config = self.config.clone(); @@ -1602,18 +1686,17 @@ impl PlayerInternal { if start_playback { self.ensure_sink_running(); - self.send_event(PlayerEvent::Playing { track_id, play_request_id, position_ms, - duration_ms: loaded_track.duration_ms, }); self.state = PlayerState::Playing { track_id, play_request_id, decoder: loaded_track.decoder, + audio_item: loaded_track.audio_item, normalisation_data: loaded_track.normalisation_data, normalisation_factor, stream_loader_controller: loaded_track.stream_loader_controller, @@ -1632,6 +1715,7 @@ impl PlayerInternal { track_id, play_request_id, decoder: loaded_track.decoder, + audio_item: loaded_track.audio_item, normalisation_data: loaded_track.normalisation_data, normalisation_factor, stream_loader_controller: loaded_track.stream_loader_controller, @@ -1646,7 +1730,6 @@ impl PlayerInternal { track_id, play_request_id, position_ms, - duration_ms: loaded_track.duration_ms, }); } } @@ -1661,38 +1744,12 @@ impl PlayerInternal { if !self.config.gapless { self.ensure_sink_stopped(play); } - // emit the correct player event - match self.state { - PlayerState::Playing { - track_id: old_track_id, - .. - } - | PlayerState::Paused { - track_id: old_track_id, - .. - } - | PlayerState::EndOfTrack { - track_id: old_track_id, - .. - } - | PlayerState::Loading { - track_id: old_track_id, - .. - } => self.send_event(PlayerEvent::Changed { - old_track_id, - new_track_id: track_id, - }), - PlayerState::Stopped => self.send_event(PlayerEvent::Started { - track_id, - play_request_id, - position_ms, - }), - PlayerState::Invalid { .. } => { - return Err(Error::internal(format!( - "Player::handle_command_load called from invalid state: {:?}", - self.state - ))); - } + + if matches!(self.state, PlayerState::Invalid { .. }) { + return Err(Error::internal(format!( + "Player::handle_command_load called from invalid state: {:?}", + self.state + ))); } // Now we check at different positions whether we already have a pre-loaded version @@ -1754,6 +1811,7 @@ impl PlayerInternal { if let PlayerState::Playing { stream_position_ms, decoder, + audio_item, stream_loader_controller, bytes_per_second, duration_ms, @@ -1764,6 +1822,7 @@ impl PlayerInternal { | PlayerState::Paused { stream_position_ms, decoder, + audio_item, stream_loader_controller, bytes_per_second, duration_ms, @@ -1776,6 +1835,7 @@ impl PlayerInternal { decoder, normalisation_data, stream_loader_controller, + audio_item, bytes_per_second, duration_ms, stream_position_ms, @@ -1925,14 +1985,24 @@ impl PlayerInternal { Ok(new_position_ms) => { if let PlayerState::Playing { ref mut stream_position_ms, + track_id, + play_request_id, .. } | PlayerState::Paused { ref mut stream_position_ms, + track_id, + play_request_id, .. } = self.state { *stream_position_ms = new_position_ms; + + self.send_event(PlayerEvent::Seeked { + play_request_id, + track_id, + position_ms: new_position_ms, + }); } } Err(e) => error!("PlayerInternal::handle_command_seek error: {}", e), @@ -1945,35 +2015,12 @@ impl PlayerInternal { self.preload_data_before_playback()?; if let PlayerState::Playing { - track_id, - play_request_id, ref mut reported_nominal_start_time, - duration_ms, .. } = self.state { *reported_nominal_start_time = Instant::now().checked_sub(Duration::from_millis(position_ms as u64)); - self.send_event(PlayerEvent::Playing { - track_id, - play_request_id, - position_ms, - duration_ms, - }); - } - if let PlayerState::Paused { - track_id, - play_request_id, - duration_ms, - .. - } = self.state - { - self.send_event(PlayerEvent::Paused { - track_id, - play_request_id, - position_ms, - duration_ms, - }); } Ok(()) @@ -2003,34 +2050,78 @@ impl PlayerInternal { PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback, - PlayerCommand::EmitVolumeSetEvent(volume) => { - self.send_event(PlayerEvent::VolumeSet { volume }) + PlayerCommand::EmitVolumeChangedEvent(volume) => { + self.send_event(PlayerEvent::VolumeChanged { volume }) } + PlayerCommand::EmitRepeatChangedEvent(repeat) => { + self.send_event(PlayerEvent::RepeatChanged { repeat }) + } + + PlayerCommand::EmitShuffleChangedEvent(shuffle) => { + self.send_event(PlayerEvent::ShuffleChanged { shuffle }) + } + + PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => { + self.send_event(PlayerEvent::AutoPlayChanged { auto_play }) + } + + PlayerCommand::EmitSessionClientChangedEvent { + client_id, + client_name, + client_brand_name, + client_model_name, + } => self.send_event(PlayerEvent::SessionClientChanged { + client_id, + client_name, + client_brand_name, + client_model_name, + }), + + PlayerCommand::EmitSessionConnectedEvent { + connection_id, + user_name, + } => self.send_event(PlayerEvent::SessionConnected { + connection_id, + user_name, + }), + + PlayerCommand::EmitSessionDisconnectedEvent { + connection_id, + user_name, + } => self.send_event(PlayerEvent::SessionDisconnected { + connection_id, + user_name, + }), + PlayerCommand::SetAutoNormaliseAsAlbum(setting) => { self.auto_normalise_as_album = setting } - PlayerCommand::SkipExplicitContent() => { - if let PlayerState::Playing { - track_id, - play_request_id, - is_explicit, - .. - } - | PlayerState::Paused { - track_id, - play_request_id, - is_explicit, - .. - } = self.state - { - if is_explicit { - warn!("Currently loaded track is explicit, which client setting forbids -- skipping to next track."); - self.send_event(PlayerEvent::EndOfTrack { - track_id, - play_request_id, - }) + PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => { + self.send_event(PlayerEvent::FilterExplicitContentChanged { filter }); + + if filter { + if let PlayerState::Playing { + track_id, + play_request_id, + is_explicit, + .. + } + | PlayerState::Paused { + track_id, + play_request_id, + is_explicit, + .. + } = self.state + { + if is_explicit { + warn!("Currently loaded track is explicit, which client setting forbids -- skipping to next track."); + self.send_event(PlayerEvent::EndOfTrack { + track_id, + play_request_id, + }) + } } } } @@ -2133,7 +2224,7 @@ impl Drop for PlayerInternal { impl fmt::Debug for PlayerCommand { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { + match self { PlayerCommand::Load { track_id, play, @@ -2156,14 +2247,58 @@ impl fmt::Debug for PlayerCommand { PlayerCommand::SetSinkEventCallback(_) => { f.debug_tuple("SetSinkEventCallback").finish() } - PlayerCommand::EmitVolumeSetEvent(volume) => { - f.debug_tuple("VolumeSet").field(&volume).finish() - } + PlayerCommand::EmitVolumeChangedEvent(volume) => f + .debug_tuple("EmitVolumeChangedEvent") + .field(&volume) + .finish(), PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f .debug_tuple("SetAutoNormaliseAsAlbum") .field(&setting) .finish(), - PlayerCommand::SkipExplicitContent() => f.debug_tuple("SkipExplicitContent").finish(), + PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => f + .debug_tuple("EmitFilterExplicitContentChangedEvent") + .field(&filter) + .finish(), + PlayerCommand::EmitSessionConnectedEvent { + connection_id, + user_name, + } => f + .debug_tuple("EmitSessionConnectedEvent") + .field(&connection_id) + .field(&user_name) + .finish(), + PlayerCommand::EmitSessionDisconnectedEvent { + connection_id, + user_name, + } => f + .debug_tuple("EmitSessionDisconnectedEvent") + .field(&connection_id) + .field(&user_name) + .finish(), + PlayerCommand::EmitSessionClientChangedEvent { + client_id, + client_name, + client_brand_name, + client_model_name, + } => f + .debug_tuple("EmitSessionClientChangedEvent") + .field(&client_id) + .field(&client_name) + .field(&client_brand_name) + .field(&client_model_name) + .finish(), + PlayerCommand::EmitShuffleChangedEvent(shuffle) => f + .debug_tuple("EmitShuffleChangedEvent") + .field(&shuffle) + .finish(), + PlayerCommand::EmitRepeatChangedEvent(repeat) => f + .debug_tuple("EmitRepeatChangedEvent") + .field(&repeat) + .finish(), + PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f + .debug_tuple("EmitAutoPlayChangedEvent") + .field(&auto_play) + .finish(), } } } diff --git a/src/main.rs b/src/main.rs index e1af4c10..aac7119a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,6 @@ use futures_util::StreamExt; use log::{error, info, trace, warn}; use sha1::{Digest, Sha1}; use thiserror::Error; -use tokio::sync::mpsc::UnboundedReceiver; use url::Url; use librespot::{ @@ -29,7 +28,7 @@ use librespot::{ }, dither, mixer::{self, MixerConfig, MixerFn}, - player::{coefficient_to_duration, duration_to_coefficient, Player, PlayerEvent}, + player::{coefficient_to_duration, duration_to_coefficient, Player}, }, }; @@ -37,7 +36,7 @@ use librespot::{ use librespot::playback::mixer::alsamixer::AlsaMixer; mod player_event_handler; -use player_event_handler::{emit_sink_event, run_program_on_events}; +use player_event_handler::{run_program_on_sink_events, EventHandler}; fn device_id(name: &str) -> String { hex::encode(Sha1::digest(name.as_bytes())) @@ -1598,10 +1597,10 @@ async fn main() { let mut last_credentials = None; let mut spirc: Option = None; let mut spirc_task: Option> = None; - let mut player_event_channel: Option> = None; let mut auto_connect_times: Vec = vec![]; let mut discovery = None; let mut connecting = false; + let mut _event_handler: Option = None; let session = Session::new(setup.session_config.clone(), setup.cache.clone()); @@ -1669,32 +1668,21 @@ async fn main() { let format = setup.format; let backend = setup.backend; let device = setup.device.clone(); - let (player, event_channel) = - Player::new(player_config, session.clone(), soft_volume, move || { - (backend)(device, format) - }); + let player = Player::new(player_config, session.clone(), soft_volume, move || { + (backend)(device, format) + }); - if setup.emit_sink_events { - if let Some(player_event_program) = setup.player_event_program.clone() { + if let Some(player_event_program) = setup.player_event_program.clone() { + _event_handler = Some(EventHandler::new(player.get_player_event_channel(), &player_event_program)); + + if setup.emit_sink_events { player.set_sink_event_callback(Some(Box::new(move |sink_status| { - match emit_sink_event(sink_status, &player_event_program) { - Ok(e) if e.success() => (), - Ok(e) => { - if let Some(code) = e.code() { - warn!("Sink event program returned exit code {}", code); - } else { - warn!("Sink event program returned failure"); - } - }, - Err(e) => { - warn!("Emitting sink event failed: {}", e); - }, - } + run_program_on_sink_events(sink_status, &player_event_program) }))); } }; - let (spirc_, spirc_task_) = match Spirc::new(connect_config, session.clone(), last_credentials.clone().unwrap(), player, mixer).await { + let (spirc_, spirc_task_) = match Spirc::new(connect_config, session.clone(), last_credentials.clone().unwrap_or_default(), player, mixer).await { Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_), Err(e) => { error!("could not initialize spirc: {}", e); @@ -1703,7 +1691,6 @@ async fn main() { }; spirc = Some(spirc_); spirc_task = Some(Box::pin(spirc_task_)); - player_event_channel = Some(event_channel); connecting = false; }, @@ -1732,41 +1719,6 @@ async fn main() { }, } }, - event = async { - match player_event_channel.as_mut() { - Some(p) => p.recv().await, - _ => None - } - }, if player_event_channel.is_some() => match event { - Some(event) => { - if let Some(program) = &setup.player_event_program { - if let Some(child) = run_program_on_events(event, program) { - if let Ok(mut child) = child { - tokio::spawn(async move { - match child.wait().await { - Ok(e) if e.success() => (), - Ok(e) => { - if let Some(code) = e.code() { - warn!("On event program returned exit code {}", code); - } else { - warn!("On event program returned failure"); - } - }, - Err(e) => { - warn!("On event program failed: {}", e); - }, - } - }); - } else { - warn!("On event program failed to start"); - } - } - } - }, - None => { - player_event_channel = None; - } - }, _ = tokio::signal::ctrl_c() => { break; }, diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 99b1645d..44d92eb5 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -1,148 +1,307 @@ -use log::info; +use log::{debug, error, warn}; -use std::{ - collections::HashMap, - io::{Error, ErrorKind, Result}, - process::{Command, ExitStatus}, +use std::{collections::HashMap, process::Command, thread}; + +use librespot::{ + metadata::audio::UniqueFields, + playback::player::{PlayerEvent, PlayerEventChannel, SinkStatus}, }; -use tokio::process::{Child as AsyncChild, Command as AsyncCommand}; - -use librespot::playback::player::{PlayerEvent, SinkStatus}; - -pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option> { - let mut env_vars = HashMap::new(); - match event { - PlayerEvent::Changed { - old_track_id, - new_track_id, - } => match old_track_id.to_base62() { - Err(e) => { - return Some(Err(Error::new( - ErrorKind::InvalidData, - format!("PlayerEvent::Changed: Invalid old track id: {}", e), - ))) - } - Ok(old_id) => match new_track_id.to_base62() { - Err(e) => { - return Some(Err(Error::new( - ErrorKind::InvalidData, - format!("PlayerEvent::Changed: Invalid old track id: {}", e), - ))) - } - Ok(new_id) => { - env_vars.insert("PLAYER_EVENT", "changed".to_string()); - env_vars.insert("OLD_TRACK_ID", old_id); - env_vars.insert("TRACK_ID", new_id); - } - }, - }, - PlayerEvent::Started { track_id, .. } => match track_id.to_base62() { - Err(e) => { - return Some(Err(Error::new( - ErrorKind::InvalidData, - format!("PlayerEvent::Started: Invalid track id: {}", e), - ))) - } - Ok(id) => { - env_vars.insert("PLAYER_EVENT", "started".to_string()); - env_vars.insert("TRACK_ID", id); - } - }, - PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() { - Err(e) => { - return Some(Err(Error::new( - ErrorKind::InvalidData, - format!("PlayerEvent::Stopped: Invalid track id: {}", e), - ))) - } - Ok(id) => { - env_vars.insert("PLAYER_EVENT", "stopped".to_string()); - env_vars.insert("TRACK_ID", id); - } - }, - PlayerEvent::Playing { - track_id, - duration_ms, - position_ms, - .. - } => match track_id.to_base62() { - Err(e) => { - return Some(Err(Error::new( - ErrorKind::InvalidData, - format!("PlayerEvent::Playing: Invalid track id: {}", e), - ))) - } - Ok(id) => { - env_vars.insert("PLAYER_EVENT", "playing".to_string()); - env_vars.insert("TRACK_ID", id); - env_vars.insert("DURATION_MS", duration_ms.to_string()); - env_vars.insert("POSITION_MS", position_ms.to_string()); - } - }, - PlayerEvent::Paused { - track_id, - duration_ms, - position_ms, - .. - } => match track_id.to_base62() { - Err(e) => { - return Some(Err(Error::new( - ErrorKind::InvalidData, - format!("PlayerEvent::Paused: Invalid track id: {}", e), - ))) - } - Ok(id) => { - env_vars.insert("PLAYER_EVENT", "paused".to_string()); - env_vars.insert("TRACK_ID", id); - env_vars.insert("DURATION_MS", duration_ms.to_string()); - env_vars.insert("POSITION_MS", position_ms.to_string()); - } - }, - PlayerEvent::Preloading { track_id, .. } => match track_id.to_base62() { - Err(e) => { - return Some(Err(Error::new( - ErrorKind::InvalidData, - format!("PlayerEvent::Preloading: Invalid track id: {}", e), - ))) - } - Ok(id) => { - env_vars.insert("PLAYER_EVENT", "preloading".to_string()); - env_vars.insert("TRACK_ID", id); - } - }, - PlayerEvent::VolumeSet { volume } => { - env_vars.insert("PLAYER_EVENT", "volume_set".to_string()); - env_vars.insert("VOLUME", volume.to_string()); - } - _ => return None, - } - - let mut v: Vec<&str> = onevent.split_whitespace().collect(); - info!("Running {:?} with environment variables {:?}", v, env_vars); - Some( - AsyncCommand::new(&v.remove(0)) - .args(&v) - .envs(env_vars.iter()) - .spawn(), - ) +pub struct EventHandler { + thread_handle: Option>, } -pub fn emit_sink_event(sink_status: SinkStatus, onevent: &str) -> Result { +impl EventHandler { + pub fn new(mut player_events: PlayerEventChannel, onevent: &str) -> Self { + let on_event = onevent.to_string(); + let thread_handle = Some(thread::spawn(move || loop { + match player_events.blocking_recv() { + None => break, + Some(event) => { + let mut env_vars = HashMap::new(); + + match event { + PlayerEvent::TrackChanged { audio_item } => { + match audio_item.track_id.to_base62() { + Err(e) => { + warn!("PlayerEvent::TrackChanged: Invalid track id: {}", e) + } + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "track_changed".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("URI", audio_item.uri); + env_vars.insert("NAME", audio_item.name); + env_vars.insert( + "COVERS", + audio_item + .covers + .into_iter() + .map(|c| c.url) + .collect::>() + .join("\n"), + ); + env_vars.insert("LANGUAGE", audio_item.language.join("\n")); + env_vars + .insert("DURATION_MS", audio_item.duration_ms.to_string()); + env_vars + .insert("IS_EXPLICIT", audio_item.is_explicit.to_string()); + + match audio_item.unique_fields { + UniqueFields::Track { + artists, + album, + album_artists, + popularity, + number, + disc_number, + } => { + env_vars.insert("ITEM_TYPE", "Track".to_string()); + env_vars.insert( + "ARTISTS", + artists + .0 + .into_iter() + .map(|a| a.name) + .collect::>() + .join("\n"), + ); + env_vars + .insert("ALBUM_ARTISTS", album_artists.join("\n")); + env_vars.insert("ALBUM", album); + env_vars.insert("POPULARITY", popularity.to_string()); + env_vars.insert("NUMBER", number.to_string()); + env_vars.insert("DISC_NUMBER", disc_number.to_string()); + } + UniqueFields::Episode { + description, + publish_time, + show_name, + } => { + env_vars.insert("ITEM_TYPE", "Episode".to_string()); + env_vars.insert("DESCRIPTION", description); + env_vars.insert( + "PUBLISH_TIME", + publish_time.unix_timestamp().to_string(), + ); + env_vars.insert("SHOW_NAME", show_name); + } + } + } + } + } + PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() { + Err(e) => warn!("PlayerEvent::Stopped: Invalid track id: {}", e), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "stopped".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::Playing { + track_id, + position_ms, + .. + } => match track_id.to_base62() { + Err(e) => warn!("PlayerEvent::Playing: Invalid track id: {}", e), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "playing".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("POSITION_MS", position_ms.to_string()); + } + }, + PlayerEvent::Paused { + track_id, + position_ms, + .. + } => match track_id.to_base62() { + Err(e) => warn!("PlayerEvent::Paused: Invalid track id: {}", e), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "paused".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("POSITION_MS", position_ms.to_string()); + } + }, + PlayerEvent::Loading { track_id, .. } => match track_id.to_base62() { + Err(e) => warn!("PlayerEvent::Loading: Invalid track id: {}", e), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "loading".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::Preloading { track_id, .. } => match track_id.to_base62() { + Err(e) => warn!("PlayerEvent::Preloading: Invalid track id: {}", e), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "preloading".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::TimeToPreloadNextTrack { track_id, .. } => { + match track_id.to_base62() { + Err(e) => warn!( + "PlayerEvent::TimeToPreloadNextTrack: Invalid track id: {}", + e + ), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "preload_next".to_string()); + env_vars.insert("TRACK_ID", id); + } + } + } + PlayerEvent::EndOfTrack { track_id, .. } => match track_id.to_base62() { + Err(e) => warn!("PlayerEvent::EndOfTrack: Invalid track id: {}", e), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "end_of_track".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::Unavailable { track_id, .. } => match track_id.to_base62() { + Err(e) => warn!("PlayerEvent::Unavailable: Invalid track id: {}", e), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "unavailable".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::VolumeChanged { volume } => { + env_vars.insert("PLAYER_EVENT", "volume_changed".to_string()); + env_vars.insert("VOLUME", volume.to_string()); + } + PlayerEvent::Seeked { + track_id, + position_ms, + .. + } => match track_id.to_base62() { + Err(e) => warn!("PlayerEvent::Seeked: Invalid track id: {}", e), + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "seeked".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("POSITION_MS", position_ms.to_string()); + } + }, + PlayerEvent::PositionCorrection { + track_id, + position_ms, + .. + } => match track_id.to_base62() { + Err(e) => { + warn!("PlayerEvent::PositionCorrection: Invalid track id: {}", e) + } + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "position_correction".to_string()); + env_vars.insert("TRACK_ID", id); + env_vars.insert("POSITION_MS", position_ms.to_string()); + } + }, + PlayerEvent::SessionConnected { + connection_id, + user_name, + } => { + env_vars.insert("PLAYER_EVENT", "session_connected".to_string()); + env_vars.insert("CONNECTION_ID", connection_id); + env_vars.insert("USER_NAME", user_name); + } + PlayerEvent::SessionDisconnected { + connection_id, + user_name, + } => { + env_vars.insert("PLAYER_EVENT", "session_disconnected".to_string()); + env_vars.insert("CONNECTION_ID", connection_id); + env_vars.insert("USER_NAME", user_name); + } + PlayerEvent::SessionClientChanged { + client_id, + client_name, + client_brand_name, + client_model_name, + } => { + env_vars.insert("PLAYER_EVENT", "session_client_changed".to_string()); + env_vars.insert("CLIENT_ID", client_id); + env_vars.insert("CLIENT_NAME", client_name); + env_vars.insert("CLIENT_BRAND_NAME", client_brand_name); + env_vars.insert("CLIENT_MODEL_NAME", client_model_name); + } + PlayerEvent::ShuffleChanged { shuffle } => { + env_vars.insert("PLAYER_EVENT", "shuffle_changed".to_string()); + env_vars.insert("SHUFFLE", shuffle.to_string()); + } + PlayerEvent::RepeatChanged { repeat } => { + env_vars.insert("PLAYER_EVENT", "repeat_changed".to_string()); + env_vars.insert("REPEAT", repeat.to_string()); + } + PlayerEvent::AutoPlayChanged { auto_play } => { + env_vars.insert("PLAYER_EVENT", "auto_play_changed".to_string()); + env_vars.insert("AUTO_PLAY", auto_play.to_string()); + } + + PlayerEvent::FilterExplicitContentChanged { filter } => { + env_vars.insert( + "PLAYER_EVENT", + "filter_explicit_content_changed".to_string(), + ); + env_vars.insert("FILTER", filter.to_string()); + } + } + + if !env_vars.is_empty() { + run_program(env_vars, &on_event); + } + } + } + })); + + Self { thread_handle } + } +} + +impl Drop for EventHandler { + fn drop(&mut self) { + debug!("Shutting down EventHandler thread ..."); + if let Some(handle) = self.thread_handle.take() { + if let Err(e) = handle.join() { + error!("EventHandler thread Error: {:?}", e); + } + } + } +} + +pub fn run_program_on_sink_events(sink_status: SinkStatus, onevent: &str) { let mut env_vars = HashMap::new(); + env_vars.insert("PLAYER_EVENT", "sink".to_string()); + let sink_status = match sink_status { SinkStatus::Running => "running", SinkStatus::TemporarilyClosed => "temporarily_closed", SinkStatus::Closed => "closed", }; - env_vars.insert("SINK_STATUS", sink_status.to_string()); - let mut v: Vec<&str> = onevent.split_whitespace().collect(); - info!("Running {:?} with environment variables {:?}", v, env_vars); - Command::new(&v.remove(0)) + env_vars.insert("SINK_STATUS", sink_status.to_string()); + + run_program(env_vars, onevent); +} + +fn run_program(env_vars: HashMap<&str, String>, onevent: &str) { + let mut v: Vec<&str> = onevent.split_whitespace().collect(); + + debug!( + "Running {} with environment variables:\n{:#?}", + onevent, env_vars + ); + + match Command::new(&v.remove(0)) .args(&v) .envs(env_vars.iter()) - .spawn()? - .wait() + .spawn() + { + Err(e) => warn!("On event program {} failed to start: {}", onevent, e), + Ok(mut child) => match child.wait() { + Err(e) => warn!("On event program {} failed: {}", onevent, e), + Ok(e) if e.success() => (), + Ok(e) => { + if let Some(code) = e.code() { + warn!("On event program {} returned exit code {}", onevent, code); + } else { + warn!("On event program {} returned failure: {}", onevent, e); + } + } + }, + } }