Merge pull request #1053 from JasonLG1979/connect-event

Major events overhaul
This commit is contained in:
Roderick van Domburg 2022-09-22 21:43:09 +02:00 committed by GitHub
commit ccd501d22e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1239 additions and 639 deletions

View file

@ -79,9 +79,20 @@ https://github.com/librespot-org/librespot
disabled such content. Applications that use librespot as a library without disabled such content. Applications that use librespot as a library without
Connect should use the 'filter-explicit-content' user attribute in the session. Connect should use the 'filter-explicit-content' user attribute in the session.
- [metadata] All metadata fields in the protobufs are now exposed (breaking) - [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 ### 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 ### Removed
- [main] `autoplay` is no longer a command-line option. Instead, librespot now - [main] `autoplay` is no longer a command-line option. Instead, librespot now

View file

@ -44,6 +44,8 @@ use crate::{
pub enum SpircError { pub enum SpircError {
#[error("response payload empty")] #[error("response payload empty")]
NoData, NoData,
#[error("playback of local files is not supported")]
UnsupportedLocalPlayBack,
#[error("message addressed at another ident: {0}")] #[error("message addressed at another ident: {0}")]
Ident(String), Ident(String),
#[error("message pushed for another URI")] #[error("message pushed for another URI")]
@ -52,10 +54,10 @@ pub enum SpircError {
impl From<SpircError> for Error { impl From<SpircError> for Error {
fn from(err: SpircError) -> Self { fn from(err: SpircError) -> Self {
use SpircError::*;
match err { match err {
SpircError::NoData => Error::unavailable(err), NoData | UnsupportedLocalPlayBack => Error::unavailable(err),
SpircError::Ident(_) => Error::aborted(err), Ident(_) | InvalidUri(_) => Error::aborted(err),
SpircError::InvalidUri(_) => Error::aborted(err),
} }
} }
} }
@ -113,6 +115,7 @@ struct SpircTask {
static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0); static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug)]
pub enum SpircCommand { pub enum SpircCommand {
Play, Play,
PlayPause, PlayPause,
@ -122,7 +125,11 @@ pub enum SpircCommand {
VolumeUp, VolumeUp,
VolumeDown, VolumeDown,
Shutdown, Shutdown,
Shuffle, Shuffle(bool),
Repeat(bool),
Disconnect,
SetPosition(u32),
SetVolume(u16),
} }
const CONTEXT_TRACKS_HISTORY: usize = 10; const CONTEXT_TRACKS_HISTORY: usize = 10;
@ -243,10 +250,8 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes); msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes);
{ {
let repeated = msg.mut_stringValue(); 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/track"));
repeated.push(::std::convert::Into::into("audio/episode")); repeated.push(::std::convert::Into::into("audio/episode"));
repeated.push(::std::convert::Into::into("local"));
repeated.push(::std::convert::Into::into("track")) repeated.push(::std::convert::Into::into("track"))
}; };
msg msg
@ -416,8 +421,20 @@ impl Spirc {
pub fn shutdown(&self) -> Result<(), Error> { pub fn shutdown(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Shutdown)?) Ok(self.commands.send(SpircCommand::Shutdown)?)
} }
pub fn shuffle(&self) -> Result<(), Error> { pub fn shuffle(&self, shuffle: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Shuffle)?) 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> { fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> {
let active = self.device.get_is_active(); if matches!(cmd, SpircCommand::Shutdown) {
match cmd { trace!("Received SpircCommand::Shutdown");
SpircCommand::Play => { CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?;
if active { 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.handle_play();
self.notify(None) self.notify(None)
} else {
CommandSender::new(self, MessageType::kMessageTypePlay).send()
} }
} SpircCommand::PlayPause => {
SpircCommand::PlayPause => {
if active {
self.handle_play_pause(); self.handle_play_pause();
self.notify(None) self.notify(None)
} else {
CommandSender::new(self, MessageType::kMessageTypePlayPause).send()
} }
} SpircCommand::Pause => {
SpircCommand::Pause => {
if active {
self.handle_pause(); self.handle_pause();
self.notify(None) self.notify(None)
} else {
CommandSender::new(self, MessageType::kMessageTypePause).send()
} }
} SpircCommand::Prev => {
SpircCommand::Prev => {
if active {
self.handle_prev(); self.handle_prev();
self.notify(None) self.notify(None)
} else {
CommandSender::new(self, MessageType::kMessageTypePrev).send()
} }
} SpircCommand::Next => {
SpircCommand::Next => {
if active {
self.handle_next(); self.handle_next();
self.notify(None) self.notify(None)
} else {
CommandSender::new(self, MessageType::kMessageTypeNext).send()
} }
} SpircCommand::VolumeUp => {
SpircCommand::VolumeUp => {
if active {
self.handle_volume_up(); self.handle_volume_up();
self.notify(None) self.notify(None)
} else {
CommandSender::new(self, MessageType::kMessageTypeVolumeUp).send()
} }
} SpircCommand::VolumeDown => {
SpircCommand::VolumeDown => {
if active {
self.handle_volume_down(); self.handle_volume_down();
self.notify(None) self.notify(None)
} else {
CommandSender::new(self, MessageType::kMessageTypeVolumeDown).send()
} }
} SpircCommand::Disconnect => {
SpircCommand::Shutdown => { self.handle_disconnect();
CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?; self.notify(None)
self.player.stop();
self.shutdown = true;
if let Some(rx) = self.commands.as_mut() {
rx.close()
} }
Ok(()) SpircCommand::Shuffle(shuffle) => {
} self.state.set_shuffle(shuffle);
SpircCommand::Shuffle => { self.notify(None)
CommandSender::new(self, MessageType::kMessageTypeShuffle).send() }
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 { match event {
PlayerEvent::EndOfTrack { .. } => self.handle_end_of_track(), PlayerEvent::EndOfTrack { .. } => self.handle_end_of_track(),
PlayerEvent::Loading { .. } => { PlayerEvent::Loading { .. } => {
trace!("==> kPlayStatusLoading"); match self.play_status {
self.state.set_status(PlayStatus::kPlayStatusLoading); 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) self.notify(None)
} }
PlayerEvent::Playing { position_ms, .. } => { PlayerEvent::Playing { position_ms, .. }
| PlayerEvent::PositionCorrection { position_ms, .. }
| PlayerEvent::Seeked { position_ms, .. } => {
trace!("==> kPlayStatusPlay"); trace!("==> kPlayStatusPlay");
let new_nominal_start_time = self.now_ms() - position_ms as i64; let new_nominal_start_time = self.now_ms() - position_ms as i64;
match self.play_status { match self.play_status {
@ -674,17 +703,14 @@ impl SpircTask {
} => { } => {
trace!("==> kPlayStatusPause"); trace!("==> kPlayStatusPause");
match self.play_status { match self.play_status {
SpircPlayStatus::Paused { SpircPlayStatus::Paused { .. } | SpircPlayStatus::Playing { .. } => {
ref mut position_ms, self.state.set_status(PlayStatus::kPlayStatusPause);
.. self.update_state_position(new_position_ms);
} => { self.play_status = SpircPlayStatus::Paused {
if *position_ms != new_position_ms { position_ms: new_position_ms,
*position_ms = new_position_ms; preloading_of_next_track_triggered: false,
self.update_state_position(new_position_ms); };
self.notify(None) self.notify(None)
} else {
Ok(())
}
} }
SpircPlayStatus::LoadingPlay { .. } SpircPlayStatus::LoadingPlay { .. }
| SpircPlayStatus::LoadingPause { .. } => { | SpircPlayStatus::LoadingPause { .. } => {
@ -762,7 +788,13 @@ impl SpircTask {
); );
if key == "filter-explicit-content" && new_value == "1" { 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 { } else {
trace!( trace!(
@ -785,13 +817,31 @@ impl SpircTask {
return Err(SpircError::Ident(ident.to_string()).into()); 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() { for entry in update.get_device_state().get_metadata().iter() {
if entry.get_field_type() == "client_id" { match entry.get_field_type() {
self.session.set_client_id(entry.get_metadata()); "client-id" => self.session.set_client_id(entry.get_metadata()),
break; "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() { match update.get_typ() {
MessageType::kMessageTypeHello => self.notify(Some(ident)), MessageType::kMessageTypeHello => self.notify(Some(ident)),
@ -800,6 +850,40 @@ impl SpircTask {
let now = self.now_ms(); let now = self.now_ms();
self.device.set_is_active(true); self.device.set_is_active(true);
self.device.set_became_active_at(now); 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); self.update_tracks(&update);
@ -852,12 +936,17 @@ impl SpircTask {
} }
MessageType::kMessageTypeRepeat => { 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) self.notify(None)
} }
MessageType::kMessageTypeShuffle => { 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() { if self.state.get_shuffle() {
let current_index = self.state.get_playing_track_index(); let current_index = self.state.get_playing_track_index();
let tracks = self.state.mut_track(); let tracks = self.state.mut_track();
@ -873,6 +962,9 @@ impl SpircTask {
let context = self.state.get_context_uri(); let context = self.state.get_context_uri();
debug!("{:?}", context); debug!("{:?}", context);
} }
self.player.emit_shuffle_changed_event(shuffle);
self.notify(None) self.notify(None)
} }
@ -882,6 +974,14 @@ impl SpircTask {
} }
MessageType::kMessageTypeReplace => { 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); self.update_tracks(&update);
if let SpircPlayStatus::Playing { if let SpircPlayStatus::Playing {
@ -915,16 +1015,23 @@ impl SpircTask {
&& self.device.get_became_active_at() && self.device.get_became_active_at()
<= update.get_device_state().get_became_active_at() <= update.get_device_state().get_became_active_at()
{ {
self.device.set_is_active(false); self.handle_disconnect();
self.handle_stop();
} }
Ok(()) self.notify(None)
} }
_ => Ok(()), _ => 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) { fn handle_stop(&mut self) {
self.player.stop(); self.player.stop();
} }
@ -1100,17 +1207,7 @@ impl SpircTask {
} }
if new_index >= tracks_len { if new_index >= tracks_len {
let autoplay = self if self.session.autoplay() {
.session
.get_user_attribute("autoplay")
.unwrap_or_else(|| {
warn!(
"Unable to get autoplay user attribute. Continuing with autoplay disabled."
);
"0".into()
});
if autoplay == "1" {
// Extend the playlist // Extend the playlist
debug!("Extending playlist <{}>", context_uri); debug!("Extending playlist <{}>", context_uri);
self.update_tracks_from_context(); self.update_tracks_from_context();
@ -1282,12 +1379,10 @@ impl SpircTask {
|| context_uri.starts_with("spotify:dailymix:") || context_uri.starts_with("spotify:dailymix:")
{ {
self.context_fut = self.resolve_station(&context_uri); self.context_fut = self.resolve_station(&context_uri);
} else if let Some(autoplay) = self.session.get_user_attribute("autoplay") { } else if self.session.autoplay() {
if &autoplay == "1" { info!("Fetching autoplay context uri");
info!("Fetching autoplay context uri"); // Get autoplay_station_uri for regular playlists
// Get autoplay_station_uri for regular playlists self.autoplay_fut = self.resolve_autoplay_uri(&context_uri);
self.autoplay_fut = self.resolve_autoplay_uri(&context_uri);
}
} }
self.player self.player
@ -1422,12 +1517,18 @@ impl SpircTask {
} }
fn set_volume(&mut self, volume: u16) { fn set_volume(&mut self, volume: u16) {
self.device.set_volume(volume as u32); let old_volume = self.device.get_volume();
self.mixer.set_volume(volume); let new_volume = volume as u32;
if let Some(cache) = self.session.cache() { if old_volume != new_volume {
cache.save_volume(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);
} }
} }

View file

@ -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))

View file

@ -26,7 +26,7 @@ impl From<AuthenticationError> for Error {
} }
/// The credentials are used to log into the Spotify API. /// 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 struct Credentials {
pub username: String, pub username: String,

View file

@ -73,6 +73,9 @@ pub struct UserData {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
struct SessionData { struct SessionData {
client_id: String, client_id: String,
client_name: String,
client_brand_name: String,
client_model_name: String,
connection_id: String, connection_id: String,
time_delta: i64, time_delta: i64,
invalid: bool, invalid: bool,
@ -383,6 +386,30 @@ impl Session {
self.0.data.write().client_id = client_id.to_owned(); 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 { pub fn connection_id(&self) -> String {
self.0.data.read().connection_id.clone() self.0.data.read().connection_id.clone()
} }
@ -403,6 +430,20 @@ impl Session {
self.0.data.read().user_data.country.clone() 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<String> { pub fn set_user_attribute(&self, key: &str, value: &str) -> Option<String> {
let mut dummy_attributes = UserAttributes::new(); let mut dummy_attributes = UserAttributes::new();
dummy_attributes.insert(key.to_owned(), value.to_owned()); dummy_attributes.insert(key.to_owned(), value.to_owned());

View file

@ -36,7 +36,7 @@ async fn main() {
exit(1); 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) backend(None, audio_format)
}); });

View file

@ -97,7 +97,7 @@ impl TryFrom<&<Self as Metadata>::Message> for Album {
date: album.get_date().try_into()?, date: album.get_date().try_into()?,
popularity: album.get_popularity(), popularity: album.get_popularity(),
genres: album.get_genre().to_vec(), genres: album.get_genre().to_vec(),
covers: album.get_cover().into(), covers: album.get_cover_group().into(),
external_ids: album.get_external_id().into(), external_ids: album.get_external_id().into(),
discs: album.get_disc().try_into()?, discs: album.get_disc().try_into()?,
reviews: album.get_review().to_vec(), reviews: album.get_review().to_vec(),

View file

@ -1,11 +1,14 @@
use std::fmt::Debug; use std::fmt::Debug;
use crate::{ use crate::{
artist::ArtistsWithRole,
availability::{AudioItemAvailability, Availabilities, UnavailabilityReason}, availability::{AudioItemAvailability, Availabilities, UnavailabilityReason},
episode::Episode, episode::Episode,
error::MetadataError, error::MetadataError,
image::{ImageSize, Images},
restriction::Restrictions, restriction::Restrictions,
track::{Track, Tracks}, track::{Track, Tracks},
Metadata,
}; };
use super::file::AudioFiles; use super::file::AudioFiles;
@ -16,98 +19,259 @@ use librespot_core::{
pub type AudioItemResult = Result<AudioItem, Error>; pub type AudioItemResult = Result<AudioItem, Error>;
// 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)] #[derive(Debug, Clone)]
pub struct AudioItem { pub struct AudioItem {
pub id: SpotifyId, pub track_id: SpotifyId,
pub spotify_uri: String, pub uri: String,
pub files: AudioFiles, pub files: AudioFiles,
pub name: String, pub name: String,
pub duration: i32, pub covers: Vec<CoverImage>,
pub language: Vec<String>,
pub duration_ms: u32,
pub is_explicit: bool,
pub availability: AudioItemAvailability, pub availability: AudioItemAvailability,
pub alternatives: Option<Tracks>, pub alternatives: Option<Tracks>,
pub is_explicit: bool, pub unique_fields: UniqueFields,
}
#[derive(Debug, Clone)]
pub enum UniqueFields {
Track {
artists: ArtistsWithRole,
album: String,
album_artists: Vec<String>,
popularity: u8,
number: u32,
disc_number: u32,
},
Episode {
description: String,
publish_time: Date,
show_name: String,
},
} }
impl AudioItem { impl AudioItem {
pub async fn get_file(session: &Session, id: SpotifyId) -> AudioItemResult { 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 { match id.item_type {
SpotifyItemType::Track => Track::get_audio_item(session, id).await, SpotifyItemType::Track => {
SpotifyItemType::Episode => Episode::get_audio_item(session, id).await, 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::<Vec<String>>();
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)), _ => Err(Error::unavailable(MetadataError::NonPlayable)),
} }
} }
} }
#[async_trait] fn get_covers(covers: Images, image_url: String) -> Vec<CoverImage> {
pub trait InnerAudioItem { let mut covers = covers;
async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult;
fn allowed_for_user( covers.sort_by(|a, b| b.width.cmp(&a.width));
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| { covers
restriction .iter()
.catalogue_strs .filter_map(|cover| {
.iter() let cover_id = cover.id.to_string();
.any(|restricted_catalogue| restricted_catalogue == user_catalogue)
}) { if !cover_id.is_empty() {
if let Some(allowed_countries) = &premium_restriction.countries_allowed { let cover_image = CoverImage {
// A restriction will specify either a whitelast *or* a blacklist, url: image_url.replace("{file_id}", &cover_id),
// but not both. So restrict availability if there is a whitelist size: cover.size,
// and the country isn't on it. width: cover.width,
if allowed_countries.iter().any(|allowed| country == allowed) { height: cover.height,
return Ok(()); };
} else {
return Err(UnavailabilityReason::NotWhitelisted); Some(cover_image)
} } else {
None
} }
})
if let Some(forbidden_countries) = &premium_restriction.countries_forbidden { .collect()
if forbidden_countries }
.iter()
.any(|forbidden| country == forbidden) fn allowed_for_user(user_data: &UserData, restrictions: &Restrictions) -> AudioItemAvailability {
{ let country = &user_data.country;
return Err(UnavailabilityReason::Blacklisted); let user_catalogue = match user_data.attributes.get("catalogue") {
} else { Some(catalogue) => catalogue,
return Ok(()); None => "premium",
} };
}
} for premium_restriction in restrictions.iter().filter(|restriction| {
restriction
Ok(()) // no restrictions in place .catalogue_strs
} .iter()
.any(|restricted_catalogue| restricted_catalogue == user_catalogue)
fn available(availability: &Availabilities) -> AudioItemAvailability { }) {
if availability.is_empty() { if let Some(allowed_countries) = &premium_restriction.countries_allowed {
// not all items have availability specified // A restriction will specify either a whitelast *or* a blacklist,
return Ok(()); // 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) {
if !(availability return Ok(());
.iter() } else {
.any(|availability| Date::now_utc() >= availability.start)) return Err(UnavailabilityReason::NotWhitelisted);
{ }
return Err(UnavailabilityReason::Embargo); }
}
if let Some(forbidden_countries) = &premium_restriction.countries_forbidden {
Ok(()) if forbidden_countries
} .iter()
.any(|forbidden| country == forbidden)
fn available_for_user( {
user_data: &UserData, return Err(UnavailabilityReason::Blacklisted);
availability: &Availabilities, } else {
restrictions: &Restrictions, return Ok(());
) -> AudioItemAvailability { }
Self::available(availability)?; }
Self::allowed_for_user(user_data, restrictions)?; }
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(())
} }

View file

@ -2,4 +2,4 @@ pub mod file;
pub mod item; pub mod item;
pub use file::{AudioFileFormat, AudioFiles}; pub use file::{AudioFileFormat, AudioFiles};
pub use item::AudioItem; pub use item::{AudioItem, UniqueFields};

View file

@ -5,10 +5,7 @@ use std::{
}; };
use crate::{ use crate::{
audio::{ audio::file::AudioFiles,
file::AudioFiles,
item::{AudioItem, AudioItemResult, InnerAudioItem},
},
availability::Availabilities, availability::Availabilities,
content_rating::ContentRatings, content_rating::ContentRatings,
image::Images, image::Images,
@ -36,7 +33,7 @@ pub struct Episode {
pub covers: Images, pub covers: Images,
pub language: String, pub language: String,
pub is_explicit: bool, pub is_explicit: bool,
pub show: SpotifyId, pub show_name: String,
pub videos: VideoFiles, pub videos: VideoFiles,
pub video_previews: VideoFiles, pub video_previews: VideoFiles,
pub audio_previews: AudioFiles, pub audio_previews: AudioFiles,
@ -57,29 +54,6 @@ pub struct Episodes(pub Vec<SpotifyId>);
impl_deref_wrapped!(Episodes, Vec<SpotifyId>); impl_deref_wrapped!(Episodes, Vec<SpotifyId>);
#[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] #[async_trait]
impl Metadata for Episode { impl Metadata for Episode {
type Message = protocol::metadata::Episode; type Message = protocol::metadata::Episode;
@ -107,7 +81,7 @@ impl TryFrom<&<Self as Metadata>::Message> for Episode {
covers: episode.get_cover_image().get_image().into(), covers: episode.get_cover_image().get_image().into(),
language: episode.get_language().to_owned(), language: episode.get_language().to_owned(),
is_explicit: episode.get_explicit().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(), videos: episode.get_video().into(),
video_previews: episode.get_video_preview().into(), video_previews: episode.get_video_preview().into(),
audio_previews: episode.get_audio_preview().into(), audio_previews: episode.get_audio_preview().into(),

View file

@ -7,4 +7,8 @@ pub enum MetadataError {
Empty, Empty,
#[error("audio item is non-playable when it should be")] #[error("audio item is non-playable when it should be")]
NonPlayable, NonPlayable,
#[error("audio item duration can not be: {0}")]
InvalidDuration(i32),
#[error("track is marked as explicit, which client setting forbids")]
ExplicitContentFiltered,
} }

View file

@ -10,6 +10,7 @@ use librespot_core::{FileId, SpotifyId};
use librespot_protocol as protocol; use librespot_protocol as protocol;
use protocol::metadata::Image as ImageMessage; use protocol::metadata::Image as ImageMessage;
use protocol::metadata::ImageGroup;
pub use protocol::metadata::Image_Size as ImageSize; pub use protocol::metadata::Image_Size as ImageSize;
use protocol::playlist4_external::PictureSize as PictureSizeMessage; use protocol::playlist4_external::PictureSize as PictureSizeMessage;
use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage; use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage;
@ -25,6 +26,12 @@ pub struct Image {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct Images(pub Vec<Image>); pub struct Images(pub Vec<Image>);
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<Image>); impl_deref_wrapped!(Images, Vec<Image>);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -8,11 +8,8 @@ use uuid::Uuid;
use crate::{ use crate::{
artist::{Artists, ArtistsWithRole}, artist::{Artists, ArtistsWithRole},
audio::{ audio::file::AudioFiles,
file::AudioFiles, availability::Availabilities,
item::{AudioItem, AudioItemResult, InnerAudioItem},
},
availability::{Availabilities, UnavailabilityReason},
content_rating::ContentRatings, content_rating::ContentRatings,
external_id::ExternalIds, external_id::ExternalIds,
restriction::Restrictions, restriction::Restrictions,
@ -58,42 +55,6 @@ pub struct Tracks(pub Vec<SpotifyId>);
impl_deref_wrapped!(Tracks, Vec<SpotifyId>); impl_deref_wrapped!(Tracks, Vec<SpotifyId>);
#[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] #[async_trait]
impl Metadata for Track { impl Metadata for Track {
type Message = protocol::metadata::Track; type Message = protocol::metadata::Track;

View file

@ -442,14 +442,16 @@ impl Sink for AlsaSink {
} }
fn stop(&mut self) -> SinkResult<()> { fn stop(&mut self) -> SinkResult<()> {
// Zero fill the remainder of the period buffer and if self.pcm.is_some() {
// write any leftover data before draining the actual PCM buffer. // Zero fill the remainder of the period buffer and
self.period_buffer.resize(self.period_buffer.capacity(), 0); // write any leftover data before draining the actual PCM buffer.
self.write_buf()?; 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(()) Ok(())
} }
@ -489,17 +491,29 @@ impl AlsaSink {
pub const NAME: &'static str = "alsa"; pub const NAME: &'static str = "alsa";
fn write_buf(&mut self) -> SinkResult<()> { 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) { match pcm.io_bytes().writei(&self.period_buffer) {
// Capture and log the original error as a warning, and then try to recover. Ok(_) => Ok(()),
// If recovery fails then forward that error back to player. Err(e) => {
warn!( // Capture and log the original error as a warning, and then try to recover.
"Error writing from AlsaSink buffer to PCM, trying to recover, {}", // If recovery fails then forward that error back to player.
e 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(); self.period_buffer.clear();

View file

@ -111,9 +111,26 @@ enum PlayerCommand {
Seek(u32), Seek(u32),
AddEventSender(mpsc::UnboundedSender<PlayerEvent>), AddEventSender(mpsc::UnboundedSender<PlayerEvent>),
SetSinkEventCallback(Option<SinkEventCallback>), SetSinkEventCallback(Option<SinkEventCallback>),
EmitVolumeSetEvent(u16), EmitVolumeChangedEvent(u16),
SetAutoNormaliseAsAlbum(bool), 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)] #[derive(Debug, Clone)]
@ -123,19 +140,6 @@ pub enum PlayerEvent {
play_request_id: u64, play_request_id: u64,
track_id: SpotifyId, 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. // The player is delayed by loading a track.
Loading { Loading {
play_request_id: u64, play_request_id: u64,
@ -157,14 +161,12 @@ pub enum PlayerEvent {
play_request_id: u64, play_request_id: u64,
track_id: SpotifyId, track_id: SpotifyId,
position_ms: u32, position_ms: u32,
duration_ms: u32,
}, },
// The player entered a paused state. // The player entered a paused state.
Paused { Paused {
play_request_id: u64, play_request_id: u64,
track_id: SpotifyId, track_id: SpotifyId,
position_ms: u32, position_ms: u32,
duration_ms: u32,
}, },
// The player thinks it's a good idea to issue a preload command for the next track now. // 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. // This event is intended for use within spirc.
@ -173,8 +175,7 @@ pub enum PlayerEvent {
track_id: SpotifyId, track_id: SpotifyId,
}, },
// The player reached the end of a track. // The player reached the end of a track.
// This event is intended for use within spirc. Spirc will respond by issuing another command // 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)
EndOfTrack { EndOfTrack {
play_request_id: u64, play_request_id: u64,
track_id: SpotifyId, track_id: SpotifyId,
@ -185,9 +186,48 @@ pub enum PlayerEvent {
track_id: SpotifyId, track_id: SpotifyId,
}, },
// The mixer volume was set to a new level. // The mixer volume was set to a new level.
VolumeSet { VolumeChanged {
volume: u16, 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<AudioItem>,
},
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 { impl PlayerEvent {
@ -200,9 +240,6 @@ impl PlayerEvent {
| Unavailable { | Unavailable {
play_request_id, .. play_request_id, ..
} }
| Started {
play_request_id, ..
}
| Playing { | Playing {
play_request_id, .. play_request_id, ..
} }
@ -217,8 +254,14 @@ impl PlayerEvent {
} }
| Stopped { | Stopped {
play_request_id, .. play_request_id, ..
}
| PositionCorrection {
play_request_id, ..
}
| Seeked {
play_request_id, ..
} => Some(*play_request_id), } => Some(*play_request_id),
Changed { .. } | Preloading { .. } | VolumeSet { .. } => None, _ => None,
} }
} }
} }
@ -370,12 +413,11 @@ impl Player {
session: Session, session: Session,
volume_getter: Box<dyn VolumeGetter + Send>, volume_getter: Box<dyn VolumeGetter + Send>,
sink_builder: F, sink_builder: F,
) -> (Player, PlayerEventChannel) ) -> Self
where where
F: FnOnce() -> Box<dyn Sink> + Send + 'static, F: FnOnce() -> Box<dyn Sink> + Send + 'static,
{ {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (event_sender, event_receiver) = mpsc::unbounded_channel();
if config.normalisation { if config.normalisation {
debug!("Normalisation Type: {:?}", config.normalisation_type); debug!("Normalisation Type: {:?}", config.normalisation_type);
@ -421,7 +463,7 @@ impl Player {
sink_status: SinkStatus::Closed, sink_status: SinkStatus::Closed,
sink_event_callback: None, sink_event_callback: None,
volume_getter, volume_getter,
event_senders: [event_sender].to_vec(), event_senders: vec![],
converter, converter,
normalisation_peak: 0.0, normalisation_peak: 0.0,
@ -440,14 +482,11 @@ impl Player {
debug!("PlayerInternal thread finished."); debug!("PlayerInternal thread finished.");
}); });
( Self {
Player { commands: Some(cmd_tx),
commands: Some(cmd_tx), thread_handle: Some(handle),
thread_handle: Some(handle), play_request_id_generator: SeqGenerator::new(0),
play_request_id_generator: SeqGenerator::new(0), }
},
event_receiver,
)
} }
fn command(&self, cmd: PlayerCommand) { fn command(&self, cmd: PlayerCommand) {
@ -512,16 +551,57 @@ impl Player {
self.command(PlayerCommand::SetSinkEventCallback(callback)); self.command(PlayerCommand::SetSinkEventCallback(callback));
} }
pub fn emit_volume_set_event(&self, volume: u16) { pub fn emit_volume_changed_event(&self, volume: u16) {
self.command(PlayerCommand::EmitVolumeSetEvent(volume)); self.command(PlayerCommand::EmitVolumeChangedEvent(volume));
} }
pub fn set_auto_normalise_as_album(&self, setting: bool) { pub fn set_auto_normalise_as_album(&self, setting: bool) {
self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting)); self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting));
} }
pub fn skip_explicit_content(&self) { pub fn emit_filter_explicit_content_changed_event(&self, filter: bool) {
self.command(PlayerCommand::SkipExplicitContent()); 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, decoder: Decoder,
normalisation_data: NormalisationData, normalisation_data: NormalisationData,
stream_loader_controller: StreamLoaderController, stream_loader_controller: StreamLoaderController,
audio_item: AudioItem,
bytes_per_second: usize, bytes_per_second: usize,
duration_ms: u32, duration_ms: u32,
stream_position_ms: u32, stream_position_ms: u32,
@ -573,6 +654,7 @@ enum PlayerState {
track_id: SpotifyId, track_id: SpotifyId,
play_request_id: u64, play_request_id: u64,
decoder: Decoder, decoder: Decoder,
audio_item: AudioItem,
normalisation_data: NormalisationData, normalisation_data: NormalisationData,
normalisation_factor: f64, normalisation_factor: f64,
stream_loader_controller: StreamLoaderController, stream_loader_controller: StreamLoaderController,
@ -587,6 +669,7 @@ enum PlayerState {
play_request_id: u64, play_request_id: u64,
decoder: Decoder, decoder: Decoder,
normalisation_data: NormalisationData, normalisation_data: NormalisationData,
audio_item: AudioItem,
normalisation_factor: f64, normalisation_factor: f64,
stream_loader_controller: StreamLoaderController, stream_loader_controller: StreamLoaderController,
bytes_per_second: usize, bytes_per_second: usize,
@ -660,6 +743,7 @@ impl PlayerState {
stream_loader_controller, stream_loader_controller,
stream_position_ms, stream_position_ms,
is_explicit, is_explicit,
audio_item,
.. ..
} => { } => {
*self = EndOfTrack { *self = EndOfTrack {
@ -669,6 +753,7 @@ impl PlayerState {
decoder, decoder,
normalisation_data, normalisation_data,
stream_loader_controller, stream_loader_controller,
audio_item,
bytes_per_second, bytes_per_second,
duration_ms, duration_ms,
stream_position_ms, stream_position_ms,
@ -694,6 +779,7 @@ impl PlayerState {
track_id, track_id,
play_request_id, play_request_id,
decoder, decoder,
audio_item,
normalisation_data, normalisation_data,
normalisation_factor, normalisation_factor,
stream_loader_controller, stream_loader_controller,
@ -707,13 +793,15 @@ impl PlayerState {
track_id, track_id,
play_request_id, play_request_id,
decoder, decoder,
audio_item,
normalisation_data, normalisation_data,
normalisation_factor, normalisation_factor,
stream_loader_controller, stream_loader_controller,
duration_ms, duration_ms,
bytes_per_second, bytes_per_second,
stream_position_ms, 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, suggested_to_preload_next_track,
is_explicit, is_explicit,
}; };
@ -736,20 +824,22 @@ impl PlayerState {
track_id, track_id,
play_request_id, play_request_id,
decoder, decoder,
audio_item,
normalisation_data, normalisation_data,
normalisation_factor, normalisation_factor,
stream_loader_controller, stream_loader_controller,
duration_ms, duration_ms,
bytes_per_second, bytes_per_second,
stream_position_ms, stream_position_ms,
reported_nominal_start_time: _,
suggested_to_preload_next_track, suggested_to_preload_next_track,
is_explicit, is_explicit,
..
} => { } => {
*self = Paused { *self = Paused {
track_id, track_id,
play_request_id, play_request_id,
decoder, decoder,
audio_item,
normalisation_data, normalisation_data,
normalisation_factor, normalisation_factor,
stream_loader_controller, stream_loader_controller,
@ -777,13 +867,13 @@ struct PlayerTrackLoader {
} }
impl PlayerTrackLoader { impl PlayerTrackLoader {
async fn find_available_alternative(&self, audio: AudioItem) -> Option<AudioItem> { async fn find_available_alternative(&self, audio_item: AudioItem) -> Option<AudioItem> {
if let Err(e) = audio.availability { if let Err(e) = audio_item.availability {
error!("Track is unavailable: {}", e); error!("Track is unavailable: {}", e);
None None
} else if !audio.files.is_empty() { } else if !audio_item.files.is_empty() {
Some(audio) Some(audio_item)
} else if let Some(alternatives) = &audio.alternatives { } else if let Some(alternatives) = &audio_item.alternatives {
let alternatives: FuturesUnordered<_> = alternatives let alternatives: FuturesUnordered<_> = alternatives
.iter() .iter()
.map(|alt_id| AudioItem::get_file(&self.session, *alt_id)) .map(|alt_id| AudioItem::get_file(&self.session, *alt_id))
@ -822,7 +912,7 @@ impl PlayerTrackLoader {
spotify_id: SpotifyId, spotify_id: SpotifyId,
position_ms: u32, position_ms: u32,
) -> Option<PlayerLoadedTrackData> { ) -> Option<PlayerLoadedTrackData> {
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 { Ok(audio) => match self.find_available_alternative(audio).await {
Some(audio) => audio, Some(audio) => audio,
None => { None => {
@ -841,31 +931,9 @@ impl PlayerTrackLoader {
info!( info!(
"Loading <{}> with Spotify URI <{}>", "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 // (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it
let formats = match self.config.bitrate { let formats = match self.config.bitrate {
Bitrate::Bitrate96 => [ Bitrate::Bitrate96 => [
@ -900,13 +968,16 @@ impl PlayerTrackLoader {
let (format, file_id) = let (format, file_id) =
match formats match formats
.iter() .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)), Some(&file_id) => Some((*format, file_id)),
_ => None, _ => None,
}) { }) {
Some(t) => t, Some(t) => t,
None => { None => {
warn!("<{}> is not available in any supported format", audio.name); warn!(
"<{}> is not available in any supported format",
audio_item.name
);
return None; 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, // 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 // 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 // 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. // Ensure streaming mode now that we are ready to play from the requested position.
stream_loader_controller.set_stream_mode(); 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 { return Some(PlayerLoadedTrackData {
decoder, decoder,
normalisation_data, normalisation_data,
stream_loader_controller, stream_loader_controller,
audio_item,
bytes_per_second, bytes_per_second,
duration_ms, duration_ms,
stream_position_ms, stream_position_ms,
@ -1164,7 +1249,6 @@ impl Future for PlayerInternal {
normalisation_factor, normalisation_factor,
ref mut stream_position_ms, ref mut stream_position_ms,
ref mut reported_nominal_start_time, ref mut reported_nominal_start_time,
duration_ms,
.. ..
} = self.state } = self.state
{ {
@ -1226,11 +1310,10 @@ impl Future for PlayerInternal {
if notify_about_position { if notify_about_position {
*reported_nominal_start_time = *reported_nominal_start_time =
now.checked_sub(new_stream_position); now.checked_sub(new_stream_position);
self.send_event(PlayerEvent::Playing { self.send_event(PlayerEvent::PositionCorrection {
track_id,
play_request_id, play_request_id,
track_id,
position_ms: new_stream_position_ms as u32, position_ms: new_stream_position_ms as u32,
duration_ms,
}); });
} }
} }
@ -1315,7 +1398,7 @@ impl PlayerInternal {
Ok(()) => self.sink_status = SinkStatus::Running, Ok(()) => self.sink_status = SinkStatus::Running,
Err(e) => { Err(e) => {
error!("{}", e); error!("{}", e);
exit(1); self.handle_pause();
} }
} }
} }
@ -1396,7 +1479,6 @@ impl PlayerInternal {
track_id, track_id,
play_request_id, play_request_id,
stream_position_ms, stream_position_ms,
duration_ms,
.. ..
} = self.state } = self.state
{ {
@ -1405,7 +1487,6 @@ impl PlayerInternal {
track_id, track_id,
play_request_id, play_request_id,
position_ms: stream_position_ms, position_ms: stream_position_ms,
duration_ms,
}); });
self.ensure_sink_running(); self.ensure_sink_running();
} else { } else {
@ -1414,25 +1495,24 @@ impl PlayerInternal {
} }
fn handle_pause(&mut self) { fn handle_pause(&mut self) {
if let PlayerState::Playing { match self.state {
track_id, PlayerState::Paused { .. } => self.ensure_sink_stopped(false),
play_request_id, PlayerState::Playing {
stream_position_ms,
duration_ms,
..
} = self.state
{
self.state.playing_to_paused();
self.ensure_sink_stopped(false);
self.send_event(PlayerEvent::Paused {
track_id, track_id,
play_request_id, play_request_id,
position_ms: stream_position_ms, stream_position_ms,
duration_ms, ..
}); } => {
} else { self.state.playing_to_paused();
error!("Player::pause called from invalid state: {:?}", self.state);
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) { if let Err(e) = self.sink.write(packet, &mut self.converter) {
error!("{}", e); error!("{}", e);
exit(1); self.handle_pause();
} }
} }
} }
@ -1587,6 +1667,10 @@ impl PlayerInternal {
loaded_track: PlayerLoadedTrackData, loaded_track: PlayerLoadedTrackData,
start_playback: bool, 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 position_ms = loaded_track.stream_position_ms;
let mut config = self.config.clone(); let mut config = self.config.clone();
@ -1602,18 +1686,17 @@ impl PlayerInternal {
if start_playback { if start_playback {
self.ensure_sink_running(); self.ensure_sink_running();
self.send_event(PlayerEvent::Playing { self.send_event(PlayerEvent::Playing {
track_id, track_id,
play_request_id, play_request_id,
position_ms, position_ms,
duration_ms: loaded_track.duration_ms,
}); });
self.state = PlayerState::Playing { self.state = PlayerState::Playing {
track_id, track_id,
play_request_id, play_request_id,
decoder: loaded_track.decoder, decoder: loaded_track.decoder,
audio_item: loaded_track.audio_item,
normalisation_data: loaded_track.normalisation_data, normalisation_data: loaded_track.normalisation_data,
normalisation_factor, normalisation_factor,
stream_loader_controller: loaded_track.stream_loader_controller, stream_loader_controller: loaded_track.stream_loader_controller,
@ -1632,6 +1715,7 @@ impl PlayerInternal {
track_id, track_id,
play_request_id, play_request_id,
decoder: loaded_track.decoder, decoder: loaded_track.decoder,
audio_item: loaded_track.audio_item,
normalisation_data: loaded_track.normalisation_data, normalisation_data: loaded_track.normalisation_data,
normalisation_factor, normalisation_factor,
stream_loader_controller: loaded_track.stream_loader_controller, stream_loader_controller: loaded_track.stream_loader_controller,
@ -1646,7 +1730,6 @@ impl PlayerInternal {
track_id, track_id,
play_request_id, play_request_id,
position_ms, position_ms,
duration_ms: loaded_track.duration_ms,
}); });
} }
} }
@ -1661,38 +1744,12 @@ impl PlayerInternal {
if !self.config.gapless { if !self.config.gapless {
self.ensure_sink_stopped(play); self.ensure_sink_stopped(play);
} }
// emit the correct player event
match self.state { if matches!(self.state, PlayerState::Invalid { .. }) {
PlayerState::Playing { return Err(Error::internal(format!(
track_id: old_track_id, "Player::handle_command_load called from invalid state: {:?}",
.. self.state
} )));
| 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
)));
}
} }
// Now we check at different positions whether we already have a pre-loaded version // Now we check at different positions whether we already have a pre-loaded version
@ -1754,6 +1811,7 @@ impl PlayerInternal {
if let PlayerState::Playing { if let PlayerState::Playing {
stream_position_ms, stream_position_ms,
decoder, decoder,
audio_item,
stream_loader_controller, stream_loader_controller,
bytes_per_second, bytes_per_second,
duration_ms, duration_ms,
@ -1764,6 +1822,7 @@ impl PlayerInternal {
| PlayerState::Paused { | PlayerState::Paused {
stream_position_ms, stream_position_ms,
decoder, decoder,
audio_item,
stream_loader_controller, stream_loader_controller,
bytes_per_second, bytes_per_second,
duration_ms, duration_ms,
@ -1776,6 +1835,7 @@ impl PlayerInternal {
decoder, decoder,
normalisation_data, normalisation_data,
stream_loader_controller, stream_loader_controller,
audio_item,
bytes_per_second, bytes_per_second,
duration_ms, duration_ms,
stream_position_ms, stream_position_ms,
@ -1925,14 +1985,24 @@ impl PlayerInternal {
Ok(new_position_ms) => { Ok(new_position_ms) => {
if let PlayerState::Playing { if let PlayerState::Playing {
ref mut stream_position_ms, ref mut stream_position_ms,
track_id,
play_request_id,
.. ..
} }
| PlayerState::Paused { | PlayerState::Paused {
ref mut stream_position_ms, ref mut stream_position_ms,
track_id,
play_request_id,
.. ..
} = self.state } = self.state
{ {
*stream_position_ms = new_position_ms; *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), Err(e) => error!("PlayerInternal::handle_command_seek error: {}", e),
@ -1945,35 +2015,12 @@ impl PlayerInternal {
self.preload_data_before_playback()?; self.preload_data_before_playback()?;
if let PlayerState::Playing { if let PlayerState::Playing {
track_id,
play_request_id,
ref mut reported_nominal_start_time, ref mut reported_nominal_start_time,
duration_ms,
.. ..
} = self.state } = self.state
{ {
*reported_nominal_start_time = *reported_nominal_start_time =
Instant::now().checked_sub(Duration::from_millis(position_ms as u64)); 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(()) Ok(())
@ -2003,34 +2050,78 @@ impl PlayerInternal {
PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback, PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback,
PlayerCommand::EmitVolumeSetEvent(volume) => { PlayerCommand::EmitVolumeChangedEvent(volume) => {
self.send_event(PlayerEvent::VolumeSet { 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) => { PlayerCommand::SetAutoNormaliseAsAlbum(setting) => {
self.auto_normalise_as_album = setting self.auto_normalise_as_album = setting
} }
PlayerCommand::SkipExplicitContent() => { PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => {
if let PlayerState::Playing { self.send_event(PlayerEvent::FilterExplicitContentChanged { filter });
track_id,
play_request_id, if filter {
is_explicit, if let PlayerState::Playing {
.. track_id,
} play_request_id,
| PlayerState::Paused { is_explicit,
track_id, ..
play_request_id, }
is_explicit, | PlayerState::Paused {
.. track_id,
} = self.state play_request_id,
{ is_explicit,
if is_explicit { ..
warn!("Currently loaded track is explicit, which client setting forbids -- skipping to next track."); } = self.state
self.send_event(PlayerEvent::EndOfTrack { {
track_id, if is_explicit {
play_request_id, 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 { impl fmt::Debug for PlayerCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self { match self {
PlayerCommand::Load { PlayerCommand::Load {
track_id, track_id,
play, play,
@ -2156,14 +2247,58 @@ impl fmt::Debug for PlayerCommand {
PlayerCommand::SetSinkEventCallback(_) => { PlayerCommand::SetSinkEventCallback(_) => {
f.debug_tuple("SetSinkEventCallback").finish() f.debug_tuple("SetSinkEventCallback").finish()
} }
PlayerCommand::EmitVolumeSetEvent(volume) => { PlayerCommand::EmitVolumeChangedEvent(volume) => f
f.debug_tuple("VolumeSet").field(&volume).finish() .debug_tuple("EmitVolumeChangedEvent")
} .field(&volume)
.finish(),
PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f
.debug_tuple("SetAutoNormaliseAsAlbum") .debug_tuple("SetAutoNormaliseAsAlbum")
.field(&setting) .field(&setting)
.finish(), .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(),
} }
} }
} }

View file

@ -13,7 +13,6 @@ use futures_util::StreamExt;
use log::{error, info, trace, warn}; use log::{error, info, trace, warn};
use sha1::{Digest, Sha1}; use sha1::{Digest, Sha1};
use thiserror::Error; use thiserror::Error;
use tokio::sync::mpsc::UnboundedReceiver;
use url::Url; use url::Url;
use librespot::{ use librespot::{
@ -29,7 +28,7 @@ use librespot::{
}, },
dither, dither,
mixer::{self, MixerConfig, MixerFn}, 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; use librespot::playback::mixer::alsamixer::AlsaMixer;
mod player_event_handler; 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 { fn device_id(name: &str) -> String {
hex::encode(Sha1::digest(name.as_bytes())) hex::encode(Sha1::digest(name.as_bytes()))
@ -1598,10 +1597,10 @@ async fn main() {
let mut last_credentials = None; let mut last_credentials = None;
let mut spirc: Option<Spirc> = None; let mut spirc: Option<Spirc> = None;
let mut spirc_task: Option<Pin<_>> = None; let mut spirc_task: Option<Pin<_>> = None;
let mut player_event_channel: Option<UnboundedReceiver<PlayerEvent>> = None;
let mut auto_connect_times: Vec<Instant> = vec![]; let mut auto_connect_times: Vec<Instant> = vec![];
let mut discovery = None; let mut discovery = None;
let mut connecting = false; let mut connecting = false;
let mut _event_handler: Option<EventHandler> = None;
let session = Session::new(setup.session_config.clone(), setup.cache.clone()); let session = Session::new(setup.session_config.clone(), setup.cache.clone());
@ -1669,32 +1668,21 @@ async fn main() {
let format = setup.format; let format = setup.format;
let backend = setup.backend; let backend = setup.backend;
let device = setup.device.clone(); let device = setup.device.clone();
let (player, event_channel) = let player = Player::new(player_config, session.clone(), soft_volume, move || {
Player::new(player_config, session.clone(), soft_volume, move || { (backend)(device, format)
(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| { player.set_sink_event_callback(Some(Box::new(move |sink_status| {
match emit_sink_event(sink_status, &player_event_program) { run_program_on_sink_events(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);
},
}
}))); })));
} }
}; };
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_), Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_),
Err(e) => { Err(e) => {
error!("could not initialize spirc: {}", e); error!("could not initialize spirc: {}", e);
@ -1703,7 +1691,6 @@ async fn main() {
}; };
spirc = Some(spirc_); spirc = Some(spirc_);
spirc_task = Some(Box::pin(spirc_task_)); spirc_task = Some(Box::pin(spirc_task_));
player_event_channel = Some(event_channel);
connecting = false; 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() => { _ = tokio::signal::ctrl_c() => {
break; break;
}, },

View file

@ -1,148 +1,307 @@
use log::info; use log::{debug, error, warn};
use std::{ use std::{collections::HashMap, process::Command, thread};
collections::HashMap,
io::{Error, ErrorKind, Result}, use librespot::{
process::{Command, ExitStatus}, metadata::audio::UniqueFields,
playback::player::{PlayerEvent, PlayerEventChannel, SinkStatus},
}; };
use tokio::process::{Child as AsyncChild, Command as AsyncCommand}; pub struct EventHandler {
thread_handle: Option<thread::JoinHandle<()>>,
use librespot::playback::player::{PlayerEvent, SinkStatus};
pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result<AsyncChild>> {
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 fn emit_sink_event(sink_status: SinkStatus, onevent: &str) -> Result<ExitStatus> { 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::<Vec<String>>()
.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::<Vec<String>>()
.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(); let mut env_vars = HashMap::new();
env_vars.insert("PLAYER_EVENT", "sink".to_string()); env_vars.insert("PLAYER_EVENT", "sink".to_string());
let sink_status = match sink_status { let sink_status = match sink_status {
SinkStatus::Running => "running", SinkStatus::Running => "running",
SinkStatus::TemporarilyClosed => "temporarily_closed", SinkStatus::TemporarilyClosed => "temporarily_closed",
SinkStatus::Closed => "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) .args(&v)
.envs(env_vars.iter()) .envs(env_vars.iter())
.spawn()? .spawn()
.wait() {
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);
}
}
},
}
} }