#include "volume_worker.h" // yeney.cpp -- bridges a Sonos zone player into an LMS session // // Copyright (c) 2026 Jaap van Vliet // // Original implementation for the YeneY project. // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Licensed under the PolyForm Noncommercial License 1.0.0. See LICENSE. // // THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. #include "legacy_environment.h" #include "debug_settings.h" #include "lms_artwork.h" #include "upnp/own_speaker_control.h" #include "upnp/backend.h" #include "upnp/title_format.h" #include "stream_display.h" #include "queue_lms_commands.h" #include "upnp/list_rooms.h" #include #include "resume_state.h" #include "source_ownership.h" #include "transport_intent.h" #include "pause_mode.h" #include "sbstreamer.h" #include "sonos-position.h" #include "sonos-status.h" #include "player_name.h" #include "stream_metrics.h" #include "stream_pacing.h" #include "stop_debounce.h" #include "stream_session.h" #include "audio_mode.h" #include "obsolete_player.h" void runCoreClient(const char*, const uint8_t*, const char*); extern "C" { unsigned get_squeezebox_stream_id(void); } #include "playback_mode.h" #include "queue_room.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { void onSonosEvent(void* handle); const char* findFlag(int argc, char** argv, const std::string& flag); const char* findOption(int argc, char** argv, const std::string& option); } // namespace std::shared_ptr gStreamServer; std::shared_ptr gPlayer; bridge::QueueRoom* gQueueRoom = nullptr; uint8_t gMac[6]; std::atomic gEvent{true}; static std::string gServer; // The restart window lasts from allocation until PlayStream completes. A separate // mutex serializes network transport calls; output never waits for that mutex. static std::atomic streamId(0); static std::atomic lmsStreamSerial(0); extern "C" unsigned get_lms_stream_serial(void) { return lmsStreamSerial.load(); } static std::atomic completedStream(0); static bool stream_just_restarted() { return streamId.load() != completedStream.load(); } static std::atomic lmsPaused(false); static std::atomic ourStreamStarted(false); static std::mutex resumeMutex; static ResumeState resumeState; extern "C" int yeney_is_paused(void) { return lmsPaused.load(); } static std::mutex transportMutex; static std::mutex intentMutex; static TransportIntent transportIntent; static RetryBudget streamStartRetry; // protected by transportMutex static unsigned retryStream = 0; static uint64_t retryRevision = 0; extern "C" void end_squeezebox_response(void); extern "C" void note_squeezebox_device_close(void); extern "C" void flush_squeezebox_response(void); extern "C" void hold_squeezebox_resume(unsigned stream); extern "C" int squeezebox_response_ended(unsigned stream); extern "C" int squeezebox_response_open(unsigned stream); extern "C" int squeezebox_response_streaming(unsigned stream); extern "C" int squeezebox_request_open(unsigned stream); extern "C" void configure_squeezebox_close_logging(bool own); extern "C" void acknowledge_squeezebox_resume(unsigned stream); extern "C" void invalidate_squeezebox_held_get(unsigned stream); static std::mutex stopMutex; static StopDebounce deferredStop; static bool PlaySqueezeBoxLocked(unsigned stream_id, bool resetPosition); static bool alreadyPlayingCurrentStream(unsigned stream); extern "C" unsigned get_squeezebox_stream_id(void) { return streamId.load(); } extern "C" void new_squeezebox_stream_id(void) { unsigned id = streamId.fetch_add(1) + 1; reset_sonos_position(id); { std::lock_guard lock(resumeMutex); resumeState.streamStarted(); } printf("Creating new stream (%u) for Sonos\n", id); } static void dispatchTransportIntent() { if (gQueueRoom && gQueueRoom->queueActive()) return; std::unique_lock transport(transportMutex, std::try_to_lock); if (speakerRelinquished.load() || !transport.owns_lock() || stream_just_restarted() || !ourStreamStarted.load() || !gPlayer) return; TransportIntent intent; { std::lock_guard lock(intentMutex); if (!transportIntent.pending || !transportIntent.retry.ready(RetryBudget::Clock::now())) return; intent = transportIntent; transportIntent.pending = false; // a newer revision can arrive during I/O } const char command = intent.command; const bool pause = command == 'p'; auto unpause = intent.unpause; // A new stream's successful PlayStream already satisfies a deferred play. if (!pause && (intent.stream != streamId.load() || intent.restartPending)) { if (intent.deferred) printf("strm u: deferred intent applied by new stream\n"); return; } if (unpause == ResumeState::Unpause::FeedHeldGet) { if (squeezebox_response_open(streamId.load())) { printf("strm u: feeding held GET, no same-URL resume\n"); acknowledge_squeezebox_resume(streamId.load()); if (intent.deferred) printf("strm u: deferred transport applied\n"); return; } // A preceding command may have ended the GET while this intent waited. unpause = ResumeState::Unpause::SameURL; } { bool ended = !pause && squeezebox_response_ended(streamId.load()); bool missing = unpause == ResumeState::Unpause::SameURL; bool error = !pause && gPlayer->transportInfo().status == "ERROR_LOST_CONNECTION"; if (ended) printf("strm u after ended response -> PlayStream(same URL)\n"); else if (missing) printf("strm u without open response -> PlayStream(same URL)\n"); else if (pause && pauseMode() == PauseMode::Stop) printf("strm p -> UPnP Stop (pause=stop)\n"); else printf("strm %c -> UPnP %s\n", command, pause ? "Pause" : "Play"); bool ok; const bool playStreamAttempt = ended || missing || error; if (playStreamAttempt) { if (error) printf("device in error state -> re-issuing PlayStream\n"); if (intent.retry.count() && alreadyPlayingCurrentStream(streamId.load())) return; if (missing) invalidate_squeezebox_held_get(streamId.load()); intent.retry.begin(); ok = PlaySqueezeBoxLocked(streamId.load(), false); } else { // Sonos withholds its Pause acknowledgement until the stream closes. // End the response first, regardless of whether Pause succeeds. if (pause) end_squeezebox_response(); auto upnpStart = std::chrono::steady_clock::now(); const bool stopForPause = pause && pauseMode() == PauseMode::Stop; if (stopForPause) { std::lock_guard stateLock(resumeMutex); resumeState.stopForPause(streamId.load()); } ok = pause ? (stopForPause ? gPlayer->stop() : gPlayer->pause()) : gPlayer->play(); auto upnpMs = std::chrono::duration_cast( std::chrono::steady_clock::now() - upnpStart).count(); printf("gPlayer->%s took %lldms\n", pause ? (stopForPause ? "Stop" : "Pause") : "Play", (long long)upnpMs); } if (ok && intent.deferred) printf("strm %c: deferred transport applied\n", command); if (!ok) { printf("strm %c: device transport command failed\n", command); if (playStreamAttempt) { std::lock_guard lock(intentMutex); if (transportIntent.revision == intent.revision) { intent.retry.failed(RetryBudget::Clock::now()); transportIntent.retry = intent.retry; transportIntent.unpause = ResumeState::Unpause::SameURL; transportIntent.pending = !intent.retry.exhausted(); transportIntent.deferred = true; printf("PlayStream(same URL): attempt %u/3 failed; %s\n", intent.retry.count(), transportIntent.pending ? "retry in 1 s" : "giving up until next stream/command"); } } } } } extern "C" void yeney_transport(char command) { if (command == 's' || command == 'p' || command == 'u') { std::lock_guard lock(stopMutex); bool pending = deferredStop.cancel(); if (pending && command == 's') printf("strm q: superseded by strm s, no Pause\n"); } const bool reclaim = (command == 's' || command == 'u') && speakerRelinquished.exchange(false); if (reclaim) { speakerReclaimPending.store(true); printf("reclaimed by LMS strm %c\n", command); std::lock_guard lock(resumeMutex); resumeState = ResumeState{}; } ResumeState::Unpause unpause; bool responseOpen; { std::lock_guard lock(resumeMutex); responseOpen = command == 'u' && squeezebox_response_open(streamId.load()); unpause = resumeState.command(command, responseOpen); if (pauseMode() == PauseMode::Stop && responseOpen && unpause == ResumeState::Unpause::SameURL) unpause = ResumeState::Unpause::FeedHeldGet; if (reclaim && command == 'u') unpause = ResumeState::Unpause::SameURL; } uint64_t revision = 0; if (command == 'p' || command == 'u' || command == 's' || command == 'q') { std::lock_guard lock(intentMutex); revision = ++transportIntent.revision; transportIntent.command = command; transportIntent.stream = streamId.load(); transportIntent.unpause = unpause; transportIntent.deferred = false; transportIntent.retry = RetryBudget{}; transportIntent.restartPending = stream_just_restarted(); lmsPaused.store(command == 'p' || command == 'q'); transportIntent.pending = (command == 'p' || command == 'u') && unpause != ResumeState::Unpause::NewStream && streamId.load() != 0; } // Heartbeats still pass through ResumeState, but need no decision log. if (command != 't') { const char* decision = "None"; switch (unpause) { case ResumeState::Unpause::NewStream: decision = "NewStream"; break; case ResumeState::Unpause::FeedHeldGet: decision = "FeedHeldGet"; break; case ResumeState::Unpause::SameURL: decision = "SameURL"; break; case ResumeState::Unpause::None: break; } printf("strm %c: decision=%s stream=%u\n", command, decision, streamId.load()); } if (command == 's') { reset_sonos_position(streamId.load()); ++lmsStreamSerial; // release a producer waiting on an obsolete HTTP request return; } if (speakerRelinquished.load()) return; if (command != 'p' && command != 'q' && command != 'u') return; if (!ourStreamStarted.load() && (streamId.load() == 0 || command == 'q')) { printf("strm %c: transport ignored before first bridge stream\n", command); return; } if (unpause == ResumeState::Unpause::NewStream) { printf("strm u: new stream pending from strm s, no same-URL resume\n"); return; // decoded track boundary allocates the ID and calls PlaySqueezeBox } if (command == 'q') { flush_squeezebox_response(); std::lock_guard lock(stopMutex); deferredStop.schedule(StopDebounce::Clock::now()); printf("strm q: deferring %s for 400 ms\n", pauseMode() == PauseMode::Stop ? "Stop" : "Pause"); return; // receive the next strm s without waiting on UPnP } { std::lock_guard lock(intentMutex); if (transportIntent.revision == revision && transportIntent.pending) { transportIntent.deferred = true; printf("strm %c: transport deferred until restart/transport ready\n", command); } } } // Independent of the main loop's network/status queries and of slimproto input. // Serialize the eventual command with PlayStream, and recheck cancellation only // after acquiring that lock so a q/s burst cannot pause a freshly installed URI. static void dispatchDeferredStop() { if (gQueueRoom && gQueueRoom->queueActive()) return; { std::lock_guard lock(stopMutex); if (!deferredStop.isDue(StopDebounce::Clock::now())) return; } std::unique_lock transport(transportMutex, std::try_to_lock); if (!transport.owns_lock()) return; { std::lock_guard lock(stopMutex); if (!deferredStop.takeDue(StopDebounce::Clock::now())) return; } if (speakerRelinquished.load() || !ourStreamStarted.load() || stream_just_restarted() || !lmsPaused.load()) return; const bool stopForPause = pauseMode() == PauseMode::Stop; if (stopForPause) { std::lock_guard lock(resumeMutex); resumeState.stopForPause(streamId.load()); } printf(stopForPause ? "strm q -> UPnP Stop (400 ms elapsed, pause=stop)\n" : "strm q -> UPnP Pause (400 ms elapsed)\n"); if (!(stopForPause ? gPlayer->stop() : gPlayer->pause())) printf("strm q: device transport command failed\n"); else if (stopForPause) { std::lock_guard lock(resumeMutex); resumeState.completedQStop(streamId.load()); } flush_squeezebox_response(); } // Queue-room class boundary only; cancel the outgoing continuous session. extern "C" void yeney_queue_stream_handoff() { std::lock_guard transport(transportMutex); { std::lock_guard lock(stopMutex); deferredStop.cancel(); } { std::lock_guard lock(intentMutex); transportIntent.pending = false; } end_squeezebox_response(); ourStreamStarted = false; completedStream = streamId.load(); } static void checkGetPairConfirmation() { std::lock_guard lock(resumeMutex); if (resumeState.expireGetPairConfirmation()) printf("Device-initiated resume: GET pair not confirmed\n"); } class StopTimer { public: StopTimer() : worker([this] { while (running.load()) { dispatchTransportIntent(); dispatchDeferredStop(); checkGetPairConfirmation(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } }) {} ~StopTimer() { running.store(false); worker.join(); } private: std::atomic running{true}; std::thread worker; }; static std::atomic_bool streamTrackChanged{false}, streamTopologyPending{false}, streamMemberBlocked{false}; extern "C" void yeney_stream_new_track() { streamTopologyPending = true; } extern "C" void yeney_stream_track_changed() { streamTrackChanged = true; } struct TrackInfo { std::string id; std::string title; std::string artist; std::string album; std::string artworkUrl; std::string duration; bool remote = false; }; static std::string lmsUrldecode(const std::string& s) { std::string result; for (size_t i = 0; i < s.size(); ) { if (s[i] == '%' && i + 2 < s.size()) { char hex[3] = { s[i + 1], s[i + 2], '\0' }; result += (char)strtol(hex, nullptr, 16); i += 3; } else if (s[i] == '+') { result += ' '; ++i; } else { result += s[i++]; } } return result; } // Query LMS CLI (port 9090) for current track metadata of player identified by mac. // Returns empty fields on any error so the caller can gracefully fall back. static TrackInfo fetchLmsTrackInfo(const std::string& server, const uint8_t* mac, bool current = false) { TrackInfo info; if (server.empty()) return info; // Strip optional :port suffix to get the bare hostname / IP std::string host = server; size_t col = host.rfind(':'); if (col != std::string::npos) { std::string tail = host.substr(col + 1); bool allDigits = !tail.empty(); for (char c : tail) allDigits = allDigits && isdigit((unsigned char)c); if (allDigits) host = host.substr(0, col); } struct addrinfo hints = {}, *ai = nullptr; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; if (getaddrinfo(host.c_str(), "9090", &hints, &ai) != 0) return info; int fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); if (fd < 0) { freeaddrinfo(ai); return info; } struct timeval tv { 2, 0 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); bool ok = (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0); freeaddrinfo(ai); if (!ok) { close(fd); return info; } // LMS CLI expects the raw MAC with literal colons (xx:xx:xx:xx:xx:xx) char macFmt[18]; snprintf(macFmt, sizeof(macFmt), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); // Include artwork and remote/source fields; title and id are always returned std::string position = "-"; if (!current && gQueueRoom && !gQueueRoom->queueActive() && gQueueRoom->streamMetadataIndex() >= 0) position = std::to_string(gQueueRoom->streamMetadataIndex()); std::string query = std::string(macFmt) + " status " + position + " 1 tags:aAlKuj\n"; send(fd, query.c_str(), query.size(), 0); std::string response; char ch; while (response.size() < 8192 && recv(fd, &ch, 1, 0) == 1 && ch != '\n') response += ch; close(fd); if (!current) printf("LMS response: %s\n", response.c_str()); // Each space-separated token is URL-encoded "key:value" std::string playlistTimestamp, playlistIndex, time, duration, mode; std::string artwork, remote, sourceUrl; std::istringstream ss(response); std::string token; while (ss >> token) { std::string decoded = lmsUrldecode(token); size_t sep = decoded.find(':'); if (sep == std::string::npos) continue; std::string key = decoded.substr(0, sep); std::string val = decoded.substr(sep + 1); if (key == "title") info.title = val; else if (key == "artist") info.artist = val; else if (key == "album") info.album = val; else if (key == "id") info.id = val; else if (key == "artwork_url") artwork = val; else if (key == "remote") remote = val; else if (key == "url") sourceUrl = val; else if (key == "playlist_timestamp") playlistTimestamp = val; else if (key == "playlist_cur_index") playlistIndex = val; else if (key == "time") time = val; else if (key == "duration") duration = val; else if (key == "mode") mode = val; } if (!current) printf("LMS identity: id=%s playlist_timestamp=%s index=%s time=%s duration=%s mode=%s\n", info.id.c_str(), playlistTimestamp.c_str(), playlistIndex.c_str(), time.c_str(), duration.c_str(), mode.c_str()); info.duration = duration; info.remote = bridge::remoteArtworkTrack(info.id, remote, sourceUrl); info.artworkUrl = bridge::lmsArtwork(host, info.id, artwork, info.remote); return info; } // Relay device transport intent using the raw player MAC. static bool sendLmsCommand(const std::string& server, const uint8_t* mac, const char* command) { if (server.empty()) { printf("LMS CLI: no server configured\n"); return false; } std::string host = server; size_t col = host.rfind(':'); if (col != std::string::npos) { std::string tail = host.substr(col + 1); bool allDigits = !tail.empty(); for (char c : tail) allDigits = allDigits && isdigit((unsigned char)c); if (allDigits) host = host.substr(0, col); } struct addrinfo hints = {}, *ai = nullptr; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; if (getaddrinfo(host.c_str(), "9090", &hints, &ai) != 0) { printf("LMS CLI: cannot resolve server\n"); return false; } int fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); if (fd < 0) { freeaddrinfo(ai); return false; } struct timeval tv { 2, 0 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); bool ok = (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0); freeaddrinfo(ai); if (!ok) { printf("LMS CLI: connection failed\n"); close(fd); return false; } char macFmt[18]; snprintf(macFmt, sizeof(macFmt), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); std::string cmd = std::string(macFmt) + " " + command + "\n"; ok = send(fd, cmd.c_str(), cmd.size(), MSG_NOSIGNAL) == (ssize_t)cmd.size(); if (ok) printf("LMS CLI: %s", cmd.c_str()); else printf("LMS CLI: failed to send %s\n", command); close(fd); return ok; } // Runs yeney-core on a dedicated thread; returns when the // LMS connection is torn down (e.g. process shutdown). static void runPlayerClient(const char* server, std::string playerName) { if (gQueueRoom) bridge::runQueueCoreClient(server, gMac, playerName.c_str(), *gQueueRoom); else runCoreClient(server, gMac, playerName.c_str()); printf("player client thread stopped\n"); } // Shared by SetAVTransportURI and HTTP redirects, including resource parameters. std::string SqueezeBoxURL(unsigned stream_id) { const auto res = gStreamServer->resource(SBSTREAMER_CNAME); if (res.uri.empty()) return ""; return gPlayer->controllerUri() + res.uri + (res.uri.find('?') == std::string::npos ? "?" : "&") + "session=" + streamSessionToken() + "&stream=" + std::to_string(stream_id); } // Resolve unknown ownership once per observation; status polling has already // populated this cache in the usual event and periodic refresh paths. static bool stopLmsForMemberSource() { const auto live = gPlayer->speaker(); if (live.coordinator == live.name) return false; if (gQueueRoom) { gQueueRoom->refusePlayback(true); return true; } char mac[18]; snprintf(mac,sizeof mac,"%02x:%02x:%02x:%02x:%02x:%02x",gMac[0],gMac[1],gMac[2],gMac[3],gMac[4],gMac[5]); bridge::roomLmsCommand(gServer.substr(0,gServer.find(':')),mac,"stop","speaker-source-change", [](const auto &line) { printf("yeney: queue %s\n",line.c_str()); }); return true; } static bool ObserveSpeakerOwnership() { auto info = gPlayer->transportInfo(); std::string uri = info.uri; if (!info.uriKnown && !gPlayer->currentUri(uri)) return false; const auto description = speakerUriDescription(uri, streamSessionToken()); const bool ours = description.compare(0, 7, "stream=") == 0; bool takeover = false; { std::lock_guard lock(sourceMutex); const std::string source = ours ? "ours" : description; if (source != lastSpeakerSource) { printf("speaker source: %s → %s\n", lastSpeakerSource.empty() ? "unknown" : lastSpeakerSource.c_str(), source.c_str()); lastSpeakerSource = source; } if (!ours && ourStreamStarted.load() && !speakerReclaimPending.load() && !speakerRelinquished.exchange(true)) takeover = true; } if (takeover) { printf("Foreign source took over (%s): LMS pause, no resume\n", description.substr(6).c_str()); printf("relinquished\n"); { std::lock_guard lock(resumeMutex); resumeState = ResumeState{}; lmsPaused.store(true); } end_squeezebox_response(); if (!stopLmsForMemberSource()) sendLmsCommand(gServer, gMac, "pause 1"); } return ours && !speakerRelinquished.load(); } static void ObserveDeviceTransport(const std::string& state, bool ownershipChecked = false) { if (!ownershipChecked && !ObserveSpeakerOwnership()) return; bool relay, expectedClose; static std::string previousState; { std::lock_guard lock(resumeMutex); if (resumeState.expireResume()) printf("Device-initiated resume lease expired: 5 s without strm u\n"); expectedClose = state != previousState && (state == "STOPPED" || state == "PAUSED_PLAYBACK"); previousState = state; relay = resumeState.observe(state); } if (expectedClose) note_squeezebox_device_close(); if (relay) { printf("Device-initiated pause -> LMS pause\n"); // The device has already paused: promptly deliver EOF even if LMS CLI // is slow or unavailable. Its later strm p repeats this idempotently. end_squeezebox_response(); sendLmsCommand(gServer, gMac, "pause 1"); } } // Called while a current-ID GET waits for PCM. A probe/reconnect is not itself // a resume: require an observed pause (PAUSED or Stop-for-pause STOPPED) // followed by PLAYING/TRANSITIONING, and an LMS pause or completed q-Stop without a subsequent u/s/q. // Poll cached device state because GET and event callbacks // can arrive in either order. Do not hold transportMutex across LMS CLI I/O: // the resulting strm u feeds the held GET without another device command. void ResumeSqueezeBox(unsigned requested) { if (gQueueRoom && gQueueRoom->queueActive()) return; if (!ourStreamStarted.load() || stream_just_restarted()) return; if (!ObserveSpeakerOwnership()) return; ObserveDeviceTransport(gPlayer->transportInfo().state, true); bool resume; { std::lock_guard lock(resumeMutex); resume = !speakerRelinquished.load() && resumeState.takeResume(requested, streamId.load()); // Publish the HTTP hold before CLI I/O: LMS can reply with q/s before // sendLmsCommand returns. Status and GET callbacks share this decision. if (requested == streamId.load() && resumeState.stopResumeRequested()) hold_squeezebox_resume(requested); } if (resume && !speakerRelinquished.load()) { printf("Device-initiated resume: current stream %u\n", requested); if (!sendLmsCommand(gServer, gMac, "play")) { printf("Device-initiated resume: LMS play failed; retaining 5 s lease\n"); } } } // Called outside the stream ownership lock, in the same backend-independent // resume path used by transport events. Never wait for SOAP to resolve a pair. void ResumeSqueezeBoxGetPair(unsigned stream, unsigned long long active, unsigned long long standby, ResumeState::Clock::duration separation) { if (!ourStreamStarted.load() || stream_just_restarted() || stream != streamId.load()) return; if (!ObserveSpeakerOwnership()) return; bool accepted; { std::lock_guard lock(resumeMutex); accepted = resumeState.observeGetPair(stream, separation); } if (!accepted) return; printf("Device-initiated resume: GET pair #%llu/#%llu dt=%.1f ms\n", active, standby, std::chrono::duration(separation).count()); ResumeSqueezeBox(stream); } static bool alreadyPlayingCurrentStream(unsigned stream) { upnp::TransportInfo info; std::string uri; const auto expected = SqueezeBoxURL(stream); if (expected.empty() || !gPlayer->readTransportInfo(info) || (info.state != "PLAYING" && info.state != "TRANSITIONING") || !gPlayer->currentUri(uri) || uri != expected || stream != streamId.load()) return false; speakerReclaimPending.store(false); ourStreamStarted.store(true); completedStream.store(stream); acknowledge_squeezebox_resume(stream); printf("PlayStream(stream %u): device already playing current stream; no retry\n", stream); return true; } static bool permitRoomPlayback(unsigned stream_id) { auto own = std::dynamic_pointer_cast(gPlayer); if (own && (!own->refreshTopology() || own->speaker().coordinator != own->speaker().name)) { const auto live = own->speaker(); char mac[18]; snprintf(mac, sizeof mac, "%02x:%02x:%02x:%02x:%02x:%02x", gMac[0],gMac[1],gMac[2],gMac[3],gMac[4],gMac[5]); const bool member = live.coordinator != live.name; if (gQueueRoom) { gQueueRoom->refusePlayback(member); completedStream = stream_id; return false; } if (member) printf("yeney: room is group member room=%s coordinator=%s; not playing\n", live.name.c_str(),live.coordinator.c_str()); bridge::roomLmsCommand(gServer.substr(0,gServer.find(':')),mac,"stop",member ? "group-member" : "room-fault", [](const auto &line) { printf("yeney: queue %s\n",line.c_str()); }); completedStream = stream_id; return false; // The stop is final, not a failed start to retry. } return true; } static void displayLmsTrack(TrackInfo track) { upnp::sanitizeXmlFields({{"title", &track.title}, {"artist", &track.artist}, {"album", &track.album}}); unsigned seconds = 0; try { seconds = unsigned(std::stod(track.duration)); } catch (...) {} char duration[32]; snprintf(duration,sizeof duration,"%u:%02u:%02u",seconds/3600,seconds/60%60,seconds%60); bridge::displayStreamTrack(upnp::formatTitle(track.title,track.artist),track.artist,track.album,duration); } static bool PlaySqueezeBoxLocked(unsigned stream_id, bool resetPosition) { if (speakerRelinquished.load() || stream_id != streamId.load()) return false; if (!permitRoomPlayback(stream_id)) return true; std::string streamURL = SqueezeBoxURL(stream_id); bool ok = false; if (!streamURL.empty()) { const auto res = gStreamServer->resource(SBSTREAMER_CNAME); TrackInfo track = fetchLmsTrackInfo(gServer, gMac); displayLmsTrack(track); std::string title = track.title.empty() ? "Squeezebox" : track.title; std::string artUrl = track.artworkUrl.empty() && !track.remote ? gPlayer->controllerUri() + res.iconUri : track.artworkUrl; const auto changed = upnp::sanitizeXmlFields({{"title", &title}, {"artist", &track.artist}, {"album", &track.album}, {"artwork", &artUrl}}); if (!changed.empty()) printf("metadata sanitized mode=stream stream=%u %s\n", stream_id, changed.c_str()); printf("PlaySqueezeBox: title='%s' art='%s'\n", upnp::formatTitle(title, track.artist).c_str(), artUrl.c_str()); if (resetPosition) reset_sonos_position(stream_id); ok = gPlayer->playStream(streamURL, title, artUrl, track.artist, track.album); if (ok) { speakerReclaimPending.store(false); ourStreamStarted.store(true); acknowledge_squeezebox_resume(stream_id); } } if (ok && stream_id == streamId.load()) completedStream.store(stream_id); if (!ok) printf("PlaySqueezeBox: stream %u failed\n", stream_id); return ok; } static void dispatchStreamStart() { std::unique_lock transport(transportMutex, std::try_to_lock); if (speakerRelinquished.load() || streamMemberBlocked.load() || !transport.owns_lock()) return; const unsigned current = streamId.load(); if (!current || completedStream.load() == current) return; uint64_t revision; { std::lock_guard lock(intentMutex); revision = transportIntent.revision; } const bool newStream = current != retryStream; if (newStream || revision != retryRevision) { streamStartRetry = RetryBudget{}; retryStream = current; retryRevision = revision; } if (!streamStartRetry.ready(RetryBudget::Clock::now())) return; if (streamStartRetry.count() && alreadyPlayingCurrentStream(current)) { streamStartRetry.succeeded(); return; } streamStartRetry.begin(); if (PlaySqueezeBoxLocked(current, newStream)) { streamStartRetry.succeeded(); } else { streamStartRetry.failed(RetryBudget::Clock::now()); printf("PlayStream(stream %u): attempt %u/3 failed; %s\n", current, streamStartRetry.count(), streamStartRetry.exhausted() ? "giving up until next stream/command" : "retry in 1 s"); } } namespace { // Poll the playback position once per loop; successful reads are cached for // one second, retaining the device-tested polling interval. void pollSonosPosition() { auto token = sonos_position_poll_token(); uint32_t ms; if (!gPlayer->positionInfo(ms)) return; // A zero RelTime can seed a rate-change start only after PLAYING. During // TRANSITIONING it describes preparation, rather than audible playback. if (!ms && gPlayer->transportInfo().state != "PLAYING") return; set_sonos_position_ms(token, ms); } // Runs the periodic (every ~30s, or immediately on a Sonos event) status // refresh: transport-state mirroring, error-state resume, and the printed // status table when anything actually changed. void refreshStatus(bridge::Status& status) { status.update(); std::string transportState = status.getTransportState(); if (gQueueRoom && gQueueRoom->queueActive()) { if (status.changed()) status.print(); return; } if (ourStreamStarted.load() && !stream_just_restarted()) { ObserveDeviceTransport(transportState); // Without a live GET, relay the observed device resume here. With // a live GET, its HTTP worker owns the same exclusive resume decision. if (!squeezebox_response_open(streamId.load())) ResumeSqueezeBox(streamId.load()); } if (status.changed()) status.print(); } void runBridgeLoop(bridge::Status& status) { unsigned idleTicks = 0; constexpr unsigned kStatusRefreshTicks = 3000; // ~30s at the 10ms sleep below status.update(); auto nextPoll = std::chrono::steady_clock::now() + std::chrono::milliseconds(gPlayer->pollIntervalMs()); for (;;) { bridge::streamMetrics().tick(); if (streamTopologyPending.exchange(false) && !gQueueRoom) { auto own = std::dynamic_pointer_cast(gPlayer); const bool known = own && own->refreshTopology(); const auto live = gPlayer->speaker(); if (!known || live.coordinator != live.name) { if (!streamMemberBlocked.exchange(true)) { char mac[18]; snprintf(mac,sizeof mac,"%02x:%02x:%02x:%02x:%02x:%02x",gMac[0],gMac[1],gMac[2],gMac[3],gMac[4],gMac[5]); if (known) printf("yeney: room is group member room=%s coordinator=%s; not playing\n",live.name.c_str(),live.coordinator.c_str()); bridge::roomLmsCommand(gServer.substr(0,gServer.find(':')),mac,"stop",known ? "group-member" : "room-fault", [](const auto &line) { printf("yeney: queue %s\n",line.c_str()); }); } } else streamMemberBlocked = false; } if (streamTrackChanged.exchange(false) && (!gQueueRoom || !gQueueRoom->queueActive())) { displayLmsTrack(fetchLmsTrackInfo(gServer, gMac, true)); gEvent = true; } if (gQueueRoom && gQueueRoom->queueActive()) { auto &display = bridge::streamDisplay(); std::lock_guard lock(display.mutex); display.active = false; } if (!gQueueRoom || !gQueueRoom->queueActive()) { dispatchStreamStart(); pollSonosPosition(); } const bool eventPending = gEvent.exchange(false); const auto pollInterval = gPlayer->pollIntervalMs(); const bool pollDue = pollInterval && std::chrono::steady_clock::now() >= nextPoll; if (idleTicks >= kStatusRefreshTicks || eventPending || pollDue) { nextPoll = std::chrono::steady_clock::now() + std::chrono::milliseconds(pollInterval); refreshStatus(status); idleTicks = 0; } else { ++idleTicks; } usleep(10000); // 10ms } } } // namespace // Slim discovery replies echo 'E', followed by four-byte tag / byte length / value. static bool parseDiscoveryResponse(const char* data, size_t len, std::string& outName) { outName.clear(); if (!data || len < 1 || data[0] != 'E') return false; std::string name; size_t pos = 1; while (pos < len) { if (len - pos < 5) return false; size_t length = static_cast(data[pos + 4]); if (length > len - pos - 5) return false; if (memcmp(data + pos, "NAME", 4) == 0) name.assign(data + pos + 5, length); pos += 5 + length; } outName = name; return true; } static std::string discoverLmsServer(unsigned timeoutMs = 3000, bool quiet = false) { if (!timeoutMs) return ""; // a zero SO_RCVTIMEO would wait indefinitely int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) return ""; int broadcast = 1; timeval tv{static_cast(timeoutMs / 1000), static_cast((timeoutMs % 1000) * 1000)}; if (setsockopt(fd, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast)) < 0 || setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) { close(fd); return ""; } sockaddr_in destination{}; destination.sin_family = AF_INET; destination.sin_port = htons(3483); destination.sin_addr.s_addr = INADDR_BROADCAST; const char request[] = "eNAME\0JSON\0UUID\0VERS\0"; if (sendto(fd, request, sizeof(request) - 1, 0, reinterpret_cast(&destination), sizeof(destination)) != static_cast(sizeof(request) - 1)) { close(fd); return ""; } char data[65536]; sockaddr_in sender{}; socklen_t senderSize = sizeof(sender); ssize_t size = recvfrom(fd, data, sizeof(data), 0, reinterpret_cast(&sender), &senderSize); close(fd); std::string name; if (size < 0 || sender.sin_family != AF_INET || !parseDiscoveryResponse(data, static_cast(size), name)) return ""; std::string host = inet_ntoa(sender.sin_addr); if (!quiet) printf("LMS server from UDP discovery: %s%s%s\n", host.c_str(), name.empty() ? "" : " name=", name.c_str()); return host; } static std::string readLmsServerFromConfig(const char* path = "/etc/yeney/config") { std::ifstream config(path); std::string line; const char* whitespace = " \t\r\n\f\v"; while (std::getline(config, line)) { size_t start = line.find_first_not_of(whitespace); if (start == std::string::npos || line[start] == '#') continue; if (line.compare(start, 11, "LMS_SERVER=") != 0) continue; start = line.find_first_not_of(whitespace, start + 11); if (start == std::string::npos) return ""; return line.substr(start, line.find_last_not_of(whitespace) - start + 1); } return ""; } static int findServerCommand() { const auto host = discoverLmsServer(3000, true); if (host.empty()) return 2; printf("%s\n", host.c_str()); return 0; } // Keep stdout machine-readable even when the selected backend logs discovery. // Complete backend destruction while diagnostics are still redirected. static int listRoomsCommand(const std::string& ip, bool details) { fflush(stdout); const int outputFd = dup(STDOUT_FILENO); if (outputFd < 0 || dup2(STDERR_FILENO, STDOUT_FILENO) < 0) { if (outputFd >= 0) close(outputFd); fprintf(stderr, "Cannot prepare room discovery output\n"); return 2; } std::ostringstream rooms; int result = 2; try { (void)upnp::backend(); upnp::OwnSpeakerControl control([] { return 0u; }); result = upnp::listRooms(control, ip, rooms, std::cerr, details); } catch (const std::exception& error) { fprintf(stderr, "Room discovery failed: %s\n", error.what()); } fflush(stdout); if (dup2(outputFd, STDOUT_FILENO) < 0) { close(outputFd); fprintf(stderr, "Cannot restore room discovery output\n"); return 2; } close(outputFd); if (!result) std::cout << rooms.str(); return result; } int main(int argc, char** argv) { setvbuf(stdout, nullptr, _IOLBF, 0); warnLegacyEnvironment(); bridge::warnDebugSettings(); if (findOption(argc, argv, "--file") || findFlag(argc, argv, "--file")) { fprintf(stderr, "--file is no longer supported\n"); return EXIT_FAILURE; } if (findFlag(argc, argv, "--find-server")) return findServerCommand(); if (findFlag(argc, argv, "--list-rooms")) { const auto ip = findOption(argc, argv, "--ip"); return listRoomsCommand(ip ? ip : "", findFlag(argc, argv, "--details")); } (void)pauseMode(); (void)audioMode(); warnObsoletePlayer(); printf("Player engine: yeney-core\n"); (void)upnp::streamContentMode(); (void)upnp::titleFormat(); (void)upnp::backend(); try { printf("Stream session: %s\n", streamSessionToken().c_str()); } catch (const std::exception& error) { fprintf(stderr, "%s\n", error.what()); return EXIT_FAILURE; } const char* ip = findOption(argc, argv, "--ip"); const char* room = findOption(argc, argv, "--room"); const char* server = findOption(argc, argv, "--server"); printf("\n\n| YeneY -- bridges a Sonos zone player into an LMS session\n\n\n"); configure_squeezebox_close_logging(true); { auto serverBackend = std::make_shared(); gStreamServer = serverBackend; printf("yeney: HTTP port=%u max_connections=16\n", serverBackend->port()); gPlayer = std::make_shared([] { return gStreamServer->port(); }, 1400, [] { const auto id = streamId.load(); return upnp::StreamActivity{bool(squeezebox_response_streaming(id)), bool(squeezebox_request_open(id)), !lmsPaused.load() && !speakerRelinquished.load()}; }, [] { onSonosEvent(nullptr); }, serverBackend); // Core's clean signal path calls exit(), which skips main's // automatic Status (and its shared player reference) destructor. std::atexit([] { auto own = std::dynamic_pointer_cast(gPlayer); if (own) own->shutdownEvents(); auto server = std::dynamic_pointer_cast(gStreamServer); if (server) server->shutdown(); }); } if (!room) { printf("Please specify a room to join with the --room option\n"); return EXIT_FAILURE; } if (!gPlayer->discover(room, ip ? ip : "")) return EXIT_FAILURE; const auto selectedSpeaker = gPlayer->speaker(); bridge::playerName() = bridge::playerNames(selectedSpeaker.name, findOption(argc, argv, "--show-sonos-suffix"), std::getenv("YENEY_CONFIG") ? std::getenv("YENEY_CONFIG") : "/etc/yeney/config"); bridge::streamMetrics().configure(selectedSpeaker.name); bridge::roomVolumeSettings() = bridge::volumeSettings(selectedSpeaker.name, findOption(argc, argv, "--lms-volume"), std::getenv("YENEY_CONFIG") ? std::getenv("YENEY_CONFIG") : "/etc/yeney/config"); bridge::roomPodcastSettings() = bridge::podcastSettings(selectedSpeaker.name, std::getenv("YENEY_CONFIG") ? std::getenv("YENEY_CONFIG") : "/etc/yeney/config"); const auto playback = bridge::selectPlaybackMode(std::getenv("YENEY_PLAYBACK_MODE"), selectedSpeaker); if (playback.selected == bridge::PlaybackMode::Stream) printf("yeney: stream pacing room=%s selected=%s\n", upnp::logValue(selectedSpeaker.name).c_str(), bridge::streamPacingSonos() ? "sonos" : "wall"); if (playback.invalid) printf("yeney: invalid YENEY_PLAYBACK_MODE='%s'; using stream\n", upnp::logValue(playback.requested).c_str()); printf("yeney: playback selection room=%s coordinator=%s requested=%s selected=%s reason=%s\n", upnp::logValue(selectedSpeaker.name).c_str(), upnp::logValue(selectedSpeaker.coordinator).c_str(), upnp::logValue(playback.requested).c_str(), bridge::playbackModeName(playback.selected), playback.reason.c_str()); static bridge::SBStreamer streamer; streamer.registerWith(*gStreamServer); bridge::Status status(gPlayer); status.get_mac(gMac); char identityMac[18]; snprintf(identityMac, sizeof identityMac, "%02x:%02x:%02x:%02x:%02x:%02x", gMac[0],gMac[1],gMac[2],gMac[3],gMac[4],gMac[5]); bridge::playerName().mac = identityMac; printf(" (MAC = %02X:%02X:%02X:%02X:%02X:%02X)\n\n", gMac[0], gMac[1], gMac[2], gMac[3], gMac[4], gMac[5]); if (server) { gServer = server; printf("LMS server from --server: %s\n", gServer.c_str()); } else { gServer = readLmsServerFromConfig(); if (!gServer.empty()) printf("LMS server from /etc/yeney/config: %s\n", gServer.c_str()); else gServer = discoverLmsServer(); } if (gServer.empty()) { printf("No LMS server resolved from --server, config, or UDP discovery. " "Track metadata and Sonos-app pause/play relay are unavailable without a resolved server. " "Core's independent discovery will keep retrying for playback; " "restart with a reachable server to enable metadata and relay.\n"); } if (playback.selected == bridge::PlaybackMode::Queue) { char rawMac[18]; snprintf(rawMac, sizeof rawMac, "%02x:%02x:%02x:%02x:%02x:%02x", gMac[0], gMac[1], gMac[2], gMac[3], gMac[4], gMac[5]); gQueueRoom = new bridge::QueueRoom(selectedSpeaker, gStreamServer, std::dynamic_pointer_cast(gPlayer), playback, gServer, rawMac, gMac, [] { return SqueezeBoxURL(streamId.load()); }); } else { static auto* mode = new bridge::RoomStatus; bridge::ModeStatus initial; initial.requested = playback.requested; initial.active = bridge::playbackModeName(playback.selected); initial.reason = playback.reason; mode->publish(initial); bridge::setRoomStatus(mode); } static std::unique_ptr volume; char volumeMac[18]; snprintf(volumeMac, sizeof volumeMac, "%02x:%02x:%02x:%02x:%02x:%02x", gMac[0], gMac[1], gMac[2], gMac[3], gMac[4], gMac[5]); volume.reset(new bridge::VolumeWorker(std::dynamic_pointer_cast(gPlayer), gServer.substr(0, gServer.find(':')), volumeMac, bridge::roomVolumeSettings())); static StopTimer stopTimer; std::thread playerThread(runPlayerClient, gServer.empty() ? nullptr : gServer.c_str(), bridge::playerName().displayName); runBridgeLoop(status); playerThread.join(); return 0; } namespace { void onSonosEvent(void* handle) { (void)handle; gEvent.store(true); } const char* findFlag(int argc, char** argv, const std::string& flag) { char** end = argv + argc; char** hit = std::find(argv, end, flag); return (hit != end) ? *hit : nullptr; } const char* findOption(int argc, char** argv, const std::string& option) { for (char** it = argv; it != argv + argc; ++it) { if (strncmp(*it, option.c_str(), option.length()) == 0 && (*it)[option.length()] == '=') return &((*it)[option.length() + 1]); } return nullptr; } } // namespace