Skip to content

feat: re-enable Spotify Connect integration#226

Open
stiefenm wants to merge 3 commits into
michaelherger:masterfrom
stiefenm:pr/connect
Open

feat: re-enable Spotify Connect integration#226
stiefenm wants to merge 3 commits into
michaelherger:masterfrom
stiefenm:pr/connect

Conversation

@stiefenm

@stiefenm stiefenm commented May 22, 2026

Copy link
Copy Markdown

Summary

Re-adds Spotify Connect support (receiver mode) to Spotty, built on a fundamentally different
architecture than the implementation removed in commit 75ff6bf (2025-08-15).

Herger removed the previous Connect implementation citing fragile track-by-track state
synchronization between two competing state machines (LMS and librespot). This re-addition
addresses that root cause: the new implementation replaces the FIFO pipe with an embedded
HTTP server in the spotty binary
(HttpStreamSink). LMS consumes a single continuous PCM
stream over HTTP using native canDirectStream. The binary owns the full Spirc session; LMS
just plays audio. No per-track synchronization. No fragile FIFO lifetime management.


Architecture

Three-layer design:

Rust binary (PR #28 on michaelherger/librespot):

  • HttpStreamSink + http_stream_server: listens on a random localhost port, serves a
    continuous S16LE PCM audio stream
  • Announces the port at startup via stream_port=NNNN on stdout
  • Includes seek-flush (watch-channel drain) to eliminate stale audio after Spotify seek events
  • --autoplay on/off flag for queue continuation after playlist ends

Perl Daemon layer (Connect/Daemon.pm, Connect/DaemonManager.pm):

  • Pipe-based process launch captures stream_port from binary stdout (IO::Select, no SIGALRM)
  • Multi-player manager with MAC→deviceId mapping, sync-group resilience
  • Crash-restart watchdog with exponential backoff constants
  • Clean shutdown: unsubscribes event handlers, terminates daemon process

Perl ProtocolHandler + Connect layer:

  • canDirectStream: returns http://127.0.0.1:<port>/stream for Connect players
  • LMS handles all buffering, seeking, and rate-control natively
  • streamingProgressBar calls in getNextTrack and _connectEvent for accurate progress display
  • Event dispatch via LMS CLI callbacks: track change, seek, pause, volume

What Is Included

This PR covers everything except the API routing/authentication fix (that is PR #225):

  • Connect daemon lifecycle: discovery, multi-player support, sync-group resilience (v3.1)
  • Event dispatch: track change, seek, pause, volume — all routed via LMS CLI
  • HTTP streaming transport: replaced FIFO with embedded HTTP server (Phase 22-23)
  • Progress bar: streamingProgressBar wired for accurate seek/duration display (Phase 24)
  • Autoplay: --autoplay on/off flag wired to per-player enableAutoplay pref (Phase 25)
  • Stability: recursion guard in AccountHelper.pm::getCredentials (unlink before recursive call)
  • Updated binaries: Bin/i386-linux/spotty-x86_64 and Bin/aarch64-linux/spotty at v2.2.0

Commit Structure

# Commit What
1 feat(connect): Spotify Connect integration — daemon lifecycle, event dispatch, API control Initial Connect re-addition: all Connect/*, ProtocolHandler.pm stream-mode, API Connect methods, settings UI, custom-convert.conf
2 feat(connect): HTTP streaming transport — replace FIFO with embedded HTTP server FIFO removal, HttpStreamSink wiring, canDirectStream activation, code review fixes
3 fix(connect): progress bar, autoplay, stability polish + binary v2.2.0 streamingProgressBar, --autoplay flag, AccountHelper scrub, updated binaries

Dependencies

Required (binary): PR #28 on michaelherger/librespot — provides the HttpStreamSink + http_stream_server in the spotty binary. Without it, canDirectStream returns a URL that nothing is listening on. Pre-built binaries are included in Bin/ for convenience.

Recommended (merge order): Merge PR #225 first. PR #225 adds the dual-OAuth dispatch (own Dev ID for me/*, bundled-default for browse/*) that allows Connect and API browsing to coexist on different auth flavors. If this PR is merged without #225, Connect still works, but users may see 429 errors on Liked Songs browsing under heavy load.


Tested On

  • LMS: 9.2.0 (build 1778040950, Debian x86_64)
  • Perl: 5.38.2 (x86_64-linux-gnu-thread-multi)
  • spotty binary: v2.2.0 (HttpStreamSink, built from pr/lms-glue tip)
  • Spotify account: Premium, EU region
  • Players tested: x86_64 dev box ("claude" player), production Raspberry Pi 4 (aarch64)
  • Scenarios verified: play/pause, skip, volume, seek, multiroom sync, daemon crash recovery, autoplay queue continuation

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR re-enables Spotify Connect support in the Spotty plugin by implementing daemon-managed helper processes, LMS↔Spotify state synchronization, playlist context tracking, and device-scoped player control APIs. The implementation maintains LMS as the playback controller while exposing each player as a controllable endpoint in the Spotify app.

Changes

Spotify Connect Reintegration

Layer / File(s) Summary
Spotify API player control methods
API.pm
Device-scoped control methods (playerTransfer, playerPause, playerNext, playerPrevious, playerQueue, playerSeek, playerVolume) with MAC-to-device-ID resolution and bulk device listing.
Daemon process lifecycle management
Connect/Daemon.pm, Connect/DaemonManager.pm
Per-device helper process startup/shutdown with crash-backoff, preference-driven lifecycle coordination, and Spotify API reconciliation.
Playlist/album context tracking
Connect/Context.pm
Maintains known-track counts and play history to detect context exhaustion and autoplay behavior.
Connect state machine and event dispatch
Connect.pm
Core implementation: JSON-RPC event dispatch, LMS↔Spotify synchronization via periodic polling, per-song context, pre-buffer optimization, and grace-window handling for volume/pause races.
Connect-aware protocol handler
ProtocolHandler.pm
Blocks stop for Connect clients, routes getNextTrack to Connect subsystem, handles spotify://connect-* URI expansion.
Plugin initialization and daemon coordination
Plugin.pm
Capability gating, preference initialization, daemon lifecycle hooks, and process cleanup.
Settings configuration and user interface
Settings.pm, Settings/Player.pm, HTML/EN/plugins/Spotty/settings/*, strings.txt
Per-player and global Connect enable/disable toggles, autoplay configuration, capability flags, and multilingual descriptions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Connect is reborn, devices now glow—
Daemon shepherds lead the Spotify show.
LMS keeps the beat, but the app has control,
Multiroom's alive—what a Spotty goal!
Let the context track, let the helpers run free,
Synchronized playback, as it was meant to be. 🎵

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: re-enable Spotify Connect integration' directly and clearly summarizes the main objective of the PR — re-enabling Spotify Connect functionality.
Linked Issues check ✅ Passed The PR implements all core objectives from #224: daemon lifecycle management (Connect/Daemon.pm, Connect/DaemonManager.pm), event dispatch (Connect.pm), bidirectional API control (API.pm methods), settings UI with version guard (canSpotifyConnect), and maintains LMS as authoritative playback controller.
Out of Scope Changes check ✅ Passed All code changes align with the PR objectives: daemon/API/event handling infrastructure for Spotify Connect, corresponding settings/UI updates, plugin initialization changes, and helper binary updates. No unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
Connect/DaemonManager.pm (1)

30-30: ⚡ Quick win

Time::HiRes::time availability is ensured via Connect.pm load order (optional cleanup)

  • Connect.pm explicitly does use Time::HiRes; and is the module that requires/initializes Plugins::Spotty::Connect::DaemonManager (so Time::HiRes is loaded before the timers in Connect/DaemonManager.pm run).
  • The other require Plugins::Spotty::Connect::DaemonManager call is gated behind canSpotifyConnect(), which require Plugins::Spotty::Connect; (and thus also loads Time::HiRes).

Optional: add use Time::HiRes; to Connect/DaemonManager.pm to make the dependency self-contained.

🤖 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/DaemonManager.pm` at line 30, The timer call in
Slim::Utils::Timers::setTimer uses Time::HiRes::time but relies on Connect.pm
loading Time::HiRes indirectly; make the dependency explicit by adding a
top-level use Time::HiRes; in Connect/DaemonManager.pm so Time::HiRes::time is
always available when initHelpers is scheduled (this keeps the setTimer call in
the file-safe and self-contained regardless of load order involving
Plugins::Spotty::Connect::DaemonManager and Connect.pm).
🤖 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 `@API.pm`:
- Around line 401-402: The devices() helper currently calls the callback with no
arguments (it invokes $cb->() if $cb), which discards the fetched API result;
update the callback invocation inside devices() to pass the retrieved devices
payload (the variable holding the API response) to $cb so callers receive the
device list, e.g. invoke $cb->($devices_payload) when $cb is set and ensure any
error/empty-case branches similarly forward the appropriate value to $cb.

In `@Connect.pm`:
- Around line 77-97: The new Slim::Control::Request::subscribe calls (the named
handler _onPlaylistJump and the anonymous pre-buffer subscriber) are not removed
on shutdown; store the coderefs returned by subscribe (or assign the anonymous
subscriber to a lexical variable) and unregister them in the module
shutdown/cleanup path using Slim::Control::Request::unsubscribe with the same
handler references so re-init doesn't stack duplicate handlers (apply the same
change for the other subscribe pair around the 283-287 area).
- Around line 174-177: Guard access to $state->{item}->{uri} before calling
$song->streamUrl to avoid deref errors: check that $state->{item} and
$state->{item}->{uri} are defined, and only call
$song->streamUrl($state->{item}->{uri}) when present; if the uri is missing,
skip setting the stream URL (optionally log or set a safe fallback) but still
proceed with $class->setSpotifyConnect($client, $state), clear the pluginData
via $client->pluginData(newTrack => 0), and invoke $successCb->() so transition
state is not left dirty.

In `@Connect/Context.pm`:
- Line 205: The shared in-memory LRU cache (%memCache) is limited to ENTRIES =>
10 causing active contexts to evict each other; increase the capacity (e.g., set
a much larger fixed value or read ENTRIES from a config/constant) where the
cache is tied using Tie::Cache::LRU::Expires so it can hold all active players’
multiple keys without premature eviction, and ensure the change is applied to
the tie call for %memCache and validated in integration tests.

In `@Connect/Daemon.pm`:
- Around line 53-61: Guard against Slim::Player::Client::getClient($self->mac)
returning undef before dereferencing: after assigning my $client =
Slim::Player::Client::getClient($self->mac) check defined $client and only call
$client->isSynced, $client->model, $client->name or
Slim::Player::Sync::syncname($client) when $client is defined; if undefined,
skip changing $self->name or set a safe fallback (e.g. keep existing $self->name
or use an empty/placeholder string) so Plugins::Spotty::Helper->get() startup
won't dereference undef and crash.

In `@Connect/DaemonManager.pm`:
- Around line 85-89: The change hook registered via
preferences('server')->setChange only watches 'authorize' and 'username' so
Connect helpers aren't restarted when the LMS password changes; update that call
to include 'password' in the watched keys (the same setChange invocation that
currently lists 'authorize' and 'username') so that $class->shutdown() and
initHelpers() run on password rotation and running daemons pick up new
credentials.

In `@Settings.pm`:
- Around line 109-110: The template is hiding the checkbox because Settings.pm
uses the runtime-enabled check as a capability flag by calling
Plugins::Spotty::Plugin->canSpotifyConnect(1); change this to query the
capability (not the runtime enable state) by calling
Plugins::Spotty::Plugin->canSpotifyConnect() (or canSpotifyConnect(0) if the
method explicitly accepts a flag to ignore runtime state) and assign that result
to $paramRef->{canSpotifyConnect} so the UI can still show the control when
Spotify Connect is globally disabled.

In `@Settings/Player.pm`:
- Around line 34-36: The settings handler is performing an initializing
capability check by calling Plugins::Spotty::Plugin->canSpotifyConnect() which
can trigger Connect component initialization; change the call to
Plugins::Spotty::Plugin->canSpotifyConnect(1) so the check is non-initializing,
leaving the rest of the logic that sets $params->{errorString} =
$client->string('PLUGIN_SPOTTY_NEED_HELPER_UPDATE') intact.

---

Nitpick comments:
In `@Connect/DaemonManager.pm`:
- Line 30: The timer call in Slim::Utils::Timers::setTimer uses
Time::HiRes::time but relies on Connect.pm loading Time::HiRes indirectly; make
the dependency explicit by adding a top-level use Time::HiRes; in
Connect/DaemonManager.pm so Time::HiRes::time is always available when
initHelpers is scheduled (this keeps the setTimer call in the file-safe and
self-contained regardless of load order involving
Plugins::Spotty::Connect::DaemonManager and Connect.pm).
🪄 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: 8e6de831-a8ce-4e50-ae13-f28b28f2a9c1

📥 Commits

Reviewing files that changed from the base of the PR and between c1d9dfa and 79fff9e.

📒 Files selected for processing (14)
  • API.pm
  • Bin/aarch64-linux/spotty
  • Bin/i386-linux/spotty-x86_64
  • Connect.pm
  • Connect/Context.pm
  • Connect/Daemon.pm
  • Connect/DaemonManager.pm
  • HTML/EN/plugins/Spotty/settings/basic.html
  • HTML/EN/plugins/Spotty/settings/player.html
  • Plugin.pm
  • ProtocolHandler.pm
  • Settings.pm
  • Settings/Player.pm
  • strings.txt

Comment thread API.pm
Comment on lines +401 to +402
$cb->() if $cb;
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return the fetched devices payload to the callback.

devices() currently swallows the API result and always invokes $cb without args, which breaks callers that need the device list from this helper.

Suggested fix
 sub devices {
 	my ( $self, $cb ) = `@_`;
@@
 	$self->_call('me/player/devices',
 		sub {
 			my ($result) = `@_`;
@@
-			$cb->() if $cb;
+			$cb->($result) if $cb;
 		},
 		GET => {
 			_nocache => 1,
 		}
 	);
 }
📝 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.

Suggested change
$cb->() if $cb;
},
sub devices {
my ( $self, $cb ) = `@_`;
$self->_call('me/player/devices',
sub {
my ($result) = `@_`;
$cb->($result) if $cb;
},
GET => {
_nocache => 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 `@API.pm` around lines 401 - 402, The devices() helper currently calls the
callback with no arguments (it invokes $cb->() if $cb), which discards the
fetched API result; update the callback invocation inside devices() to pass the
retrieved devices payload (the variable holding the API response) to $cb so
callers receive the device list, e.g. invoke $cb->($devices_payload) when $cb is
set and ensure any error/empty-case branches similarly forward the appropriate
value to $cb.

Comment thread Connect.pm Outdated
Comment thread Connect.pm Outdated
Comment thread Connect/Context.pm

use Tie::Cache::LRU::Expires;

tie my %memCache, 'Tie::Cache::LRU::Expires', EXPIRES => 86400 * 7, ENTRIES => 10;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Increase shared MemoryCache capacity to avoid active-context eviction.

Line 205 caps a global shared cache at 10 entries. Because each context uses multiple keys, a handful of active players can evict each other’s state and break last-track/history decisions.

Suggested fix
-tie my %memCache, 'Tie::Cache::LRU::Expires', EXPIRES => 86400 * 7, ENTRIES => 10;
+tie my %memCache, 'Tie::Cache::LRU::Expires', EXPIRES => 86400 * 7, ENTRIES => 256;
📝 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.

Suggested change
tie my %memCache, 'Tie::Cache::LRU::Expires', EXPIRES => 86400 * 7, ENTRIES => 10;
tie my %memCache, 'Tie::Cache::LRU::Expires', EXPIRES => 86400 * 7, ENTRIES => 256;
🤖 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` at line 205, The shared in-memory LRU cache (%memCache)
is limited to ENTRIES => 10 causing active contexts to evict each other;
increase the capacity (e.g., set a much larger fixed value or read ENTRIES from
a config/constant) where the cache is tied using Tie::Cache::LRU::Expires so it
can hold all active players’ multiple keys without premature eviction, and
ensure the change is applied to the tie call for %memCache and validated in
integration tests.

Comment thread Connect/Daemon.pm
Comment on lines +53 to +61
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against missing client before dereferencing.

getClient($self->mac) can return undef during disconnect/reconnect races, and the next calls dereference it immediately. That can crash helper startup.

Proposed fix
 	my $helperPath = Plugins::Spotty::Helper->get();
 	my $client = Slim::Player::Client::getClient($self->mac);
+	if (!$client) {
+		$log->warn("Cannot start Spotty Connect daemon: no client for " . $self->mac);
+		return;
+	}
📝 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.

Suggested change
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));
my $helperPath = Plugins::Spotty::Helper->get();
my $client = Slim::Player::Client::getClient($self->mac);
if (!$client) {
$log->warn("Cannot start Spotty Connect daemon: no client for " . $self->mac);
return;
}
# 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));
🤖 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 53 - 61, Guard against
Slim::Player::Client::getClient($self->mac) returning undef before
dereferencing: after assigning my $client =
Slim::Player::Client::getClient($self->mac) check defined $client and only call
$client->isSynced, $client->model, $client->name or
Slim::Player::Sync::syncname($client) when $client is defined; if undefined,
skip changing $self->name or set a safe fallback (e.g. keep existing $self->name
or use an empty/placeholder string) so Plugins::Spotty::Helper->get() startup
won't dereference undef and crash.

Comment thread Connect/DaemonManager.pm
Comment on lines +85 to +89
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restart helpers when LMS password changes.

The auth-change hook handles authorize and username, but not password. After password rotation, running daemons can keep stale auth and fail callbacks until another restart trigger occurs.

Proposed fix
 	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');
+	}, 'authorize', 'username', '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.

Suggested change
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');
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', 'password');
🤖 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/DaemonManager.pm` around lines 85 - 89, The change hook registered
via preferences('server')->setChange only watches 'authorize' and 'username' so
Connect helpers aren't restarted when the LMS password changes; update that call
to include 'password' in the watched keys (the same setChange invocation that
currently lists 'authorize' and 'username') so that $class->shutdown() and
initHelpers() run on password rotation and running daemons pick up new
credentials.

Comment thread Plugin.pm
Comment on lines +391 to +399
if ($helper) {
my @pids = split /\n/, `pgrep -f $helper 2>/dev/null`;
my @orphans = grep { $_ && !$connectPids{$_} } @pids;
kill('TERM', @orphans) if @orphans;
}
unless ($helper && $helper =~ /spotty-custom/) {
my @pids = split /\n/, `pgrep -f spotty-custom 2>/dev/null`;
my @orphans = grep { $_ && !$connectPids{$_} } @pids;
kill('TERM', @orphans) if @orphans;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid shell-interpolated pgrep with helper path.

Interpolating $helper directly into backticks is unsafe and can also match unintended processes due to regex interpretation. Use list-form process execution and an escaped pattern.

Proposed fix
-				if ($helper) {
-					my `@pids` = split /\n/, `pgrep -f $helper 2>/dev/null`;
+				if ($helper) {
+					my $pattern = quotemeta($helper);
+					open my $pgrep_fh, '-|', 'pgrep', '-f', '--', $pattern;
+					my `@pids` = grep { /\S/ } map { chomp; $_ } <$pgrep_fh>;
+					close $pgrep_fh;
 					my `@orphans` = grep { $_ && !$connectPids{$_} } `@pids`;
 					kill('TERM', `@orphans`) if `@orphans`;
 				}
📝 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.

Suggested change
if ($helper) {
my @pids = split /\n/, `pgrep -f $helper 2>/dev/null`;
my @orphans = grep { $_ && !$connectPids{$_} } @pids;
kill('TERM', @orphans) if @orphans;
}
unless ($helper && $helper =~ /spotty-custom/) {
my @pids = split /\n/, `pgrep -f spotty-custom 2>/dev/null`;
my @orphans = grep { $_ && !$connectPids{$_} } @pids;
kill('TERM', @orphans) if @orphans;
if ($helper) {
my $pattern = quotemeta($helper);
open my $pgrep_fh, '-|', 'pgrep', '-f', '--', $pattern;
my `@pids` = grep { /\S/ } map { chomp; $_ } <$pgrep_fh>;
close $pgrep_fh;
my `@orphans` = grep { $_ && !$connectPids{$_} } `@pids`;
kill('TERM', `@orphans`) if `@orphans`;
}
unless ($helper && $helper =~ /spotty-custom/) {
my `@pids` = split /\n/, `pgrep -f spotty-custom 2>/dev/null`;
my `@orphans` = grep { $_ && !$connectPids{$_} } `@pids`;
kill('TERM', `@orphans`) if `@orphans`;

Comment thread Settings.pm
Comment on lines +109 to +110
$paramRef->{canDiscovery} = Plugins::Spotty::Plugin->canDiscovery();
$paramRef->{canSpotifyConnect} = Plugins::Spotty::Plugin->canSpotifyConnect(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not use runtime enable-state as capability flag.

canSpotifyConnect(1) returns false when Spotify Connect is globally disabled, so the template treats it as unsupported and hides the very checkbox needed to re-enable it.

🤖 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 `@Settings.pm` around lines 109 - 110, The template is hiding the checkbox
because Settings.pm uses the runtime-enabled check as a capability flag by
calling Plugins::Spotty::Plugin->canSpotifyConnect(1); change this to query the
capability (not the runtime enable state) by calling
Plugins::Spotty::Plugin->canSpotifyConnect() (or canSpotifyConnect(0) if the
method explicitly accepts a flag to ignore runtime state) and assign that result
to $paramRef->{canSpotifyConnect} so the UI can still show the control when
Spotify Connect is globally disabled.

Comment thread Settings/Player.pm
Comment on lines +34 to +36
if ( !Plugins::Spotty::Plugin->canSpotifyConnect() ) {
$params->{errorString} = $client->string('PLUGIN_SPOTTY_NEED_HELPER_UPDATE');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a non-initializing capability check in settings handler.

This settings path should not initialize Connect components. Use canSpotifyConnect(1) here to avoid side effects from a read-only page load.

Proposed fix
-	if ( !Plugins::Spotty::Plugin->canSpotifyConnect() ) {
+	if ( !Plugins::Spotty::Plugin->canSpotifyConnect(1) ) {
 		$params->{errorString} = $client->string('PLUGIN_SPOTTY_NEED_HELPER_UPDATE');
 	}
📝 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.

Suggested change
if ( !Plugins::Spotty::Plugin->canSpotifyConnect() ) {
$params->{errorString} = $client->string('PLUGIN_SPOTTY_NEED_HELPER_UPDATE');
}
if ( !Plugins::Spotty::Plugin->canSpotifyConnect(1) ) {
$params->{errorString} = $client->string('PLUGIN_SPOTTY_NEED_HELPER_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 `@Settings/Player.pm` around lines 34 - 36, The settings handler is performing
an initializing capability check by calling
Plugins::Spotty::Plugin->canSpotifyConnect() which can trigger Connect component
initialization; change the call to Plugins::Spotty::Plugin->canSpotifyConnect(1)
so the check is non-initializing, leaving the rest of the logic that sets
$params->{errorString} = $client->string('PLUGIN_SPOTTY_NEED_HELPER_UPDATE')
intact.

stiefenm added 3 commits May 28, 2026 17:02
…dispatch, API control

Adds Spotify Connect support (receiver mode) to the Spotty plugin.
Herger removed Connect in 75ff6bf (2025-08-15) citing fragile track-by-track
state synchronization. This re-addition addresses that fundamental issue via
a completely different transport architecture (see commit 2).

Changes in this commit (Phases 15-20 — initial integration):
- Connect.pm: event loop, sync-group resilience, _connectEvent callbacks,
  LMS CLI dispatch (pause, volume, track change, position seek)
- Connect/Context.pm: per-player context (iconCode, port, MAC mapping)
- Connect/Daemon.pm: daemon lifecycle — start/stop/restart, FIFO stream path,
  crash-restart watchdog, stream-backoff constants
- Connect/DaemonManager.pm: multi-player manager, MAC-to-deviceId mapping,
  checkAPIConnectPlayer, stopForSync differential restart (v3.1 resilience)
- Plugin.pm: Connect route registration, canSpotifyConnect version guard
- Settings.pm / Settings/Player.pm: Connect prefs UI, player assignment
- HTML/EN/plugins/Spotty/settings/basic.html: Connect settings section
- HTML/EN/plugins/Spotty/settings/player.html: per-player Connect assignment
- ProtocolHandler.pm: stream-mode formatOverride branch
- API.pm: Connect API methods — playerTransfer, playerPause, playerNext,
  playerPrevious, playerSeek, playerVolume, idFromMac, withIdFromMac, devices
- API/Cache.pm: remove() method for account cleanup
- AccountHelper.pm: recursion-safe getCredentials (unlink before recursive call)
- custom-convert.conf / custom-types.conf: spotty-connect transcoding entry
- strings.txt: Connect-related strings
…HTTP server

Replaces the FIFO-based stream path with an embedded HTTP server in the spotty
binary (HttpStreamSink + http_stream_server). This resolves the fundamental
fragility that led to Connect removal in 75ff6bf:

Architecture:
  Rust (binary): HttpStreamSink writes continuous S16LE PCM to an HTTP server
    on localhost. Port is printed to stdout at startup (stream_port=NNNN).
  Perl DaemonManager: pipe-based launch reads stream_port from binary stdout.
  Perl ProtocolHandler: canDirectStream returns http://127.0.0.1:<port>/stream.
    LMS handles buffering, seeking, and rate-control natively — no Spotify
    session state in LMS, no fragile per-track synchronization.

Phase 22-23 changes (HTTP transport core + code review):
- Connect/DaemonManager.pm: pipe-based start, _streamPort attribute,
  streamPortForClient accessor, IO::Select port-read (replaces alarm/SIGALRM),
  null-guard getAPIHandler at all call sites, remove dead FIFO branch
- Connect/Daemon.pm: remove fifoPathForClient, suppress encode_base64 newline,
  unsubscribe _onPlaylistJump on shutdown
- Connect.pm: canDirectStream activation — returns HTTP stream URL,
  spc PCM passthrough, progress-bar fix, guard undef $state in getNextTrack,
  WR-02 getAPIHandler call consolidation, WR-03 userId pass fix
- ProtocolHandler.pm: canDirectStream returns HTTP URL for Connect players
- Plugin.pm: remove dead fifoPathForClient reference, correct settings guard
- API.pm: remove orphaned playerQueue method

Requires companion PR michaelherger#28 (michaelherger/librespot) which provides the
HttpStreamSink + http_stream_server in the spotty binary.
Phase 24-25 polish changes:

Progress bar (Phase 24):
- Connect.pm: streamingProgressBar calls in getNextTrack and _connectEvent,
  reset startOffset/playPoint on track change, defer seek on new connect join
- Uses $song->streamUrl (HTTP URL) as cache key, guarded by duration_ms check

Autoplay + stability (Phase 25):
- Connect/Daemon.pm: --autoplay on/off flag wired to per-player enableAutoplay
  pref, gated on getCapability('autoplay') for binary compatibility
- AccountHelper.pm: remove duplicate unlink in getCredentials corruption handler
  (STB-01) — the file is already unlinked before deleteCacheFolder is called,
  second unlink was a no-op that left an audit gap in logs

Binary update (Phase 24/26):
- Bin/i386-linux/spotty-x86_64: v2.2.0 (x86_64-linux-musl static-pie)
- Bin/aarch64-linux/spotty: v2.2.0 (aarch64-linux-musl static)
  Built from michaelherger/librespot spotty branch + pr/lms-glue patches.
  Binary adds HttpStreamSink, http_stream_server, watch-channel seek-flush,
  --autoplay flag. Required companion to this PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant