feat(connect): full Spotify Connect event dispatch, API control methods, and settings UI#222
feat(connect): full Spotify Connect event dispatch, API control methods, and settings UI#222stiefenm wants to merge 21 commits into
Conversation
- use constant CONNECT_HELPER_VERSION => '2.1.0' (verified against spotty-custom --check) - $prefs->init: add disableDiscovery => 0 (Discovery ON per D-01) - $prefs->init: add checkDaemonConnected => 0 (explicitly disabled per REQUIREMENTS.md)
- add canSpotifyConnect(): checks CONNECT_HELPER_VERSION via Slim::Utils::Versions->checkVersion, requires Plugins::Spotty::Connect and calls Connect->init() unless dontInit is set - add isSpotifyConnect(): delegates to canSpotifyConnect(1) + Connect->isSpotifyConnect() - postinitPlugin: call $class->canSpotifyConnect() after OPML->init() to initialize Connect - shutdownPlugin: call Connect->shutdown() guarded by canSpotifyConnect(1) before process cleanup - killHangingProcesses REG-01 guard: collect DaemonManager->helperInstances PIDs before kill, switch from blind pkill to pgrep + PID-filtered kill to preserve Connect daemon process
- Connect/Daemon.pm: Proc::Background wrapper with crash-backoff - Connect/DaemonManager.pm: lifecycle orchestration with 60s watchdog - Connect/Context.pm: play-history + playlist-state tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Settings/Player.pm: enableSpotifyConnect as first pref, enableAutoplay conditional - Settings/Player.pm: canSpotifyConnect() check in handler, errorString on old binary - Settings.pm: disableDiscovery pref when canDiscovery() is true - Settings.pm: canSpotifyConnect template variable in handler
- Add playerTransfer, playerPause, playerVolume, idFromMac, withIdFromMac, devices - playerTransfer uses body-PUT pattern (JSON body with device_ids + play) - playerPause, playerVolume use query-params PUT pattern - devices uses GET with _nocache => 1, calls checkAPIConnectPlayers - withIdFromMac resolves MAC to Spotify device_id via devices() fallback - Update player() callback: add checkAPIConnectPlayer call for MAC->device_id mapping - All me/player/* endpoints route through own-flavor RT via existing _call dispatch
- Add sub isRepeatingStream: returns true when isSpotifyConnect, prevents LMS from treating Connect streams as finite tracks - Add sub getNextTrack: delegates to Connect->getNextTrack for Connect players, preventing --single-track subprocess spawning (credentials.json race fix) - In explodePlaylist: add /connect-\d+/ guard — returns Connect URLs directly without expansion into track lists - In getMetadataFor: add Connect-URL recovery (get latest song if outdated url) and skip getBulkMetadata for spotify:connect-* URIs to prevent invalid lookups
- Register addDispatch(['spottyconnect','_cmd']) to resolve 'no match in dispatchDB!' warning
- _connectEvent handles all 5 Plan-B wire vocabulary events (start, change, stop, volume, seek)
- Volume inline handler with VOLUME_GRACE_PERIOD (CON-07) and source-marking (T-08-08)
- change-to-start upgrade logic (Pitfall 5) with defensive $result ||= {} (Pitfall 6)
- _onPause and _onVolume subscribers with source-check early returns (loop prevention Pattern 2)
- _onPause calls playerPause, _onVolume calls playerVolume (D-05 bidirectionality)
- Per-player cacheFolder with credentials.json copy isolation (REG-02, T-08-07)
- isSpotifyConnect checks pluginData('SpotifyConnect')
- No 'pause' event match (Plan B sends stop for both Paused and Stopped)
- player.html: enableSpotifyConnect checkbox in WRAPPER setting block - player.html: errorString shown in red when canSpotifyConnect() is false - player.html: enableAutoplay conditional on canAutoplay - strings.txt: PLUGIN_SPOTTY_SPOTIFY_CONNECT_DESC (7 locales) - strings.txt: PLUGIN_SPOTTY_NEED_HELPER_UPDATE (7 locales) - strings.txt: PLUGIN_SPOTTY_DISABLE_DISCOVERY_DESC (7 locales)
Two bugs discovered during Phase 8 smoke test: 1. Connect.pm:312 calls $spotty->playerNext() but the method was never added to API.pm. Add POST /me/player/next wrapper. 2. Connect::cacheFolder only created per-player credential folders when discovery was enabled. The daemon ALWAYS needs its own folder to prevent writing to the main account dir (7716df85 corruption). Remove the discovery-mode condition. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two bugs fixed: 1. Daemon.pm: use 127.0.0.1 instead of Slim::Utils::Network::serverAddr() for the --lms parameter. serverAddr() can return a stale IP when the host changes networks (e.g. WiFi roaming), causing all spottyconnect JSON-RPC events to be sent to an unreachable address. The daemon always runs on the same machine as LMS, so localhost is correct. 2. Connect.pm _onVolume: remove hasDigitalOut/digitalVolumeControl guard that blocked LMS→Spotify volume forwarding. The guard was inherited from v0.7 reference code where it prevented double volume control on digital output players. But for Connect mode, the playerVolume API call controls Spotify's volume, not the local output — the guard must not apply. Confirmed bidirectional volume sync after fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ransitions playerNext (POST me/player/next) advanced Spotify's state before LMS was ready, causing: (1) Spotify app jumping to the next track prematurely, (2) daemon stop/change events racing with the pre-buffer flow, and (3) a 2-4s audible gap from retry polling. Replace with playerQueue (GET me/player/queue) which peeks at the next track without side effects. The daemon advances Spotify naturally when the track ends. Queue peek responds in ~0.4s with no retries needed. Also fixes: - Source-mark the stop in _sendPause to prevent double-pause via _onPause - Guard _connectEvent stop handler with newTrack flag to prevent premature pause during track transitions - Add playerQueue method to API.pm (GET me/player/queue, returns first queued item) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ions Add bidirectional seek sync between LMS and Spotify: - playerSeek API method (PUT me/player/seek) for LMS→Spotify - _onSeek subscriber forwards local seeks to Spotify (0.3s debounce) - Source-mark daemon seek and change-event seeks to prevent echo loops - newTrack guard in _onSeek prevents spurious seek-to-0 during transitions Fix track-end Spotify advancement: - Schedule playerNext at track end (not 7s early) so Spotify advances in sync with LMS audio completion - Clear newTrack flag after queue peek to prevent stop-event suppression from persisting beyond the pre-buffer window - _firePlayerNext timer with cleanup in _getNextTrack Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Volume sync at initial Connect was unreachable — the SpotifyConnect flag was set to 1 before checking if it was 0. Move the volume sync before the flag assignment. 2. URI/URL format mismatch in _getNextTrack — addPlay() and isLastTrack() were called with raw streamUrl (spotify:track:xxx) but Context.pm stores keys in uri2url format (spotify://track:xxx). Playlist end detection never triggered. Normalize with uri2url(). 3. killHangingProcesses used helperInstances() which returns Daemon objects, not PIDs. The connectPids hash got object references as keys instead of numeric PIDs, so Connect daemons were not protected from the orphan kill. Use helperPids() which extracts actual PIDs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add disableSpotifyConnect => 0 to $prefs->init (default: Connect enabled) - canSpotifyConnect: return early if disableSpotifyConnect pref is set (gate before binary check) - Settings.pm prefs(): push disableSpotifyConnect when binary version is sufficient (avoids chicken-and-egg with disabled pref)
…gle UI - basic.html: replace static CONNECT_DESC_LONG block with capability-aware IF canSpotifyConnect conditional - Shows positive description (CONNECT_DESC_LONG) + global disable checkbox when binary is capable - Shows NEED_HELPER_UPDATE text when binary version is insufficient - strings.txt: replace obsolete "What happened to Connect mode?" with positive "is available" text (5 languages: CS, DE, EN, FR, NL) - strings.txt: add new PLUGIN_SPOTTY_DISABLE_SPOTIFYCONNECT_DESC in 5 languages (CS, DE, EN, FR, NL)
- Connect.pm lines 61/64: rewrite (D-05) comments to explain bidirectional state sync - Connect/Daemon.pm line 75: rewrite D-01 comment to generic English discovery explanation
Three issues fixed for connecting to a device while music is already playing in the Spotify app: 1. Grace period for spurious stop events: during session setup, the binary fires a stop event (is_playing=0, track=none) that is a librespot session transition artifact, not a real user pause. Now suppressed within 12s of a start event. 2. Stale-API fallback for start events: when the /me/player API returns no track on a start event, fall back to the binary's _p2 parameter (track_id) to synthesize the track info. Same pattern as the change event fallback. 3. getNextTrack stale-API fallback: after playlist play, getNextTrack polls /me/player again. If the API still returns no item, use the event track URI stored in pluginData by the start handler. This eliminates the 15s wait for the safety-net poll. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On playlist jumps, the /me/player API poll often returns the old track (stale response) while the binary's TrackChanged event already carries the correct new track_id in the _p2 parameter. Previously, the stale API response caused the change→start upgrade to not trigger, leaving LMS on the old track until the 15s safety-net poll caught up. Now: if the API returns the same track we already have, but the event's _p2 parameter carries a different track_id, we override the API result with the event data. This makes playlist jumps respond in < 1s instead of waiting for the safety-net poll. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nc safety net Three interacting bugs fixed: 1. Double-advance at track boundaries: both the binary's TrackChanged event and our scheduled _firePlayerNext timer would advance Spotify, skipping tracks and causing progressive queue desync. Fix: kill _firePlayerNext in both the change→start upgrade path and the pure change handler. 2. Stale-progress seek after skip: /me/player returns stale progress_ms during track transitions. The old code stored this and applied it via _onNewSong, causing the new song to jump to mid-track. Fix: only store progress for genuine initial transfers (not change→start upgrades), and source-mark the _onNewSong seek to prevent echo via _onSeek. 3. Playlist jumps invisible to LMS: when users tap a track further down in the playlist, the binary doesn't emit a PlayerEvent (TrackChanged is not dispatched in our Rust code). Fix: add a 15-second periodic sync poll (_syncPoll) that compares LMS track with /me/player and switches on drift. This is a Perl-side workaround; the root cause is in the binary's event dispatcher. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…jump In Connect mode, Spotify owns the playback queue — local LMS skip/prev caused a desync where LMS jumped to a pre-fetched track while Spotify stayed on the current one. The safety net would correct it after ~15s, but this was jarring. Now: - ProtocolHandler::canDoAction blocks local skip (action=stop) in Connect mode, preventing the StreamingController from advancing - Connect::_onPlaylistJump subscribes to playlist jump/index commands and forwards them to Spotify Web API (playerNext / playerPrevious) - API::playerPrevious added (POST /me/player/previous) - _syncPoll extended with position-drift detection (>30s threshold) to catch transfers that start at position 0 due to stale API data User-facing behavior: - Next button → Spotify skips to next track, daemon sends change event - Back 1× (>3s into song) → Spotify restarts current track - Back 2× → Spotify goes to previous track - Mid-song transfer drift corrected within 15s by sync poll Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CONNECT_START_GRACE was referenced at line 879 but never declared - Causes 'Bareword not allowed under strict subs' compilation error - Set to 10 seconds (session setup artifacts window, similar to VOLUME_GRACE_PERIOD=20) - Discovered during LMS smoke test (Task 1)
📝 WalkthroughWalkthroughThis PR introduces complete Spotify Connect support to the Spotty plugin. It adds a daemon-managed helper process per player, wires event dispatch between Spotify and LMS, implements bidirectional playback synchronization, and extends the Spotify API with device-aware player controls. The feature is gated by plugin version checks and preference flags, with per-player and global configuration options. ChangesSpotify Connect Feature Implementation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (5)
Connect/Daemon.pm (1)
36-48: ⚡ Quick winParameter mutation in constructor may cause unexpected behavior.
Line 42 modifies the
$idparameter withs/://g, which mutates the caller's variable if they passed a scalar variable. While this may work in the current usage, it's an unexpected side effect.♻️ Proposed fix to avoid parameter mutation
sub new { my ($class, $id) = `@_`; my $self = $class->SUPER::new(); $self->mac($id); - $id =~ s/://g; - $self->id($id); + (my $normalizedId = $id) =~ s/://g; + $self->id($normalizedId); $self->_startTimes([]); $self->start(); return $self; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Connect/Daemon.pm` around lines 36 - 48, The constructor sub new mutates the incoming $id parameter with $id =~ s/://g, which can unexpectedly modify the caller's variable; instead make a local copy (e.g., my $clean_id = $id) and perform the s/://g on that copy, then call $self->id($clean_id) while leaving the original $id untouched; keep the calls to $self->mac($id), $self->_startTimes([]) and $self->start() as-is.Plugin.pm (1)
334-351: ⚡ Quick winAdd error handling around
requireandinit()calls.The
requirestatement at line 346 could fail if the module is missing or has syntax errors, andConnect->init()at line 348 is called without checking for errors. While these are unlikely in production, defensive error handling would improve robustness.🛡️ Proposed defensive wrapper
require Plugins::Spotty::Connect; - Plugins::Spotty::Connect->init() unless $dontInit; + eval { + Plugins::Spotty::Connect->init() unless $dontInit; + }; + + if ($@) { + $log->error("Failed to initialize Spotify Connect: $@"); + return; + } return 1;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Plugin.pm` around lines 334 - 351, The canSpotifyConnect function currently calls require Plugins::Spotty::Connect and Plugins::Spotty::Connect->init() without guarding against load or init failures; wrap the require in an eval { require Plugins::Spotty::Connect; 1 } and check the result, logging any $@ via $log->error and returning undef if the require failed, and only call Plugins::Spotty::Connect->init() inside a separate eval so you can catch and log exceptions from init (again using $log->error and returning undef on failure) — keep the existing dontInit short-circuit behavior so init() is skipped when requested.Connect/Context.pm (1)
198-224: 💤 Low valueMemoryCache uses shared global hash across all instances.
The
%memCachetied hash at line 205 is declared at package scope, meaning allMemoryCacheinstances share the same underlying storage. While this may be intentional for memory efficiency, it means keys from different Context instances can collide. Since Context prefixes keys with$self->_id(line 187), this is mitigated, but the shared state is not obvious from the API and could cause subtle bugs if key prefixes are not unique.Consider either documenting this behavior or making the cache instance-specific by storing the tied hash reference in the blessed object.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Connect/Context.pm` around lines 198 - 224, The package-level tied hash %memCache in Plugins::Spotty::Connect::MemoryCache creates shared storage across all instances; change MemoryCache to be instance-specific by moving the tie out of package scope into the constructor (new) and store the tied hash reference in the blessed object (e.g. $self->{_cache}), then update get, set and remove to operate on that instance cache (use $self->{_cache}{$_[1]} etc.) instead of the global %memCache; alternatively, if shared global behavior is intended, add a clear comment in the package header documenting that %memCache is shared and that callers must namespace keys (Context uses $self->_id).Connect.pm (2)
340-377: ⚖️ Poor tradeoffQueue peek mutates song streamUrl before track transition completes.
Line 354 directly assigns the next track URI to
$song->streamUrl($uri)while the current track is still playing. This premature mutation could cause metadata inconsistencies if the user seeks, pauses, or if playback is interrupted before the track naturally ends.Consider storing the peeked URI in pluginData instead of mutating streamUrl, then applying it during the actual track transition in the newTrack flow.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Connect.pm` around lines 340 - 377, The code mutates $song->streamUrl($uri) inside the $spotty->playerQueue callback while the current track is still playing; instead, store the peeked URI in the client's/plugin's pluginData (e.g. set pluginData newTrack or another key on $client or $song) and remove the immediate $song->streamUrl call; then update the actual $song->streamUrl only when the transition occurs (in the _firePlayerNext/newTrack flow that currently consumes pluginData 'newTrack'), ensuring _delayedStop and the existing hasPlay checks still reference the stored pluginData and that timers set with Slim::Utils::Timers::setTimer remain unchanged.
891-932: 💤 Low valueIndentation issue and potentially redundant pause logic in stop event handler.
Lines 895-932 have an indentation problem that makes the control flow difficult to read. Line 895 starts an
ifblock that should be indented further relative to line 891.Additionally, both the device-match branch (895-905) and the device-mismatch branch (906-923) issue a pause command when the client is playing. The second branch (lines 906-923) clears Connect state, suggesting playback was transferred elsewhere, but the first branch (895-905) does not. This asymmetry may be intentional (pause without clearing state keeps Connect mode active), but the logic would benefit from a clarifying comment.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Connect.pm` around lines 891 - 932, The nested if starting at the device check (around Plugins::Spotty::Connect::DaemonManager->idFromMac usage) is mis-indented — re-indent the block so the inner if/elsif/elsif sit clearly inside the outer "if ($result->{device})" scope; also remove ambiguity about the duplicated pause logic by adding a short clarifying comment in the device-match branch (the branch that calls Slim::Control::Request->new(... ['pause',1]) when __PACKAGE__->isSpotifyConnect($client) and the device id/name matches) indicating that we intentionally only pause playback while leaving Connect state intact, whereas the device-mismatch branch intentionally pauses and then clears Connect state (clearing via $client->playingSong()->pluginData(context=>0), $client->pluginData(SpotifyConnect=>0), and Slim::Utils::Timers::killTimers($client,\&_syncPoll)); keep the existing behavior but make indentation and intent explicit so reviewers understand the asymmetry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Connect.pm`:
- Around line 279-291: The shutdown function fails to unsubscribe the
playlist-jump handler, leaving _onPlaylistJump active after cleanup; update the
shutdown routine (in Connect::shutdown) to call
Slim::Control::Request::unsubscribe(\&_onPlaylistJump) alongside the existing
unsubscribes so the handler subscribed earlier
(Slim::Control::Request::subscribe(\&_onPlaylistJump)) is properly removed
before clearing $initialized.
- Around line 514-523: The code calls $client->playingSong()->pluginData(context
=> 0) without ensuring playingSong() is defined; guard that call by fetching the
playing song into a variable (e.g. $song = $client->playingSong()) and only call
$song->pluginData(...) if $song is defined to avoid a null-pointer crash, while
leaving the subsequent $client->pluginData(SpotifyConnect => 0),
Slim::Utils::Timers::killTimers(...) calls and
__PACKAGE__->getAPIHandler($client)->playerPause(...) unchanged.
- Around line 266-272: The File::Copy::copy call that copies credentials.json
into the per-player cache (using File::Copy::copy with catfile($cacheFolder,
'credentials.json') => catfile($playerCacheFolder, 'credentials.json'))
currently ignores failures; update the code around that call to check its
boolean return value and handle errors (e.g., log via the existing logger,
croak/die, or return an error) when the copy fails, including the
source/destination and $! in the message so permission/disk errors are visible
and the caller can fail fast instead of proceeding without credentials.
- Around line 232-234: The eval block that reads credentials.json (creating
$credentials from from_json(...) using $credentialsFile) opens a lexical
filehandle $fh but relies on scope to close it, which can leak inside eval;
change the read to explicitly open, slurp into a scalar, close $fh, then call
from_json on that scalar (or use a safe slurp helper like
File::Slurp/Path::Tiny) and keep the surrounding eval for error capture so $fh
is always closed even on error; update the code around the $credentials = eval {
... } expression to perform explicit close of $fh after reading.
- Around line 81-98: The subscriber in Slim::Control::Request::subscribe creates
a 5s timer with Slim::Utils::Timers::setTimer for each client but never cancels
previous timers; fix by tracking the timer id on the $client (e.g.,
$client->{prebuffer_timer}), and before calling setTimer check for and cancel
any existing timer via Slim::Utils::Timers::killTimer($client,
$client->{prebuffer_timer}) (or delete the field) and then store the new timer
id into $client->{prebuffer_timer} so rapid client/new events don't accumulate
timers.
In `@Connect/Daemon.pm`:
- Around line 92-100: The encode_base64 call used when pushing the '--lms-auth'
arg is producing a trailing newline; update the encode_base64 invocation in the
block that builds `@helperArgs` (the line that encodes sprintf("%s:%s",
$serverPrefs->get('username'), $serverPrefs->get('password'))) to pass an empty
string as the second argument so the returned Base64 string has no newline,
ensuring the '--lms-auth' command-line value is not terminated with a newline.
---
Nitpick comments:
In `@Connect.pm`:
- Around line 340-377: The code mutates $song->streamUrl($uri) inside the
$spotty->playerQueue callback while the current track is still playing; instead,
store the peeked URI in the client's/plugin's pluginData (e.g. set pluginData
newTrack or another key on $client or $song) and remove the immediate
$song->streamUrl call; then update the actual $song->streamUrl only when the
transition occurs (in the _firePlayerNext/newTrack flow that currently consumes
pluginData 'newTrack'), ensuring _delayedStop and the existing hasPlay checks
still reference the stored pluginData and that timers set with
Slim::Utils::Timers::setTimer remain unchanged.
- Around line 891-932: The nested if starting at the device check (around
Plugins::Spotty::Connect::DaemonManager->idFromMac usage) is mis-indented —
re-indent the block so the inner if/elsif/elsif sit clearly inside the outer "if
($result->{device})" scope; also remove ambiguity about the duplicated pause
logic by adding a short clarifying comment in the device-match branch (the
branch that calls Slim::Control::Request->new(... ['pause',1]) when
__PACKAGE__->isSpotifyConnect($client) and the device id/name matches)
indicating that we intentionally only pause playback while leaving Connect state
intact, whereas the device-mismatch branch intentionally pauses and then clears
Connect state (clearing via $client->playingSong()->pluginData(context=>0),
$client->pluginData(SpotifyConnect=>0), and
Slim::Utils::Timers::killTimers($client,\&_syncPoll)); keep the existing
behavior but make indentation and intent explicit so reviewers understand the
asymmetry.
In `@Connect/Context.pm`:
- Around line 198-224: The package-level tied hash %memCache in
Plugins::Spotty::Connect::MemoryCache creates shared storage across all
instances; change MemoryCache to be instance-specific by moving the tie out of
package scope into the constructor (new) and store the tied hash reference in
the blessed object (e.g. $self->{_cache}), then update get, set and remove to
operate on that instance cache (use $self->{_cache}{$_[1]} etc.) instead of the
global %memCache; alternatively, if shared global behavior is intended, add a
clear comment in the package header documenting that %memCache is shared and
that callers must namespace keys (Context uses $self->_id).
In `@Connect/Daemon.pm`:
- Around line 36-48: The constructor sub new mutates the incoming $id parameter
with $id =~ s/://g, which can unexpectedly modify the caller's variable; instead
make a local copy (e.g., my $clean_id = $id) and perform the s/://g on that
copy, then call $self->id($clean_id) while leaving the original $id untouched;
keep the calls to $self->mac($id), $self->_startTimes([]) and $self->start()
as-is.
In `@Plugin.pm`:
- Around line 334-351: The canSpotifyConnect function currently calls require
Plugins::Spotty::Connect and Plugins::Spotty::Connect->init() without guarding
against load or init failures; wrap the require in an eval { require
Plugins::Spotty::Connect; 1 } and check the result, logging any $@ via
$log->error and returning undef if the require failed, and only call
Plugins::Spotty::Connect->init() inside a separate eval so you can catch and log
exceptions from init (again using $log->error and returning undef on failure) —
keep the existing dontInit short-circuit behavior so init() is skipped when
requested.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3718bdd5-e94c-4925-bb5c-49022d93ae26
📒 Files selected for processing (12)
API.pmConnect.pmConnect/Context.pmConnect/Daemon.pmConnect/DaemonManager.pmHTML/EN/plugins/Spotty/settings/basic.htmlHTML/EN/plugins/Spotty/settings/player.htmlPlugin.pmProtocolHandler.pmSettings.pmSettings/Player.pmstrings.txt
| Slim::Control::Request::subscribe(sub { | ||
| my $request = shift; | ||
| my $client = $request->client(); | ||
|
|
||
| if (!$prefs->get('optimizePreBuffer')) { | ||
| # 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']]); |
There was a problem hiding this comment.
Buffer optimization timer may leak on rapid client/new events.
The client/new subscriber sets a 5-second timer without first killing any existing timer for that client. If multiple new events fire in quick succession (e.g., during sync-group reconfiguration), timers could accumulate.
🛡️ Proposed fix
Slim::Control::Request::subscribe(sub {
my $request = shift;
my $client = $request->client();
if (!$prefs->get('optimizePreBuffer')) {
+ Slim::Utils::Timers::killTimers($client, sub { shift; }); # kill existing optimization check
# Wait a few seconds before the buffer size is known
Slim::Utils::Timers::setTimer($client, time() + 5, sub {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Connect.pm` around lines 81 - 98, The subscriber in
Slim::Control::Request::subscribe creates a 5s timer with
Slim::Utils::Timers::setTimer for each client but never cancels previous timers;
fix by tracking the timer id on the $client (e.g., $client->{prebuffer_timer}),
and before calling setTimer check for and cancel any existing timer via
Slim::Utils::Timers::killTimer($client, $client->{prebuffer_timer}) (or delete
the field) and then store the new timer id into $client->{prebuffer_timer} so
rapid client/new events don't accumulate timers.
| my $credentials = eval { | ||
| from_json(do { local $/; open(my $fh, '<', $credentialsFile) or die $!; <$fh> }); | ||
| }; |
There was a problem hiding this comment.
File handle leak in credentials.json read.
The inline file read at line 233 opens a filehandle but never explicitly closes it. The do { local $/; open(my $fh, '<', $credentialsFile) or die $!; <$fh> } pattern relies on lexical scope cleanup, but in an eval this is fragile. If the eval succeeds, the filehandle $fh goes out of scope and should close, but explicitly closing after reading is more robust and prevents potential leak if Perl's reference counting is delayed.
🛡️ Proposed fix
my $credentials = eval {
- from_json(do { local $/; open(my $fh, '<', $credentialsFile) or die $!; <$fh> });
+ my $content = do {
+ open(my $fh, '<', $credentialsFile) or die $!;
+ local $/;
+ my $data = <$fh>;
+ close $fh;
+ $data;
+ };
+ from_json($content);
};🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Connect.pm` around lines 232 - 234, The eval block that reads
credentials.json (creating $credentials from from_json(...) using
$credentialsFile) opens a lexical filehandle $fh but relies on scope to close
it, which can leak inside eval; change the read to explicitly open, slurp into a
scalar, close $fh, then call from_json on that scalar (or use a safe slurp
helper like File::Slurp/Path::Tiny) and keep the surrounding eval for error
capture so $fh is always closed even on error; update the code around the
$credentials = eval { ... } expression to perform explicit close of $fh after
reading.
| if (!-e catfile($playerCacheFolder, 'credentials.json')) { | ||
| require File::Copy; | ||
| File::Copy::copy( | ||
| catfile($cacheFolder, 'credentials.json'), | ||
| catfile($playerCacheFolder, 'credentials.json') | ||
| ); | ||
| } |
There was a problem hiding this comment.
No error handling for credentials.json copy operation.
The File::Copy::copy call at lines 268-270 does not check for errors. If the copy fails (e.g., due to permission issues or disk full), the per-player cache folder will lack credentials, causing subsequent daemon startup or API calls to fail silently or with confusing errors.
🛡️ Proposed fix
if (!-e catfile($playerCacheFolder, 'credentials.json')) {
require File::Copy;
- File::Copy::copy(
+ File::Copy::copy(
catfile($cacheFolder, 'credentials.json'),
catfile($playerCacheFolder, 'credentials.json')
- );
+ ) or $log->error("Failed to copy credentials.json: $!");
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (!-e catfile($playerCacheFolder, 'credentials.json')) { | |
| require File::Copy; | |
| File::Copy::copy( | |
| catfile($cacheFolder, 'credentials.json'), | |
| catfile($playerCacheFolder, 'credentials.json') | |
| ); | |
| } | |
| if (!-e catfile($playerCacheFolder, 'credentials.json')) { | |
| require File::Copy; | |
| File::Copy::copy( | |
| catfile($cacheFolder, 'credentials.json'), | |
| catfile($playerCacheFolder, 'credentials.json') | |
| ) or $log->error("Failed to copy credentials.json: $!"); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Connect.pm` around lines 266 - 272, The File::Copy::copy call that copies
credentials.json into the per-player cache (using File::Copy::copy with
catfile($cacheFolder, 'credentials.json') => catfile($playerCacheFolder,
'credentials.json')) currently ignores failures; update the code around that
call to check its boolean return value and handle errors (e.g., log via the
existing logger, croak/die, or return an error) when the copy fails, including
the source/destination and $! in the message so permission/disk errors are
visible and the caller can fail fast instead of proceeding without credentials.
| sub shutdown { | ||
| if ($initialized) { | ||
| require Plugins::Spotty::Connect::DaemonManager; | ||
| Plugins::Spotty::Connect::DaemonManager->shutdown(); | ||
|
|
||
| Slim::Control::Request::unsubscribe(\&_onNewSong); | ||
| Slim::Control::Request::unsubscribe(\&_onPause); | ||
| Slim::Control::Request::unsubscribe(\&_onVolume); | ||
| Slim::Control::Request::unsubscribe(\&_onSeek); | ||
|
|
||
| $initialized = 0; | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing unsubscribe for _onPlaylistJump in shutdown.
The shutdown method unsubscribes _onNewSong, _onPause, _onVolume, and _onSeek, but does not unsubscribe _onPlaylistJump (subscribed at line 78). This will leave the playlist jump handler active after shutdown, potentially causing callbacks to fire into a partially cleaned-up state.
🛡️ Proposed fix
Slim::Control::Request::unsubscribe(\&_onNewSong);
Slim::Control::Request::unsubscribe(\&_onPause);
Slim::Control::Request::unsubscribe(\&_onVolume);
Slim::Control::Request::unsubscribe(\&_onSeek);
+ Slim::Control::Request::unsubscribe(\&_onPlaylistJump);
$initialized = 0;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| sub shutdown { | |
| if ($initialized) { | |
| require Plugins::Spotty::Connect::DaemonManager; | |
| Plugins::Spotty::Connect::DaemonManager->shutdown(); | |
| Slim::Control::Request::unsubscribe(\&_onNewSong); | |
| Slim::Control::Request::unsubscribe(\&_onPause); | |
| Slim::Control::Request::unsubscribe(\&_onVolume); | |
| Slim::Control::Request::unsubscribe(\&_onSeek); | |
| $initialized = 0; | |
| } | |
| } | |
| sub shutdown { | |
| if ($initialized) { | |
| require Plugins::Spotty::Connect::DaemonManager; | |
| Plugins::Spotty::Connect::DaemonManager->shutdown(); | |
| Slim::Control::Request::unsubscribe(\&_onNewSong); | |
| Slim::Control::Request::unsubscribe(\&_onPause); | |
| Slim::Control::Request::unsubscribe(\&_onVolume); | |
| Slim::Control::Request::unsubscribe(\&_onSeek); | |
| Slim::Control::Request::unsubscribe(\&_onPlaylistJump); | |
| $initialized = 0; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Connect.pm` around lines 279 - 291, The shutdown function fails to
unsubscribe the playlist-jump handler, leaving _onPlaylistJump active after
cleanup; update the shutdown routine (in Connect::shutdown) to call
Slim::Control::Request::unsubscribe(\&_onPlaylistJump) alongside the existing
unsubscribes so the handler subscribed earlier
(Slim::Control::Request::subscribe(\&_onPlaylistJump)) is properly removed
before clearing $initialized.
| 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); | ||
| Slim::Utils::Timers::killTimers($client, \&_syncPoll); | ||
|
|
||
| __PACKAGE__->getAPIHandler($client)->playerPause(undef, $client->id); |
There was a problem hiding this comment.
Potential null-pointer on playingSong() when exiting Connect mode.
Line 517 calls $client->playingSong()->pluginData(context => 0) without checking if playingSong() returns a defined song. If the newsong event fires during a track transition (between tracks), playingSong() may return undef, causing a runtime error.
🛡️ Proposed fix
main::INFOLOG && $log->is_info && $log->info(
"Got a new track event, but this is no longer Spotify Connect"
);
- $client->playingSong()->pluginData(context => 0);
+ my $song = $client->playingSong();
+ $song->pluginData(context => 0) if $song;
$client->pluginData(SpotifyConnect => 0);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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); | |
| Slim::Utils::Timers::killTimers($client, \&_syncPoll); | |
| __PACKAGE__->getAPIHandler($client)->playerPause(undef, $client->id); | |
| main::INFOLOG && $log->is_info && $log->info( | |
| "Got a new track event, but this is no longer Spotify Connect" | |
| ); | |
| my $song = $client->playingSong(); | |
| $song->pluginData(context => 0) if $song; | |
| $client->pluginData(SpotifyConnect => 0); | |
| Slim::Utils::Timers::killTimers($client, \&_syncController); | |
| Slim::Utils::Timers::killTimers($client, \&_getNextTrack); | |
| Slim::Utils::Timers::killTimers($client, \&_syncPoll); | |
| __PACKAGE__->getAPIHandler($client)->playerPause(undef, $client->id); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Connect.pm` around lines 514 - 523, The code calls
$client->playingSong()->pluginData(context => 0) without ensuring playingSong()
is defined; guard that call by fetching the playing song into a variable (e.g.
$song = $client->playingSong()) and only call $song->pluginData(...) if $song is
defined to avoid a null-pointer crash, while leaving the subsequent
$client->pluginData(SpotifyConnect => 0), Slim::Utils::Timers::killTimers(...)
calls and __PACKAGE__->getAPIHandler($client)->playerPause(...) unchanged.
| 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."); | ||
| } | ||
| } |
There was a problem hiding this comment.
encode_base64 includes trailing newline by default.
Line 95 uses encode_base64 for Basic authentication, but by default this function appends a newline character. Command-line arguments should not contain newlines. Pass an empty string as the second argument to suppress it.
🐛 Proposed fix
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')));
+ push `@helperArgs`, '--lms-auth', encode_base64(sprintf("%s:%s", $serverPrefs->get('username'), $serverPrefs->get('password')), '');
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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."); | |
| } | |
| } | |
| 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."); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Connect/Daemon.pm` around lines 92 - 100, The encode_base64 call used when
pushing the '--lms-auth' arg is producing a trailing newline; update the
encode_base64 invocation in the block that builds `@helperArgs` (the line that
encodes sprintf("%s:%s", $serverPrefs->get('username'),
$serverPrefs->get('password'))) to pass an empty string as the second argument
so the returned Base64 string has no newline, ensuring the '--lms-auth'
command-line value is not terminated with a newline.
|
Closing in favor of consolidated PRs as discussed — will resubmit as focused issues + PRs. |
Summary
Complete the Spotify Connect stack: implement
Connect.pmwith full player-eventdispatch (track changes, play/pause, volume), add Connect-specific API methods to
API.pm, and extend the Settings and ProtocolHandler to support Connect flows.Background
This PR is the final piece of the three-part Connect re-implementation (#220 → #221 → this PR).
With #220 establishing the feature flag and #221 providing daemon lifecycle, this PR adds
the actual event dispatch:
Connect.pmreceives JSON-RPC callbacks from thespottybinary (via
--lmsmode) and forwards them to LMS as player commands.The API methods added here (
playerNext,playerPrev,playerPlay,playerPause,playerVolume,transferPlayback) are called byConnect.pmto control LMS playbackin response to Spotify Connect commands from remote clients.
Note on PR #210: This PR overlaps with PR #210 (by hansherlighed) on most Connect
files. Both implement Spotify Connect event dispatch but take different architectural
approaches. We leave the decision to the maintainer; the two PRs are not merge-compatible.
Changes
Connect.pm
spottyconnectJSON-RPC handler: parse events (TrackChanged, PlaybackStart,PlaybackStop, VolumeChanged) and dispatch to LMS player
API.pm
playerNext,playerPrev,playerPlay,playerPause,playerVolume,transferPlaybackgetConnectDevicesfor listing active Connect endpointsConnect/*.pm (Context, Daemon, DaemonManager)
ProtocolHandler.pm
spotify-connect://URI scheme for Connect-initiated playbackPlugin.pm / Settings.pm / Settings/Player.pm
HTML / strings.txt
Dependency Notes
Merge after #220 and #221. The API methods
added here are called by
Connect.pm; the Connect infrastructure from #220/#221 must bepresent. Semantically also depends on #218 for the flavor-aware
_callthat the new API methods use.Full merge order recommendation: #216 → #217 → #218 → #219 → #215 → #220 → #221 → #222.
Submitted as draft pending A-series merge.
Tested On
Spotify app updates LMS playback; Play/Pause from Spotify app reflected in LMS;
multiple players (including sync-groups) handled correctly