Merge branch 'develop' into feature/android-openssl-compatibility

This commit is contained in:
sajjad sabzkar 2025-04-25 21:36:55 +03:30 committed by GitHub
commit 69a64ef7f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1526 additions and 288 deletions

View file

@ -75,7 +75,7 @@ jobs:
mkdir -p build/doxygen
doxygen 2>&1 | tee build/doxygen.log
WARNINGS=$(cat build/doxygen.log | sort | uniq | grep -e ": warning: " | wc -l)
MAX_ALLOWED=768
MAX_ALLOWED=593
echo "Doxygen finished with $WARNINGS warnings, max allowed: $MAX_ALLOWED"
if [ "$WARNINGS" -gt "$MAX_ALLOWED" ]; then exit $WARNINGS; else exit 0; fi;
@ -129,7 +129,7 @@ jobs:
- name: configure
run: |
cmake -S . -B build \
-DWERROR=ON \
-DWERROR=OFF \
-DBUILD_TESTS=ON \
-D${{ matrix.param }} \
-DBOOST_ROOT=boost_${BOOST_VERSION} \
@ -401,14 +401,15 @@ jobs:
with:
#path: ${VCPKG_INSTALLATION_ROOT}\installed
path: c:\vcpkg\installed
key: ${{ runner.os }}-dependencies
key: ${{ runner.os }}-${{ matrix.compiler }}-${{ matrix.build_type }}-ccache-${{ github.sha }}
restore-keys: ${{ runner.os }}-${{ matrix.compiler }}-${{ matrix.build_type }}-ccache-
- name: dependencies
if: steps.cache-dependencies.outputs.cache-hit != 'true'
run: |
cd c:\vcpkg
git pull
vcpkg.exe update
vcpkg.exe --triplet x64-windows install libflac libvorbis soxr opus boost-asio catch2
vcpkg.exe --triplet x64-windows install libflac libvorbis soxr opus boost-asio boost-beast catch2
- name: configure
run: |
echo vcpkg installation root: ${env:VCPKG_INSTALLATION_ROOT}

View file

@ -169,7 +169,7 @@ jobs:
key: ${{ runner.os }}-dependencies
- name: Get dependenciesenv
if: steps.cache-dependencies.outputs.cache-hit != 'true'
run: vcpkg.exe install libflac libvorbis soxr opus boost-asio --triplet x64-windows
run: vcpkg.exe install libflac libvorbis soxr opus boost-asio boost-beast --triplet x64-windows
- name: configure
run: |
echo vcpkg installation root: $env:VCPKG_INSTALLATION_ROOT

View file

@ -8,7 +8,7 @@ endif()
project(
snapcast
LANGUAGES CXX
VERSION 0.31.0)
VERSION 0.31.100)
set(PROJECT_DESCRIPTION "Multiroom client-server audio player")
set(PROJECT_URL "https://github.com/badaix/snapcast")
@ -69,12 +69,12 @@ else()
endif()
if(ASAN)
add_compile_options(-fsanitize=address -Wno-error=maybe-uninitialized)
add_compile_options(-fsanitize=address)
add_link_options(-fsanitize=address)
endif()
if(TSAN)
add_compile_options(-fsanitize=thread -Wno-error=tsan)
add_compile_options(-fsanitize=thread)
add_link_options(-fsanitize=thread)
endif()

View file

