diff --git a/API.pm b/API.pm index 1a9e1e7..d7bcd2b 100644 --- a/API.pm +++ b/API.pm @@ -208,6 +208,11 @@ sub player { $result->{track} = $libraryCache->normalize($result->{item}); } + # keep track of MAC -> ID mapping + if ( Plugins::Spotty::Plugin->canSpotifyConnect() ) { + Plugins::Spotty::Connect::DaemonManager->checkAPIConnectPlayer($self, $result); + } + $cb->($result); return; } @@ -221,6 +226,143 @@ sub player { ) } +sub playerTransfer { + my ( $self, $cb, $device ) = @_; + + $self->withIdFromMac(sub { + my $deviceId = shift; + + if (!$deviceId) { + $cb->() if $cb; + return; + } + + $self->_call('me/player', + sub { + $cb->() if $cb; + }, + PUT => { + body => encode_json({ + device_ids => [ $deviceId ], + play => 1 + }) + } + ); + }, $device); +} + +sub playerPause { + my ( $self, $cb, $device ) = @_; + + $self->withIdFromMac(sub { + my $args = {}; + $args->{device_id} = $_[0] if $_[0]; + + $self->_call('me/player/pause', + sub { + $cb->() if $cb; + }, + PUT => $args + ); + }, $device); +} + +sub playerNext { + my ( $self, $cb, $device ) = @_; + + $self->withIdFromMac(sub { + my $args = {}; + $args->{device_id} = $_[0] if $_[0]; + + $self->_call('me/player/next', + sub { + $cb->() if $cb; + }, + POST => $args + ); + }, $device); +} + +sub playerSeek { + my ( $self, $cb, $device, $songtime ) = @_; + + $self->withIdFromMac(sub { + my $args = { + position_ms => int($songtime * 1000), + }; + + $args->{device_id} = $_[0] if $_[0]; + + $self->_call('me/player/seek', + sub { + $cb->() if $cb; + }, + PUT => $args + ); + }, $device); +} + +sub playerVolume { + my ( $self, $cb, $device, $volume ) = @_; + + $self->withIdFromMac(sub { + my $args = { + volume_percent => int($volume), + }; + + $args->{device_id} = $_[0] if $_[0]; + + $self->_call('me/player/volume', + sub { + $cb->() if $cb; + }, + PUT => $args + ); + }, $device); +} + +sub idFromMac { + my ( $class, $mac ) = @_; + + return Plugins::Spotty::Plugin->canSpotifyConnect() + && Plugins::Spotty::Connect::DaemonManager->idFromMac($mac); +} + +sub withIdFromMac { + my ( $self, $cb, $mac ) = @_; + + my $id = $self->idFromMac($mac); + + if ( $id || $mac !~ /((?:[a-f0-9]{2}:){5}[a-f0-9]{2})/i ) { + $cb->($id || $mac); + } + else { + # ID wasn't in the cache yet, let's get the playerlist + $self->devices(sub { + $cb->($self->idFromMac($mac)); + }); + } +} + +sub devices { + my ( $self, $cb ) = @_; + + $self->_call('me/player/devices', + sub { + my ($result) = @_; + + if ( Plugins::Spotty::Plugin->canSpotifyConnect() ) { + Plugins::Spotty::Connect::DaemonManager->checkAPIConnectPlayers($self, $result); + } + + $cb->() if $cb; + }, + GET => { + _nocache => 1, + } + ); +} + sub search { my ( $self, $cb, $args ) = @_; @@ -1463,6 +1605,13 @@ sub canPodcast { $self->_canPodcast(Plugins::Spotty::Helper->getCapability('podcasts') || 0); } +sub doesAutoplay { + my $self = $_[0]; + + return unless $prefs->client($self->client)->get('enableAutoplay'); + return Plugins::Spotty::Helper->getCapability('autoplay'); +} + sub _DEFAULT_LIMIT { Plugins::Spotty::Plugin->hasDefaultIcon() ? DEFAULT_LIMIT : MAX_LIMIT; }; diff --git a/AccountHelper.pm b/AccountHelper.pm index d05022a..36522b4 100644 --- a/AccountHelper.pm +++ b/AccountHelper.pm @@ -132,13 +132,12 @@ sub renameCacheFolder { $newId = substr( md5_hex(Slim::Utils::Unicode::utf8toLatin1Transliterate($credentials->{username} || '')), 0, 8 ); } - main::INFOLOG && $log->info("Trying to rename $oldId to $newId"); - if (main::DEBUGLOG && $log->is_debug && !$newId) { Slim::Utils::Log::logBacktrace("No newId found in '$oldId'"); } if ($oldId && $newId) { + main::INFOLOG && $log->info("Trying to rename $oldId to $newId"); my $from = $class->cacheFolder($oldId); if (!-e $from) { diff --git a/Connect.pm b/Connect.pm new file mode 100644 index 0000000..79e2338 --- /dev/null +++ b/Connect.pm @@ -0,0 +1,585 @@ +package Plugins::Spotty::Connect; + +use strict; + +use File::Path qw(mkpath); +use File::Slurp; +use File::Spec::Functions qw(catdir catfile); +use JSON::XS::VersionOneAndTwo; +use Scalar::Util qw(blessed); + +use Slim::Utils::Log; +use Slim::Utils::Cache; +use Slim::Utils::Prefs; +use Slim::Utils::Timers; + +use Plugins::Spotty::API qw(uri2url); + +use constant SEEK_THRESHOLD => 3; +use constant VOLUME_GRACE_PERIOD => 20; +use constant PRE_BUFFER_TIME => 7; +use constant PRE_BUFFER_SIZE_THRESHOLD => 10 * 1024 * 1024; + +my $cache = Slim::Utils::Cache->new(); +my $prefs = preferences('plugin.spotty'); +my $serverPrefs = preferences('server'); +my $log = logger('plugin.spotty'); + +my $initialized; + +sub init { + my ($class) = @_; + + return if $initialized; + + require Plugins::Spotty::Connect::Context; + +# |requires Client +# | |is a Query +# | | |has Tags +# | | | |Function to call +# C Q T F + Slim::Control::Request::addDispatch(['spottyconnect','_cmd'], + [1, 0, 1, \&_connectEvent] + ); + + # listen to playlist change events so we know when Spotify Connect mode ends + Slim::Control::Request::subscribe(\&_onNewSong, [['playlist'], ['newsong']]); + + # we want to tell the Spotify controller to pause playback when we pause locally + Slim::Control::Request::subscribe(\&_onPause, [['playlist'], ['pause', 'stop']]); + + # we want to tell the Spotify about local volume changes + Slim::Control::Request::subscribe(\&_onVolume, [['mixer'], ['volume']]); + + # set optimizePreBuffer if client with huge buffer connects + Slim::Control::Request::subscribe(sub { + my $request = shift; + my $client = $request->client(); + + if (!$prefs->get('optimizePreBuffer')) { + # we have to wait a few seconds before the buffer size is known + Slim::Utils::Timers::setTimer($client, time() + 5, sub { + my $c = shift; + if ($c && $c->bufferSize > PRE_BUFFER_SIZE_THRESHOLD) { + main::INFOLOG && $log->is_info && $log->info(sprintf("Enabling pre-buffer optimization as player seems to be using very large buffer: %s (%sMB)", $c->name, int($c->bufferSize/1024/1024))); + $prefs->set('optimizePreBuffer', 1); + } + }); + } + }, [['client'], ['new']]); + + require Plugins::Spotty::Connect::DaemonManager; + Plugins::Spotty::Connect::DaemonManager->init(); + + $initialized = 1; +} + +sub isSpotifyConnect { + my ( $class, $client ) = @_; + + return unless $client; + $client = $client->master; + my $song = $client->playingSong(); + + return unless $client->pluginData('SpotifyConnect'); + + return ($client->pluginData('newTrack') || _contextTime($song)) ? 1 : 0; +} + +sub _contextTime { + my ($song) = @_; + + return unless $song && $song->pluginData('context'); + return $song->pluginData('context')->time() || 0; +} + +sub setSpotifyConnect { + my ( $class, $client, $context ) = @_; + + return unless $client; + + $client = $client->master; + if (my $song = $client->playingSong()) { + # state on song: need to know whether we're currently in Connect mode. Is lost when new track plays. + $song->pluginData('context') || $song->pluginData( context => Plugins::Spotty::Connect::Context->new($class->getAPIHandler($client)) ); + $song->pluginData('context')->update($context); + $song->pluginData('context')->time(time()); + } + + # state on client: need to know whether we've been in Connect mode. If this is set, then we've been playing from Connect, but are no more. + $client->pluginData( SpotifyConnect => 1 ); +} + +sub getNextTrack { + my ($class, $song, $successCb, $errorCb) = @_; + + my $client = $song->master; + + Slim::Utils::Timers::killTimers($client, \&_getNextTrack); + + if ( $client->pluginData('newTrack') ) { + main::INFOLOG && $log->is_info && $log->info("Don't get next track as we got called by a play track event from spotty"); + + my $spotty = $class->getAPIHandler($client); + + $spotty->player(sub { + my $state = $_[0]; + + if ( !$state->{item} && (my $uri = $client->pluginData('episodeUri')) ) { + $state->{item} = { + uri => $uri + }; + $client->pluginData( episodeUri => '' ); + + $spotty->track(sub { + $state->{item} = $_[0]; + $class->setSpotifyConnect($client, $state); + }, $uri); + } + + $song->streamUrl($state->{item}->{uri}); + $class->setSpotifyConnect($client, $state); + $client->pluginData( newTrack => 0 ); + $successCb->(); + }); + } + elsif ( $prefs->get('optimizePreBuffer') ) { + my $duration = $client->controller()->playingSongDuration() || 0; + my $remaining = $duration - (Slim::Player::Source::songTime($client) || 0); + + main::INFOLOG && $log->is_info && $log->info(Data::Dump::dump({ + duration => $duration, + remaining => $remaining, + current_url => $song->streamUrl, + })); + + if ($remaining && $remaining > PRE_BUFFER_TIME) { + $remaining -= PRE_BUFFER_TIME; + main::INFOLOG && $log->is_info && $log->info("We're still far away from the end - delay getting the next track by ${remaining}s."); + Slim::Utils::Timers::setTimer($client, Time::HiRes::time() + $remaining, \&_getNextTrack, $class, $song, $successCb); + + Slim::Utils::Timers::killTimers($client, \&_syncController); + if ($remaining > 20) { + Slim::Utils::Timers::setTimer($client, Time::HiRes::time() + $remaining - 15, \&_syncController); + } + } + elsif (!$duration || !$remaining) { + main::INFOLOG && $log->is_info && $log->info("Ignoring 'getNextTrack' call, as we've been called before"); + } + else { + _getNextTrack($client, $class, $song, $successCb); + } + } + else { + _getNextTrack($client, $class, $song, $successCb); + } +} + +sub _getNextTrack { + # params kind of reversed, to help the timer keep track of the client + my ($client, $class, $song, $successCb) = @_; + + Slim::Utils::Timers::killTimers($client, \&_syncController); + Slim::Utils::Timers::killTimers($client, \&_getNextTrack); + + if (!$client->isPlaying() || !$class->isSpotifyConnect($client)) { + main::INFOLOG && $log->is_info && $log->info("Don't get next track, we're no longer playing or not in Connect mode"); + return; + } + + my $spotty = $class->getAPIHandler($client); + + main::INFOLOG && $log->is_info && $log->info("We're approaching the end of a track - get the next track"); + $client->pluginData( newTrack => 1 ); + + # add current track to the history + $song->pluginData('context')->addPlay($song->streamUrl); + + # for playlists and albums we can know the last track. In this case no further check would be required. + if ( $song->pluginData('context')->isLastTrack($song->streamUrl) && !$spotty->doesAutoplay ) { + $class->_delayedStop($client); + $successCb->(); + return; + } + + $spotty->playerNext(sub { + # Delay querying Spotify's REST API state - it often lags behind the skip + # command (which is processed much faster via Spirc/WebSocket) and still + # reports the old track as "current". Querying immediately causes a false + # positive on the "already played" check, triggering a premature stop. + Slim::Utils::Timers::setTimer($client, Time::HiRes::time() + 1, sub { + $spotty->player(sub { + my ($result) = @_; + + if ( $result && ref $result && (my $uri = $result->{item}->{uri}) ) { + main::INFOLOG && $log->is_info && $log->info("Got a new track to be played next: $uri"); + + $uri = uri2url($uri); + + # stop playback if we've played this track before. It's likely trying to start over. + if ( $song->pluginData('context')->hasPlay($uri) && !($result->{repeat_state} && $result->{repeat_state} eq 'on')) { + $class->_delayedStop($client); + } + else { + $song->streamUrl($uri); + $class->setSpotifyConnect($client, $result); + } + + $successCb->(); + } + }); + }); + }); +} + +sub _syncController { + my ($client) = @_; + + Slim::Utils::Timers::killTimers($client, \&_syncController); + + my $songtime = Slim::Player::Source::songTime($client); + + __PACKAGE__->getAPIHandler($client)->playerSeek(undef, $client->id, $songtime) if $songtime; +} + +sub _delayedStop { + my ($class, $client) = @_; + + # set a timer to stop playback at the end of the track + my $remaining = $client->controller()->playingSongDuration() - Slim::Player::Source::songTime($client); + main::INFOLOG && $log->is_info && $log->info("Stopping playback in ${remaining}s, as we have likely reached the end of our context (playlist, album, ...)"); + + Slim::Utils::Timers::killTimers($client, \&_sendPause); + Slim::Utils::Timers::setTimer($client, Time::HiRes::time() + $remaining, \&_sendPause, $class); +} + +sub _sendPause { + my $client = shift || return; + my $class = shift; + + Slim::Utils::Timers::killTimers($client, \&_sendPause); + $client->pluginData( newTrack => 0 ); + $class->getAPIHandler($client)->playerPause(sub { + $client->execute(['stop']); + }, $client->id); +} + +sub _onNewSong { + my $request = shift; + + return if $request->source && $request->source eq __PACKAGE__; + + my $client = $request->client(); + return if !defined $client; + $client = $client->master; + + if (__PACKAGE__->isSpotifyConnect($client)) { + # if we're in Connect mode and have seek information, go there + if ( $client && (my $progress = $client->pluginData('progress')) ) { + $client->pluginData( progress => 0 ); + $client->execute( ['time', int($progress)] ); + } + + return; + } + + return unless $client->pluginData('SpotifyConnect'); + + main::INFOLOG && $log->is_info && $log->info("Got a new track event, but this is no longer Spotify Connect"); + $client->playingSong()->pluginData( context => 0 ); + $client->pluginData( SpotifyConnect => 0 ); + Slim::Utils::Timers::killTimers($client, \&_syncController); + Slim::Utils::Timers::killTimers($client, \&_getNextTrack); + __PACKAGE__->getAPIHandler($client)->playerPause(undef, $client->id); +} + +sub _onPause { + my $request = shift; + + return if $request->source && $request->source eq __PACKAGE__; + + # no need to pause if we unpause + return if $request->isCommand([['playlist'],['pause']]) && !$request->getParam('_newvalue'); + + my $client = $request->client(); + return if !defined $client; + $client = $client->master; + + # ignore pause while we're fetching a new track + return if $client->pluginData('newTrack'); + + return if !__PACKAGE__->isSpotifyConnect($client); + + if ( $request->isCommand([['playlist'],['stop','pause']]) && _contextTime($client->playingSong()) > time() - 5 ) { + main::INFOLOG && $log->is_info && $log->info("Got a stop event within 5s after start of a new track - do NOT tell Spotify Connect controller to pause"); + return; + } + + main::INFOLOG && $log->is_info && $log->info("Got a pause event - tell Spotify Connect controller to pause, too"); + __PACKAGE__->getAPIHandler($client)->playerPause(undef, $client->id); +} + +sub _onVolume { + my $request = shift; + + return if $request->source && $request->source eq __PACKAGE__; + + my $client = $request->client(); + return if !defined $client || ($client->hasDigitalOut && $serverPrefs->client($client)->get('digitalVolumeControl')); + + $client = $client->master; + return if $client->hasDigitalOut && $serverPrefs->client($client)->get('digitalVolumeControl'); + + return if !__PACKAGE__->isSpotifyConnect($client); + + my $volume = $client->volume; + + # buffer volume change events, as they often come in bursts + Slim::Utils::Timers::killTimers($client, \&_bufferedSetVolume); + Slim::Utils::Timers::setTimer($client, Time::HiRes::time() + 0.5, \&_bufferedSetVolume, $volume); +} + +sub _bufferedSetVolume { + my ($client, $volume) = @_; + main::INFOLOG && $log->is_info && $log->info("Got a volume event - tell Spotify Connect controller to adjust volume, too: $volume"); + __PACKAGE__->getAPIHandler($client)->playerVolume(undef, $client->id, $volume); +} + +sub _connectEvent { + my $request = shift; + my $client = $request->client()->master; + + my $cmd = $request->getParam('_cmd'); + + # Handle seek events immediately using the position already known from the + # Spirc event - avoids a REST API roundtrip that frequently returns stale + # state (the API lags 500ms+ behind the WebSocket path Spirc uses). + # This must be checked before the newTrack guard to avoid accidentally + # clearing that flag during a seek. + if ( $cmd eq 'seek' ) { + my $position = $request->getParam('_p2'); + if ( defined $position && $client->isPlaying && __PACKAGE__->isSpotifyConnect($client) ) { + main::INFOLOG && $log->is_info && $log->info("Got a seek event from Spotify - seeking LMS to ${position}s"); + $client->execute( ['time', int($position)] ); + } + return; + } + + if ( $client->pluginData('newTrack') ) { + main::INFOLOG && $log->info("Ignoring request, as it's a follow up to a new track event: $cmd"); + $client->pluginData( newTrack => 0 ); + return; + } + + main::INFOLOG && $log->is_info && $log->info(sprintf('Got called from spotty helper for %s: %s', $client->id, $cmd)); + + my $spotty = __PACKAGE__->getAPIHandler($client); + + if ( $cmd eq 'volume' && !($request->source && $request->source eq __PACKAGE__) ) { + my $volume = $request->getParam('_p2') || return; + + # sometimes volume would be reset to a default 50 right after the daemon start - ignore + if ( Plugins::Spotty::Connect::DaemonManager->uptime($client->id) < VOLUME_GRACE_PERIOD ) { + main::INFOLOG && $log->is_info && $log->info("Ignoring initial volume reset right after daemon start"); + # this is kind of the "onConnect" handler - get a list of all players + $spotty->devices(); + return; + } + + # sometimes volume would be reset to a default 49 for whatever reason. Just ignore it. Always. + if ( $volume == 49 ) { + main::INFOLOG && $log->is_info && $log->info("Ignoring volume reset to 49"); + return; + } + + main::INFOLOG && $log->is_info && $log->info("Set volume to $volume"); + + # we don't let spotty handle volume directly to prevent getting caught in a call loop + my $request = Slim::Control::Request->new( $client->id, [ 'mixer', 'volume', $volume ] ); + $request->source(__PACKAGE__); + $request->execute(); + + return; + } + + $spotty->player(sub { + my ($result) = @_; + + my $song = $client->playingSong(); + my $streamUrl = ($song ? $song->streamUrl : '') || ''; + $streamUrl =~ s/\/\///; + + $song && $song->pluginData('context') && $song->pluginData('context')->update($result); + + $result ||= {}; + + main::INFOLOG && $log->is_info && $log->info("Current Connect state: \n" . Data::Dump::dump($result, $cmd)); + + # the spotty helper would send us the track ID, but unfortunately not the full URI. Let's assume this was an episode ID if we're currently in an episode and got an ID... + if ( $cmd =~ /^start|change$/ && ($result->{currently_playing_type} || '') eq 'episode' && !$result->{track} && (my $uri = $request->getParam('_p2')) ) { + $uri = "spotify:episode:$uri"; + main::INFOLOG && $log->is_info && $log->info("Didn't get track info in player request, but in notification from spotty helper: $uri"); + $result->{track} = { + uri => $uri + }; + } + + # in case of a change event we need to figure out what actually changed... + if ( $cmd eq 'change' && ref $result->{track} && (($streamUrl ne $result->{track}->{uri} && $result->{is_playing}) || !__PACKAGE__->isSpotifyConnect($client)) ) { + main::INFOLOG && $log->is_info && $log->info("Got a $cmd event, but actually this is a play next track event"); + $cmd = 'start'; + } + elsif ( $cmd eq 'change' && !$client->isPlaying && ref $result->{track} && (($streamUrl eq $result->{track}->{uri} && $result->{is_playing})) ) { + main::INFOLOG && $log->is_info && $log->info("Got a $cmd event, but actually this is a resume event"); + $cmd = 'start'; + } + + if ( $cmd eq 'start' && $result->{track} ) { + if ( $streamUrl ne $result->{track}->{uri} || !__PACKAGE__->isSpotifyConnect($client) ) { + main::INFOLOG && $log->is_info && $log->info("Got a new track to be played: " . $result->{track}->{uri}); + + # Sometimes we want to know whether we're in Spotify Connect mode or not + $client->pluginData( SpotifyConnect => 1 ); + $client->pluginData( newTrack => 1 ); + + # we need to keep track of the episodeUri, as it won't be sent in the player status response. Stupid. + $client->pluginData( episodeUri => $result->{track}->{uri}) if ($result->{currently_playing_type} || '') eq 'episode'; + + my $request = $client->execute( [ 'playlist', 'play', sprintf("spotify://connect-%u", Time::HiRes::time() * 1000) ] ); + $request->source(__PACKAGE__); + + # sync volume up to spotify if we just got connected + if ( !$client->pluginData('SpotifyConnect') ) { + $spotty->playerVolume(undef, $client->id, $client->volume); + } + + # on interactive Spotify Connect use we're going to reset the play history. + # this isn't really solving the problem of lack of context. But it's better than nothing... + $song && $song->pluginData('context') && $song->pluginData('context')->reset(); + + $result->{progress} ||= ($result->{progress_ms} / 1000) if $result->{progress_ms}; + + # if status is already more than 10s in, then do seek + if ( $result->{progress} && $result->{progress} > 10 ) { + $song && $client->pluginData( progress => $result->{progress} ); + } + } + elsif ( !$client->isPlaying ) { + main::INFOLOG && $log->is_info && $log->info("Got to resume playback"); + __PACKAGE__->setSpotifyConnect($client, $result); + my $request = Slim::Control::Request->new( $client->id, ['play'] ); + $request->source(__PACKAGE__); + $request->execute(); + } + } + elsif ( $cmd eq 'stop' && $result->{device} ) { + my $clientId = $client->id; + + # if we're playing, got a stop event, and current Connect device is us, then pause + if ( $client->isPlaying && ($result->{device}->{id} eq Plugins::Spotty::Connect::DaemonManager->idFromMac($clientId) || $result->{device}->{name} eq $client->name) && __PACKAGE__->isSpotifyConnect($client) ) { + main::INFOLOG && $log->is_info && $log->info("Spotify told us to pause: " . $client->id); + + my $request = Slim::Control::Request->new( $client->id, ['pause', 1] ); + $request->source(__PACKAGE__); + $request->execute(); + } + elsif ( $client->isPlaying && ($result->{device}->{id} ne Plugins::Spotty::Connect::DaemonManager->idFromMac($clientId) && $result->{device}->{name} ne $client->name) && __PACKAGE__->isSpotifyConnect($client) ) { + main::INFOLOG && $log->is_info && $log->info("Spotify told us to pause, but current player is no longer the Connect target"); + + my $request = Slim::Control::Request->new( $client->id, ['pause', 1] ); + $request->source(__PACKAGE__); + $request->execute(); + + # reset Connect status on this device + $client->playingSong()->pluginData( context => 0 ); + $client->pluginData( SpotifyConnect => 0 ); + } + # if we're playing, got a stop event, and current Connect device is NOT us, then + # disable Connect and let the track end + elsif ( $client->isPlaying ) { + main::INFOLOG && $log->is_info && $log->info("Spotify told us to pause, but current player is not Connect target"); + $client->playingSong()->pluginData( context => 0 ); + $client->pluginData( SpotifyConnect => 0 ); + } + } + elsif ( $cmd eq 'change' ) { + # seeking event from Spotify - we would only seek if the difference was larger than x seconds, as we'll never be perfectly in sync + if ( $client->isPlaying && abs($result->{progress} - Slim::Player::Source::songTime($client)) > SEEK_THRESHOLD ) { + $client->execute( ['time', int($result->{progress})] ); + } + } + elsif (main::INFOLOG && $log->is_info) { + $log->info("Unknown command called? $cmd\n" . Data::Dump::dump($result)); + } + }); +} + +=pod + Here we're overriding some of the default handlers. In Connect mode, when discovery is enabled, + we could be streaming from any account, not only those configured in Spotty. Therefore we need + to use different cache folders with credentials. Use the currently set in Spotty as default, + but read actual value whenever accessing the API. We won't keep these credentials around, to + prevent using a visitor's account. +=cut +sub getAPIHandler { + my ($class, $client) = @_; + + return unless $client; + + if (!blessed $client) { + $client = Slim::Player::Client::getClient($client); + } + + my $api; + + my $cacheFolder = $class->cacheFolder($client); + my $credentialsFile = catfile($cacheFolder, 'credentials.json'); + + my $credentials = eval { + from_json(read_file($credentialsFile)); + }; + + if ( !$@ && $credentials || ref $credentials && $credentials->{auth_data} ) { + $api = Plugins::Spotty::API->new({ + client => $client, + cache => $cacheFolder, + username => $credentials->{username}, + }); + } + + return $api || Plugins::Spotty::Plugin->getAPIHandler($client); +} + +sub cacheFolder { + my ($class, $clientId) = @_; + + $clientId = $clientId->id if $clientId && blessed $clientId; + + my $cacheFolder = Plugins::Spotty::AccountHelper->cacheFolder( Plugins::Spotty::AccountHelper->getAccount($clientId) ); + + # create a temporary account folder with the player's MAC address + if ( Plugins::Spotty::Plugin->canDiscovery() && !$prefs->get('disableDiscovery') ) { + my $id = $clientId; + $id =~ s/://g; + + my $playerCacheFolder = catdir($serverPrefs->get('cachedir'), 'spotty', $id); + mkpath $playerCacheFolder unless -e $playerCacheFolder; + + if ( !-e catfile($playerCacheFolder, 'credentials.json') ) { + require File::Copy; + File::Copy::copy(catfile($cacheFolder, 'credentials.json'), catfile($playerCacheFolder, 'credentials.json')); + } + $cacheFolder = $playerCacheFolder; + } + + return $cacheFolder +} + +sub shutdown { + if ($initialized) { + Plugins::Spotty::Connect::DaemonManager->shutdown(); + } +} + +1; diff --git a/Connect/Context.pm b/Connect/Context.pm new file mode 100644 index 0000000..7b53304 --- /dev/null +++ b/Connect/Context.pm @@ -0,0 +1,208 @@ +package Plugins::Spotty::Connect::Context; + +=pod + Unfortunately we don't always get the context in which a track is being played. + Therefore we add some rough rules here: + + - if we have a context (album, playlist), get the list of tracks to be played. + Whenever a track is played, it's removed from that list. When the list is + empty, stop playback. + + - if we don't have any context, keep a list of tracks we played. When the next + track to be played has been played before, we're going to assume that we've + played them all. This would effectively not allow us to play the same track + in the same context twice. Or if you started on track 3, the playback would + wrap and play tracks 1 & 2 last. + + Fortunately albums and playlists are the most popular items to be played. +=cut + +use strict; + +use base qw(Slim::Utils::Accessor); + +use Slim::Utils::Log; +use Slim::Utils::Cache; +use Slim::Utils::Prefs; +# use Slim::Utils::Timers; + +use Plugins::Spotty::API qw(uri2url); + +use constant HISTORY_KEY => 'spotty-connect-history'; +use constant KNOWN_TRACKS_KEY => 'spotty-connect-known-tracks'; + +__PACKAGE__->mk_accessor( rw => qw( + time + shuffled + _id + _api + _cache + _context + _contextId + _lastURL +) ); + +#my $prefs = preferences('plugin.spotty'); +my $log = logger('plugin.spotty'); + +my $memoryCache; + +sub new { + my ($class, $api) = @_; + + my $self = $class->SUPER::new(); + + $log->info("Create new Connect context..."); + + $self->time(time()); + $self->_api($api); + $self->_id(Slim::Utils::Misc::createUUID()); + $self->_cache( + preferences('server')->get('dbhighmem') > 1 + ? Plugins::Spotty::Connect::MemoryCache->new() + : Slim::Utils::Cache->new() + ); + + $self->reset(); + + return $self; +} + +sub update { + my ($self, $context) = @_; + + if ( $context && ref $context && $context->{context} && ref $context->{context} + && ($context->{context}->{uri} || '') ne $self->_contextId + ) { + $self->reset(); + $self->_context($context->{context}); + $self->_contextId($context->{context}->{uri}); + $self->shuffled($context->{context}->{shuffle_state}); + $self->_lastURL(''); + + if ($self->_context->{type} =~ /album|playlist/) { + $self->_api->trackURIsFromURI( sub { + my ($tracks) = @_; + + if ($tracks && ref $tracks) { + my $knownTracks; + my $lastTrack = $tracks->[-1]; + my @lastTrackOccurrences; + + my $x = 0; + map { + push @lastTrackOccurrences, $x if $_ eq $lastTrack; + $knownTracks->{uri2url($_)}++; + $x++; + } @{ $tracks || [] }; + + # TODO - use @lastTrackOccurrences to define a smarter filter, respecting previous track(s) or similar + + $self->_lastURL(uri2url($lastTrack)) unless scalar @lastTrackOccurrences > 1; + $self->_setCache(KNOWN_TRACKS_KEY, $knownTracks); + } + }, $self->_contextId ); + } + + # when we're called, we're already playing an item of our context + $self->addPlay(uri2url($context->{item}->{uri})); + } +} + +sub reset { + my $self = shift; + + $self->shuffled(0); + $self->_context({}); + $self->_contextId(''); + $self->_cache->remove($self->_id . HISTORY_KEY); + $self->_cache->remove($self->_id . KNOWN_TRACKS_KEY); + $self->_lastURL(''); +} + +sub addPlay { + my ($self, $url) = @_; + + main::INFOLOG && $log->info("Adding track to played list: $url"); + + if ( my $knownTracks = $self->_getCache(KNOWN_TRACKS_KEY) ) { + if ( $knownTracks->{$url} ) { + $knownTracks->{$url}--; + delete $knownTracks->{$url} if !$knownTracks->{$url}; + + $self->_setCache(KNOWN_TRACKS_KEY, $knownTracks) + } + } + + my $history = $self->_getCache(HISTORY_KEY) || {}; + $history->{$url}++; + $self->_setCache(HISTORY_KEY, $history); +} + +sub getPlay { + my ($self, $url) = @_; + my $history = $self->_getCache(HISTORY_KEY) || {}; + + main::INFOLOG && $log->info("Has $url been played? " . ($history->{$url} ? 'yes' : 'no')); + + return $history->{$url}; +} + +sub hasPlay { + return $_[0]->getPlay($_[1]) ? 1 : 0; +} + +sub isLastTrack { + my ($self, $url) = @_; + + # if we have a known last track, and this $url is it, then we're at the end + return 1 if $self->_lastURL && $self->_lastURL eq $url && !$self->shuffled; + + # if we had a list with known tracks, but it's empty now, then we've played it all + if ( my $knownTracks = $self->_getCache(KNOWN_TRACKS_KEY) ) { + return 1 if !keys %$knownTracks; + } + + return 0; +} + +sub _setCache { + my ($self, $key, $value, $expiry) = @_; + $self->_cache->set($self->_id . $key, $value); +} + +sub _getCache { + my ($self, $key) = @_; + return $self->_cache->get($self->_id . $key); +} + +1; + + +# a simple memory cache module, providing the same set/get interface to a hash +package Plugins::Spotty::Connect::MemoryCache; + +use strict; + +use Tie::Cache::LRU::Expires; + +tie my %memCache, 'Tie::Cache::LRU::Expires', EXPIRES => 86400 * 7, ENTRIES => 10; + +sub new { + my ($class) = @_; + return bless {}, $class; +} + +sub get { + return $memCache{$_[1]}; +} + +sub set { + $memCache{$_[1]} = $_[2]; +} + +sub remove { + delete $memCache{$_[1]}; +} + +1; diff --git a/Connect/Daemon.pm b/Connect/Daemon.pm new file mode 100644 index 0000000..586f0c1 --- /dev/null +++ b/Connect/Daemon.pm @@ -0,0 +1,185 @@ +package Plugins::Spotty::Connect::Daemon; + +use strict; + +use base qw(Slim::Utils::Accessor); + +use File::Path qw(rmtree); +use File::Spec::Functions qw(catdir); +use MIME::Base64 qw(encode_base64); +use Proc::Background; + +use Slim::Utils::Log; +use Slim::Utils::Prefs; + +# disable discovery mode if we have to restart more than x times in y minutes +use constant MAX_FAILURES_BEFORE_DISABLE_DISCOVERY => 3; +use constant MAX_INTERVAL_BEFORE_DISABLE_DISCOVERY => 5 * 60; + +use constant SPOTIFY_ID_TTL => 600; + +__PACKAGE__->mk_accessor( rw => qw( + id + mac + name + cache + _lastSeen + _spotifyId + _proc + _startTimes +) ); + +my $prefs = preferences('plugin.spotty'); +my $serverPrefs = preferences('server'); +my $log = logger('plugin.spotty'); + +sub new { + my ($class, $id) = @_; + + my $self = $class->SUPER::new(); + + $self->mac($id); + $id =~ s/://g; + $self->id($id); + $self->_startTimes([]); + $self->start(); + + return $self; +} + +sub start { + my $self = shift; + + my $helperPath = Plugins::Spotty::Helper->get(); + my $client = Slim::Player::Client::getClient($self->mac); + + # Spotify can't handle long player names + $self->name(substr( + ($client->isSynced() && $client->model ne 'group') + ? Slim::Player::Sync::syncname($client) + : $client->name, + 0, 60)); + + $self->cache(Plugins::Spotty::Connect->cacheFolder($self->mac)); + + $self->_checkStartTimes(); + + my @helperArgs = ( + '-c', $self->cache, + '-n', $self->name, + '--disable-audio-cache', +# '--bitrate', 96, +# '--initial-volume', $serverPrefs->client($client)->get('volume'), + '--player-mac', $self->mac, + '--lms', Slim::Utils::Network::serverAddr() . ':' . preferences('server')->get('httpport'), + ); + + if ( !Plugins::Spotty::Plugin->canDiscovery() || $prefs->get('disableDiscovery') ) { + push @helperArgs, '--disable-discovery'; + } + + if ( $prefs->client($client)->get('enableAutoplay') ) { + push @helperArgs, '--autoplay', 'on'; + } + + if ( $prefs->get('forceFallbackAP') && !Plugins::Spotty::Helper->getCapability('no-ap-port') ) { + push @helperArgs, '--ap-port=12321'; + } + + if (main::INFOLOG && $log->is_info) { + $log->info("Starting Spotty Connect daemon: \n$helperPath " . join(' ', @helperArgs)); + push @helperArgs, '--verbose' if Plugins::Spotty::Helper->getCapability('debug'); + } + + # add authentication data (after the log statement) + if ( $serverPrefs->get('authorize') ) { + if ( Plugins::Spotty::Helper->getCapability('lms-auth') ) { + main::INFOLOG && $log->is_info && $log->info("Adding authentication data to Spotty Connect daemon configuration."); + push @helperArgs, '--lms-auth', encode_base64(sprintf("%s:%s", $serverPrefs->get('username'), $serverPrefs->get('password'))); + } + else { + $log->error("Your Lyrion Music Server is password protected, but your spotty helper can't deal with it! Spotty will NOT work. Please update."); + } + } + + eval { + $self->_proc( Proc::Background->new( + { 'die_upon_destroy' => 1 }, + $helperPath, + @helperArgs + ) ); + }; + + if ($@) { + $log->warn("Failed to launch the Spotty Connect deamon: $@"); + } +} + +sub _checkStartTimes { + my $self = shift; + + if ( scalar @{$self->_startTimes} > MAX_FAILURES_BEFORE_DISABLE_DISCOVERY ) { + splice @{$self->_startTimes}, 0, @{$self->_startTimes} - MAX_FAILURES_BEFORE_DISABLE_DISCOVERY; + + if ( time() - $self->_startTimes->[0] < MAX_INTERVAL_BEFORE_DISABLE_DISCOVERY + && !$prefs->get('disableDiscovery') + ) { + $log->warn(sprintf( + 'The spotty helper has crashed %s times within less than %s minutes - disable local announcement of the Connect daemon.', + MAX_FAILURES_BEFORE_DISABLE_DISCOVERY, + MAX_INTERVAL_BEFORE_DISABLE_DISCOVERY / 60 + )); + + $prefs->set('disableDiscovery', 1); + } + } + + push @{$self->_startTimes}, time(); +} + +sub stop { + my $self = shift; + + if ($self->alive) { + main::INFOLOG && $log->is_info && $log->info("Quitting Spotty Connect daemon for " . $self->mac); + $self->_proc->die; + + rmtree catdir(preferences('server')->get('cachedir'), 'spotty', $self->id); + } + elsif (main::INFOLOG && $log->is_info) { + $log->info("This daemon is dead already... no need to stop it!"); + } +} + +sub spotifyId { + my ($self, $value) = @_; + + if (defined $value) { + $self->_spotifyId($value); + $self->_lastSeen(time); + } + + return $self->_spotifyId; +} + +sub spotifyIdIsRecent { + my $self = shift; + return (time() - $self->_lastSeen) <= SPOTIFY_ID_TTL ? $self->_spotifyId : undef; +} + +sub pid { + my $self = shift; + return $self->_proc && $self->_proc->pid; +} + +sub alive { + my $self = shift; + return 1 if $self->_proc && $self->_proc->alive; +} + +sub uptime { + my $self = shift; + return Time::HiRes::time() - ($self->_startTimes->[-1] || time()); +} + +1; diff --git a/Connect/DaemonManager.pm b/Connect/DaemonManager.pm new file mode 100644 index 0000000..4112e75 --- /dev/null +++ b/Connect/DaemonManager.pm @@ -0,0 +1,259 @@ +package Plugins::Spotty::Connect::DaemonManager; + +use strict; + +use Scalar::Util qw(blessed); + +use Slim::Utils::Log; +use Slim::Utils::Prefs; +use Slim::Utils::Timers; + +use Plugins::Spotty::Plugin; +use Plugins::Spotty::Connect::Daemon; + +# buffer the helper initialization to prevent a flurry of activity when players connect etc. +use constant DAEMON_INIT_DELAY => 2; +use constant DAEMON_WATCHDOG_INTERVAL => 60; + +my $prefs = preferences('plugin.spotty'); +my $log = logger('plugin.spotty'); + +my %helperInstances; + +sub init { + my $class = shift; + + # manage helper application instances + Slim::Control::Request::subscribe(sub { + Slim::Utils::Timers::killTimers( $class, \&initHelpers ); + Slim::Utils::Timers::setTimer( $class, Time::HiRes::time() + DAEMON_INIT_DELAY, \&initHelpers );; + }, [['client'], ['new', 'disconnect']]); + + Slim::Control::Request::subscribe(sub { + my $request = shift; + + return if $request->isNotCommand([['sync']]); + + # we're not going to try to be smart... just kill them all :-/ + __PACKAGE__->shutdown(); + + Slim::Utils::Timers::killTimers( $class, \&initHelpers ); + Slim::Utils::Timers::setTimer( $class, Time::HiRes::time() + DAEMON_INIT_DELAY, \&initHelpers );; + }, [['sync']]); + + # start/stop helpers when the Connect flag changes + $prefs->setChange(\&initHelpers, 'enableSpotifyConnect'); + + $prefs->setChange(sub { + $prefs->set('checkDaemonConnected', 0) if !$_[1]; + }, 'disableDiscovery'); + + # re-initialize helpers when the active account for a player changes + $prefs->setChange(sub { + my ($pref, $new, $client, $old) = @_; + + return unless $client && $client->id; + + main::INFOLOG && $log->is_info && $log->info("Spotify setting $pref for player " . $client->id . " has changed - re-initialize Connect helper"); + $class->stopHelper($client); + initHelpers(); + }, 'account', 'helper', 'enableAutoplay'); + + $prefs->setChange(sub { + my ($pref, $new, undef, $old) = @_; + + return if !($new || $old) && $new eq $old; + + Slim::Utils::Timers::killTimers( $class, \&initHelpers ); + + if (main::INFOLOG && $log->is_info) { + $pref eq 'disableDiscovery' && $log->info("Discovery mode for Connect has changed - re-initialize Connect helpers"); + $pref eq 'helper' && $log->info("Helper binary was re-configured - re-initialize Connect helpers"); + } + + $class->shutdown(); + + # call the initialization asynchronously, to allow other change handlers to finish before we restart + Slim::Utils::Timers::setTimer( $class, time() + 1, \&initHelpers ); + }, 'helper', 'forceFallbackAP', Plugins::Spotty::Plugin->canDiscovery() ? 'disableDiscovery' : undef); + + preferences('server')->setChange(sub { + main::INFOLOG && $log->is_info && $log->info("Authentication information for LMS has changed - re-initialize Connect helpers"); + $class->shutdown(); + initHelpers(); + }, 'authorize', 'username'); +} + +sub initHelpers { + my $class = __PACKAGE__; + + Slim::Utils::Timers::killTimers( $class, \&initHelpers ); + + main::INFOLOG && $log->is_info && $log->info("Checking Spotty Connect helper daemons..."); + + # shut down orphaned instances + $class->shutdown('inactive-only'); + + for my $client ( Slim::Player::Client::clients() ) { + my $syncMaster; + + # if the player is part of the sync group, only start daemon for the group, not the individual players + if ( Slim::Player::Sync::isSlave($client) && (my $master = $client->master) ) { + if ( $prefs->client($master)->get('enableSpotifyConnect') ) { + $syncMaster = $master->id; + } + # if the master of the sync group doesn't have connect enabled, enable anyway if one of the slaves has + else { + ($syncMaster) = map { $_->id } grep { + $prefs->client($_)->get('enableSpotifyConnect') + } sort { $a->id cmp $b->id } Slim::Player::Sync::slaves($master); + } + } + + if ( $syncMaster && $syncMaster eq $client->id ) { + main::INFOLOG && $log->is_info && $log->info("This is not the sync group's master itself, but the first slave with Connect enabled: $syncMaster"); + $class->startHelper($client); + } + elsif ( $syncMaster ) { + main::INFOLOG && $log->is_info && $log->info("This is not the sync group's master, and not the first slave with Connect either: $syncMaster"); + $class->stopHelper($client); + } + elsif ( !$syncMaster && $prefs->client($client)->get('enableSpotifyConnect') ) { + main::INFOLOG && $log->is_info && $log->info("This is the sync group's master, or a standalone player with Spotify Connect enabled: " . $client->id); + $class->startHelper($client); + } + else { + main::INFOLOG && $log->is_info && $log->info("This is a standalone player with Spotify Connect disabled: " . $client->id); + $class->stopHelper($client); + } + } + + Slim::Utils::Timers::setTimer( $class, Time::HiRes::time() + DAEMON_WATCHDOG_INTERVAL, \&initHelpers ); +} + +sub startHelper { + my ($class, $clientId) = @_; + + $clientId = $clientId->id if $clientId && blessed $clientId; + + # no need to restart if it's already there + my $helper = $helperInstances{$clientId}; + + if (!$helper) { + main::INFOLOG && $log->is_info && $log->info("Need to create Connect daemon for $clientId"); + $helper = $helperInstances{$clientId} = Plugins::Spotty::Connect::Daemon->new($clientId); + } + elsif (!$helper->alive) { + main::INFOLOG && $log->is_info && $log->info("Need to (re-)start Connect daemon for $clientId"); + $helper->start; + } + # Every few minutes we'll verify whether the daemon is still connected to Spotify + elsif ( $prefs->get('checkDaemonConnected') && !$helper->spotifyIdIsRecent ) { + main::INFOLOG && $log->is_info && $log->info("Haven't seen this daemon online in a while - get an updated list ($clientId)"); + + my $spotty = Plugins::Spotty::Connect->getAPIHandler($clientId); + Slim::Utils::Timers::killTimers( $spotty, \&_getDevices ); + Slim::Utils::Timers::setTimer( $spotty, time() + 2, \&_getDevices); + } + + return $helper if $helper && $helper->alive; +} + +sub _getDevices { + my ($spotty) = shift; + Slim::Utils::Timers::killTimers( $spotty, \&_getDevices ); + $spotty->devices(); +} + +sub stopHelper { + my ($class, $clientId) = @_; + + $clientId = $clientId->id if $clientId && blessed $clientId; + + my $helper = delete $helperInstances{$clientId}; + + if ($helper && $helper->alive) { + main::INFOLOG && $log->is_info && $log->info(sprintf("Shutting down Connect daemon for $clientId (pid: %s)", $helper->pid)); + $helper->stop; + } +} + +sub idFromMac { + my ($class, $mac) = @_; + + return unless $mac; + + my $helper = $helperInstances{$mac} || return; + + # refresh the Connect status every now and then + if (!$helper->spotifyIdIsRecent) { + _getDevices(Plugins::Spotty::Connect->getAPIHandler($mac)); + } + + return $helper->spotifyId; +} + +sub checkAPIConnectPlayers { + my ($class, $spotty, $data, $oneHelper) = @_; + + if ($data && ref $data && $data->{devices}) { + my %connectDevices = map { + $_->{name} => $_->{id}; + } @{$data->{devices}}; + + my $cacheFolder = $spotty->cache; + + foreach my $helper ( $oneHelper || values %helperInstances ) { + my $spotifyId = $connectDevices{$helper->name}; + + if ( !$oneHelper && !$spotifyId && $helper->cache eq $cacheFolder ) { + $log->warn("Connect daemon is running, but not connected - shutting down to force restart: " . $helper->mac . " " . $helper->name); + $class->stopHelper($helper->mac); + + # flag this system as flaky if we have to restart and the user is relying on the server + # $prefs->set('checkDaemonConnected', 1) if $prefs->get('disableDiscovery'); + } + elsif ( $spotifyId ) { + main::INFOLOG && $log->is_info && $log->info("Updating id of Connect connected daemon for " . $helper->mac); + $helper->spotifyId($spotifyId); + } + } + } +} + +sub checkAPIConnectPlayer { + my ($class, $spotty, $data) = @_; + + if ( $data && ref $data && $data->{device} && $spotty && $spotty->client && (my $helper = $helperInstances{$spotty->client->id}) ) { + $class->checkAPIConnectPlayers($spotty, { + devices => [ + $data->{device} + ] + }, $helper); + } +} + +sub shutdown { + my ($class, $inactiveOnly) = @_; + + my %clientIds = map { $_->id => 1 } Slim::Player::Client::clients() if $inactiveOnly; + + foreach my $clientId ( keys %helperInstances ) { + next if $clientIds{$clientId}; + $class->stopHelper($clientId); + } + + Slim::Utils::Timers::killTimers( $class, \&initHelpers ); +} + +sub uptime { + my ($class, $clientId) = @_; + + return unless $clientId; + + my $helper = $helperInstances{$clientId} || return 0; + + return $helper->uptime(); +} + +1; diff --git a/HTML/EN/plugins/Spotty/settings/basic.html b/HTML/EN/plugins/Spotty/settings/basic.html index b1023aa..cab7189 100644 --- a/HTML/EN/plugins/Spotty/settings/basic.html +++ b/HTML/EN/plugins/Spotty/settings/basic.html @@ -121,6 +121,48 @@ [% PROCESS "plugins/Spotty/settings/helpers.html" %] + [% WRAPPER setting title="PLUGIN_SPOTTY_SPOTIFY_CONNECT" desc="PLUGIN_SPOTTY_SPOTIFY_CONNECT_DESC_LONG" %] + [% IF !canConnect %] + [% "PLUGIN_SPOTTY_NEED_HELPER_UPDATE" | string %] + [% ELSE %] + [% IF players.size == 0 %] + [% "NO_PLAYER_FOUND" | string %] + [% END %] +
| + + + | + [% IF (loop.index + 1) % maxCols == 0 %]