mirror of
https://github.com/badaix/snapcast.git
synced 2025-04-28 17:57:05 +02:00
Merge branch 'develop' into feature/android-openssl-compatibility
This commit is contained in:
commit
69a64ef7f3
43 changed files with 1526 additions and 288 deletions
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
@ -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}
|
||||
|
|
2
.github/workflows/package.yml
vendored
2
.github/workflows/package.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
19
changelog.md
19
changelog.md
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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_;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 ()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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>`.
|
||||
|
|
|
@ -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..)
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
334
server/etc/plug-ins/plex_bridge.py
Normal file
334
server/etc/plug-ins/plex_bridge.py
Normal 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)
|
|
@ -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][¶ms=<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][¶ms=<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>
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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())
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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})
|
||||
|
||||
|
|
|
@ -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");
|
||||
|
|
Loading…
Add table
Reference in a new issue