Skip to content

Replace airtunes lib by airplay-sender#2439

Open
bertrandda wants to merge 10 commits intoGladysAssistant:masterfrom
bertrandda:fix/change-airplay-lib
Open

Replace airtunes lib by airplay-sender#2439
bertrandda wants to merge 10 commits intoGladysAssistant:masterfrom
bertrandda:fix/change-airplay-lib

Conversation

@bertrandda
Copy link
Copy Markdown
Contributor

@bertrandda bertrandda commented Feb 21, 2026

Pull Request check-list

To ensure your Pull Request can be accepted as fast as possible, make sure to review and check all of these items:

  • If your changes affect the code, did you write the tests?
  • Are tests passing? (npm test on both front/server)
  • Is the linter passing? (npm run eslint on both front/server)
  • Did you run prettier? (npm run prettier on both front/server)
  • Did you test this pull request in real life? With real devices? If this development is a big feature or a new service, we recommend that you provide a Docker image to the community (forum) for testing before merging.

NOTE: these things are not required to open a PR and can be done afterwards / while the PR is open.

Description of change

Change airtunes2 in airplay integration by airplay-sender.
This new lib is modern, code is cleaner, ts only (airtunes used C++ binding). It will avoid failure on future Nodejs upgrades and avoid required lib build on installation.

Also update bonjour lib.

Summary by CodeRabbit

  • Dependencies

    • Switched to a new AirPlay sender implementation.
  • Improvements

    • Playback now streams steady PCM audio with explicit sender readiness and improved lifecycle management.
    • Added automatic stop after a maximum notification duration to avoid runaway playback.
  • Bug Fixes

    • Handles decoder startup failures, spawn errors, end-of-stream, and ensures sender cleanup to prevent stuck streams.
  • Tests

    • Updated tests to cover sender lifecycle, readiness timing, and failure scenarios.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 21, 2026

📝 Walkthrough

Walkthrough

Replaced runtime node-airtunes2 usage with @lox-audioserver/node-airplay-sender. AirplayHandler and setValue now initialize and use an airplaySender that receives PCM via an FFmpeg stdout pipeline; tests and package.json updated to the new sender contract.

Changes

Cohort / File(s) Summary
Dependency Migration
server/services/airplay/package.json
Removed node-airtunes2, added @lox-audioserver/node-airplay-sender, bumped bonjour.
Service Bootstrap
server/services/airplay/index.js, server/services/airplay/lib/index.js
Replaced runtime require and constructor parameter airtunesairplaySender; instance property renamed to this.airplaySender.
Playback Logic Refactor
server/services/airplay/lib/airplay.setValue.js
Removed Airtunes client flow; use airplaySender factory with readiness callback; spawn FFmpeg to decode audio to PCM and stream stdout chunks via sendPcm; added cleanup, MAX_NOTIFICATION_DURATION timeout, stderr/startup/error handling, and buffer/end handling.
Tests Updated
server/test/services/airplay/lib/airplay.init.test.js, server/test/services/airplay/lib/airplay.setValue.test.js, server/test/services/airplay/lib/airplay_scan.test.js
Replaced Airtunes mocks with airplaySender factory mock that signals readiness; adjusted childProcess stdout/stderr mocks; added tests for execvp-style stderr, decode-process errors, buffer-end handling, max-duration timeout, and synchronized readiness/timing control.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Handler as AirplayHandler
    participant Sender as AirplaySender
    participant FFmpeg as FFmpeg
    participant Device as Device

    Client->>Handler: setValue(audioValue)
    Handler->>Sender: start({ host, airplay2:true, volume }, callback)
    Sender-->>Handler: { event: "device", message: "ready" }
    Handler->>FFmpeg: spawn(decoder -> PCM stdout)
    FFmpeg-->>Handler: stdout chunk (PCM)
    Handler->>Sender: sendPcm(chunk)
    Sender->>Device: stream PCM
    FFmpeg-->>Handler: stdout end
    Handler->>Sender: schedule stop() (after 7s)
    Handler->>Sender: stop()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped from old tunes to a humming sender,
