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 %] + + [% FOREACH player IN players %] + [% maxCols = 5 %] + [% IF loop.index() % maxCols == 0 %][% END %] + + [% IF (loop.index + 1) % maxCols == 0 %][% END %] + [% END %] +
+ + +
+ [% END %] + [% END %] + + [% WRAPPER setting title="PLUGIN_SPOTTY_ADVANCED_SETTING" desc="" %] + [% "PLUGIN_SPOTTY_ADVANCED_SETTING_DESC" | string %] + [% END %] + + [% IF canConnect; WRAPPER setting title="" desc="" %] + + + [% END; ELSE %] + + [% END %] + + [% IF canConnect && canDiscovery; WRAPPER setting title="" desc="" %] + + + [% END; END %] + + [% IF canConnect && canDiscovery; WRAPPER setting title="" desc="" %] + + + [% END; END %] + [% IF canApPort; WRAPPER setting title="" desc="PLUGIN_SPOTTY_FORCE_FALLBACK_AP_DESC" %] @@ -130,8 +172,4 @@ [% END %] - [% WRAPPER setting title="PLUGIN_SPOTTY_SPOTIFY_CONNECT" desc="" %] - [% "PLUGIN_SPOTTY_SPOTIFY_CONNECT_DESC_LONG" | string %] - [% END %] - [% PROCESS settings/footer.html %] diff --git a/HTML/EN/plugins/Spotty/settings/player.html b/HTML/EN/plugins/Spotty/settings/player.html index cecb4a3..f178daa 100644 --- a/HTML/EN/plugins/Spotty/settings/player.html +++ b/HTML/EN/plugins/Spotty/settings/player.html @@ -31,4 +31,18 @@ [% END %] + [% WRAPPER setting title="PLUGIN_SPOTTY_SPOTIFY_CONNECT" desc="" %] + [% IF errorString %] +
[% errorString %]
+ [% ELSE %] + + + [% IF canAutoplay %] +
+ + + [% END %] + [% END %] + [% END %] + [% PROCESS settings/footer.html %] diff --git a/Helper.pm b/Helper.pm index 7591533..d0cd0f3 100644 --- a/Helper.pm +++ b/Helper.pm @@ -24,6 +24,12 @@ sub init { $prefs->setChange ( sub { $helper = $helperVersion = $helperCapabilities = undef; + + # can't call this immediately, as it would trigger another onChange event + Slim::Utils::Timers::setTimer(undef, time() + 1, sub { + Plugins::Spotty::Connect->init(); + }) if Plugins::Spotty::Plugin->canSpotifyConnect(); + }, 'helper') if !main::SCANNER; } diff --git a/OPML.pm b/OPML.pm index 1b7397e..53b54f8 100755 --- a/OPML.pm +++ b/OPML.pm @@ -1048,7 +1048,26 @@ sub transferPlaylist { my $items = []; - if ( $info && $info->{context} ) { + # if Connect is enabled for the target player, switch playback + if ( $info && $info->{device} && Plugins::Spotty::Plugin->canSpotifyConnect() && $prefs->client($client)->get('enableSpotifyConnect') ) { + push @$items, { + name => cstring($client, 'PLUGIN_SPOTTY_TRANSFER_CONNECT_DESC'), + type => 'textarea' + },{ + name => $info->{device}->{name}, + url => sub { + Plugins::Spotty::Plugin->getAPIHandler($client)->playerTransfer(sub { + $cb->({ + nextWindow => 'nowPlaying' + }); + }, $client->id); + }, + nextWindow => 'nowPlaying', + } + } + + # otherwise just try to play + elsif ( $info && $info->{context} ) { push @$items, { name => cstring($client, 'PLUGIN_SPOTTY_TRANSFER_DESC'), type => 'textarea' diff --git a/Plugin.pm b/Plugin.pm index 74c2dca..22fb30c 100644 --- a/Plugin.pm +++ b/Plugin.pm @@ -19,6 +19,7 @@ use Plugins::Spotty::Helper; use Plugins::Spotty::OPML; use Plugins::Spotty::ProtocolHandler; +use constant CONNECT_HELPER_VERSION => '2.0.0'; use constant CAN_IMPORTER => (Slim::Utils::Versions->compareVersions($::VERSION, '8.0.0') >= 0); use constant KILL_PROCESS_INTERVAL => 3600; @@ -67,6 +68,8 @@ sub initPlugin { 'recently-played' => -1, }, accountSwitcherMenu => 0, + checkDaemonConnected => 0, + disableDiscovery => 0, displayNames => {}, products => {}, helper => '', @@ -107,6 +110,12 @@ sub initPlugin { }); } + # we probably turned this on for too many users - let's start over + $prefs->migrate(2, sub { + $prefs->set('checkDaemonConnected', 0); + return 1; + }); + # Spotty seems to be disabling all those hosts... use fallback by default now... $prefs->migrate(3, sub { $prefs->set('forceFallbackAP', 1); @@ -154,6 +163,8 @@ sub postinitPlugin { if (main::TRANSCODING) { # we're going to hijack the Spotify URI schema Slim::Player::ProtocolHandlers->registerHandler('spotify', 'Plugins::Spotty::ProtocolHandler'); + $class->canSpotifyConnect(); + # if user has the Don't Stop The Music plugin enabled, register ourselves if ( Slim::Utils::PluginManager->isEnabled('Slim::Plugin::DontStopTheMusic::Plugin') && Slim::Utils::Versions->compareVersions($::VERSION, '7.9.0') >= 0 ) @@ -323,6 +334,27 @@ sub getAPIHandler { return $api; } +sub canSpotifyConnect { + my ($class, $dontInit) = @_; + + # we need a minimum helper application version + if ( !Slim::Utils::Versions->checkVersion(Plugins::Spotty::Helper->getVersion(), CONNECT_HELPER_VERSION, 10) ) { + $log->error("Cannot support Spotty Connect, need at least helper version " . CONNECT_HELPER_VERSION); + return; + } + + require Plugins::Spotty::Connect; + + Plugins::Spotty::Connect->init() unless $dontInit; + + return 1; +} + +sub isSpotifyConnect { + my $class = shift; + return $class->canSpotifyConnect() && Plugins::Spotty::Connect->isSpotifyConnect(@_); +} + sub canDiscovery { 1 } sub killHangingProcesses { @@ -364,6 +396,14 @@ sub killHangingProcesses { sub shutdownPlugin { if (main::TRANSCODING) { Plugins::Spotty::AccountHelper->purgeAudioCache(1); __PACKAGE__->killHangingProcesses(1); + + if (main::WEBUI) { + Plugins::Spotty::Settings::Auth->shutdownHelper(); + } + + if (__PACKAGE__->canSpotifyConnect()) { + Plugins::Spotty::Connect->shutdown(); + } } } 1; diff --git a/ProtocolHandler.pm b/ProtocolHandler.pm index 59742cb..5315f90 100644 --- a/ProtocolHandler.pm +++ b/ProtocolHandler.pm @@ -77,7 +77,10 @@ sub explodePlaylist { } main::INFOLOG && $log->is_info && $log->info("Explode URI: $uri"); - if (my $spotty = Plugins::Spotty::Plugin->getAPIHandler($client)) { + if ($uri =~ m|/connect-\d+|) { + $cb->([$uri]); + } + elsif (my $spotty = Plugins::Spotty::Plugin->getAPIHandler($client)) { $spotty->tracksFromURI(sub { my $result = shift || []; my $firstItem = $result->[0]; @@ -101,6 +104,35 @@ sub explodePlaylist { } } +sub isRepeatingStream { + my ( undef, $song ) = @_; + + return $song && Plugins::Spotty::Plugin->isSpotifyConnect($song->master()); +} + +sub canDoAction { + my ( $class, $client, $url, $action ) = @_; + + if ( $action eq 'pause' && $prefs->get('optimizePreBuffer') && Plugins::Spotty::Plugin->isSpotifyConnect($client) ) { + return 0; + } + + return 1; +} + +sub getNextTrack { + my ( $class, $song, $successCb, $errorCb ) = @_; + + my $client = $song->master(); + + if (Plugins::Spotty::Plugin->isSpotifyConnect($client)) { + Plugins::Spotty::Connect->getNextTrack($song, $successCb, $errorCb); + return; + } + + $successCb->(); +} + sub getMetadataFor { my ( $class, $client, $url, undef, $song ) = @_; @@ -130,6 +162,11 @@ sub getMetadataFor { $meta = undef; + # sometimes we wouldn't get a song object, and an outdated url. Get latest data instead! + if ((!$url || $url =~ /connect-/) && !$song && Plugins::Spotty::Plugin->isSpotifyConnect($client) && ($song = $client->playingSong)) { + $url = $song->streamUrl; + } + if ( $client && ($song ||= $client->currentSongForUrl($url)) ) { # we store a copy of the metadata in the song object - no need to read from the disk cache my $info = $song->pluginData('info'); @@ -151,7 +188,7 @@ sub getMetadataFor { $song->duration($info->{duration}); - main::INFOLOG && $log->is_info && $log->info("Returning metadata cached in song object for $url"); + main::DEBUGLOG && $log->is_debug && $log->debug("Returning metadata cached in song object for $url"); main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($info)); return $info; } @@ -176,7 +213,7 @@ sub getMetadataFor { main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($meta)); } - if (!$meta) { + if (!$meta && $uri !~ /^spotify:connect-/) { # grab missing metadata asynchronously main::INFOLOG && $log->is_info && $log->info("No metadata found - need to look online: $uri"); $class->getBulkMetadata($client, $song ? undef : $url); diff --git a/Settings.pm b/Settings.pm index 448f49b..4ff34fb 100644 --- a/Settings.pm +++ b/Settings.pm @@ -44,7 +44,8 @@ sub page { } sub prefs { - my @prefs = qw(myAlbumsOnly cleanupTags bitrate iconCode accountSwitcherMenu helper sortAlbumsAlphabetically sortArtistsAlphabetically sortPlaylisttracksByAddition); + my @prefs = qw(myAlbumsOnly cleanupTags bitrate iconCode accountSwitcherMenu helper optimizePreBuffer sortAlbumsAlphabetically sortArtistsAlphabetically sortPlaylisttracksByAddition); + push @prefs, 'disableDiscovery', 'checkDaemonConnected' if Plugins::Spotty::Plugin->canDiscovery(); push @prefs, 'sortSongsAlphabetically' if !Plugins::Spotty::Plugin->hasDefaultIcon(); push @prefs, 'forceFallbackAP' if !Plugins::Spotty::Helper->getCapability('no-ap-port'); return ($prefs, @prefs); @@ -68,6 +69,10 @@ sub handler { if ($paramRef->{saveSettings}) { $paramRef->{pref_iconCode} ||= Plugins::Spotty::Plugin->initIcon(); + foreach my $client ( Slim::Player::Client::clients() ) { + $prefs->client($client)->set('enableSpotifyConnect', $paramRef->{'connect_' . $client->id} ? 1 : 0); + } + if ($paramRef->{clearPlaylistFolderCache}) { Plugins::Spotty::PlaylistFolders->purgeCache(1); } @@ -114,6 +119,7 @@ sub handler { { name => $_->name, id => $_->id, + enabled => $prefs->client($_)->get('enableSpotifyConnect'), } } Slim::Player::Client::clients() ]; @@ -157,6 +163,7 @@ sub beforeRender { $paramRef->{helperPath} = $helperPath; $paramRef->{helperVersion} = $helperVersion ? "v$helperVersion" : string('PLUGIN_SPOTTY_HELPER_ERROR'); + $paramRef->{canConnect} = Plugins::Spotty::Plugin->canSpotifyConnect(); $paramRef->{canApPort} = !Plugins::Spotty::Helper->getCapability('no-ap-port'); $paramRef->{hasDefaultIcon} = Plugins::Spotty::Plugin->hasDefaultIcon(); diff --git a/Settings/Player.pm b/Settings/Player.pm index fc72ed5..a6cbe45 100644 --- a/Settings/Player.pm +++ b/Settings/Player.pm @@ -23,13 +23,18 @@ sub page { sub prefs { my ($class, $client) = @_; - my @prefs = qw(replaygain filterExplicitContent reversePodcastOrder); + my @prefs = qw(enableSpotifyConnect replaygain filterExplicitContent reversePodcastOrder); + push @prefs, 'enableAutoplay' if Plugins::Spotty::Helper->getCapability('autoplay'); return ($prefs->client($client), @prefs); } sub handler { my ($class, $client, $params, $callback, $httpClient, $response) = @_; + if ( !Plugins::Spotty::Plugin->canSpotifyConnect() ) { + $params->{errorString} = $client->string('PLUGIN_SPOTTY_NEED_HELPER_UPDATE'); + } + $params->{canAutoplay} = Plugins::Spotty::Helper->getCapability('autoplay'); # get Home menu items if a client is connected diff --git a/install.xml b/install.xml index 1873af5..e177a20 100644 --- a/install.xml +++ b/install.xml @@ -18,5 +18,5 @@ 7.7 2 - 4.60.7 + 4.60.7-local diff --git a/strings.txt b/strings.txt index 649f963..8427d46 100644 --- a/strings.txt +++ b/strings.txt @@ -320,7 +320,23 @@ PLUGIN_SPOTTY_HELPER_PORTS HU Kérjük, győződjön meg arról, hogy a 80-as, 443-as és 4070-es portokon engedélyezi a helper alkalmazás számára az internet elérését. NL Zorg ervoor dat je toestaat dat de hulptoepassing internettoegang heeft op de poorten 80, 443 en 4070. -PLUGIN_SPOTTY_SHOW_MY_ALBUMS_ONLY_DESC +PLUGIN_SPOTTY_DISABLE_DISCOVERY_DESC + CS Neoznamovat ve své síti přehrávače Squeezebox spuštěné v režimu Spotify Connect. Tuto možnost zaškrtněte, pokud nechcete, aby se váš přehrávač Squeezebox s povoleným režimem Spotify Connect zobrazoval ve všech aplikacích Spotify ve vaší síti. + DA Annoncér ikke Squeezebox Playere på det lokale netværk i Spotify Connect tilstand. Aktivér denne mulighed, hvis du ikke ønsker, at din Spotify Connect-aktiverede Squeezebox-afspillere skal vises i alle Spotify-apps på dit netværk. + DE Squeezebox Player im Spotify Connect Modus nicht im lokalen Netzwerk bekannt geben. Aktiviere diese Option, falls dein Player im Spotify Connect Modus nur vom Spotify-Konto gesehen werden soll, welches zuletzt für dieses Gerät verwendet wurde. + EN Don't announce Squeezebox players running in Spotify Connect mode in your network. Check this option if you don't want your Spotify Connect enabled Squeezebox player to show up in all Spotify apps in your network. + FR Ne pas annoncer les platines Squeezebox compatibles Spotify Connect sur votre réseau. Cocher cette option si vous ne souhaitez pas que les platines Squeezebox compatibles Spotify Connect apparaissent dans toutes les applications Spotify de votre réseau. + HU Ne jelentse be a hálózaton a Spotify Connect módban futó Squeezebox-lejátszókat. Jelölje be ezt a lehetőséget, ha nem szeretné, hogy a Spotify Connect-kompatibilis Squeezebox lejátszója megjelenjen a hálózaton lévő összes Spotify alkalmazásban. + NL Kondig geen Squeezebox muziekspelers aan in de Spotify Connect modus in jouw netwerk. Vink deze optie aan als je niet wilt dat Spotify Connect compatibele Squeezebox muziekspelers in alle Spotify apps op jouw netwerk verschijnen. + +PLUGIN_SPOTTY_OPTIMIZE_PRE_BUFFERING + CS Optimalizovat přednačítání - povolte pouze v případě, kdy se zdá, že vaše aplikace Spotify předbíhá přehrávání Spotty v režimu Connect. + DA Optimér mellemlagring - anbefales kun, hvis din Spotify-app i Connect-tilstand ser ud til at være numre foran Spotty-afspilning. + DE Pre-Buffering optimieren - nur empfohlen, wenn die Spotify App im Connect Modus Spotty weit voraus zu sein scheint. + EN Optimize Pre-Buffering - only enable if your Spotify application seems to be tracks ahead of Spotty playback in Connect mode. + FR Optimiser la mise en cache anticipée. A n'activer que si votre application Spotify est en avance sur Spotty en mode Connect. + HU Az előpufferelés optimalizálása – csak akkor kapcsolja be, ha a Spotify-alkalmazás úgy tűnik, hogy a csatlakozási módban a Spotty lejátszás előtt van. + NL Optimaliseer pre-buffering - schakel deze optie alleen in als de Spotify App nummers voorloopt op de Spotty weergave in de Connect modus. CS Zobrazit pouze alba mé knihovny pro interprety mé knihovny DA Vis kun albums i mit bibliotek for kunstnere i mit bibliotek DE Zeige nur meine Alben unter meinen Interpreten @@ -383,7 +399,14 @@ PLUGIN_SPOTTY_LO_POWER_PI HU Úgy tűnik, a Spotty a legalacsonyabb specifikációjú Raspberry Pi-n (első generációs Pi A/A+/B/B+ vagy Pi Zero) fut. A Spotty valószínűleg nem fog működni ezen a platformon. NL Het lijkt erop dat je Spotty gebruikt op een Raspberry Pi met de laagste specificatie (eerste generatie Pi A/A+/B/B+ of Pi Zero). Spotty zal waarschijnlijk niet werken op dit platform. -PLUGIN_SPOTTY_DOCKER_NETWORK_HINT +PLUGIN_SPOTTY_CHECK_DAEMON_IS_CONNECTED + CS Monitorovat připojení pomocníka Spotty Connect k serverům Spotify. To může být užitečné, pokud vaše zařízení s povolenou funkcí Spotty Connect pravidelně mizí z aplikací Spotify. + DA Overvåg forbindelsen fra Spotty Connect hjælpeprogrammet til Spotify serverne. Dette kan hjælpe, hvis Spotty Connect aktiverede enheder regelmæssigt forsvinder fra Spotify apps. + DE Überwache die Verbindung der Spotty Connect Helferanwendung mit den Spotify Servern. Dies kann helfen, wenn Spotty Connect aktivierte Geräte regelmässig aus den Spotify-Anwendungen verschwinden. + EN Monitor the connection of the Spotty Connect helper with the Spotify servers. This can be useful if your Spotty Connect enabled devices regularly disappear from the Spotify apps. + FR Surveiller la connexion de Spotty Connect Helper avec les serveurs Spotify. Cela peut être utile si vos appareils compatibles Spotty Connect disparaissent régulièrement des applications Spotify. + HU Figyelje a Spotty Connect kapcsolatát a Spotify szerverekkel. Ez akkor lehet hasznos, ha a Spotty Connect-kompatibilis eszközei rendszeresen eltűnnek a Spotify alkalmazásokból. + NL Monitor de verbinding van de Spotty Connect hulptoepassing met de Spotify servers. Dit kan handig zijn als jouw voor Spotty Connect geschikte apparaten regelmatig uit de Spotify apps verdwijnen. DE ⚠️ Sie scheinen Lyrion Music Server in einem Docker-Container auszuführen. Stellen Sie sicher, dass Sie ihn im Host-Modus betreiben oder den Parameter "advertiseaddr" definiert haben (siehe Docker Readme). Andernfalls funktioniert die Authentifizierung nicht. EN ⚠️ You seem to be running Lyrion Music Server in a Docker container. Make sure you run it in host mode, or have the "advertiseaddr" parameter defined (see Docker readme). Otherwise authentication will not work. FR ⚠️ Il semble que vous exécutiez Lyrion Music Server dans un conteneur Docker. Assurez-vous de l'exécuter en mode "host" ou d'avoir défini le paramètre "advertiseaddr" (voir Docker readme). Sinon, l'authentification ne fonctionnera pas. @@ -416,7 +439,23 @@ PLUGIN_SPOTTY_FORCE_FALLBACK_AP_DESC HU Néha a Spotify egyes hozzáférési pontjai nem válaszolnak megfelelően. Ebben a helyzetben megpróbálhatja helyette a tartalék szervereket használni. NL Soms reageren de Access Points van Spotify niet correct. In deze situatie kan het helpen om in plaats daarvan de fallback servers te gebruiken. -PLUGIN_SPOTTY_WHATS_NEW +PLUGIN_SPOTTY_ADVANCED_SETTING + CS Pokročilá nastavení + DA Avancerede indstillinger + DE Erweiterte Einstellungen + EN Advanced Settings + FR Réglages avancés + HU Speciális beállítások + NL Geavanceerde Instellingen + +PLUGIN_SPOTTY_ADVANCED_SETTING_DESC + CS Následující nastavení neměňte, pokud přesně nevíte, co děláte, nebo pokud jsem vám to neřekl. Při nesprávném použití mohou mít negativní dopad na zážitek ze Spotty. + DA Venligst kun ændre følgende indstillinger, hvis du ved, hvad du laver, eller bliver bedt om det. Forkerte indstillinger kan have en negativ indvirkning på Spottys drift. + DE Bitte verändere die folgenden Einstellungen nur, wenn du weisst was du tust, oder dazu aufgefordert wirst. Falsche Einstellungen können einen negativen Einfluss haben auf den Betrieb von Spotty. + EN Please don't change the following settings, unless you know exactly what you're doing - or I told you to do so. Used the wrong way they can have a negative impact on the Spotty experience. + FR Veuillez ne pas modifier les paramètres suivants, à moins que vous ne sachiez exactement ce que vous faites (ou que je vous aie dit de le faire). Utilisés de la mauvaise manière, ils peuvent avoir un impact négatif sur l'expérience Spotty. + HU Kérjük, ne változtassa meg a következő beállításokat, hacsak nem tudja pontosan, hogy mit csinál – vagy én mondtam, hogy tegye meg. Rossz módon használva negatív hatással lehetnek a Spotty-élményre. + NL Wijzig de volgende instellingen niet, tenzij je precies weet wat je doet (of als het wordt gevraagd). Als ze op de verkeerde manier worden gebruikt, kunnen ze de Spotty ervaring negatief beïnvloeden. CS Novinky DA Nyheder DE Neuigkeiten @@ -674,7 +713,14 @@ PLUGIN_SPOTTY_TRANSFER_DESC HU A Spotify lejátszást átviheti az alább felsorolt ​​eszközről a Squeezeboxra. Felhívjuk figyelmét, hogy nem mindig lehet pontosan ugyanazt a lejátszási listát visszaállítani. NL Je kunt de Spotify weergave van het onderstaande apparaat overzetten naar jouw Squeezebox. Houd er rekening mee dat het niet altijd mogelijk is om exact dezelfde afspeellijst te herstellen. -PLUGIN_SPOTTY_NO_PLAYER_FOUND +PLUGIN_SPOTTY_TRANSFER_CONNECT_DESC + CS Přehrávání Spotify z níže uvedeného zařízení můžete přenést do Squeezeboxu. Přehrávání bude pokračovat v režimu Spotify Connect. To znamená, že se přehrávání na původním zařízení zastaví. + DA Du kan overføre Spotify-afspilning fra enheden på listen nedenfor til din Squeezebox. Afspilningen fortsætter i Spotify Connect tilstand. Det betyder, at afspilningen på den oprindelige enhed stopper. + DE Sie können die Spotify Wiedergabe vom unten gelisteten Gerät übernehmen. Die Wiedergabe wirde im Spotify Connect Modus weitergeführt. D.h. dass die Wiedergabe auf dem anderen Gerät angehalten wird. + EN You can transfer Spotify playback from below listed device to your Squeezebox. Playback will continue in Spotify Connect mode. This means that playback on the origin device will stop. + FR Vous pouvez transférer la lecture Spotify de l'appareil répertorié ci-dessous vers votre Squeezebox. La lecture se poursuivra en mode Spotify Connect. Cela signifie que la lecture sur le périphérique d'origine s'arrêtera. + HU A Spotify lejátszást átviheti az alább felsorolt ​​eszközről a Squeezeboxra. A lejátszás Spotify Connect módban folytatódik. Ez azt jelenti, hogy a lejátszás az eredeti eszközön leáll. + NL Je kunt de Spotify weergave van het onderstaande apparaat overzetten naar jouw Squeezebox. Het afspelen gaat verder in de Spotify Connect modus. Dit betekent dat het afspelen op het oorspronkelijke apparaat stopt. CS Nebylo nalezeno žádné zařízení přehrávající Spotify. DA Der blev ikke fundet nogen enhed eller afspilleliste. DE Es wurde kein Gerät oder keine Wiedergabeliste gefunden. @@ -886,7 +932,31 @@ PLUGIN_SPOTTY_SPOTIFY_CONNECT EN Spotify Connect PLUGIN_SPOTTY_SPOTIFY_CONNECT_DESC_LONG - CS Co se stalo s režimem Connect? Zmizel. S nejnovějšími změnami API na straně Spotify jsem musel dohnat vývoj librespot. To je knihovna, která zajišťuje veškerou magii pro přehrávání Spotify. Bohužel můj hack pro implementaci režimu Connect ve Spotty nebyl kompatibilní s jejich nejnovějším vývojem. A protože byl režim Connect stejně vždycky „nestabilní“, rozhodl jsem se ho odstranit. Doufejme, že se v ne příliš vzdálené budoucnosti vrátím k práci na nové implementaci. + CS Vyberte přehrávače, které chcete používat se službou Spotify Connect. Mějte na paměti, že každé povolené zařízení spustí proces na pozadí. To může vést k problémům na serverech s nedostatkem paměti. + DA Vælg hvilke afspillere du ønsker at bruge med Spotify Connect. Husk, at hver aktiveret enhed vil starte en baggrundsproces. Dette kan medføre problemer på servere, der løber tør for hukommelse. + DE Wählen Sie aus, welche Geräte als Spotify Connect Endpunkte zur Auswahl stehen sollen. Bitte beachten Sie, dass für jedes Gerät ein Hintergrundprozess gestartet wird. Dies kann auf Geräten mit wenig Speicher u.U. zu Problemen führen. + EN Select which players you'd like to use with Spotify Connect. Please keep in mind that every enabled device will launch a background process. This could potentially lead to issues on servers running low on memory. + FR Sélectionner les platines à utiliser avec Spotify Connect. Garder à l'esprit que chaque appareil activé lancera un processus en arrière-plan. Cela pourrait potentiellement entraîner des problèmes sur des serveurs avec peu de mémoire. + HU Válassza ki, mely lejátszókat szeretné használni a Spotify Connect szolgáltatással. Ne feledje, hogy minden engedélyezett eszköz elindít egy háttérfolyamatot. Ez problémákat okozhat a kiszolgálókon, amelyeknél kevés a memória. + NL Selecteer welke muziekspelers je wilt gebruiken met Spotify Connect. Houd er rekening mee dat er voor elk apparaat een proces start op de achtergrond. Dit kan mogelijk tot problemen leiden op servers met weinig geheugen. + +PLUGIN_SPOTTY_SPOTIFY_CONNECT_DESC + CS Povolit přístupový bod Spotify Connect pro tento přehrávač + DA Aktivér Spotify Connect for denne afspiller + DE Dieses Gerät für Spotify Connect zur Verfügung stellen + EN Enable Spotify Connect endpoint for this player + FR Rendre cette platine disponible pour Spotify Connect + HU Spotify Connect végpont engedélyezése ehhez a lejátszóhoz + NL Schakel Spotify Connect in voor deze muziekspeler + +PLUGIN_SPOTTY_NEED_HELPER_UPDATE + CS Spotify Connect není k dispozici. + DA Spotify Connect er ikke tilgængelig. + DE Spotify Connect ist nicht verfügbar. + EN Spotify Connect is not available. + FR Spotify Connect n'est pas disponible. + HU A Spotify Connect nem érhető el. + NL Spotify Connect is niet beschikbaar. Zmizel. S nejnovějšími změnami API na straně Spotify jsem musel dohnat vývoj librespot. To je knihovna, která zajišťuje veškerou magii pro přehrávání Spotify. Bohužel můj hack pro implementaci režimu Connect ve Spotty nebyl kompatibilní s jejich nejnovějším vývojem. A protože byl režim Connect stejně vždycky „nestabilní“, rozhodl jsem se ho odstranit. Doufejme, že se v ne příliš vzdálené budoucnosti vrátím k práci na nové implementaci. DE Was ist mit dem Connect-Modus passiert? Er ist verschwunden. Durch die neuesten API-Änderungen seitens Spotify musste ich die Entwicklung von librespot einholen. Das ist die Bibliothek, die die Spotify-Wiedergabe ermöglicht. Leider war mein Hack, um den Connect-Modus in Spotty zu implementieren, mit der aktuellen Entwicklung nicht mehr kompatibel. Da der Connect-Modus ohnehin immer "spotty" war, habe ich ihn entfernt. Hoffentlich kann ich in naher Zukunft an einer neuen Implementierung arbeiten. EN What happened to Connect mode? It's gone. With the latest API changes on Spotify's end I had to catch up with librespot development. This is the library which does all the magic for the Spotify playback. Unfortunately my hack to implement Connect mode in Spotty was not compatible with their latest development. As Connect mode had been "spotty" anyway, I decided to remove it. Hopefully I'll get back to working on a new implementation in the not too distant future. FR Qu'est-il arrivé au mode Connect ? Il a disparu. Avec les derniers changements d'API côté Spotify, j'ai dû suivre le développement de librespot. C'est la bibliothèque qui gère toute la magie de la lecture Spotify. Malheureusement, mon hack pour implémenter le mode Connect dans Spotty n'était plus compatible avec leurs dernières évolutions. Comme le mode Connect était de toute façon instable, j'ai décidé de le retirer. J'espère pouvoir travailler sur une nouvelle version dans un avenir pas trop lointain.