Skip to content

Commit 0e5387c

Browse files
committed
fix: harden Sonos playback and E2E diagnostics
1 parent 817aebb commit 0e5387c

11 files changed

Lines changed: 631 additions & 121 deletions

docker/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ FROM --platform=$TARGETPLATFORM node:24-slim AS base
55

66
# System deps needed for native builds (e.g., bcrypt) and git optional deps
77
RUN apt-get update && \
8-
apt-get install -y --no-install-recommends python3 build-essential && \
8+
apt-get install -y --no-install-recommends python3 build-essential git && \
99
rm -rf /var/lib/apt/lists/* && \
1010
npm cache clean --force
1111

docker/Dockerfile-local

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
# The --platform flag is used here to make sure we use a multi-platform base image
33
FROM --platform=$BUILDPLATFORM node:26-slim AS base
44

5-
# Update and install git (if needed for your application)
6-
#RUN apk update && \
7-
# apk upgrade
5+
# System deps needed by startup diagnostics and optional dependencies.
6+
RUN apt-get update && \
7+
apt-get install -y --no-install-recommends git && \
8+
rm -rf /var/lib/apt/lists/*
89

910
# Clear npm cache to reduce image size and avoid potential issues
1011
RUN npm cache clean --force
@@ -25,4 +26,4 @@ COPY . .
2526
RUN chmod -R 755 /app
2627

2728
# Command to run the application
28-
CMD ["node", "index.js"]
29+
CMD ["node", "index.js"]

index.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,11 @@ const getReleaseVersion = () => {
7272

7373
// 2. Git commit SHA (for native/local development)
7474
try {
75-
const sha = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
75+
const gitExecOptions = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] };
76+
const sha = execSync('git rev-parse --short HEAD', gitExecOptions).trim();
7677
// Try to get tag from git if available
7778
try {
78-
const tag = execSync('git describe --tags --exact-match HEAD 2>/dev/null', { encoding: 'utf8' }).trim();
79+
const tag = execSync('git describe --tags --exact-match HEAD', gitExecOptions).trim();
7980
if (tag) {
8081
return tag; // Return exact tag if on tagged commit
8182
}

lib/add-handlers.js

Lines changed: 31 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
*/
88

99
const queueUtils = require('./queue-utils');
10+
const {
11+
getFirstQueuedTrackNumber,
12+
playFromQueue
13+
} = require('./sonos-playback');
1014