FFmpeg hums and PCM rivers grow tender.
Chunks take a leap down my carrot track,
The sender sings, the speakers clap back.
A rabbit cheers — playback, never slack! 🎶

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: replacing the airtunes library with airplay-sender, which is the primary objective across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/services/airplay/lib/airplay.setValue.js`:
- Around line 24-28: Replace the falsy-check fallback for the numeric optional
parameter so a legitimate 0 is preserved: change the use of logical OR on
options?.volume to nullish coalescing (use options?.volume ?? 70) where the
object with host/ipAddress/airplay2/volume is built in airplay.setValue.js (the
volume property assignment), ensuring other numeric optional parameters in the
same file follow the same pattern if present.
- Around line 31-61: The spawned ffmpeg process (decodeProcess created via
this.childProcess.spawn) lacks an 'error' event listener, so add
decodeProcess.on('error', ...) to handle spawn failures: log the error with
logger.error including the error object/message, and call sender.stop() (and any
necessary cleanup) to avoid an unhandled exception; ensure the handler
references decodeProcess and sender so the listener is tied to the correct
process and tears down playback on failure.

@relativeci
Copy link
Copy Markdown

relativeci bot commented Feb 21, 2026

#4129 Bundle Size — 11.44MiB (0%).

a560324(current) vs b3cfa5a master#4120(baseline)

Warning

Bundle contains 2 duplicate packages – View duplicate packages

Bundle metrics  no changes
                 Current
#4129
     Baseline
#4120
No change  Initial JS 6.42MiB 6.42MiB
No change  Initial CSS 310.54KiB 310.54KiB
No change  Cache Invalidation 0% 0%
No change  Chunks 51 51
No change  Assets 179 179
No change  Modules 1643 1643
No change  Duplicate Modules 21 21
No change  Duplicate Code 0.94% 0.94%
No change  Packages 136 136
No change  Duplicate Packages 2 2
Bundle size by type  no changes
                 Current
#4129
     Baseline
#4120
No change  JS 8.32MiB 8.32MiB
No change  IMG 2.68MiB 2.68MiB
No change  CSS 328.39KiB 328.39KiB
No change  Fonts 93.55KiB 93.55KiB
No change  Other 18.82KiB 18.82KiB
No change  HTML 13.58KiB 13.58KiB

Bundle analysis reportBranch bertrandda:fix/change-airplay-li...Project dashboard


Generated by RelativeCIDocumentationReport issue

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 21, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.81%. Comparing base (b3cfa5a) to head (a560324).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #2439   +/-   ##
=======================================
  Coverage   98.81%   98.81%           
=======================================
  Files        1009     1009           
  Lines       17670    17683   +13     
=======================================
+ Hits        17460    17473   +13     
  Misses        210      210           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/test/services/airplay/lib/airplay.setValue.test.js (1)

66-72: Redundant sinon.reset() calls.

Having sinon.reset() in both beforeEach and afterEach is unnecessary—one is sufficient to isolate tests.

♻️ Suggested simplification
-  beforeEach(() => {
-    sinon.reset();
-  });
-
   afterEach(() => {
     sinon.reset();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/airplay/lib/airplay.setValue.test.js` around lines 66 -
72, The tests call sinon.reset() in both beforeEach and afterEach which is
redundant; remove one of the calls so reset is performed only once per test
cycle. Edit the test file to keep either beforeEach(() => sinon.reset()) or
afterEach(() => sinon.reset()) and delete the duplicate block (references:
beforeEach, afterEach, sinon.reset) to ensure proper isolation without
duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/test/services/airplay/lib/airplay.setValue.test.js`:
- Around line 66-72: The tests call sinon.reset() in both beforeEach and
afterEach which is redundant; remove one of the calls so reset is performed only
once per test cycle. Edit the test file to keep either beforeEach(() =>
sinon.reset()) or afterEach(() => sinon.reset()) and delete the duplicate block
(references: beforeEach, afterEach, sinon.reset) to ensure proper isolation
without duplication.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/test/services/airplay/lib/airplay.setValue.test.js (1)

97-130: Consider asserting no PCM is sent on execvp failure.

If the intended behavior is that streaming never starts when ffmpeg fails to spawn, a notCalled assertion will lock in that contract.

Proposed test tightening
     await new Promise((resolve) => {
       setImmediate(resolve);
     });
     sinon.assert.calledOnce(stopSender);
+    sinon.assert.notCalled(sendPcm);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/airplay/lib/airplay.setValue.test.js` around lines 97 -
130, The test should also assert that no PCM/audio sending is attempted when
ffmpeg fails to spawn: after creating airplayHandlerWithExecvpError and calling
setValue(device, ...), add a sinon assertion that the PCM sending stub (e.g.,
airplaySender.sendPCM or the specific stub used to stream audio in tests) was
not called (sinon.assert.notCalled(sendPCM)). Keep the existing check that
stopSender was called; place the notCalled check before or after the stopSender
assertion to lock in the contract that streaming never starts on execvp failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/test/services/airplay/lib/airplay.setValue.test.js`:
- Around line 97-130: The test should also assert that no PCM/audio sending is
attempted when ffmpeg fails to spawn: after creating
airplayHandlerWithExecvpError and calling setValue(device, ...), add a sinon
assertion that the PCM sending stub (e.g., airplaySender.sendPCM or the specific
stub used to stream audio in tests) was not called
(sinon.assert.notCalled(sendPCM)). Keep the existing check that stopSender was
called; place the notCalled check before or after the stopSender assertion to
lock in the contract that streaming never starts on execvp failure.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/test/services/airplay/lib/airplay.setValue.test.js`:
- Around line 21-33: The mock childProcessMock.spawn currently invokes the
stdout 'end' callback (inside stdout.on) which triggers production
setTimeout(7000); remove the automatic invocation of the 'end' event in the mock
so only the 'data' emission occurs during the test—edit the
childProcessMock.spawn implementation (stdout.on handler) to stop calling cb()
for type === 'end' (or only call it when an explicit test helper flag is set),
leaving the 'data' emission intact; this prevents the real 7-second timer from
being scheduled while keeping assertions based on the 'data' event working.