@ -1,5 +1,24 @@
# Snapcast changelog
## Version 0.32.0
### Features
- Client: Add support for (secure-) websockets (Issue #1325, PR #1340)
- Server: Add client authentication (Issue #1334, PR #1340)
- Server: Add control script for Plex (PR #1346)
### Bugfixes
- Fix typos (PR #1333, PR #1341, PR #1345)
### General
- Client: Command line arguments '--host' and '--port' are deprecated
- Update binary_protocol.md (Issue #1339)
_Johannes Pohl <snapcast@badaix.de> Thu, 23 Jan 2025 00:13:37 +0200_
## Version 0.31.0
### Features

View file

@ -62,6 +62,14 @@ elseif(NOT ANDROID)
endif(PULSE_FOUND)
endif(MACOSX)
find_package(OpenSSL)
if(OpenSSL_FOUND)
add_compile_definitions(HAS_OPENSSL)
list(APPEND CLIENT_LIBRARIES OpenSSL::Crypto OpenSSL::SSL)
else()
message(NOTICE "OpenSSL not found, building without SSL support")
endif()
if(ANDROID)
list(APPEND CLIENT_LIBRARIES oboe::oboe)
list(APPEND CLIENT_LIBRARIES boost::boost)

View file

@ -24,19 +24,32 @@
#include "common/str_compat.hpp"
// 3rd party headers
#include <boost/asio/buffer.hpp>
#include <boost/asio/connect.hpp>
#include <boost/asio/read.hpp>
#include <boost/asio/streambuf.hpp>
#include <boost/asio/write.hpp>
#include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/core/stream_traits.hpp>
#include <boost/system/detail/error_code.hpp>
// standard headers
#include <cstdint>
#include <iostream>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
using namespace std;
namespace http = beast::http; // from <boost/beast/http.hpp>
static constexpr auto LOG_TAG = "Connection";
static constexpr const char* WS_CLIENT_NAME = "Snapcast";
PendingRequest::PendingRequest(const boost::asio::strand<boost::asio::any_io_executor>& strand, uint16_t reqId, const MessageHandler<msg::BaseMessage>& handler)
: id_(reqId), timer_(strand), strand_(strand), handler_(handler)
{
@ -93,31 +106,9 @@ bool PendingRequest::operator<(const PendingRequest& other) const
ClientConnection::ClientConnection(boost::asio::io_context& io_context, ClientSettings::Server server)
: strand_(boost::asio::make_strand(io_context.get_executor())), resolver_(strand_), socket_(strand_), reqId_(1), server_(std::move(server))
: strand_(boost::asio::make_strand(io_context.get_executor())), resolver_(strand_), reqId_(1), server_(std::move(server)),
base_msg_size_(base_message_.getSize())
{
base_msg_size_ = base_message_.getSize();
buffer_.resize(base_msg_size_);
}
ClientConnection::~ClientConnection()
{
disconnect();
}
std::string ClientConnection::getMacAddress()
{
std::string mac =
#ifndef WINDOWS
::getMacAddress(socket_.native_handle());
#else
::getMacAddress(socket_.local_endpoint().address().to_string());
#endif
if (mac.empty())
mac = "00:00:00:00:00:00";
LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << socket_.native_handle() << "\n";
return mac;
}
@ -138,8 +129,8 @@ void ClientConnection::connect(const ResultHandler& handler)
for (const auto& iter : iterator)
{
LOG(INFO, LOG_TAG) << "Connecting to " << iter.endpoint() << "\n";
socket_.connect(iter, ec);
LOG(INFO, LOG_TAG) << "Connecting to host: " << iter.endpoint() << ", port: " << server_.port << ", protocol: " << server_.protocol << "\n";
ec = doConnect(iter.endpoint());
if (!ec || (ec == boost::system::errc::interrupted))
{
// We were successful or interrupted, e.g. by sig int
@ -148,9 +139,12 @@ void ClientConnection::connect(const ResultHandler& handler)
}
if (ec)
{
LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n";
disconnect();
}
else
LOG(NOTICE, LOG_TAG) << "Connected to " << socket_.remote_endpoint().address().to_string() << "\n";
LOG(NOTICE, LOG_TAG) << "Connected to " << server_.host << "\n";
handler(ec);
@ -181,38 +175,18 @@ void ClientConnection::connect(const ResultHandler& handler)
}
void ClientConnection::disconnect()
{
LOG(DEBUG, LOG_TAG) << "Disconnecting\n";
if (!socket_.is_open())
{
LOG(DEBUG, LOG_TAG) << "Not connected\n";
return;
}
boost::system::error_code ec;
socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec);
if (ec)
LOG(ERROR, LOG_TAG) << "Error in socket shutdown: " << ec.message() << "\n";
socket_.close(ec);
if (ec)
LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n";
boost::asio::post(strand_, [this]() { pendingRequests_.clear(); });
LOG(DEBUG, LOG_TAG) << "Disconnected\n";
}
void ClientConnection::sendNext()
{
auto& message = messages_.front();
static boost::asio::streambuf streambuf;
std::ostream stream(&streambuf);
std::ostream stream(&streambuf_);
tv t;
message.msg->sent = t;
message.msg->serialize(stream);
auto handler = message.handler;
ResultHandler handler = message.handler;
boost::asio::async_write(socket_, streambuf, [this, handler](boost::system::error_code ec, std::size_t length)
write(streambuf_, [this, handler](boost::system::error_code ec, std::size_t length)
{
streambuf_.consume(length);
if (ec)
LOG(ERROR, LOG_TAG) << "Failed to send message, error: " << ec.message() << "\n";
else
@ -267,7 +241,78 @@ void ClientConnection::sendRequest(const msg::message_ptr& message, const chrono
}
void ClientConnection::getNextMessage(const MessageHandler<msg::BaseMessage>& handler)
void ClientConnection::messageReceived(std::unique_ptr<msg::BaseMessage> message, const MessageHandler<msg::BaseMessage>& handler)
{
for (auto iter = pendingRequests_.begin(); iter != pendingRequests_.end(); ++iter)
{
auto request = *iter;
if (auto req = request.lock())
{
if (req->id() == base_message_.refersTo)
{
req->setValue(std::move(message));
pendingRequests_.erase(iter);
getNextMessage(handler);
return;
}
}
}
if (handler)
handler({}, std::move(message));
}
///////////////////////////////////// TCP /////////////////////////////////////
ClientConnectionTcp::ClientConnectionTcp(boost::asio::io_context& io_context, ClientSettings::Server server)
: ClientConnection(io_context, std::move(server)), socket_(strand_)
{
buffer_.resize(base_msg_size_);
}
ClientConnectionTcp::~ClientConnectionTcp()
{
disconnect();
}
void ClientConnectionTcp::disconnect()
{
LOG(DEBUG, LOG_TAG) << "Disconnecting\n";
if (!socket_.is_open())
{
LOG(DEBUG, LOG_TAG) << "Not connected\n";
return;
}
boost::system::error_code ec;
socket_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec);
if (ec)
LOG(ERROR, LOG_TAG) << "Error in socket shutdown: " << ec.message() << "\n";
socket_.close(ec);
if (ec)
LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n";
boost::asio::post(strand_, [this]() { pendingRequests_.clear(); });
LOG(DEBUG, LOG_TAG) << "Disconnected\n";
}
std::string ClientConnectionTcp::getMacAddress()
{
std::string mac =
#ifndef WINDOWS
::getMacAddress(socket_.native_handle());
#else
::getMacAddress(socket_.local_endpoint().address().to_string());
#endif
if (mac.empty())
mac = "00:00:00:00:00:00";
LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << socket_.native_handle() << "\n";
return mac;
}
void ClientConnectionTcp::getNextMessage(const MessageHandler<msg::BaseMessage>& handler)
{
boost::asio::async_read(socket_, boost::asio::buffer(buffer_, base_msg_size_), [this, handler](boost::system::error_code ec, std::size_t length) mutable
{
@ -316,23 +361,328 @@ void ClientConnection::getNextMessage(const MessageHandler<msg::BaseMessage>& ha
auto response = msg::factory::createMessage(base_message_, buffer_.data());
if (!response)
LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n";
for (auto iter = pendingRequests_.begin(); iter != pendingRequests_.end(); ++iter)
{
auto request = *iter;
if (auto req = request.lock())
{
if (req->id() == base_message_.refersTo)
{
req->setValue(std::move(response));
pendingRequests_.erase(iter);
getNextMessage(handler);
return;
}
}
}
if (handler)
handler(ec, std::move(response));
messageReceived(std::move(response), handler);
});
});
}
boost::system::error_code ClientConnectionTcp::doConnect(boost::asio::ip::basic_endpoint<boost::asio::ip::tcp> endpoint)
{
boost::system::error_code ec;
socket_.connect(endpoint, ec);
return ec;
}
void ClientConnectionTcp::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler)
{
boost::asio::async_write(socket_, buffer, write_handler);
}
///////////////////////////////// Websockets //////////////////////////////////
ClientConnectionWs::ClientConnectionWs(boost::asio::io_context& io_context, ClientSettings::Server server)
: ClientConnection(io_context, std::move(server)), tcp_ws_(strand_)
{
}
ClientConnectionWs::~ClientConnectionWs()
{
disconnect();
}
tcp_websocket& ClientConnectionWs::getWs()
{
// Looks like the websocket must be recreated after disconnect:
// https://github.com/boostorg/beast/issues/2409#issuecomment-1103685782
std::lock_guard lock(ws_mutex_);
if (tcp_ws_.has_value())
return tcp_ws_.value();
tcp_ws_.emplace(strand_);
return tcp_ws_.value();
}
void ClientConnectionWs::disconnect()
{
LOG(DEBUG, LOG_TAG) << "Disconnecting\n";
boost::system::error_code ec;
if (getWs().is_open())
getWs().close(websocket::close_code::normal, ec);
// if (ec)
// LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n";
if (getWs().next_layer().is_open())
{
getWs().next_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec);
getWs().next_layer().close(ec);
}
boost::asio::post(strand_, [this]() { pendingRequests_.clear(); });
tcp_ws_ = std::nullopt;
LOG(DEBUG, LOG_TAG) << "Disconnected\n";
}
std::string ClientConnectionWs::getMacAddress()
{
std::string mac =
#ifndef WINDOWS
::getMacAddress(getWs().next_layer().native_handle());
#else
::getMacAddress(getWs().next_layer().local_endpoint().address().to_string());
#endif
if (mac.empty())
mac = "00:00:00:00:00:00";
LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << getWs().next_layer().native_handle() << "\n";
return mac;
}
void ClientConnectionWs::getNextMessage(const MessageHandler<msg::BaseMessage>& handler)
{
getWs().async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable
{
tv now;
LOG(TRACE, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n";
// This indicates that the session was closed
if (ec == websocket::error::closed)
{
if (handler)
handler(ec, nullptr);
return;
}
if (ec)
{
LOG(ERROR, LOG_TAG) << "ControlSessionWebsocket::on_read_ws error: " << ec.message() << "\n";
if (handler)
handler(ec, nullptr);
return;
}
buffer_.consume(bytes_transferred);
auto* data = static_cast<char*>(buffer_.data().data());
base_message_.deserialize(data);
base_message_.received = now;
auto response = msg::factory::createMessage(base_message_, data + base_msg_size_);
if (!response)
LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n";
else
LOG(TRACE, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id
<< ", refers: " << response->refersTo << "\n";
messageReceived(std::move(response), handler);
});
}
boost::system::error_code ClientConnectionWs::doConnect(boost::asio::ip::basic_endpoint<boost::asio::ip::tcp> endpoint)
{
boost::system::error_code ec;
getWs().binary(true);
getWs().next_layer().connect(endpoint, ec);
if (ec.failed())
return ec;
// Set suggested timeout settings for the websocket
getWs().set_option(websocket::stream_base::timeout::suggested(beast::role_type::client));
// Set a decorator to change the User-Agent of the handshake
getWs().set_option(websocket::stream_base::decorator([](websocket::request_type& req) { req.set(http::field::user_agent, WS_CLIENT_NAME); }));
// Perform the websocket handshake
getWs().handshake(server_.host + ":" + std::to_string(server_.port), "/stream", ec);
return ec;
}
void ClientConnectionWs::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler)
{
getWs().async_write(boost::asio::buffer(buffer.data()), write_handler);
}
/////////////////////////////// SSL Websockets ////////////////////////////////
#ifdef HAS_OPENSSL
ClientConnectionWss::ClientConnectionWss(boost::asio::io_context& io_context, boost::asio::ssl::context& ssl_context, ClientSettings::Server server)
: ClientConnection(io_context, std::move(server)), ssl_context_(ssl_context)
{
getWs();
}
ssl_websocket& ClientConnectionWss::getWs()
{
// Looks like the websocket must be recreated after disconnect:
// https://github.com/boostorg/beast/issues/2409#issuecomment-1103685782
std::lock_guard lock(ws_mutex_);
if (ssl_ws_.has_value())
return ssl_ws_.value();
ssl_ws_.emplace(strand_, ssl_context_);
if (server_.server_certificate.has_value())
{
ssl_ws_->next_layer().set_verify_mode(boost::asio::ssl::verify_peer);
ssl_ws_->next_layer().set_verify_callback([](bool preverified, boost::asio::ssl::verify_context& ctx)
{
// The verify callback can be used to check whether the certificate that is
// being presented is valid for the peer. For example, RFC 2818 describes
// the steps involved in doing this for HTTPS. Consult the OpenSSL
// documentation for more details. Note that the callback is called once
// for each certificate in the certificate chain, starting from the root
// certificate authority.
// In this example we will simply print the certificate's subject name.
char subject_name[256];
X509* cert = X509_STORE_CTX_get_current_cert(ctx.native_handle());
X509_NAME_oneline(X509_get_subject_name(cert), subject_name, 256);
LOG(INFO, LOG_TAG) << "Verifying cert: '" << subject_name << "', pre verified: " << preverified << "\n";
return preverified;
});
}
return ssl_ws_.value();
}
ClientConnectionWss::~ClientConnectionWss()
{
disconnect();
}
void ClientConnectionWss::disconnect()
{
LOG(DEBUG, LOG_TAG) << "Disconnecting\n";
boost::system::error_code ec;
if (getWs().is_open())
getWs().close(websocket::close_code::normal, ec);
// if (ec)
// LOG(ERROR, LOG_TAG) << "Error in socket close: " << ec.message() << "\n";
if (getWs().next_layer().lowest_layer().is_open())
{
getWs().next_layer().lowest_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec);
getWs().next_layer().lowest_layer().close(ec);
}
boost::asio::post(strand_, [this]() { pendingRequests_.clear(); });
ssl_ws_ = std::nullopt;
LOG(DEBUG, LOG_TAG) << "Disconnected\n";
}
std::string ClientConnectionWss::getMacAddress()
{
std::string mac =
#ifndef WINDOWS
::getMacAddress(getWs().next_layer().lowest_layer().native_handle());
#else
::getMacAddress(getWs().next_layer().lowest_layer().local_endpoint().address().to_string());
#endif
if (mac.empty())
mac = "00:00:00:00:00:00";
LOG(INFO, LOG_TAG) << "My MAC: \"" << mac << "\", socket: " << getWs().next_layer().lowest_layer().native_handle() << "\n";
return mac;
}
void ClientConnectionWss::getNextMessage(const MessageHandler<msg::BaseMessage>& handler)
{
getWs().async_read(buffer_, [this, handler](beast::error_code ec, std::size_t bytes_transferred) mutable
{
tv now;
LOG(TRACE, LOG_TAG) << "on_read_ws, ec: " << ec << ", bytes_transferred: " << bytes_transferred << "\n";
// This indicates that the session was closed
if (ec == websocket::error::closed)
{
if (handler)
handler(ec, nullptr);
return;
}
if (ec)
{
LOG(ERROR, LOG_TAG) << "ControlSessionWebsocket::on_read_ws error: " << ec.message() << "\n";
if (handler)
handler(ec, nullptr);
return;
}
buffer_.consume(bytes_transferred);
auto* data = static_cast<char*>(buffer_.data().data());
base_message_.deserialize(data);
base_message_.received = now;
auto response = msg::factory::createMessage(base_message_, data + base_msg_size_);
if (!response)
LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n";
else
LOG(TRACE, LOG_TAG) << "getNextMessage: " << response->type << ", size: " << response->size << ", id: " << response->id
<< ", refers: " << response->refersTo << "\n";
messageReceived(std::move(response), handler);
});
}
boost::system::error_code ClientConnectionWss::doConnect(boost::asio::ip::basic_endpoint<boost::asio::ip::tcp> endpoint)
{
boost::system::error_code ec;
getWs().binary(true);
beast::get_lowest_layer(getWs()).connect(endpoint, ec);
if (ec.failed())
return ec;
// Set a timeout on the operation
// beast::get_lowest_layer(getWs()).expires_after(std::chrono::seconds(30));
// Set suggested timeout settings for the websocket
getWs().set_option(websocket::stream_base::timeout::suggested(beast::role_type::client));
// Set SNI Hostname (many hosts need this to handshake successfully)
if (!SSL_set_tlsext_host_name(getWs().next_layer().native_handle(), server_.host.c_str()))
{
LOG(ERROR, LOG_TAG) << "Failed to set SNI Hostname\n";
return boost::system::error_code(static_cast<int>(::ERR_get_error()), boost::asio::error::get_ssl_category());
}
// Perform the SSL handshake
getWs().next_layer().handshake(boost::asio::ssl::stream_base::client, ec);
if (ec.failed())
return ec;
// Set a decorator to change the User-Agent of the handshake
getWs().set_option(websocket::stream_base::decorator([](websocket::request_type& req) { req.set(http::field::user_agent, WS_CLIENT_NAME); }));
// Perform the websocket handshake
getWs().handshake(server_.host + ":" + std::to_string(server_.port), "/stream", ec);
return ec;
}
void ClientConnectionWss::write(boost::asio::streambuf& buffer, WriteHandler&& write_handler)
{
getWs().async_write(boost::asio::buffer(buffer.data()), write_handler);
}
#endif // HAS_OPENSSL

View file

@ -29,15 +29,30 @@
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/asio/strand.hpp>
#include <boost/asio/streambuf.hpp>
#include <boost/beast/core.hpp>
#ifdef HAS_OPENSSL
#include <boost/beast/ssl.hpp>
#endif
#include <boost/beast/websocket.hpp>
// standard headers
#include <deque>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
using boost::asio::ip::tcp;
// using boost::asio::ip::tcp;
namespace beast = boost::beast; // from <boost/beast.hpp>
namespace websocket = beast::websocket; // from <boost/beast/websocket.hpp>
using tcp_socket = boost::asio::ip::tcp::socket;
using tcp_websocket = websocket::stream<tcp_socket>;
#ifdef HAS_OPENSSL
using ssl_socket = boost::asio::ssl::stream<tcp_socket>;
using ssl_websocket = websocket::stream<ssl_socket>;
#endif
class ClientConnection;
@ -87,17 +102,19 @@ class ClientConnection
public:
/// Result callback with boost::error_code
using ResultHandler = std::function<void(const boost::system::error_code&)>;
/// Result callback of a write operation
using WriteHandler = std::function<void(boost::system::error_code ec, std::size_t length)>;
/// c'tor
ClientConnection(boost::asio::io_context& io_context, ClientSettings::Server server);
/// d'tor
virtual ~ClientConnection();
virtual ~ClientConnection() = default;
/// async connect
/// @param handler async result handler
void connect(const ResultHandler& handler);
/// disconnect the socket
void disconnect();
virtual void disconnect() = 0;
/// async send a message
/// @param message the message
@ -126,35 +143,43 @@ public:
}
/// @return MAC address of the client
std::string getMacAddress();
virtual std::string getMacAddress() = 0;
/// async get the next message
/// @param handler the next received message or error
void getNextMessage(const MessageHandler<msg::BaseMessage>& handler);
virtual void getNextMessage(const MessageHandler<msg::BaseMessage>& handler) = 0;
protected:
virtual void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) = 0;
/// Connect to @p endpoint
virtual boost::system::error_code doConnect(boost::asio::ip::basic_endpoint<boost::asio::ip::tcp> endpoint) = 0;
/// Handle received messages, check for response of pending requests
void messageReceived(std::unique_ptr<msg::BaseMessage> message, const MessageHandler<msg::BaseMessage>& handler);
/// Send next pending message from messages_
void sendNext();
/// Base message holding the received message
msg::BaseMessage base_message_;
/// Receive buffer
std::vector<char> buffer_;
/// Size of a base message (= message header)
size_t base_msg_size_;
/// Strand to serialize send/receive
boost::asio::strand<boost::asio::any_io_executor> strand_;
/// TCP resolver
tcp::resolver resolver_;
/// TCP socket
tcp::socket socket_;
boost::asio::ip::tcp::resolver resolver_;
/// List of pending requests, waiting for a response (Message::refersTo)
std::vector<std::weak_ptr<PendingRequest>> pendingRequests_;
/// unique request id to match a response
uint16_t reqId_;
/// Server settings (host and port)
ClientSettings::Server server_;
/// Size of a base message (= message header)
const size_t base_msg_size_;
/// Send stream buffer
boost::asio::streambuf streambuf_;
/// A pending request
struct PendingMessage
@ -172,3 +197,91 @@ protected:
/// Pending messages to be sent
std::deque<PendingMessage> messages_;
};
/// Plain TCP connection
class ClientConnectionTcp : public ClientConnection
{
public:
/// c'tor
ClientConnectionTcp(boost::asio::io_context& io_context, ClientSettings::Server server);
/// d'tor
virtual ~ClientConnectionTcp();
void disconnect() override;
std::string getMacAddress() override;
void getNextMessage(const MessageHandler<msg::BaseMessage>& handler) override;
private:
boost::system::error_code doConnect(boost::asio::ip::basic_endpoint<boost::asio::ip::tcp> endpoint) override;
void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override;
/// TCP socket
tcp_socket socket_;
/// Receive buffer
std::vector<char> buffer_;
};
/// Websocket connection
class ClientConnectionWs : public ClientConnection
{
public:
/// c'tor
ClientConnectionWs(boost::asio::io_context& io_context, ClientSettings::Server server);
/// d'tor
virtual ~ClientConnectionWs();
void disconnect() override;
std::string getMacAddress() override;
void getNextMessage(const MessageHandler<msg::BaseMessage>& handler) override;
private:
boost::system::error_code doConnect(boost::asio::ip::basic_endpoint<boost::asio::ip::tcp> endpoint) override;
void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override;
/// @return the websocket
tcp_websocket& getWs();
/// TCP web socket
std::optional<tcp_websocket> tcp_ws_;
/// Receive buffer
boost::beast::flat_buffer buffer_;
/// protect tcp_ws_
std::mutex ws_mutex_;
};
#ifdef HAS_OPENSSL
/// Websocket connection
class ClientConnectionWss : public ClientConnection
{
public:
/// c'tor
ClientConnectionWss(boost::asio::io_context& io_context, boost::asio::ssl::context& ssl_context, ClientSettings::Server server);
/// d'tor
virtual ~ClientConnectionWss();
void disconnect() override;
std::string getMacAddress() override;
void getNextMessage(const MessageHandler<msg::BaseMessage>& handler) override;
private:
boost::system::error_code doConnect(boost::asio::ip::basic_endpoint<boost::asio::ip::tcp> endpoint) override;
void write(boost::asio::streambuf& buffer, WriteHandler&& write_handler) override;
/// @return the websocket
ssl_websocket& getWs();
/// SSL context
boost::asio::ssl::context& ssl_context_;
/// SSL web socket
std::optional<ssl_websocket> ssl_ws_;
/// Receive buffer
boost::beast::flat_buffer buffer_;
/// protect ssl_ws_
std::mutex ws_mutex_;
};
#endif // HAS_OPENSSL

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2024 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -23,60 +23,101 @@
#include "player/pcm_device.hpp"
// standard headers
#include <filesystem>
#include <optional>
#include <string>
/// Snapclient settings
struct ClientSettings
{
/// Sharing mode for audio device
enum class SharingMode
{
unspecified,
exclusive,
shared
unspecified, ///< unspecified
exclusive, ///< exclusice access
shared ///< shared access
};
/// Mixer settings
struct Mixer
{
/// Mixer mode
enum class Mode
{
hardware,
software,
script,
none
hardware, ///< hardware mixer
software, ///< software mixer
script, ///< run a mixer script
none ///< no mixer
};
/// the configured mixer mode
Mode mode{Mode::software};
std::string parameter{""};
/// mixer parameter
std::string parameter;
};
/// Server settings
struct Server
{
std::string host{""};
/// server host or IP address
std::string host;
/// protocol: "tcp", "ws" or "wss"
std::string protocol{"tcp"};
/// server port
size_t port{1704};
/// server certificate
std::optional<std::filesystem::path> server_certificate;
/// Certificate file
std::filesystem::path certificate;
/// Private key file
std::filesystem::path certificate_key;
/// Password for encrypted key file
std::string key_password;
/// Is ssl in use?
bool isSsl() const
{
return (protocol == "wss");
}
};
/// The audio player (DAC)
struct Player
{
std::string player_name{""};
std::string parameter{""};
/// name of the player
std::string player_name;
/// player parameters
std::string parameter;
/// additional latency of the DAC [ms]
int latency{0};
/// the DAC
player::PcmDevice pcm_device;
/// Sampleformat to be uses, i.e. 48000:16:2
SampleFormat sample_format;
/// The sharing mode
SharingMode sharing_mode{SharingMode::unspecified};
/// Mixer settings
Mixer mixer;
};
/// Log settings
struct Logging
{
std::string sink{""};
/// The log sink (null,system,stdout,stderr,file:<filename>)
std::string sink;
/// Log filter
std::string filter{"*:info"};
};
/// The snapclient process instance
size_t instance{1};
/// The host id, presented to the server
std::string host_id;
/// Server settings
Server server;
/// Player settings
Player player;
/// Logging settings
Logging logging;
};

View file

@ -76,10 +76,53 @@ using namespace player;
static constexpr auto LOG_TAG = "Controller";
static constexpr auto TIME_SYNC_INTERVAL = 1s;
Controller::Controller(boost::asio::io_context& io_context, const ClientSettings& settings) //, std::unique_ptr<MetadataAdapter> meta)
: io_context_(io_context), timer_(io_context), settings_(settings), stream_(nullptr), decoder_(nullptr), player_(nullptr),
serverSettings_(nullptr) // meta_(std::move(meta)),
Controller::Controller(boost::asio::io_context& io_context, const ClientSettings& settings)
: io_context_(io_context),
#ifdef HAS_OPENSSL
ssl_context_(boost::asio::ssl::context::tlsv12_client),
#endif
timer_(io_context), settings_(settings), stream_(nullptr), decoder_(nullptr), player_(nullptr), serverSettings_(nullptr)
{
#ifdef HAS_OPENSSL
if (settings.server.isSsl())
{
boost::system::error_code ec;
if (settings.server.server_certificate.has_value())
{
LOG(DEBUG, LOG_TAG) << "Loading server certificate\n";
ssl_context_.set_default_verify_paths(ec);
if (ec.failed())
LOG(WARNING, LOG_TAG) << "Failed to load system certificates: " << ec << "\n";
if (!settings.server.server_certificate->empty())
{
ssl_context_.load_verify_file(settings.server.server_certificate.value().string(), ec);
if (ec.failed())
throw SnapException("Failed to load server certificate: " + settings.server.server_certificate.value().string() + ": " + ec.message());
}
}
if (!settings.server.certificate.empty() && !settings.server.certificate_key.empty())
{
if (!settings.server.key_password.empty())
{
ssl_context_.set_password_callback(
[pw = settings.server.key_password](size_t max_length, boost::asio::ssl::context_base::password_purpose purpose) -> string
{
LOG(DEBUG, LOG_TAG) << "getPassword, purpose: " << purpose << ", max length: " << max_length << "\n";
return pw;
});
}
LOG(DEBUG, LOG_TAG) << "Loading certificate file: " << settings.server.certificate << "\n";
ssl_context_.use_certificate_chain_file(settings.server.certificate.string(), ec);
if (ec.failed())
throw SnapException("Failed to load certificate: " + settings.server.certificate.string() + ": " + ec.message());
LOG(DEBUG, LOG_TAG) << "Loading certificate key file: " << settings.server.certificate_key << "\n";
ssl_context_.use_private_key_file(settings.server.certificate_key.string(), boost::asio::ssl::context::pem, ec);
if (ec.failed())
throw SnapException("Failed to load private key file: " + settings.server.certificate_key.string() + ": " + ec.message());
}
}
#endif // HAS_OPENSSL
}
@ -354,14 +397,21 @@ void Controller::start()
settings_.server.host = host;
settings_.server.port = port;
LOG(INFO, LOG_TAG) << "Found server " << settings_.server.host << ":" << settings_.server.port << "\n";
clientConnection_ = make_unique<ClientConnection>(io_context_, settings_.server);
clientConnection_ = make_unique<ClientConnectionTcp>(io_context_, settings_.server);
worker();
}
});
}
else
{
clientConnection_ = make_unique<ClientConnection>(io_context_, settings_.server);
if (settings_.server.protocol == "ws")
clientConnection_ = make_unique<ClientConnectionWs>(io_context_, settings_.server);
#ifdef HAS_OPENSSL
else if (settings_.server.protocol == "wss")
clientConnection_ = make_unique<ClientConnectionWss>(io_context_, ssl_context_, settings_.server);
#endif
else
clientConnection_ = make_unique<ClientConnectionTcp>(io_context_, settings_.server);
worker();
}
}

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2024 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -43,9 +43,12 @@ using namespace std::chrono_literals;
class Controller
{
public:
/// c'tor
Controller(boost::asio::io_context& io_context, const ClientSettings& settings); //, std::unique_ptr<MetadataAdapter> meta);
/// Start thw work
void start();
// void stop();
/// @return list of supported audio backends
static std::vector<std::string> getSupportedPlayerNames();
private:
@ -61,6 +64,9 @@ private:
void sendTimeSyncMessage(int quick_syncs);
boost::asio::io_context& io_context_;
#ifdef HAS_OPENSSL
boost::asio::ssl::context ssl_context_;
#endif
boost::asio::steady_timer timer_;
ClientSettings settings_;
SampleFormat sampleFormat_;

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2020 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -16,27 +16,32 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef PCM_DEVICE_HPP
#define PCM_DEVICE_HPP
#pragma once
// standard headers
#include <string>
namespace player
{
/// Name of the default audio device
static constexpr char DEFAULT_DEVICE[] = "default";
/// DAC identifier
struct PcmDevice
{
/// c'tor
PcmDevice() : idx(-1), name(DEFAULT_DEVICE){};
/// c'tor
PcmDevice(int idx, const std::string& name, const std::string& description = "") : idx(idx), name(name), description(description){};
/// index of the DAC (as in "aplay -L")
int idx;
/// device name
std::string name;
/// device description
std::string description;
};
} // namespace player
#endif

View file

@ -18,6 +18,7 @@
// local headers
#include "common/popl.hpp"
#include "common/utils/string_utils.hpp"
#include "controller.hpp"
#ifdef HAS_ALSA
@ -37,6 +38,7 @@
#include "common/aixlog.hpp"
#include "common/snap_exception.hpp"
#include "common/str_compat.hpp"
#include "common/stream_uri.hpp"
#include "common/version.hpp"
// 3rd party headers
@ -44,6 +46,7 @@
#include <boost/asio/signal_set.hpp>
// standard headers
#include <filesystem>
#include <iostream>
#ifndef WINDOWS
#include <csignal>
@ -133,23 +136,38 @@ int main(int argc, char** argv)
ClientSettings settings;
string pcm_device(player::DEFAULT_DEVICE);
OptionParser op("Allowed options");
auto helpSwitch = op.add<Switch>("", "help", "produce help message");
auto groffSwitch = op.add<Switch, Attribute::hidden>("", "groff", "produce groff message");
auto versionSwitch = op.add<Switch>("v", "version", "show version number");
op.add<Value<string>>("h", "host", "server hostname or ip address", "", &settings.server.host);
op.add<Value<size_t>>("p", "port", "server port", 1704, &settings.server.port);
op.add<Value<size_t>>("i", "instance", "instance id when running multiple instances on the same host", 1, &settings.instance);
op.add<Value<string>>("", "hostID", "unique host id, default is MAC address", "", &settings.host_id);
OptionParser op("Usage: snapclient [options...] [url]\n\n"
" With 'url' = "
#ifdef HAS_OPENSSL
"<tcp|ws|wss>"
#else
"<tcp|ws>"
#endif
"://<snapserver host or IP>[:port]\n"
" For example: \"tcp:\\\\192.168.1.1:1704\", or \"ws:\\\\homeserver.local\"\n"
" If 'url' is not configured, snapclient tries to resolve the snapserver IP via mDNS\n");
auto helpSwitch = op.add<Switch>("", "help", "Produce help message");
auto groffSwitch = op.add<Switch, Attribute::hidden>("", "groff", "Produce groff message");
auto versionSwitch = op.add<Switch>("v", "version", "Show version number");
auto host_opt = op.add<Value<string>>("h", "host", "(deprecated, use [url]) Server hostname or ip address", "", &settings.server.host);
auto port_opt = op.add<Value<size_t>>("p", "port", "(deprecated, use [url]) Server port", 1704, &settings.server.port);
op.add<Value<size_t>>("i", "instance", "Instance id when running multiple instances on the same host", 1, &settings.instance);
op.add<Value<string>>("", "hostID", "Unique host id, default is MAC address", "", &settings.host_id);
op.add<Value<std::filesystem::path>>("", "cert", "Client certificate file (PEM format)", settings.server.certificate, &settings.server.certificate);
op.add<Value<std::filesystem::path>>("", "cert-key", "Client private key file (PEM format)", settings.server.certificate_key,
&settings.server.certificate_key);
op.add<Value<string>>("", "key-password", "Key password (for encrypted private key)", settings.server.key_password, &settings.server.key_password);
auto server_cert_opt =
op.add<Implicit<std::filesystem::path>>("", "server-cert", "Verify server with CA certificate (PEM format)", "default certificates");
// PCM device specific
#if defined(HAS_ALSA) || defined(HAS_PULSE) || defined(HAS_WASAPI)
auto listSwitch = op.add<Switch>("l", "list", "list PCM devices");
/*auto soundcardValue =*/op.add<Value<string>>("s", "soundcard", "index or name of the pcm device", pcm_device, &pcm_device);
auto listSwitch = op.add<Switch>("l", "list", "List PCM devices");
/*auto soundcardValue =*/op.add<Value<string>>("s", "Soundcard", "index or name of the pcm device", pcm_device, &pcm_device);
#endif
/*auto latencyValue =*/op.add<Value<int>>("", "latency", "latency of the PCM device", 0, &settings.player.latency);
/*auto latencyValue =*/op.add<Value<int>>("", "Latency", "latency of the PCM device", 0, &settings.player.latency);
#ifdef HAS_SOXR
auto sample_format = op.add<Value<string>>("", "sampleformat", "resample audio stream to <rate>:<bits>:<channels>", "");
auto sample_format = op.add<Value<string>>("", "sampleformat", "Resample audio stream to <rate>:<bits>:<channels>", "");
#endif
auto supported_players = Controller::getSupportedPlayerNames();
@ -160,7 +178,7 @@ int main(int argc, char** argv)
// sharing mode
#if defined(HAS_OBOE) || defined(HAS_WASAPI)
auto sharing_mode = op.add<Value<string>>("", "sharingmode", "audio mode to use [shared|exclusive]", "shared");
auto sharing_mode = op.add<Value<string>>("", "sharingmode", "Audio mode to use [shared|exclusive]", "shared");
#endif
// mixer
@ -181,12 +199,12 @@ int main(int argc, char** argv)
// daemon settings
#ifdef HAS_DAEMON
int processPriority(-3);
auto daemonOption = op.add<Implicit<int>>("d", "daemon", "daemonize, optional process priority [-20..19]", processPriority, &processPriority);
auto daemonOption = op.add<Implicit<int>>("d", "daemon", "Daemonize, optional process priority [-20..19]", processPriority, &processPriority);
auto userValue = op.add<Value<string>>("", "user", "the user[:group] to run snapclient as when daemonized");
#endif
// logging
op.add<Value<string>>("", "logsink", "log sink [null,system,stdout,stderr,file:<filename>]", settings.logging.sink, &settings.logging.sink);
op.add<Value<string>>("", "logsink", "Log sink [null,system,stdout,stderr,file:<filename>]", settings.logging.sink, &settings.logging.sink);
auto logfilterOption = op.add<Value<string>>(
"", "logfilter", "log filter <tag>:<level>[,<tag>:<level>]* with tag = * or <log tag> and level = [trace,debug,info,notice,warning,error,fatal]",
settings.logging.filter);
@ -303,6 +321,82 @@ int main(int argc, char** argv)
else
throw SnapException("Invalid log sink: " + settings.logging.sink);
if (!op.unknown_options().empty())
{
throw SnapException("Unknown command line argument: '" + op.unknown_options().front() + "'");
}
if (host_opt->is_set() || port_opt->is_set())
{
LOG(WARNING, LOG_TAG) << "Options '--" << host_opt->long_name() << "' and '--" << port_opt->long_name()
<< "' are deprecated. Please add the server URI as last command line argument\n";
}
if (!op.non_option_args().empty())
{
StreamUri uri;
try
{
uri.parse(op.non_option_args().front());
}
catch (...)
{
#ifdef HAS_OPENSSL
throw SnapException("Invalid URI - expected format: \"<scheme>://<host or IP>[:port]\", with 'scheme' on of 'tcp', 'ws' or 'wss'");
#else
throw SnapException("Invalid URI - expected format: \"<scheme>://<host or IP>[:port]\", with 'scheme' on of 'tcp' or 'ws'");
#endif
}
if ((uri.scheme != "tcp") && (uri.scheme != "ws") && (uri.scheme != "wss"))
#ifdef HAS_OPENSSL
throw SnapException("Protocol must be one of 'tcp', 'ws' or 'wss'");
#else
throw SnapException("Protocol must be one of 'tcp' or 'ws'");
#endif
settings.server.host = uri.host;
settings.server.protocol = uri.scheme;
if (uri.port.has_value())
settings.server.port = uri.port.value();
else if (settings.server.protocol == "tcp")
settings.server.port = 1704;
else if (settings.server.protocol == "ws")
settings.server.port = 1780;
else if (settings.server.protocol == "wss")
{
settings.server.port = 1788;
#ifndef HAS_OPENSSL
throw SnapException("Snapclient is built without wss support");
#endif
}
}
if (server_cert_opt->is_set())
{
if (server_cert_opt->get_default() == server_cert_opt->value())
settings.server.server_certificate = "";
else
settings.server.server_certificate = std::filesystem::weakly_canonical(server_cert_opt->value());
if (settings.server.server_certificate.value_or("").empty())
LOG(INFO, LOG_TAG) << "Server certificate: default certificates\n";
else
LOG(INFO, LOG_TAG) << "Server certificate: " << settings.server.server_certificate.value_or("") << "\n";
}
if (!settings.server.certificate.empty() && !settings.server.certificate_key.empty())
{
namespace fs = std::filesystem;
settings.server.certificate = fs::weakly_canonical(settings.server.certificate);
if (!fs::exists(settings.server.certificate))
throw SnapException("Certificate file not found: " + settings.server.certificate.string());
settings.server.certificate_key = fs::weakly_canonical(settings.server.certificate_key);
if (!fs::exists(settings.server.certificate_key))
throw SnapException("Certificate_key file not found: " + settings.server.certificate_key.string());
}
else if (settings.server.certificate.empty() != settings.server.certificate_key.empty())
{
throw SnapException("Both SSL 'certificate' and 'certificate_key' must be set or empty");
}
#if !defined(HAS_AVAHI) && !defined(HAS_BONJOUR)
if (settings.server.host.empty())
throw SnapException("Snapserver host not configured and mDNS not available, please configure with \"--host\".");
@ -445,6 +539,7 @@ int main(int argc, char** argv)
int num_threads = 0;
std::vector<std::thread> threads;
threads.reserve(num_threads);
for (int n = 0; n < num_threads; ++n)
threads.emplace_back([&] { io_context.run(); });
io_context.run();

View file

@ -1,12 +1,10 @@
set(SOURCES resampler.cpp sample_format.cpp jwt.cpp base64.cpp
set(SOURCES resampler.cpp sample_format.cpp base64.cpp stream_uri.cpp
utils/string_utils.cpp)
if(NOT WIN32 AND NOT ANDROID)
list(APPEND SOURCES daemon.cpp)
endif()
include_directories(${OPENSSL_INCLUDE_DIR})
if(SOXR_FOUND)
include_directories(${SOXR_INCLUDE_DIRS})
endif(SOXR_FOUND)
@ -24,3 +22,4 @@ if (ANDROID)
else ()
target_link_libraries(common OpenSSL::Crypto OpenSSL::SSL)
endif ()

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2022 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -16,8 +16,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef MESSAGE_CLIENT_INFO_HPP
#define MESSAGE_CLIENT_INFO_HPP
#pragma once
// local headers
#include "json_message.hpp"
@ -27,39 +26,47 @@ namespace msg
{
/// Client information sent from client to server
/// Might also be used for sync stats and latency estimations
/// Might also be used for other things in future, like
/// - sync stats
/// - latency estimations
/// - Battery status
/// - ...
class ClientInfo : public JsonMessage
{
public:
/// c'tor
ClientInfo() : JsonMessage(message_type::kClientInfo)
{
setVolume(100);
setMuted(false);
}
/// d'tor
~ClientInfo() override = default;
/// @return the volume in percent
uint16_t getVolume()
{
return get("volume", static_cast<uint16_t>(100));
}
/// @return if muted or not
bool isMuted()
{
return get("muted", false);
}
/// Set the volume to @p volume percent
void setVolume(uint16_t volume)
{
msg["volume"] = volume;
}
/// Set muted to @p muted
void setMuted(bool muted)
{
msg["muted"] = muted;
}
};
} // namespace msg
#endif

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2022 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -16,8 +16,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef MESSAGE_CODEC_HEADER_HPP
#define MESSAGE_CODEC_HEADER_HPP
#pragma once
// local headers
#include "message.hpp"
@ -31,13 +31,15 @@ namespace msg
class CodecHeader : public BaseMessage
{
public:
explicit CodecHeader(const std::string& codecName = "", uint32_t size = 0)
: BaseMessage(message_type::kCodecHeader), payloadSize(size), payload(nullptr), codec(codecName)
/// c'tor taking the @p codec_name and @p site of the payload
explicit CodecHeader(const std::string& codec_name = "", uint32_t size = 0)
: BaseMessage(message_type::kCodecHeader), payloadSize(size), payload(nullptr), codec(codec_name)
{
if (size > 0)
payload = static_cast<char*>(malloc(size * sizeof(char)));
}
/// d'tor
~CodecHeader() override
{
free(payload);
@ -54,8 +56,11 @@ public:
return static_cast<uint32_t>(sizeof(uint32_t) + codec.size() + sizeof(uint32_t) + payloadSize);
}
/// payload size
uint32_t payloadSize;
/// the payload
char* payload;
/// name of the codec
std::string codec;
protected:
@ -65,7 +70,5 @@ protected:
writeVal(stream, payload, payloadSize);
}
};
} // namespace msg
#endif

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2024 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -30,15 +30,16 @@
namespace msg
{
/// Cast a BaseMessage @message to type "ToType"
/// @return castest message or nullptr, if the cast failed
template <typename ToType>
static std::unique_ptr<ToType> message_cast(std::unique_ptr<msg::BaseMessage> message)
{
ToType* tmp = dynamic_cast<ToType*>(message.get());
std::unique_ptr<ToType> result;
auto* tmp = dynamic_cast<ToType*>(message.get());
if (tmp != nullptr)
{
message.release();
result.reset(tmp);
std::unique_ptr<ToType> result(tmp);
return result;
}
return nullptr;
@ -47,6 +48,7 @@ static std::unique_ptr<ToType> message_cast(std::unique_ptr<msg::BaseMessage> me
namespace factory
{
/// Create a message of type T from @p base_message beaser and payload @p buffer
template <typename T>
static std::unique_ptr<T> createMessage(const BaseMessage& base_message, char* buffer)
{
@ -57,6 +59,7 @@ static std::unique_ptr<T> createMessage(const BaseMessage& base_message, char* b
return result;
}
/// Create a BaseMessage from @p base_message header and payload @p buffer
static std::unique_ptr<BaseMessage> createMessage(const BaseMessage& base_message, char* buffer)
{
std::unique_ptr<BaseMessage> result;

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2022 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -16,8 +16,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef MESSAGE_HELLO_HPP
#define MESSAGE_HELLO_HPP
#pragma once
// local headers
#include "common/str_compat.hpp"
@ -31,16 +30,20 @@
namespace msg
{
/// Hello message
/// Initial message, sent from client to server
class Hello : public JsonMessage
{
public:
/// c'tor
Hello() : JsonMessage(message_type::kHello)
{
}
Hello(const std::string& macAddress, const std::string& id, size_t instance) : JsonMessage(message_type::kHello)
/// c'tor taking @p macAddress, @p id and @p instance
Hello(const std::string& mac_address, const std::string& id, size_t instance) : JsonMessage(message_type::kHello)
{
msg["MAC"] = macAddress;
msg["MAC"] = mac_address;
msg["HostName"] = ::getHostName();
msg["Version"] = VERSION;
msg["ClientName"] = "Snapclient";
@ -51,53 +54,64 @@ public:
msg["SnapStreamProtocolVersion"] = 2;
}
/// d'tor
~Hello() override = default;
/// @return the MAC address
std::string getMacAddress() const
{
return msg["MAC"];
}
/// @return the host name
std::string getHostName() const
{
return msg["HostName"];
}
/// @return the client version
std::string getVersion() const
{
return msg["Version"];
}
/// @return the client name (e.g. "Snapclient")
std::string getClientName() const
{
return msg["ClientName"];
}
/// @return the OS name
std::string getOS() const
{
return msg["OS"];
}
/// @return the CPU architecture
std::string getArch() const
{
return msg["Arch"];
}
/// @return the instance id
int getInstance() const
{
return get("Instance", 1);
}
/// @return the protocol version
int getProtocolVersion() const
{
return get("SnapStreamProtocolVersion", 1);
}
/// @return a unqiue machine ID
std::string getId() const
{
return get("ID", getMacAddress());
}
/// @return a unqiue client ID
std::string getUniqueId() const
{
std::string id = getId();
@ -109,7 +123,5 @@ public:
return id;
}
};
} // namespace msg
#endif

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2022 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -16,8 +16,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef MESSAGE_JSON_HPP
#define MESSAGE_JSON_HPP
#pragma once
// local headers
#include "common/json.hpp"
@ -30,13 +30,16 @@ using json = nlohmann::json;
namespace msg
{
/// Base class of a message with json payload
class JsonMessage : public BaseMessage
{
public:
JsonMessage(message_type msgType) : BaseMessage(msgType)
/// c'tor taking the @p msg_type
explicit JsonMessage(message_type msg_type) : BaseMessage(msg_type)
{
}
/// d'tor
~JsonMessage() override = default;
void read(std::istream& stream) override
@ -60,6 +63,7 @@ protected:
writeVal(stream, msg.dump());
}
/// @return value for key @p what or @p def, if not found
template <typename T>
T get(const std::string& what, const T& def) const
{
@ -75,7 +79,5 @@ protected:
}
}
};
} // namespace msg
#endif

View file

@ -38,15 +38,17 @@ namespace msg
class PcmChunk : public WireChunk
{
public:
PcmChunk(const SampleFormat& sampleFormat, uint32_t ms)
: WireChunk((sampleFormat.rate() * ms / 1000) * sampleFormat.frameSize()), format(sampleFormat), idx_(0)
/// c'tor, construct from @p sample_format with duration @p ms
PcmChunk(const SampleFormat& sample_format, uint32_t ms) : WireChunk((sample_format.rate() * ms / 1000) * sample_format.frameSize()), format(sample_format)
{
}
PcmChunk() : WireChunk(), idx_(0)
/// c'tor
PcmChunk() : WireChunk()
{
}
/// d'tor
~PcmChunk() override = default;
#if 0
@ -73,16 +75,18 @@ public:
// return result;
// }
int readFrames(void* outputBuffer, uint32_t frameCount)
/// Read the next @p frame_count frames into @p output_buffer, update the internal read position
/// @return number of frames copied to @p output_buffer
int readFrames(void* output_buffer, uint32_t frame_count)
{
// logd << "read: " << frameCount << ", total: " << (wireChunk->length / format.frameSize()) << ", idx: " << idx;// << "\n";
int result = frameCount;
if (idx_ + frameCount > (payloadSize / format.frameSize()))
int result = frame_count;
if (idx_ + frame_count > (payloadSize / format.frameSize()))
result = (payloadSize / format.frameSize()) - idx_;
// logd << ", from: " << format.frameSize()*idx << ", to: " << format.frameSize()*idx + format.frameSize()*result;
if (outputBuffer != nullptr)
memcpy(static_cast<char*>(outputBuffer), static_cast<char*>(payload) + format.frameSize() * idx_, format.frameSize() * result);
if (output_buffer != nullptr)
memcpy(static_cast<char*>(output_buffer), static_cast<char*>(payload) + format.frameSize() * idx_, format.frameSize() * result);
idx_ += result;
// logd << ", new idx: " << idx << ", result: " << result << ", wireChunk->length: " << wireChunk->length << ", format.frameSize(): " <<
@ -90,6 +94,8 @@ public:
return result;
}
/// seek @p frames forward or backward
/// @return the new read position
int seek(int frames)
{
if ((frames < 0) && (-frames > static_cast<int>(idx_)))
@ -102,17 +108,20 @@ public:
return idx_;
}
/// @return start time of the current frame
chronos::time_point_clk start() const override
{
return chronos::time_point_clk(chronos::sec(timestamp.sec) + chronos::usec(timestamp.usec) +
chronos::usec(static_cast<chronos::usec::rep>(1000000. * ((double)idx_ / (double)format.rate()))));
}
/// @return time of the last frame
inline chronos::time_point_clk end() const
{
return start() + durationLeft<chronos::usec>();
}
/// @return duration of this chunk
template <typename T>
inline T duration() const
{
@ -127,42 +136,51 @@ public:
// payloadSize = newSize;
// }
void setFrameCount(int frameCount)
/// Set the @p frame_count, reserve memory
void setFrameCount(int frame_count)
{
auto newSize = format.frameSize() * frameCount;
payload = static_cast<char*>(realloc(payload, newSize));
payloadSize = newSize;
auto new_size = format.frameSize() * frame_count;
payload = static_cast<char*>(realloc(payload, new_size));
payloadSize = new_size;
}
/// @return duration of this chunk in [ms]
double durationMs() const
{
return static_cast<double>(getFrameCount()) / format.msRate();
}
/// @return time left, starting from the read pointer
template <typename T>
inline T durationLeft() const
{
return std::chrono::duration_cast<T>(chronos::nsec(static_cast<chronos::nsec::rep>(1000000 * (getFrameCount() - idx_) / format.msRate())));
}
/// @return true if the read pointer is at the end
inline bool isEndOfChunk() const
{
return idx_ >= getFrameCount();
}
/// @return number of frames
inline uint32_t getFrameCount() const
{
return (payloadSize / format.frameSize());
}
/// @return number of samples
inline uint32_t getSampleCount() const
{
return (payloadSize / format.sampleSize());
}
/// Sample format of this chunk
SampleFormat format;
private:
/// current read position (frame idx)
uint32_t idx_ = 0;
};
} // namespace msg

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2022 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -16,8 +16,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef MESSAGE_SERVER_SETTINGS_HPP
#define MESSAGE_SERVER_SETTINGS_HPP
#pragma once
// local headers
#include "json_message.hpp"
@ -26,9 +25,11 @@
namespace msg
{
/// Dynamic settings that affect the client
class ServerSettings : public JsonMessage
{
public:
/// c'tor
ServerSettings() : JsonMessage(message_type::kServerSettings)
{
setBufferMs(0);
@ -37,51 +38,57 @@ public:
setMuted(false);
}
/// d'tor
~ServerSettings() override = default;
/// @return the end to end delay in [ms]
int32_t getBufferMs()
{
return get("bufferMs", 0);
}
/// @return client specific additional latency in [ms]
int32_t getLatency()
{
return get("latency", 0);
}
/// @return the volume in [%]
uint16_t getVolume()
{
return get("volume", static_cast<uint16_t>(100));
}
/// @return if muted
bool isMuted()
{
return get("muted", false);
}
void setBufferMs(int32_t bufferMs)
/// Set the end to end delay to @p buffer_ms [ms]
void setBufferMs(int32_t buffer_ms)
{
msg["bufferMs"] = bufferMs;
msg["bufferMs"] = buffer_ms;
}
/// Set the additional client specific @p latency [ms]
void setLatency(int32_t latency)
{
msg["latency"] = latency;
}
/// Set the @p volume [%]
void setVolume(uint16_t volume)
{
msg["volume"] = volume;
}
/// Set client to @p muted
void setMuted(bool muted)
{
msg["muted"] = muted;
}
};
} // namespace msg
#endif

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2022 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -16,8 +16,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
***/
#ifndef MESSAGE_TIME_HPP
#define MESSAGE_TIME_HPP
#pragma once
// local headers
#include "message.hpp"
@ -25,13 +24,16 @@
namespace msg
{
/// Time sync message, send from client to server and back
class Time : public BaseMessage
{
public:
/// c'tor
Time() : BaseMessage(message_type::kTime)
{
}
/// d'tor
~Time() override = default;
void read(std::istream& stream) override
@ -45,6 +47,7 @@ public:
return sizeof(tv);
}
/// The latency after round trip "client => server => client"
tv latency;
protected:
@ -54,7 +57,5 @@ protected:
writeVal(stream, latency.usec);
}
};
} // namespace msg
#endif

View file

@ -3,7 +3,7 @@
( _ \ / \( _ \( )
) __/( O )) __// (_/\
(__) \__/(__) \____/
version 1.3.0
version 1.3.1
https://github.com/badaix/popl
This file is part of popl (program options parser lib)
@ -483,7 +483,7 @@ public:
std::string print(const Attribute& max_attribute = Attribute::optional) const override;
private:
std::string to_string(Option_ptr option) const;
std::string to_string(const Option_ptr& option) const;
};
@ -501,7 +501,7 @@ public:
std::string print(const Attribute& max_attribute = Attribute::optional) const override;
private:
std::string to_string(Option_ptr option) const;
std::string to_string(const Option_ptr& option) const;
};
@ -1122,7 +1122,7 @@ inline ConsoleOptionPrinter::ConsoleOptionPrinter(const OptionParser* option_par
}
inline std::string ConsoleOptionPrinter::to_string(Option_ptr option) const
inline std::string ConsoleOptionPrinter::to_string(const Option_ptr& option) const
{
std::stringstream line;
if (option->short_name() != 0)
@ -1142,7 +1142,7 @@ inline std::string ConsoleOptionPrinter::to_string(Option_ptr option) const
std::stringstream defaultStr;
if (option->get_default(defaultStr))
{
if (!defaultStr.str().empty())
if (!defaultStr.str().empty() && (defaultStr.str() != "\"\""))
line << " (=" << defaultStr.str() << ")";
}
}
@ -1167,7 +1167,7 @@ inline std::string ConsoleOptionPrinter::print(const Attribute& max_attribute) c
std::stringstream s;
if (!option_parser_->description().empty())
s << option_parser_->description() << ":\n";
s << option_parser_->description() << "\n";
size_t optionRightMargin(20);
const size_t maxDescriptionLeftMargin(40);
@ -1216,7 +1216,7 @@ inline GroffOptionPrinter::GroffOptionPrinter(const OptionParser* option_parser)
}
inline std::string GroffOptionPrinter::to_string(Option_ptr option) const
inline std::string GroffOptionPrinter::to_string(const Option_ptr& option) const
{
std::stringstream line;
if (option->short_name() != 0)

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2024 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -29,15 +29,15 @@ class SnapException : public std::exception
int error_code_;
public:
SnapException(const char* text, int error_code = 0) : text_(text), error_code_(error_code)
explicit SnapException(const char* text, int error_code = 0) : text_(text), error_code_(error_code)
{
}
SnapException(const std::string& text, int error_code = 0) : SnapException(text.c_str(), error_code)
explicit SnapException(const std::string& text, int error_code = 0) : SnapException(text.c_str(), error_code)
{
}
~SnapException() throw() override = default;
~SnapException() override = default;
int code() const noexcept
{

View file

@ -33,8 +33,6 @@ namespace strutils = utils::string;
static constexpr auto LOG_TAG = "StreamUri";
namespace streamreader
{
StreamUri::StreamUri(const std::string& uri)
{
@ -87,6 +85,12 @@ void StreamUri::parse(const std::string& stream_uri)
// pos: ^ or ^ or ^
host = strutils::uriDecode(strutils::trim_copy(tmp.substr(0, pos)));
std::string str_port;
host = utils::string::split_left(host, ':', str_port);
port = std::strtol(str_port.c_str(), nullptr, 10);
if (port == 0)
port = std::nullopt;
tmp = tmp.substr(pos);
path = tmp;
pos = std::min(path.find('?'), path.find('#'));
@ -166,9 +170,9 @@ std::string StreamUri::getQuery(const std::string& key, const std::string& def)
return def;
}
bool StreamUri::operator==(const StreamUri& other) const
{
return (other.scheme == scheme) && (other.host == host) && (other.path == path) && (other.query == query) && (other.fragment == fragment);
return (other.scheme == scheme) && (other.host == host) && (other.port == port) && (other.path == path) && (other.query == query) &&
(other.fragment == fragment);
}
} // namespace streamreader

View file

@ -24,18 +24,18 @@
// standard headers
#include <map>
#include <optional>
#include <string>
using json = nlohmann::json;
namespace streamreader
{
/// URI with the general format:
/// scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
struct StreamUri
{
/// c'tor
StreamUri() = default;
/// c'tor construct from string @p uri
explicit StreamUri(const std::string& uri);
@ -54,6 +54,8 @@ struct StreamUri
/// the host component
std::string host;
/// the port
std::optional<size_t> port;
/// the path component
std::string path;
/// the query component: "key = value" pairs
@ -76,5 +78,3 @@ struct StreamUri
/// @return true if @p other is equal to this
bool operator==(const StreamUri& other) const;
};
} // namespace streamreader

View file

@ -1,6 +1,7 @@
# Snapcast binary protocol
Each message sent with the Snapcast binary protocol is split up into two parts:
- A base message that provides general information like time sent/received, type of the message, message size, etc
- A typed message that carries the rest of the information
@ -24,15 +25,15 @@ When a client joins a server, the following exchanges happen
## Messages
| Typed Message ID | Name | Notes |
|------------------|--------------------------------------|---------------------------------------------------------------------------|
| 0 | [Base](#base) | The beginning of every message containing data about the typed message |
| 1 | [Codec Header](#codec-header) | The codec-specific data to put at the start of a stream to allow decoding |
| 2 | [Wire Chunk](#wire-chunk) | A part of an audio stream |
| 3 | [Server Settings](#server-settings) | Settings set from the server like volume, latency, etc |
| 4 | [Time](#time) | Used for synchronizing time with the server |
| 5 | [Hello](#hello) | Sent by the client when connecting with the server |
| 6 | [Stream Tags](#stream-tags) | Metadata about the stream for use by the client |
| Typed Message ID | Name | Dir | Notes |
|------------------|--------------------------------------|------|---------------------------------------------------------------------------|
| 0 | [Base](#base) | | The beginning of every message containing data about the typed message |
| 1 | [Codec Header](#codec-header) | S->C | The codec-specific data to put at the start of a stream to allow decoding |
| 2 | [Wire Chunk](#wire-chunk) | S->C | A part of an audio stream |
| 3 | [Server Settings](#server-settings) | S->C | Settings set from the server like volume, latency, etc |
| 4 | [Time](#time) | C->S<br>S->C | Used for synchronizing time with the server |
| 5 | [Hello](#hello) | C->S | Sent by the client when connecting with the server |
| 7 | [Client Info](#client-info) | C->S | Update the server when relevant information changes (e.g. client volume) |
### Base
@ -63,7 +64,6 @@ The payload depends on the used codec:
- PCM: a RIFF WAVE header, as described [here](https://de.wikipedia.org/wiki/RIFF_WAVE). PCM is not encoded, but the decoder must know the samplerate, bit depth and number of channels, which is encoded into the header
- Opus: a dummy header is sent, containing a 4 byte ID (0x4F505553, ascii for "OPUS"), 4 byte samplerate, 2 byte bit depth, 2 byte channel count (all little endian)
### Wire Chunk
| Field | Type | Description |
@ -122,3 +122,21 @@ Sample JSON payload (whitespace added for readability):
"Version": "0.17.1"
}
```
### Client Info
| Field | Type | Description |
|---------|--------|----------------------------------------------------------|
| size | uint32 | Size of the following JSON string |
| payload | char[] | JSON string containing the message (not null terminated) |
Sample JSON payload (whitespace added for readability):
```json
{
"volume": 100,
"muted": false,
}
```
- `volume` can have a value between 0-100 inclusive

View file

@ -329,8 +329,12 @@ Snapserver supports RPC via HTTP(S) and WS(S) as well as audio streaming over WS
### HTTPS
#### Server
For HTTPS/WSS, the paramter `ssl_enabled` must be set to `true` (default: `false`) and the `certificate` and `certificate_key` paramters in the `[ssl]` section must point to a certificate file and key file in PEM format.
If you want only trusted clients being able to connect, the parameter `verify_clients` must be set to `true` and the client CA certificates must be configures as list of `client_cert =` entries.
Some hints on how to create a certificate and a private key are given for instance here:
- [Create Root CA (Done once)](https://gist.github.com/fntlnz/cf14feb5a46b2eda428e000157447309)
@ -382,3 +386,11 @@ certificate_key = snapserver.key
```
Install the CA certificate `snapcastCA.crt` on you client's OS or browser.
#### Client
To use an SSL connection to the server, the client must use the secure websockets URI: `snapclient [options...] wss://<server host or IP>[:port]`.
To enable server authentication, the server CA certificate can be configured with `--server-cert=<filename>`.
If the server is confgured to authenticate the clients (`verify_clients = true` in `snapserver.conf`), you must configure the client certificate and private key with `--cert=<filename>` and `--cert-key=<filename>`.

View file

@ -92,7 +92,7 @@ Snapserver will send the `SetProperty` command to the plugin, if `canControl` is
* `track`: the current track will start again from the begining once it has finished playing
* `playlist`: the playback loops through a list of tracks
* `shuffle`: [bool] play playlist in random order
* `volume`: [int] voume in percent, valid range [0..100]
* `volume`: [int] volume in percent, valid range [0..100]
* `mute`: [bool] the current mute state
* `rate`: [float] the current playback rate, valid range (0..)

View file

@ -321,3 +321,52 @@ Use `--aout afile` and `--audiofile-file` to pipe VLC's audio output to the snap
```sh
vlc --no-video --aout afile --audiofile-file /tmp/snapfifo
```
## Plexamp
Plexamp can be configured to use Snapcast for multi-room audio by redirecting its output to a FIFO pipe. This can be managed using a Plex control script for audio playback.
### 1. Install Plexamp
Follow the [official instructions](https://www.plex.tv/plexamp/) to install Plexamp on your system. This method works with both **plexamp-headless** and the standalone **Plexamp** client.
### 2. Create a loopback FIFO pipe
You need to use **Pipewire** or **Pulseaudio**:
```sh
pactl load-module module-pipe-sink file=/tmp/snapfifo sink_name=Snapcast-Plexamp format=s16le rate=44100
```
**Note:** Ensure that the virtual sink settings match those configured in Snapserver to avoid audio issues. With this setup, resampling will be handled by your audio server.
### 3. Configure Plexamp to use the FIFO pipe
1. Go to Settings > Playback > Audio Output, and ensure it is set to Pipewire (or Pulseaudio).
2. Set the sink volume to the maximum from your system settings or using the command:
```sh
pactl set-sink-volume @DEFAULT_SINK@ 100%
```
### 4. Add a new source to Snapserver
Edit your `snapserver.conf` file to add a new stream source for the FIFO pipe:
```ini
[stream]
source = pipe:///tmp/snapfifo?name=Plexamp
```
### 5. (Optional) Configure the control script
1. Retrieve your Plex API token following the [official guide](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/).
2. Install Python package [plexapi](https://pypi.org/project/PlexAPI)
3. Modify the `snapserver.conf` file to add the control script:
```ini
[stream]
source = pipe:///tmp/snapfifo?name=Plexamp&sampleformat=44100:16:2&codec=flac&controlscript=plex_bridge.py&controlscriptparams=--token=<Plex Token> --ip=<Plex Server IP address> --player=<Player name to control>
```
This setup allows you to use Plexamp with Snapcast for synchronized multi-room audio playback.

View file

@ -6,6 +6,7 @@ set(SERVER_SOURCES
control_session_tcp.cpp
control_session_http.cpp
control_session_ws.cpp
jwt.cpp
snapserver.cpp
server.cpp
stream_server.cpp
@ -17,7 +18,6 @@ set(SERVER_SOURCES
encoder/null_encoder.cpp
streamreader/control_error.cpp
streamreader/stream_control.cpp
streamreader/stream_uri.cpp
streamreader/stream_manager.cpp
streamreader/pcm_stream.cpp
streamreader/tcp_stream.cpp
@ -37,6 +37,8 @@ include_directories(${CMAKE_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/server)
include_directories(SYSTEM ${Boost_INCLUDE_DIR})
find_package(OpenSSL REQUIRED)
if(ANDROID)
find_package(vorbis REQUIRED CONFIG)
list(APPEND SERVER_LIBRARIES boost::boost)

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2024 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -22,8 +22,8 @@
// local headers
#include "common/aixlog.hpp"
#include "common/base64.h"
#include "common/jwt.hpp"
#include "common/utils/string_utils.hpp"
#include "jwt.hpp"
// 3rd party headers

View file

@ -22,6 +22,7 @@
// local headers
#include "common/aixlog.hpp"
#include "common/json.hpp"
#include "common/snap_exception.hpp"
#include "control_session_http.hpp"
#include "control_session_tcp.hpp"
#include "server_settings.hpp"
@ -54,10 +55,50 @@ ControlServer::ControlServer(boost::asio::io_context& io_context, const ServerSe
return pw;
});
}
if (!ssl.certificate.empty() && !ssl.certificate_key.empty())
{
ssl_context_.use_certificate_chain_file(ssl.certificate);
ssl_context_.use_private_key_file(ssl.certificate_key, boost::asio::ssl::context::pem);
boost::system::error_code ec;
ssl_context_.use_certificate_chain_file(ssl.certificate, ec);
if (ec.failed())
throw SnapException("Failed to load certificate: " + settings.ssl.certificate.string() + ": " + ec.message());
ssl_context_.use_private_key_file(ssl.certificate_key, boost::asio::ssl::context::pem, ec);
if (ec.failed())
throw SnapException("Failed to load private key file: " + settings.ssl.certificate_key.string() + ": " + ec.message());
}
if (settings.ssl.verify_clients)
{
boost::system::error_code ec;
ssl_context_.set_default_verify_paths(ec);
if (ec.failed())
LOG(WARNING, LOG_TAG) << "Failed to load system certificates: " << ec << "\n";
for (const auto& cert_path : settings_.ssl.client_certs)
{
LOG(DEBUG, LOG_TAG) << "Loading client certificate: " << cert_path << "\n";
ssl_context_.load_verify_file(cert_path.string(), ec);
if (ec.failed())
throw SnapException("Failed to load client certificate: " + cert_path.string() + ": " + ec.message());
}
ssl_context_.set_verify_mode(boost::asio::ssl::verify_peer | boost::asio::ssl::verify_fail_if_no_peer_cert);
ssl_context_.set_verify_callback([](bool preverified, boost::asio::ssl::verify_context& ctx)
{
// The verify callback can be used to check whether the certificate that is
// being presented is valid for the peer. For example, RFC 2818 describes
// the steps involved in doing this for HTTPS. Consult the OpenSSL
// documentation for more details. Note that the callback is called once
// for each certificate in the certificate chain, starting from the root
// certificate authority.
// In this example we will simply print the certificate's subject name.
char subject_name[256];
X509* cert = X509_STORE_CTX_get_current_cert(ctx.native_handle());
X509_NAME_oneline(X509_get_subject_name(cert), subject_name, 256);
LOG(INFO, LOG_TAG) << "Verifying cert: '" << subject_name << "', pre verified: " << preverified << "\n";
return preverified;
});
}
// ssl_context_.use_tmp_dh_file("dh4096.pem");
}

View file

@ -0,0 +1,334 @@
#!/usr/bin/env python3
import json
import sys
import logging
import argparse
import requests
import time
from typing import Dict, Any, Optional, Union
from plexapi.server import PlexServer
from plexapi.client import PlexClient
from plexapi.exceptions import NotFound
import threading
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
filename="/tmp/plex_bridge.log"
)
class PlexSnapcastBridge:
def __init__(self, config: Dict[str, Union[str, Optional[str]]]):
self.config = config
self.plex: Optional[PlexServer] = None
self.current_player: Optional[PlexClient] = None
self.snapcast_url: Optional[str] = None
self.last_metadata: Dict[str, Any] = {}
self.running = True
if self.config.get("snapcast_host") and self.config.get("snapcast_port"):
self.snapcast_url = f"http://{self.config['snapcast_host']}:{self.config['snapcast_port']}/jsonrpc"
self.connect_to_plex()
self.send_ready_notification()
self.monitor_thread = threading.Thread(target=self.monitor_sessions, daemon=True)
self.monitor_thread.start()
def connect_to_plex(self) -> None:
try:
baseurl = f"http://{self.config['ip']}:32400"
token = self.config["token"]
self.plex = PlexServer(baseurl, token)
self.current_player = self.plex.client(self.config["player"])
self.send_log("Connected to Plex server")
except NotFound:
self.send_log(f"Player {self.config['player']} not found", "error")
raise
except Exception as e:
self.send_log(f"Failed to connect to Plex: {str(e)}", "error")
raise
def update_snapcast_status(self, properties: Dict[str, Any]) -> None:
if not self.snapcast_url:
return
try:
payload = {
"id": 1,
"jsonrpc": "2.0",
"method": "Stream.OnProperties",
"params": {
"id": self.config.get("stream", "Plex"),
"properties": properties
}
}
requests.post(self.snapcast_url, json=payload)
except requests.exceptions.RequestException as e:
self.send_log(f"Failed to update Snapcast status: {str(e)}", "error")
def send_ready_notification(self) -> None:
notification = {
"jsonrpc": "2.0",
"method": "Plugin.Stream.Ready"
}
self._send_json(notification)
def _send_json(self, data: Dict[str, Any]) -> None:
try:
json_str = json.dumps(data)
print(json_str, flush=True)
logging.debug(f"Sent: {json_str}")
except Exception as e:
logging.error(f"Error sending JSON: {str(e)}")
def send_properties_notification(self, properties: Dict[str, Any]) -> None:
notification = {
"jsonrpc": "2.0",
"method": "Plugin.Stream.Player.Properties",
"params": properties
}
self._send_json(notification)
self.update_snapcast_status(properties)
def send_log(self, message: str, severity: str = "info") -> None:
notification = {
"jsonrpc": "2.0",
"method": "Plugin.Stream.Log",
"params": {
"severity": severity,
"message": message
}
}
self._send_json(notification)
def get_current_properties(self) -> Dict[str, Any]:
if not self.current_player or not self.plex:
return self.get_default_properties()
try:
metadata: Dict[str, Any] = {}
playback_status = "stopped"
now_playing = self.plex.sessions()
player_name = self.config["player"]
active_session = None
for session in now_playing:
if hasattr(session, "players") and session.players:
if session.players[0].title == player_name:
active_session = session
raw_state = getattr(session.players[0], "state", "stopped")
playback_status = "playing" if raw_state == "playing" else "paused"
break
if active_session:
art_url = None
if hasattr(active_session, "thumb") and active_session.thumb:
art_url = f"http://{self.config['ip']}:32400{active_session.thumb}?X-Plex-Token={self.config['token']}"
metadata = {
"title": getattr(active_session, "title", "Unknown Title"),
"artist": [getattr(active_session, "originalTitle", None) or
getattr(active_session, "grandparentTitle", "Unknown Artist")],
"album": getattr(active_session, "parentTitle", "Unknown Album"),
"duration": getattr(active_session, "duration", 0) / 1000,
"trackNumber": int(getattr(active_session, "index", 0)) if getattr(active_session, "index", None) else 0,
"artUrl": art_url
}
if art_url:
metadata["artUrl"] = art_url
if hasattr(active_session, "key"):
metadata["url"] = f"http://{self.config['ip']}:32400{active_session.key}?X-Plex-Token={self.config['token']}"
return {
"canControl": True,
"canGoNext": True,
"canGoPrevious": True,
"canPlay": True,
"canPause": True,
"canSeek": False,
"playbackStatus": playback_status,
"loopStatus": "none",
"shuffle": False,
"volume": getattr(self.current_player, "volume", 100),
"mute": getattr(self.current_player, "isMuted", False),
"position": getattr(self.current_player, "viewOffset", 0) / 1000,
"rate": 1.0,
"metadata": metadata
}
except Exception as e:
self.send_log(f"Error getting properties: {str(e)}", "error")
return self.get_default_properties()
def get_default_properties(self) -> Dict[str, Any]:
return {
"canControl": True,
"canGoNext": True,
"canGoPrevious": True,
"canPlay": True,
"canPause": True,
"canSeek": False,
"playbackStatus": "stopped",
"loopStatus": "none",
"shuffle": False,
"volume": 100,
"mute": False,
"position": 0
}
def handle_control_command(self, command: str, params: Dict[str, Any]) -> Dict[str, Any]:
try:
if self.current_player is None:
raise Exception("No player connected")
self.send_log(f"Executing command: {command} with params: {params}", "debug")
if command == "play":
self.current_player.play()
elif command == "pause":
self.current_player.pause()
elif command == "stop":
self.current_player.stop()
elif command == "next":
self.current_player.skipNext()
elif command == "previous":
self.current_player.skipPrevious()
elif command == "seek":
if "offset" in params:
current_time = getattr(self.current_player, "viewOffset", 0)
new_time = int((current_time / 1000 + params["offset"]) * 1000)
self.current_player.seekTo(new_time)
elif command == "setPosition":
if "position" in params:
new_time = int(params["position"] * 1000)
self.current_player.seekTo(new_time)
time.sleep(0.2)
properties = self.get_current_properties()
self.send_properties_notification(properties)
return {"status": "ok"}
except Exception as e:
self.send_log(f"Error executing control command ({type(e).__name__}): {str(e)}", "error")
return {"error": {"code": -32000, "message": str(e)}}
def handle_set_property(self, property_name: str, value: Any) -> Dict[str, Any]:
try:
if self.current_player is None:
raise Exception("No player connected")
if property_name == "volume":
self.current_player.setVolume(value)
elif property_name == "mute":
if value:
self.current_player.mute()
else:
self.current_player.unmute()
self.send_properties_notification(self.get_current_properties())
return {"status": "ok"}
except Exception as e:
self.send_log(f"Error setting property: {str(e)}", "error")
return {"error": {"code": -32000, "message": str(e)}}
def handle_command(self, command: Dict[str, Any]) -> Dict[str, Any]:
try:
method = command.get("method")
cmd_id = command.get("id")
response: Dict[str, Any] = {
"jsonrpc": "2.0",
"id": cmd_id
}
if method == "Plugin.Stream.Player.Control":
params = command.get("params", {})
result = self.handle_control_command(params.get("command", ""), params.get("params", {}))
response["result"] = result
elif method == "Plugin.Stream.Player.SetProperty":
params = command.get("params", {})
property_name = next(iter(params.keys()))
result = self.handle_set_property(property_name, params[property_name])
response["result"] = result
elif method == "Plugin.Stream.Player.GetProperties":
response["result"] = self.get_current_properties()
else:
response["error"] = {
"code": -32601,
"message": f"Method not found: {method}"
}
return response
except Exception as e:
self.send_log(f"Error processing command: {str(e)}", "error")
return {
"jsonrpc": "2.0",
"id": command.get("id"),
"error": {
"code": -32000,
"message": str(e)
}
}
def monitor_sessions(self) -> None:
while self.running:
try:
properties = self.get_current_properties()
current_metadata = properties.get("metadata", {})
if current_metadata != self.last_metadata:
self.send_log("Track changed, updating properties", "debug")
self.send_properties_notification(properties)
self.last_metadata = current_metadata.copy()
time.sleep(1)
except Exception as e:
self.send_log(f"Error in session monitor: {str(e)}", "error")
time.sleep(1)
def run(self) -> None:
self.send_log("Starting Plex bridge")
try:
while self.running:
try:
line = sys.stdin.readline()
if not line:
self.send_log("Received EOF, exiting", "notice")
break
command = json.loads(line)
logging.debug(f"Received command: {command}")
response = self.handle_command(command)
self._send_json(response)
except json.JSONDecodeError as e:
self.send_log(f"Invalid JSON received: {str(e)}", "error")
except Exception as e:
self.send_log(f"Error processing command: {str(e)}", "error")
finally:
self.running = False
self.monitor_thread.join(timeout=1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Plex to Snapcast Bridge")
parser.add_argument("--token", type=str, required=True, help="Plex token")
parser.add_argument("--ip", type=str, required=True, help="Plex server IP address")
parser.add_argument("--player", type=str, required=True, help="Plex player name")
parser.add_argument("--stream", type=str, help="Stream ID")
parser.add_argument("--snapcast-port", type=str, help="Snapcast HTTP port")
parser.add_argument("--snapcast-host", type=str, help="Snapcast HTTP host")
args = parser.parse_args()
try:
config: Dict[str, Union[str, Optional[str]]] = {
"token": args.token,
"ip": args.ip,
"player": args.player,
"stream": args.stream,
"snapcast_host": args.snapcast_host,
"snapcast_port": args.snapcast_port
}
bridge = PlexSnapcastBridge(config)
bridge.run()
except Exception as e:
logging.error(f"Fatal error: {str(e)}")
sys.exit(1)

View file

@ -62,6 +62,13 @@
# Password for decryption of the certificate_key (only needed for encrypted certificate_key file)
#key_password =
# Verify client certificates
#verify_clients = false
# List of client CA certificate files, can be configured multiple times
#client_cert =
#client_cert =
#
###############################################################################
@ -152,7 +159,7 @@ doc_root = /usr/share/snapserver/snapweb
# and will override the default codec, sampleformat or chunk_ms settings
# Available types are:
# pipe: pipe:///<path/to/pipe>?name=<name>[&mode=create], mode can be "create" or "read"
# librespot: librespot:///<path/to/librespot>?name=<name>[&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&nomalize=false][&autoplay=false][&params=<generic librepsot process arguments>]
# librespot: librespot:///<path/to/librespot>?name=<name>[&username=<my username>&password=<my password>][&devicename=Snapcast][&bitrate=320][&wd_timeout=7800][&volume=100][&onevent=""][&normalize=false][&autoplay=false][&params=<generic librepsot process arguments>]
# note that you need to have the librespot binary on your machine
# sampleformat will be set to "44100:16:2"
# file: file:///<path/to/PCM/file>?name=<name>

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2024 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by

View file

@ -1,6 +1,6 @@
/***
This file is part of snapcast
Copyright (C) 2014-2024 Johannes Pohl
Copyright (C) 2014-2025 Johannes Pohl
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by

View file

@ -28,32 +28,49 @@
#include <string>
#include <vector>
/// Server settings
struct ServerSettings
{
/// Launch settings
struct Server
{
/// Number of worker threads
int threads{-1};
/// PID file, if running as daemon
std::string pid_file{"/var/run/snapserver/pid"};
/// User when running as deaemon
std::string user{"snapserver"};
/// Group when running as deaemon
std::string group;
/// Server data dir
std::string data_dir;
};
/// SSL settings
struct Ssl
{
/// Certificate file
std::filesystem::path certificate;
/// Private key file
std::filesystem::path certificate_key;
/// Password for encrypted key file
std::string key_password;
/// Verify client certificates
bool verify_clients = false;
/// Client CA certificates
std::vector<std::filesystem::path> client_certs;
/// @return if SSL is enabled
bool enabled() const
{
return !certificate.empty() && !certificate_key.empty();
}
};
/// User settings
struct User
{
/// c'tor
explicit User(const std::string& user_permissions_password)
{
std::string perm;
@ -62,62 +79,94 @@ struct ServerSettings
permissions = utils::string::split(perm, ',');
}
/// user name
std::string name;
/// permissions
std::vector<std::string> permissions;
/// password
std::string password;
};
std::vector<User> users;
/// HTTP settings
struct Http
{
/// enable HTTP server
bool enabled{true};
/// enable HTTPS
bool ssl_enabled{false};
/// HTTP port
size_t port{1780};
/// HTTPS port
size_t ssl_port{1788};
/// HTTP listen address
std::vector<std::string> bind_to_address{{"::"}};
/// HTTPS listen address
std::vector<std::string> ssl_bind_to_address{{"::"}};
/// doc root directory
std::string doc_root;
/// HTTP server host name
std::string host{"<hostname>"};
/// URL prefix when serving album art
std::string url_prefix;
};
/// TCP streaming client settings
struct Tcp
{
/// enable plain TCP audio streaming
bool enabled{true};
/// TCP port
size_t port{1705};
/// TCP listen addresses
std::vector<std::string> bind_to_address{{"::"}};
};
/// Stream settings
struct Stream
{
/// Audio streaming port
size_t port{1704};
/// Directory for stream plugins
std::filesystem::path plugin_dir{"/usr/share/snapserver/plug-ins"};
/// Stream sources
std::vector<std::string> sources;
/// Default codec
std::string codec{"flac"};
/// Default end to end delay
int32_t bufferMs{1000};
/// Default sample format
std::string sampleFormat{"48000:16:2"};
/// Default read size for stream sources
size_t streamChunkMs{20};
/// Send audio to muted clients?
bool sendAudioToMutedClients{false};
/// Liste addresses
std::vector<std::string> bind_to_address{{"::"}};
};
/// Client settings
struct StreamingClient
{
/// Initial volume of new clients
uint16_t initialVolume{100};
};
/// Logging settings
struct Logging
{
/// log sing
std::string sink;
/// log filter
std::string filter{"*:info"};
};
Server server;
Ssl ssl;
Http http;
Tcp tcp;
Stream stream;
StreamingClient streamingclient;
Logging logging;
Server server; ///< Server settings
Ssl ssl; ///< SSL settings
std::vector<User> users; ///< User settings
Http http; ///< HTTP settings
Tcp tcp; ///< TCP settings
Stream stream; ///< Stream settings
StreamingClient streamingclient; ///< Client settings
Logging logging; ///< Logging settings
};

View file

@ -86,6 +86,9 @@ int main(int argc, char* argv[])
conf.add<Value<std::filesystem::path>>("", "ssl.certificate_key", "private key file (PEM format)", settings.ssl.certificate_key,
&settings.ssl.certificate_key);
conf.add<Value<string>>("", "ssl.key_password", "key password (for encrypted private key)", settings.ssl.key_password, &settings.ssl.key_password);
conf.add<Value<bool>>("", "ssl.verify_clients", "Verify client certificates", settings.ssl.verify_clients, &settings.ssl.verify_clients);
auto client_cert_opt =
conf.add<Value<std::filesystem::path>>("", "ssl.client_cert", "List of client CA certificate files, can be configured multiple times", "");
#if 0 // feature: users
// Users setting
@ -276,6 +279,13 @@ int main(int argc, char* argv[])
settings.ssl.certificate_key = make_absolute(settings.ssl.certificate_key);
if (!fs::exists(settings.ssl.certificate_key))
throw SnapException("SSL certificate_key file not found: " + settings.ssl.certificate_key.native());
for (size_t n = 0; n < client_cert_opt->count(); ++n)
{
auto cert_file = std::filesystem::weakly_canonical(client_cert_opt->value(n));
if (!fs::exists(cert_file))
throw SnapException("Client certificate file not found: " + cert_file.string());
settings.ssl.client_certs.push_back(std::move(cert_file));
}
}
else if (settings.ssl.certificate.empty() != settings.ssl.certificate_key.empty())
{

View file

@ -1,34 +0,0 @@
Metadata tags
=============
The metatag interface will propegate anything to the client, ie no restrictions on the tags.
But, to avoid total confusion in the tag display app (like Kodi), we need some groundrules.
My suggestion is to stick with the Vorbis standard as described in:
http://www.xiph.org/vorbis/doc/v-comment.html
https://wiki.xiph.org/Field_names
In addition we should accept unique identifiers of the stream such as Spotify track ID or
MusicBrainz id which can be used to lookup additional information online.
Example:
ARTIST: Pink Floyd
ALBUM: Dark Side of the Moon
TITLE: Money
METADATA_BLOCK_PICTURE: base64 (https://xiph.org/flac/format.html#metadata_block_picture)
Parsing tags from streams
=========================
I've implemented parsing tags from Librespot, but, as Librespot doesn't export anything more than
track title I added a patch 'librespot-meta.patch' to apply to get artist/title.
TBH, the meta tag output from players are a mess, neither Librespot nor Shairport-sync associate
metadata with their audio interfaces, ie they don't even export metadata when the audio interface
supports it, at least Shairport-sync exports a ritch set of data.
So, to get artist/album apply the patch to librespot and recompile, otherwise you will only get the
track title.
Or, you can build librespot from https://github.com/frafall/librespot.git

View file

@ -49,7 +49,7 @@ public:
/// Starts shairport-sync and reads PCM data from stdout
/**
* Starts librespot, reads PCM data from stdout, and passes the data to an encoder.
* Starts shairport-sync, reads PCM data from stdout, and passes the data to an encoder.
* Implements EncoderListener to get the encoded data.
* Data is passed to the PcmStream::Listener
* usage:

View file

@ -24,12 +24,12 @@
#include "common/json.hpp"
#include "common/message/codec_header.hpp"
#include "common/sample_format.hpp"
#include "common/stream_uri.hpp"
#include "encoder/encoder.hpp"
#include "jsonrpcpp.hpp"
#include "properties.hpp"
#include "server_settings.hpp"
#include "stream_control.hpp"
#include "stream_uri.hpp"
// 3rd party headers
#include <boost/asio/io_context.hpp>

View file

@ -16,14 +16,16 @@ endif()
# Make test executable
set(TEST_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/test_main.cpp
${CMAKE_SOURCE_DIR}/common/jwt.cpp
${CMAKE_SOURCE_DIR}/common/stream_uri.cpp
${CMAKE_SOURCE_DIR}/common/base64.cpp
${CMAKE_SOURCE_DIR}/common/utils/string_utils.cpp
${CMAKE_SOURCE_DIR}/server/authinfo.cpp
${CMAKE_SOURCE_DIR}/server/jwt.cpp
${CMAKE_SOURCE_DIR}/server/streamreader/control_error.cpp
${CMAKE_SOURCE_DIR}/server/streamreader/properties.cpp
${CMAKE_SOURCE_DIR}/server/streamreader/metadata.cpp
${CMAKE_SOURCE_DIR}/server/streamreader/stream_uri.cpp)
${CMAKE_SOURCE_DIR}/server/streamreader/metadata.cpp)
find_package(OpenSSL REQUIRED)
include_directories(SYSTEM ${Boost_INCLUDE_DIR})

View file

@ -22,13 +22,13 @@
#include "common/aixlog.hpp"
#include "common/base64.h"
#include "common/error_code.hpp"
#include "common/jwt.hpp"
#include "common/stream_uri.hpp"
#include "common/utils/string_utils.hpp"
#include "server/authinfo.hpp"
#include "server/jwt.hpp"
#include "server/server_settings.hpp"
#include "server/streamreader/control_error.hpp"
#include "server/streamreader/properties.hpp"
#include "server/streamreader/stream_uri.hpp"
// 3rd party headers
#include <catch2/catch_test_macros.hpp>
@ -224,7 +224,6 @@ TEST_CASE("JWT")
TEST_CASE("Uri")
{
using namespace streamreader;
StreamUri uri("pipe:///tmp/snapfifo?name=default&codec=flac");
REQUIRE(uri.scheme == "pipe");
REQUIRE(uri.path == "/tmp/snapfifo");
@ -232,9 +231,11 @@ TEST_CASE("Uri")
// uri = StreamUri("scheme:[//host[:port]][/]path[?query=none][#fragment]");
// Test with all fields
uri = StreamUri("scheme://host:port/path?query=none&key=value#fragment");
uri = StreamUri("scheme://host:42/path?query=none&key=value#fragment");
REQUIRE(uri.scheme == "scheme");
REQUIRE(uri.host == "host:port");
REQUIRE(uri.host == "host");
REQUIRE(uri.port.has_value());
REQUIRE(uri.port.value() == 42);
REQUIRE(uri.path == "/path");
REQUIRE(uri.query["query"] == "none");
REQUIRE(uri.query["key"] == "value");
@ -243,9 +244,11 @@ TEST_CASE("Uri")
// Test with all fields, url encoded
// "%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D"
// "!#$%&'()*+,/:;=?@[]"
uri = StreamUri("scheme%26://%26host%3f:port/pa%2Bth?%21%23%24%25%26%27%28%29=%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D&key%2525=value#fragment%3f%21%3F");
uri = StreamUri("scheme%26://%26host%3f:23/pa%2Bth?%21%23%24%25%26%27%28%29=%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D&key%2525=value#fragment%3f%21%3F");
REQUIRE(uri.scheme == "scheme&");
REQUIRE(uri.host == "&host?:port");
REQUIRE(uri.host == "&host?");
REQUIRE(uri.port.has_value());
REQUIRE(uri.port.value() == 23);
REQUIRE(uri.path == "/pa+th");
REQUIRE(uri.query["!#$%&'()"] == "*+,/:;=?@[]");
REQUIRE(uri.query["key%25"] == "value");
@ -283,6 +286,7 @@ TEST_CASE("Uri")
uri = StreamUri("spotify:///librespot?name=Spotify&username=EMAIL&password=string%26with%26ampersands&devicename=Snapcast&bitrate=320&killall=false");
REQUIRE(uri.scheme == "spotify");
REQUIRE(uri.host.empty());
REQUIRE(!uri.port.has_value());
REQUIRE(uri.path == "/librespot");
REQUIRE(uri.query["name"] == "Spotify");
REQUIRE(uri.query["username"] == "EMAIL");