1115
// ==========================================
1216
// DEPENDENCIES (injected via initialize)
@@ -163,9 +167,12 @@ async function add(input, channel, userName) {
163167
let result = null;
164168
try {
165169
logger.info(`Attempting to queue: ${firstCandidate.name} by ${firstCandidate.artist} (URI: ${firstCandidate.uri})`);
166-
await sonos.queue(firstCandidate.uri);
170+
const queueResult = await sonos.queue(firstCandidate.uri);
167171
logger.info('Successfully queued track: ' + firstCandidate.name);
168-
result = firstCandidate;
172+
result = {
173+
...firstCandidate,
174+
queuePosition: getFirstQueuedTrackNumber(queueResult, 1)
175+
};
169176
} catch (e) {
170177
const errorDetails = e.message || String(e);
171178
const upnpErrorMatch = errorDetails.match(/errorCode[>](\d+)[<]/);
@@ -212,61 +219,7 @@ async function add(input, channel, userName) {
212219
if (state === 'stopped') {
213220
(async () => {
214221
try {
215-
try {
216-
await sonos.stop();
217-
await new Promise(resolve => setTimeout(resolve, 500));
218-
} catch (stopErr) {
219-
logger.debug('Stop before play (may already be stopped): ' + stopErr.message);
220-
}
221-
222-
// Verify queue has items before trying to play
223-
let queueReady = false;
224-
let retries = 0;
225-
while (!queueReady && retries < 5) {
226-
try {
227-
const q = await sonos.getQueue();
228-
if (q && q.items && q.items.length > 0) {
229-
queueReady = true;
230-
logger.debug(`Queue verified: ${q.items.length} items ready`);
231-
} else {
232-
logger.debug(`Queue not ready yet (attempt ${retries + 1}/5), waiting...`);
233-
await new Promise(resolve => setTimeout(resolve, 300));
234-
retries++;
235-
}
236-
} catch (queueErr) {
237-
logger.debug(`Queue check failed (attempt ${retries + 1}/5): ${queueErr.message}`);
238-
await new Promise(resolve => setTimeout(resolve, 300));
239-
retries++;
240-
}
241-
}
242-
243-
if (!queueReady) {
244-
logger.warn('Queue not ready after 5 attempts, attempting playback anyway');
245-
}
246-
247-
// Try to activate queue by seeking to position 1
248-
try {
249-
logger.debug('Attempting to seek to queue position 1 to activate queue');
250-
await sonos.avTransportService().Seek({
251-
InstanceID: 0,
252-
Unit: 'TRACK_NR',
253-
Target: '1'
254-
});
255-
logger.debug('Successfully sought to track 1, queue should be active');
256-
await new Promise(resolve => setTimeout(resolve, 500));
257-
} catch (seekErr) {
258-
logger.debug('Seek failed, trying next() to activate queue: ' + seekErr.message);
259-
try {
260-
await sonos.next();
261-
logger.debug('Used next() to activate queue');
262-
await new Promise(resolve => setTimeout(resolve, 300));
263-
} catch (nextErr) {
264-
logger.debug('next() also failed: ' + nextErr.message);
265-
}
266-
}
267-
268-
await new Promise(resolve => setTimeout(resolve, 500));
269-
await sonos.play();
222+
await playFromQueue(sonos, logger, { trackNumber: result.queuePosition || 1 });
270223
logger.info('Started playback from queue');
271224
} catch (playErr) {
272225
logger.warn('Failed to start playback: ' + playErr.message);
@@ -426,19 +379,23 @@ async function queueAlbum(result, albumSearchTerm, channel, userName) {
426379
})
427380
);
428381

429-
await Promise.allSettled(queuePromises);
382+
const queueResults = await Promise.allSettled(queuePromises);
383+
result.queuePosition = getFirstQueuedTrackNumber(
384+
queueResults.find(queueResult => queueResult.status === 'fulfilled' && queueResult.value)?.value,
385+
1
386+
);
430387
logger.info(`Added ${allowedTracks.length} tracks from album (filtered ${blacklistedTracks.length})`);
431388
} else {
432-
await sonos.queue(result.uri);
389+
const queueResult = await sonos.queue(result.uri);
390+
result.queuePosition = getFirstQueuedTrackNumber(queueResult, 1);
433391
logger.info('Added album: ' + result.name);
434392
}
435393

436394
if (isStopped) {
437-
await new Promise(resolve => setTimeout(resolve, 300));
438-
await sonos.play();
395+
await playFromQueue(sonos, logger, { trackNumber: result.queuePosition || 1 });
439396
logger.info('Started playback after album add');
440397
} else if (state !== 'playing' && state !== 'transitioning') {
441-
await sonos.play();
398+
await playFromQueue(sonos, logger);
442399
logger.info('Player was not playing, started playback.');
443400
}
444401
} catch (err) {
@@ -529,40 +486,36 @@ async function addplaylist(input, channel, userName) {
529486
}
530487
}
531488

489+
let queuedTrackNumber = null;
490+
532491
// If we have blacklisted tracks, add individually; otherwise use playlist URI
533492
if (blacklistedTracks.length > 0) {
534493
const allowedTracks = playlistTracks.filter(track =>
535494
!isTrackBlacklisted(track.name, track.artist)
536495
);
537496

538497
for (const track of allowedTracks) {
539-
await sonos.queue(track.uri);
498+
const queueResult = await sonos.queue(track.uri);
499+
queuedTrackNumber = queuedTrackNumber || getFirstQueuedTrackNumber(queueResult, 1);
540500
}
541501
logger.info(`Added ${allowedTracks.length} tracks from playlist (filtered ${blacklistedTracks.length})`);
542502
} else {
543-
await sonos.queue(result.uri);
503+
const queueResult = await sonos.queue(result.uri);
504+
queuedTrackNumber = getFirstQueuedTrackNumber(queueResult, 1);
544505
logger.info('Added playlist: ' + result.name);
545506
}
546507

547508
// Start playback if needed
548509
if (isStopped) {
549510
try {
550-
try {
551-
await sonos.stop();
552-
await new Promise(resolve => setTimeout(resolve, 500));
553-
} catch (stopErr) {
554-
logger.debug('Stop before play (may already be stopped): ' + stopErr.message);
555-
}
556-
557-
await new Promise(resolve => setTimeout(resolve, 1000));
558-
await sonos.play();
511+
await playFromQueue(sonos, logger, { trackNumber: queuedTrackNumber || 1 });
559512
logger.info('Started playback from queue');
560513
} catch (playErr) {
561514
logger.warn('Failed to start playback: ' + playErr.message);
562515
}
563516
} else if (state !== 'playing' && state !== 'transitioning') {
564517
try {
565-
await sonos.play();
518+
await playFromQueue(sonos, logger);
566519
logger.info('Player was not playing, started playback.');
567520
} catch (playErr) {
568521
logger.warn('Failed to auto-play: ' + playErr.message);
@@ -578,23 +531,6 @@ async function addplaylist(input, channel, userName) {
578531

579532
logger.info(`Sending playlist confirmation message: ${text}`);
580533
sendMessage(text, channel, { trackName: result.name });
581-
582-
// Note: Queueing is already done synchronously above (lines 532-545)
583-
// This background task only handles playback if needed
584-
(async () => {
585-
try {
586-
if (isStopped) {
587-
await new Promise(resolve => setTimeout(resolve, 300));
588-
await sonos.play();
589-
logger.info('Started playback after playlist add');
590-
} else if (state !== 'playing' && state !== 'transitioning') {
591-
await sonos.play();
592-
logger.info('Player was not playing, started playback.');
593-
}
594-
} catch (err) {
595-
logger.error('Error in background playlist queueing: ' + err.message);
596-
}
597-
})();
598534
} catch (err) {
599535
logger.error('Error adding playlist: ' + err.message);
600536
sendMessage('🔎 Couldn\'t find that playlist. Try a Spotify link, or use `searchplaylist <name>` to pick one. 🎵', channel);
@@ -645,7 +581,7 @@ async function append(input, channel, userName) {
645581
}
646582

647583
// Always add to queue (preserving existing tracks)
648-
await sonos.queue(result.uri);
584+
const queueResult = await sonos.queue(result.uri);
649585
logger.info('Appended track: ' + result.name);
650586

651587
let msg = '✅ Added *' + result.name + '* by _' + result.artist + '_ to the queue!';
@@ -656,8 +592,9 @@ async function append(input, channel, userName) {
656592
logger.info('Current state after append: ' + state);
657593

658594
if (state !== 'playing' && state !== 'transitioning') {
659-
await new Promise(resolve => setTimeout(resolve, 1000));
660-
await sonos.play();
595+
await playFromQueue(sonos, logger, {
596+
trackNumber: state === 'stopped' ? getFirstQueuedTrackNumber(queueResult, null) : null
597+
});
661598
logger.info('Started playback after append.');
662599
msg += ' Playback started! :notes:';
663600
}

lib/command-handlers.js

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
const queueUtils = require('./queue-utils');
10+
const { playFromQueue } = require('./sonos-playback');
1011

1112
// ==========================================
1213
// DEPENDENCIES (injected via initialize)
@@ -78,16 +79,14 @@ function stop(input, channel, userName) {
7879
/**
7980
* Start playback
8081
*/
81-
function play(input, channel, userName) {
82+
async function play(input, channel, userName) {
8283
logUserAction(userName, 'play');
83-
sonos
84-
.play()
85-
.then(() => {
86-
sendMessage('▶️ Let\'s gooo! Music is flowing! 🎶', channel);
87-
})
88-
.catch((err) => {
89-
logger.error('Error starting playback: ' + err);
90-
});
84+
try {
85+
await playFromQueue(sonos, logger);
86+
sendMessage('▶️ Let\'s gooo! Music is flowing! 🎶', channel);
87+
} catch (err) {
88+
logger.error('Error starting playback: ' + err);
89+
}
9190
}
9291

9392
/**

0 commit comments

Comments
 (0)