---

Duplicate comments:
In `@server/services/airplay/lib/airplay.setValue.js`:
- Around line 23-28: The volume defaulting currently uses a logical OR which
treats 0 as falsy and overwrites mute (options?.volume || 70); change it to use
the nullish coalescing operator so only null/undefined fall back (replace the
volume expression passed into this.airplaySender with options?.volume ?? 70) to
preserve a valid 0 volume; locate the call to this.airplaySender in
airplay.setValue.js and update that volume expression accordingly.

@bertrandda
Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 22, 2026

✅ Actions performed

Reviews resumed.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
server/test/services/airplay/lib/airplay.setValue.test.js (2)

74-101: Consider wrapping clock lifecycle in try/finally for test resilience.

If an assertion fails before clock.restore(), the fake timers remain active and could affect subsequent tests. Using try/finally ensures cleanup regardless of test outcome.

♻️ Suggested pattern
 it('should talk on speaker', async () => {
   airplayHandler.scanTimeout = 1;
   const devices = await airplayHandler.scan();
   const device = devices[0];
   const clock = sinon.useFakeTimers({ toFake: ['setTimeout'] });
-  await airplayHandler.setValue(device, device.features[0], 'http://play-url.com');
-  await new Promise((resolve) => {
-    setImmediate(resolve);
-  });
-  clock.tick(7000);
-  sinon.assert.calledOnce(sendPcm);
-  sinon.assert.calledOnce(stopSender);
-  clock.restore();
+  try {
+    await airplayHandler.setValue(device, device.features[0], 'http://play-url.com');
+    await new Promise((resolve) => {
+      setImmediate(resolve);
+    });
+    clock.tick(7000);
+    sinon.assert.calledOnce(sendPcm);
+    sinon.assert.calledOnce(stopSender);
+  } finally {
+    clock.restore();
+  }
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/airplay/lib/airplay.setValue.test.js` around lines 74 -
101, Wrap the fake-timer lifecycle in each test in a try/finally so
clock.restore() always runs: create the clock with sinon.useFakeTimers(...)
before the awaited setValue call, put the assertions
(sinon.assert.calledOnce(sendPcm) etc.) inside the try block, and call
clock.restore() in the finally block; update both tests that use clock (the two
it blocks invoking airplayHandler.setValue, sendPcm and stopSender) to ensure
cleanup even if an assertion or await throws.

155-162: Consider simplifying the callback sequencing.

The async/await pattern inside process.nextTick works, but the await on synchronous callbacks is redundant. Since callbacks execute immediately, a simpler approach would be clearer.

♻️ Simplified pattern
 const airplaySenderWithBufferEnd = (options, callback) => {
   const senderInstance = { sendPcm, stop: stopSender };
-  process.nextTick(async () => {
-    await callback({ event: 'device', message: 'ready' });
-    await callback({ event: 'buffer', message: 'end' });
+  process.nextTick(() => {
+    callback({ event: 'device', message: 'ready' });
+    callback({ event: 'buffer', message: 'end' });
   });
   return senderInstance;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/airplay/lib/airplay.setValue.test.js` around lines 155 -
162, The sequence inside airplaySenderWithBufferEnd uses async/await
unnecessarily for synchronous callback calls; replace the async function passed
to process.nextTick with a plain function that directly invokes callback twice
in order (first with { event: 'device', message: 'ready' } then with { event:
'buffer', message: 'end' }) and return the same senderInstance; keep sendPcm and
stop: stopSender intact and do not await the callback calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/test/services/airplay/lib/airplay.setValue.test.js`:
- Around line 74-101: Wrap the fake-timer lifecycle in each test in a
try/finally so clock.restore() always runs: create the clock with
sinon.useFakeTimers(...) before the awaited setValue call, put the assertions
(sinon.assert.calledOnce(sendPcm) etc.) inside the try block, and call
clock.restore() in the finally block; update both tests that use clock (the two
it blocks invoking airplayHandler.setValue, sendPcm and stopSender) to ensure
cleanup even if an assertion or await throws.
- Around line 155-162: The sequence inside airplaySenderWithBufferEnd uses
async/await unnecessarily for synchronous callback calls; replace the async
function passed to process.nextTick with a plain function that directly invokes
callback twice in order (first with { event: 'device', message: 'ready' } then
with { event: 'buffer', message: 'end' }) and return the same senderInstance;
keep sendPcm and stop: stopSender intact and do not await the callback calls.

Copy link
Copy Markdown
Contributor

@Pierre-Gilles Pierre-Gilles left a comment

Choose a reason for hiding this comment

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

Looks nice! Thanks for the PR! A small comment :)

},
async (event) => {
if (event.event === 'device' && event.message === 'ready') {
decodeProcess = this.childProcess.spawn('ffmpeg', [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there any risk that this process could run indefinitely?
Why not use execFile with a proper timeout, like we do in getImage.js for the RTSP camera integration?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maybe it can happen if there is a deconnection during url reading. I added a global timeout after 5 minutes (we can adjust, I don't know user's notification duration). In this case execFile is not possible, file is send in realtime using chunks to homepods, execFile needs to buffer all file in memory

@bertrandda bertrandda force-pushed the fix/change-airplay-lib branch from 54f967b to 8c26bdd Compare April 5, 2026 20:50
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/test/services/airplay/lib/airplay.setValue.test.js (1)

80-82: Consider extracting the setImmediate wait pattern to a helper.

The pattern await new Promise((resolve) => setImmediate(resolve)) is repeated 7 times across tests. A helper function would improve readability and reduce duplication.

♻️ Optional helper extraction
+const flushNextTick = () => new Promise((resolve) => setImmediate(resolve));
+
 describe('AirplayHandler.setValue', () => {
   // ...
   it('should talk on speaker', async () => {
     // ...
-    await new Promise((resolve) => {
-      setImmediate(resolve);
-    });
+    await flushNextTick();
     // ...
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/airplay/lib/airplay.setValue.test.js` around lines 80 -
82, The tests repeatedly use the pattern await new Promise((resolve) =>
setImmediate(resolve)); extract this into a small test helper (e.g.,
waitImmediate or flushImmediate) and replace the seven inline instances in
server/test/services/airplay/lib/airplay.setValue.test.js with await
waitImmediate(); create the helper in your test utils module (or a new file
under server/test/helpers) exporting the function and import it at the top of
airplay.setValue.test.js; ensure the helper simply returns new Promise(resolve
=> setImmediate(resolve)) so behavior is unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/test/services/airplay/lib/airplay.setValue.test.js`:
- Around line 80-82: The tests repeatedly use the pattern await new
Promise((resolve) => setImmediate(resolve)); extract this into a small test
helper (e.g., waitImmediate or flushImmediate) and replace the seven inline
instances in server/test/services/airplay/lib/airplay.setValue.test.js with
await waitImmediate(); create the helper in your test utils module (or a new
file under server/test/helpers) exporting the function and import it at the top
of airplay.setValue.test.js; ensure the helper simply returns new
Promise(resolve => setImmediate(resolve)) so behavior is unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e6a2921c-34da-4af8-b2b5-e0ba06302165

📥 Commits

Reviewing files that changed from the base of the PR and between 3e91e74 and a560324.

📒 Files selected for processing (1)
  • server/test/services/airplay/lib/airplay.setValue.test.js

@bertrandda bertrandda requested a review from Pierre-Gilles April 5, 2026 22:10
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.

2 participants