yeney.cpp
Rama main · réplica de lectura del repositorio original.
#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 <iostream>
#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 <algorithm>
#include <atomic>
#include <arpa/inet.h>
#include <cctype>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <mutex>
#include <memory>
#include <netdb.h>
#include <sstream>
#include <string>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
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<upnp::StreamServer> gStreamServer;
std::shared_ptr<upnp::SpeakerControl> gPlayer;
bridge::QueueRoom* gQueueRoom = nullptr;
uint8_t gMac[6];
std::atomic<bool> 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<unsigned> streamId(0);
static std::atomic<unsigned> lmsStreamSerial(0);
extern "C" unsigned get_lms_stream_serial(void) { return lmsStreamSerial.load(); }
static std::atomic<unsigned> completedStream(0);
static bool stream_just_restarted() { return streamId.load() != completedStream.load(); }
static std::atomic<bool> lmsPaused(false);
static std::atomic<bool> 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<std::mutex> lock(resumeMutex);
resumeState.streamStarted();
}
printf("Creating new stream (%u) for Sonos\n", id);
}
static void dispatchTransportIntent()
{
if (gQueueRoom && gQueueRoom->queueActive()) return;
std::unique_lock<std::mutex> transport(transportMutex, std::try_to_lock);
if (speakerRelinquished.load() || !transport.owns_lock() || stream_just_restarted() || !ourStreamStarted.load() || !gPlayer)
return;
TransportIntent intent;
{
std::lock_guard<std::mutex> 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<std::mutex> stateLock(resumeMutex);
resumeState.stopForPause(streamId.load());
}
ok = pause ? (stopForPause ? gPlayer->stop() : gPlayer->pause()) : gPlayer->play();
auto upnpMs = std::chrono::duration_cast<std::chrono::milliseconds>(
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<std::mutex> 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<std::mutex> 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<std::mutex> lock(resumeMutex);
resumeState = ResumeState{};
}
ResumeState::Unpause unpause;
bool responseOpen;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(stopMutex);
if (!deferredStop.isDue(StopDebounce::Clock::now())) return;
}
std::unique_lock<std::mutex> transport(transportMutex, std::try_to_lock);
if (!transport.owns_lock()) return;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> transport(transportMutex);
{ std::lock_guard<std::mutex> lock(stopMutex); deferredStop.cancel(); }
{ std::lock_guard<std::mutex> lock(intentMutex); transportIntent.pending = false; }
end_squeezebox_response();
ourStreamStarted = false;
completedStream = streamId.load();
}
static void checkGetPairConfirmation()
{
std::lock_guard<std::mutex> 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<bool> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<double, std::milli>(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<upnp::OwnSpeakerControl>(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<std::mutex> 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<std::mutex> 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<upnp::OwnSpeakerControl>(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<std::mutex> 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<unsigned char>(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<time_t>(timeoutMs / 1000),
static_cast<suseconds_t>((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<sockaddr*>(&destination), sizeof(destination))
!= static_cast<ssize_t>(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<sockaddr*>(&sender), &senderSize);
close(fd);
std::string name;
if (size < 0 || sender.sin_family != AF_INET
|| !parseDiscoveryResponse(data, static_cast<size_t>(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<upnp::HttpServer>();
gStreamServer = serverBackend;
printf("yeney: HTTP port=%u max_connections=16\n", serverBackend->port());
gPlayer = std::make_shared<upnp::OwnSpeakerControl>([] { 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<upnp::OwnSpeakerControl>(gPlayer);
if (own) own->shutdownEvents();
auto server = std::dynamic_pointer_cast<upnp::HttpServer>(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<upnp::OwnSpeakerControl>(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<bridge::VolumeWorker> 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<upnp::OwnSpeakerControl>(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