Skip to content

v4.0.0-beta.21

Pre-release
Pre-release

Choose a tag to compare

@rudyberends rudyberends released this 11 Sep 14:31

48 commits since beta.20, 22 of them fixes. One breaking change, and it is the one to read first.

Spotify, on Soloist alone

librespot is gone, and Soloist is the only client — in two roles rather than one daemon doing both jobs.

A room is a Connect device: one daemon per zone, advertising, never signed in from here. Whoever picks the room in their own Spotify app is the one who signs it in, so a room belongs to whoever took it last and there is nothing to pair from this side.

A room playing this server's own queue is one soloist --single-track run per track, started from the store of the account the track came from. The end of a track is a process exiting rather than something inferred from events that mean two things — which is what #360, #352 and the autoplay silencing were all working around, and all of that inference is gone. previous works again, and playing from a second account is nothing more than starting from a second directory.

Measured: a run is playing 330-430 ms after being asked and its first byte reaches the card at 700-900 ms, so a track boundary costs about 0,6-0,75 s more than a play on a warm daemon did. Silence at a skip is ~930 ms; at a natural end ~1836 ms, of which the last 700-900 ms is the engine winding down while the room plays out its buffer.

What to do when upgrading. A Soloist API key is now required for any Spotify at all — without one, no room appears in the Spotify app and no Spotify track plays. Existing per-room pairings keep a room pickable but no longer play anything of ours: playback restores a session per account, and each account is signed in once from the Accounts screen. There is no migration, because a room's store can hold more than one account with nothing to say which of them would be restored. Spotify is also no longer crossfaded, which it could not be in the first place: a blend needs two streams at once and an account plays in one place at a time.

Three more things on this backend. Loudness normalisation is a setting now instead of hardcoded on — default unchanged, because it is Spotify's own, but it is the one thing in this path that multiplies every sample (measured at 1.35 dB on one track), and on a lossless backend that belongs to whoever set the server up. A room's daemon no longer caches without limit; it was Soloist's own default, and a year of listening is a year of audio on whatever disk this server sits on. And the Spotify app no longer shows every idle room at 100% (#372): the level reaches any signed-in daemon and is read off the zone at login. That was measured rather than assumed — Soloist applies no volume itself, it hands the number to the sound server, which here is this process; the number is a label, never a taper, and a lossless stream stays bit-for-bit what Spotify sent.

A private playlist is no longer an empty one (#365). Playlist tracks are fetched pathfinder-first, on a bearer scraped anonymously from the public web player — so it cannot be someone, and every playlist the account keeps private came back as NotFound. That fell through as an empty-but-truthy page, so the Web API fallback, which holds the account's own token and can read those playlists perfectly well, never ran. The owner saw "no items could be loaded", and playing it reported "Playback unavailable".

Alerts and power

The volume fix shipped in beta.20 and the reporter still heard the outgoing track blast (#359). The whole thing hung on getLatencyMs(), and on most outputs that is not a measurement: Squeezelite and Snapcast return an offset an operator typed in, empty unless somebody filled it, and Sonos, DLNA, Cast and Music Assistant do not implement it at all. Only sendspin answers truthfully, so everywhere else the computed lead came out at zero and the level went up the moment the source switched.

The question is asked three ways now. Outputs that report their own playback position answer it from the other end — the gap between where they say they are and where the server's clock is, is the audio still in flight to that room. Squeezelite can answer too and never did; SlimProto's own heartbeat carries the client's elapsed time. And an output that says nothing either way is assumed to be holding a second, not nothing: reading silence as "instantaneous" is the one assumption there is no recovering from.

A relay that changed its mind mid-switch is put right, which is the tail of #293 that kept coming back. Switching an amp takes real time, and every alert flips a zone twice inside that window — stopped for the announcement, started again by the restore — so the "on" regularly lands while the "off" is still executing. A switch that completed after the zone had changed its mind was not written down, and the equality guards then suppressed the very command that would have corrected it. The room went on playing into a dead amp until something else moved the bookkeeping, and in the other direction a relay stayed energised after an alert.

A replaced alert sound is measured again rather than remembered (#375). The duration cache was keyed on the path alone, so replacing the 3 s bell with a 30 s clip left the old length in place for the life of the process: the new clip was cut off after three seconds, and reverting held the zone open long after the sound ended.

Alerts can also say when a speaking style is set on a model that drops it (#342). instructions was already sent, but OpenAI's own tts-1 and tts-1-hd ignore it — and tts-1 is exactly what an empty model field falls back to. The request succeeds, the announcement plays, and only the tone is missing.

Radio

TuneIn presets filed in folders reach the room (#362). TuneIn hands an account's presets back in three layouts and only the flat one was parsed, so an account using either of the others arrived with no stations — and an empty parse quietly became the demo list, which made a working username look exactly like a wrong one. A typo now reports as not found, where an unknown name answers 200 with an empty body and was indistinguishable from an empty account.

A playlist that only points at the stream is followed (#368). A station whose url is a .m3u never played: the file holds one line naming the real stream, and ffmpeg's only m3u reader wants an HLS manifest, so a pointer came back as "Invalid data found". Pointers are followed here now, up to three hops, and a .pls lists mirrors, so a dead first entry is tried past rather than reported as a dead station.

YouTube Music

A dead cookie stops looking like an empty library (#364). An expired cookie does not fail — YouTube answers 200 with a well-formed body carrying a "Sign in" prompt where the library should be, so the only trace was one log line per section and six empty folders. That prompt is the signal now, the verdict is kept per account, and a cookie is checked as it is saved and when an account is registered, so an expiry reaches you instead of waiting to be discovered. The setup wizard gives the private-window recipe that stops the expiry happening at all, since YouTube rotates account cookies on open tabs. The PO Token provider is wired as well — a different mechanism, renewing no cookies, but it is what web_music needs before YouTube hands out any format url, and that is the client which serves a Premium account its 256k stream.

A library longer than one page is not the whole library (#369). A section answers with 25 items and a continuation token for the rest; we asked once and reported that page's length as totalitems, so both the Loxone app and the web player were correctly told they had already seen everything. Continuations are walked now — in either dialect YouTube is currently straddling — for library albums, artists and playlists, and for album tracks, which truncated the same way past 25.

The library in the Loxone app

A library of thousands arrives in one piece (#347). The scanstatus fix in beta.20 was not the whole story: the reporter measured 350 of 470 albums, 200 of 1152 artists, 150 of 6293 tracks, always a whole number of 50-row pages and never the same number twice. Reading the app's own code says why. Every answer becomes a chunk, chunks arriving while the table is still drawing are parked in a queue the app drains newest-first, and the chunk holding the end of the list latches isFinished — after which every chunk still waiting is dropped. Fifty rows at a time out of six thousand guarantees that backlog, because a page comes off SQLite in about a millisecond and no table draws that fast. A folder now goes out in one answer.

Grouping

Two zones of the same type that cannot sync themselves now get fed. Whether the server had to pipe the leader's audio to a member was decided by comparing protocol names, and that answers the wrong question: what matters is not whether the protocols differ but whether anything is going to feed that member. Four output types have no group coordinator at all — DLNA, Chromecast, Music Assistant, sendspin-cast — so two DLNA zones in one group fell through both nets. The member mirrored the leader's state, showed the right track, and played nothing. Nothing to do with the mixed-groups toggle; grouping simply did not work there.

A squeezelite zone only syncs where slimproto can. Grouping in squeezelite is leader-centric, and the only test was members.length >= 2 — so a group led by a Sonos zone was treated as a slimproto sync group with a leader that has no slimproto player behind it, and nobody orchestrated it at all.

AirPlay

A phone can reach the zone it can see again (#363). The receiver moved to node-airplay, and the advertisement that replaced the native one let bonjour-service default the SRV target to this machine's own hostname — a bare label outside .local, so a sender drops the address records that arrived beside it and asks unicast DNS instead. The shipped compose file names the container sonn-core, which no DHCP lease ever registered: NXDOMAIN, every zone, every sender. Browsing still worked, so the room stayed in the picker and simply refused to connect. Each zone advertises its own <MAC>.local again, pinned to the one address it is actually reachable on. This also restores the port scan, which was computed and logged but never used — so a taken base port meant a room that never appeared at all.

Queue

A repeat set before the music survives the play (#371). Every play that builds a queue cleared the zone's repeat mode, and cleared it in silence, so the client kept showing the circle for a zone that had repeat off. Switch repeat on while a track is playing and it repeated; switch it on and then pick the track you wanted repeated — the obvious way round — and the pick turned it off again behind the icon.

A stopped output no longer winds the queue back (#360). Found while measuring the Soloist boundary: 26 ms after the queue correctly moved on, its index went back to the track that had just finished, because the old engine session reports itself stopped on its way out and that report was reconciled into the queue. The room played track two while the queue said track one — so next played what was already sounding, a second time.

An artist plays its tracks on every service, not just Apple. Playing or favouriting an artist on Deezer, Tidal, YouTube Music or SoundCloud queued albums, which those stream services reject outright, so the room stayed silent. And a library-track audiopath now resolves on all of them: six stream services each parsed a track audiopath from copies of one another and the copies had drifted, so four services turned down their own library tracks as unparseable.

Elsewhere

The analysis stream measures the audio now, not only enough to draw it: EBU R128 loudness (momentary, short-term, gated integrated and range, verified against Tech 3341 at −23.0 LUFS), true peak reconstructed at 4× with a count of the samples that reached full scale, phase correlation, DC offset, and scope and goniometer traces. Every one is opt-in by name, because the K-weighting runs two biquads over every sample and a client that only wants a spectrum should not pay for a meter it never draws. A zone also reports a session: how long this run has been playing, how many tracks, how many of those reached the output untouched, and how often the format or the clock changed under it.

The same record no longer offers a follow button on one screen and not another — an album or artist came back as followable from the library and not from search, so whether you could follow something depended on where you found it.

Dependencies: node-sonos 0.3.0 no longer depends on @svrooij/sonos, which took fast-xml-parser 3.19.0 with it — a line carrying two unpatched advisories that no range bump could reach. The S1 backend speaks UPnP itself now. node-upnp 0.4.2 also fixes a DIDL bug that reached our own DLNA input path, where an album name came back as the cover art URI. npm audit is clean.

Under the hood

The layer rules are enforced against the code a reader actually has to reason about. dependency-cruiser was running on the post-compilation graph, where a type-only import does not exist — 1055 dependencies seen against 2147 written — so four of the rules had been silently cruising past everything. Four rules become ten, each checked by writing a file that violates it: the domain has no outward dependency, application and engine cannot reach an adapter, and adapters/loxone is reachable only from the composition root and the HTTP gateway, which is what makes "Loxone is one integration, not the core" true rather than intended. npm test runs that check now; it was only in npm run lint, which the release workflow does not call.

HttpService took one options object with fifty-two fields and built every handler from it in a 444-line constructor. Each surface asks for what it needs now, so a route's dependencies can be read off a type — and the public API's view of a zone is fourteen members rather than thirty-seven, which is a type instead of an audit. Four integrations — YouTube Music, Soloist, Apple Music, TuneIn — own their admin surface as well, which is what finally made those routes testable: they probe binaries, run pip and wait on a real Spotify app, so none of them had ever had a test.

Content providers state what an item is rather than how Loxone draws it: 47 of 82 sites no longer fill in a Loxone FileType number by hand. Tests arrived on the sendspin group controller, on frame pacing and assembly where drift lives, on Apple Music's stream helpers — including the two that decide what leaves this server carrying a listener's credentials — and on the composition root, which nothing exercised before the server itself. Nine modules that nothing has ever imported are gone.