mirror of
https://github.com/badaix/snapcast.git
synced 2025-05-01 03:07:33 +02:00
Compare commits
37 commits
Author | SHA1 | Date | |
---|---|---|---|
|
40ad2bac0a | ||
|
9a11d2aacf | ||
|
5034cc4404 | ||
|
eceb234e53 | ||
|
498f878aea | ||
|
8d7e4ba278 | ||
|
648589a233 | ||
|
dfa9cb6fbc | ||
|
6a9d53f3f2 | ||
|
b57ead5037 | ||
|
b773ccda18 | ||
|
c2bebb4bae | ||
|
fb8f6b87b8 | ||
|
77d23f627d | ||
|
a69e97eb53 | ||
|
d40d86fb68 | ||
|
b7aab73781 | ||
|
0beaa09e4f | ||
|
5a535fade8 | ||
|
2addf7cc3d | ||
|
c105fecc5b | ||
|
85e8d02e5b | ||
|
be301c6931 | ||
|
29e267532a | ||
|
a407e68df6 | ||
|
054706e608 | ||
|
23107d62f9 | ||
|
b20bd90c03 | ||
|
3d5744c6b0 | ||
|
d7ddfc8b88 | ||
|
b0463fdd0c | ||
|
442b154fbf | ||
|
0a8b737f9f | ||
|
bd424a3992 | ||
|
355c75458a | ||
|
6c02252d84 | ||
|
9fbf273caa |
43 changed files with 1525 additions and 292 deletions
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
@ -75,7 +75,7 @@ jobs:
|
||||||
mkdir -p build/doxygen
|
mkdir -p build/doxygen
|
||||||
doxygen 2>&1 | tee build/doxygen.log
|
doxygen 2>&1 | tee build/doxygen.log
|
||||||
WARNINGS=$(cat build/doxygen.log | sort | uniq | grep -e ": warning: " | wc -l)
|
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"
|
echo "Doxygen finished with $WARNINGS warnings, max allowed: $MAX_ALLOWED"
|
||||||
if [ "$WARNINGS" -gt "$MAX_ALLOWED" ]; then exit $WARNINGS; else exit 0; fi;
|
if [ "$WARNINGS" -gt "$MAX_ALLOWED" ]; then exit $WARNINGS; else exit 0; fi;
|
||||||
|
|
||||||
|
@ -129,7 +129,7 @@ jobs:
|
||||||
- name: configure
|
- name: configure
|
||||||
run: |
|
run: |
|
||||||
cmake -S . -B build \
|
cmake -S . -B build \
|
||||||
-DWERROR=ON \
|
-DWERROR=OFF \
|
||||||
-DBUILD_TESTS=ON \
|
-DBUILD_TESTS=ON \
|
||||||
-D${{ matrix.param }} \
|
-D${{ matrix.param }} \
|
||||||
-DBOOST_ROOT=boost_${BOOST_VERSION} \
|
-DBOOST_ROOT=boost_${BOOST_VERSION} \
|
||||||
|
@ -401,14 +401,15 @@ jobs:
|
||||||
with:
|
with:
|
||||||
#path: ${VCPKG_INSTALLATION_ROOT}\installed
|
#path: ${VCPKG_INSTALLATION_ROOT}\installed
|
||||||
path: c:\vcpkg\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
|
- name: dependencies
|
||||||
if: steps.cache-dependencies.outputs.cache-hit != 'true'
|
if: steps.cache-dependencies.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
cd c:\vcpkg
|
cd c:\vcpkg
|
||||||
git pull
|
git pull
|
||||||
vcpkg.exe update
|
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
|
- name: configure
|
||||||
run: |
|
run: |
|
||||||
echo vcpkg installation root: ${env:VCPKG_INSTALLATION_ROOT}
|
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
|
key: ${{ runner.os }}-dependencies
|
||||||
- name: Get dependenciesenv
|
- name: Get dependenciesenv
|
||||||
if: steps.cache-dependencies.outputs.cache-hit != 'true'
|
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
|
- name: configure
|
||||||
run: |
|
run: |
|
||||||
echo vcpkg installation root: $env:VCPKG_INSTALLATION_ROOT
|
echo vcpkg installation root: $env:VCPKG_INSTALLATION_ROOT
|
||||||
|
|
|
@ -8,7 +8,7 @@ endif()
|
||||||
project(
|
project(
|
||||||
snapcast
|
snapcast
|
||||||
LANGUAGES CXX
|
LANGUAGES CXX
|
||||||
VERSION 0.31.0)
|
VERSION 0.31.100)
|
||||||
|
|
||||||
set(PROJECT_DESCRIPTION "Multiroom client-server audio player")
|
set(PROJECT_DESCRIPTION "Multiroom client-server audio player")
|
||||||
set(PROJECT_URL "https://github.com/badaix/snapcast")
|
set(PROJECT_URL "https://github.com/badaix/snapcast")
|
||||||
|
@ -69,12 +69,12 @@ else()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(ASAN)
|
if(ASAN)
|
||||||
add_compile_options(-fsanitize=address -Wno-error=maybe-uninitialized)
|
add_compile_options(-fsanitize=address)
|
||||||
add_link_options(-fsanitize=address)
|
add_link_options(-fsanitize=address)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(TSAN)
|
if(TSAN)
|
||||||
add_compile_options(-fsanitize=thread -Wno-error=tsan)
|
add_compile_options(-fsanitize=thread)
|
||||||
add_link_options(-fsanitize=thread)
|
add_link_options(-fsanitize=thread)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
@ -177,8 +177,6 @@ endif()
|
||||||
|
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
|
|
||||||
find_package(OpenSSL REQUIRED)
|
|
||||||
|
|
||||||
include(CMakePushCheckState)
|
include(CMakePushCheckState)
|
||||||
include(CheckIncludeFileCXX)
|
include(CheckIncludeFileCXX)
|
||||||
include_directories(${INCLUDE_DIRS})
|
include_directories(${INCLUDE_DIRS})
|
||||||
|
|
19
changelog.md
19
changelog.md
|
@ -1,5 +1,24 @@
|
||||||
# Snapcast changelog
|
# 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
|
## Version 0.31.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
|
@ -62,6 +62,14 @@ elseif(NOT ANDROID)
|
||||||
endif(PULSE_FOUND)
|
endif(PULSE_FOUND)
|
||||||
endif(MACOSX)
|
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)
|
if(ANDROID)
|
||||||
list(APPEND CLIENT_LIBRARIES oboe::oboe)
|
list(APPEND CLIENT_LIBRARIES oboe::oboe)
|
||||||
list(APPEND CLIENT_LIBRARIES boost::boost)
|
list(APPEND CLIENT_LIBRARIES boost::boost)
|
||||||
|
|
|
@ -24,19 +24,32 @@
|
||||||
#include "common/str_compat.hpp"
|
#include "common/str_compat.hpp"
|
||||||
|
|
||||||
// 3rd party headers
|
// 3rd party headers
|
||||||
|
#include <boost/asio/buffer.hpp>
|
||||||
|
#include <boost/asio/connect.hpp>
|
||||||
#include <boost/asio/read.hpp>
|
#include <boost/asio/read.hpp>
|
||||||
#include <boost/asio/streambuf.hpp>
|
#include <boost/asio/streambuf.hpp>
|
||||||
#include <boost/asio/write.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
|
// standard headers
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
|
||||||
using namespace std;
|
using namespace std;
|
||||||
|
namespace http = beast::http; // from <boost/beast/http.hpp>
|
||||||
|
|
||||||
static constexpr auto LOG_TAG = "Connection";
|
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)
|
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)
|
: 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)
|
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)
|
for (const auto& iter : iterator)
|
||||||
{
|
{
|
||||||
LOG(INFO, LOG_TAG) << "Connecting to " << iter.endpoint() << "\n";
|
LOG(INFO, LOG_TAG) << "Connecting to host: " << iter.endpoint() << ", port: " << server_.port << ", protocol: " << server_.protocol << "\n";
|
||||||
socket_.connect(iter, ec);
|
ec = doConnect(iter.endpoint());
|
||||||
if (!ec || (ec == boost::system::errc::interrupted))
|
if (!ec || (ec == boost::system::errc::interrupted))
|
||||||
{
|
{
|
||||||
// We were successful or interrupted, e.g. by sig int
|
// We were successful or interrupted, e.g. by sig int
|
||||||
|
@ -148,9 +139,12 @@ void ClientConnection::connect(const ResultHandler& handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ec)
|
if (ec)
|
||||||
|
{
|
||||||
LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n";
|
LOG(ERROR, LOG_TAG) << "Failed to connect to host '" << server_.host << "', error: " << ec.message() << "\n";
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
else
|
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);
|
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()
|
void ClientConnection::sendNext()
|
||||||
{
|
{
|
||||||
auto& message = messages_.front();
|
auto& message = messages_.front();
|
||||||
static boost::asio::streambuf streambuf;
|
std::ostream stream(&streambuf_);
|
||||||
std::ostream stream(&streambuf);
|
|
||||||
tv t;
|
tv t;
|
||||||
message.msg->sent = t;
|
message.msg->sent = t;
|
||||||
message.msg->serialize(stream);
|
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)
|
if (ec)
|
||||||
LOG(ERROR, LOG_TAG) << "Failed to send message, error: " << ec.message() << "\n";
|
LOG(ERROR, LOG_TAG) << "Failed to send message, error: " << ec.message() << "\n";
|
||||||
else
|
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
|
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());
|
auto response = msg::factory::createMessage(base_message_, buffer_.data());
|
||||||
if (!response)
|
if (!response)
|
||||||
LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n";
|
LOG(WARNING, LOG_TAG) << "Failed to deserialize message of type: " << base_message_.type << "\n";
|
||||||
for (auto iter = pendingRequests_.begin(); iter != pendingRequests_.end(); ++iter)
|
|
||||||
{
|
messageReceived(std::move(response), handler);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
if (handler)
|
||||||
handler(ec, std::move(response));
|
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/ip/tcp.hpp>
|
||||||
#include <boost/asio/steady_timer.hpp>
|
#include <boost/asio/steady_timer.hpp>
|
||||||
#include <boost/asio/strand.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
|
// standard headers
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#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;
|
class ClientConnection;
|
||||||
|
|
||||||
|
@ -87,17 +102,19 @@ class ClientConnection
|
||||||
public:
|
public:
|
||||||
/// Result callback with boost::error_code
|
/// Result callback with boost::error_code
|
||||||
using ResultHandler = std::function<void(const boost::system::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
|
/// c'tor
|
||||||
ClientConnection(boost::asio::io_context& io_context, ClientSettings::Server server);
|
ClientConnection(boost::asio::io_context& io_context, ClientSettings::Server server);
|
||||||
/// d'tor
|
/// d'tor
|
||||||
virtual ~ClientConnection();
|
virtual ~ClientConnection() = default;
|
||||||
|
|
||||||
/// async connect
|
/// async connect
|
||||||
/// @param handler async result handler
|
/// @param handler async result handler
|
||||||
void connect(const ResultHandler& handler);
|
void connect(const ResultHandler& handler);
|
||||||
/// disconnect the socket
|
/// disconnect the socket
|
||||||
void disconnect();
|
virtual void disconnect() = 0;
|
||||||
|
|
||||||
/// async send a message
|
/// async send a message
|
||||||
/// @param message the message
|
/// @param message the message
|
||||||
|
@ -126,35 +143,43 @@ public:
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @return MAC address of the client
|
/// @return MAC address of the client
|
||||||
std::string getMacAddress();
|
virtual std::string getMacAddress() = 0;
|
||||||
|
|
||||||
/// async get the next message
|
/// async get the next message
|
||||||
/// @param handler the next received message or error
|
/// @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:
|
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_
|
/// Send next pending message from messages_
|
||||||
void sendNext();
|
void sendNext();
|
||||||
|
|
||||||
/// Base message holding the received message
|
/// Base message holding the received message
|
||||||
msg::BaseMessage base_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
|
/// Strand to serialize send/receive
|
||||||
boost::asio::strand<boost::asio::any_io_executor> strand_;
|
boost::asio::strand<boost::asio::any_io_executor> strand_;
|
||||||
|
|
||||||
/// TCP resolver
|
/// TCP resolver
|
||||||
tcp::resolver resolver_;
|
boost::asio::ip::tcp::resolver resolver_;
|
||||||
/// TCP socket
|
|
||||||
tcp::socket socket_;
|
|
||||||
/// List of pending requests, waiting for a response (Message::refersTo)
|
/// List of pending requests, waiting for a response (Message::refersTo)
|
||||||
std::vector<std::weak_ptr<PendingRequest>> pendingRequests_;
|
std::vector<std::weak_ptr<PendingRequest>> pendingRequests_;
|
||||||
/// unique request id to match a response
|
/// unique request id to match a response
|
||||||
uint16_t reqId_;
|
uint16_t reqId_;
|
||||||
/// Server settings (host and port)
|
/// Server settings (host and port)
|
||||||
ClientSettings::Server server_;
|
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
|
/// A pending request
|
||||||
struct PendingMessage
|
struct PendingMessage
|
||||||
|
@ -172,3 +197,91 @@ protected:
|
||||||
/// Pending messages to be sent
|
/// Pending messages to be sent
|
||||||
std::deque<PendingMessage> messages_;
|
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
|
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
|
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
|
it under the terms of the GNU General Public License as published by
|
||||||
|
@ -23,60 +23,101 @@
|
||||||
#include "player/pcm_device.hpp"
|
#include "player/pcm_device.hpp"
|
||||||
|
|
||||||
// standard headers
|
// standard headers
|
||||||
|
#include <filesystem>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
|
||||||
|
/// Snapclient settings
|
||||||
struct ClientSettings
|
struct ClientSettings
|
||||||
{
|
{
|
||||||
|
/// Sharing mode for audio device
|
||||||
enum class SharingMode
|
enum class SharingMode
|
||||||
{
|
{
|
||||||
unspecified,
|
unspecified, ///< unspecified
|
||||||
exclusive,
|
exclusive, ///< exclusice access
|
||||||
shared
|
shared ///< shared access
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Mixer settings
|
||||||
struct Mixer
|
struct Mixer
|
||||||
{
|
{
|
||||||
|
/// Mixer mode
|
||||||
enum class Mode
|
enum class Mode
|
||||||
{
|
{
|
||||||
hardware,
|
hardware, ///< hardware mixer
|
||||||
software,
|
software, ///< software mixer
|
||||||
script,
|
script, ///< run a mixer script
|
||||||
none
|
none ///< no mixer
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// the configured mixer mode
|
||||||
Mode mode{Mode::software};
|
Mode mode{Mode::software};
|
||||||
std::string parameter{""};
|
/// mixer parameter
|
||||||
|
std::string parameter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Server settings
|
||||||
struct Server
|
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};
|
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
|
struct Player
|
||||||
{
|
{
|
||||||
std::string player_name{""};
|
/// name of the player
|
||||||
std::string parameter{""};
|
std::string player_name;
|
||||||
|
/// player parameters
|
||||||
|
std::string parameter;
|
||||||
|
/// additional latency of the DAC [ms]
|
||||||
int latency{0};
|
int latency{0};
|
||||||
|
/// the DAC
|
||||||
player::PcmDevice pcm_device;
|
player::PcmDevice pcm_device;
|
||||||
|
/// Sampleformat to be uses, i.e. 48000:16:2
|
||||||
SampleFormat sample_format;
|
SampleFormat sample_format;
|
||||||
|
/// The sharing mode
|
||||||
SharingMode sharing_mode{SharingMode::unspecified};
|
SharingMode sharing_mode{SharingMode::unspecified};
|
||||||
|
/// Mixer settings
|
||||||
Mixer mixer;
|
Mixer mixer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Log settings
|
||||||
struct Logging
|
struct Logging
|
||||||
{
|
{
|
||||||
std::string sink{""};
|
/// The log sink (null,system,stdout,stderr,file:<filename>)
|
||||||
|
std::string sink;
|
||||||
|
/// Log filter
|
||||||
std::string filter{"*:info"};
|
std::string filter{"*:info"};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The snapclient process instance
|
||||||
size_t instance{1};
|
size_t instance{1};
|
||||||
|
/// The host id, presented to the server
|
||||||
std::string host_id;
|
std::string host_id;
|
||||||
|
|
||||||
|
/// Server settings
|
||||||
Server server;
|
Server server;
|
||||||
|
/// Player settings
|
||||||
Player player;
|
Player player;
|
||||||
|
/// Logging settings
|
||||||
Logging logging;
|
Logging logging;
|
||||||
};
|
};
|
||||||
|
|
|
@ -76,10 +76,53 @@ using namespace player;
|
||||||
static constexpr auto LOG_TAG = "Controller";
|
static constexpr auto LOG_TAG = "Controller";
|
||||||
static constexpr auto TIME_SYNC_INTERVAL = 1s;
|
static constexpr auto TIME_SYNC_INTERVAL = 1s;
|
||||||
|
|
||||||
Controller::Controller(boost::asio::io_context& io_context, const ClientSettings& settings) //, std::unique_ptr<MetadataAdapter> meta)
|
Controller::Controller(boost::asio::io_context& io_context, const ClientSettings& settings)
|
||||||
: io_context_(io_context), timer_(io_context), settings_(settings), stream_(nullptr), decoder_(nullptr), player_(nullptr),
|
: io_context_(io_context),
|
||||||
serverSettings_(nullptr) // meta_(std::move(meta)),
|
#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.host = host;
|
||||||
settings_.server.port = port;
|
settings_.server.port = port;
|
||||||
LOG(INFO, LOG_TAG) << "Found server " << settings_.server.host << ":" << settings_.server.port << "\n";
|
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();
|
worker();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
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();
|
worker();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
it under the terms of the GNU General Public License as published by
|
||||||
|
@ -43,9 +43,12 @@ using namespace std::chrono_literals;
|
||||||
class Controller
|
class Controller
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
/// c'tor
|
||||||
Controller(boost::asio::io_context& io_context, const ClientSettings& settings); //, std::unique_ptr<MetadataAdapter> meta);
|
Controller(boost::asio::io_context& io_context, const ClientSettings& settings); //, std::unique_ptr<MetadataAdapter> meta);
|
||||||
|
/// Start thw work
|
||||||
void start();
|
void start();
|
||||||
// void stop();
|
// void stop();
|
||||||
|
/// @return list of supported audio backends
|
||||||
static std::vector<std::string> getSupportedPlayerNames();
|
static std::vector<std::string> getSupportedPlayerNames();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
@ -61,6 +64,9 @@ private:
|
||||||
void sendTimeSyncMessage(int quick_syncs);
|
void sendTimeSyncMessage(int quick_syncs);
|
||||||
|
|
||||||
boost::asio::io_context& io_context_;
|
boost::asio::io_context& io_context_;
|
||||||
|
#ifdef HAS_OPENSSL
|
||||||
|
boost::asio::ssl::context ssl_context_;
|
||||||
|
#endif
|
||||||
boost::asio::steady_timer timer_;
|
boost::asio::steady_timer timer_;
|
||||||
ClientSettings settings_;
|
ClientSettings settings_;
|
||||||
SampleFormat sampleFormat_;
|
SampleFormat sampleFormat_;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
***/
|
***/
|
||||||
|
|
||||||
#ifndef PCM_DEVICE_HPP
|
#pragma once
|
||||||
#define PCM_DEVICE_HPP
|
|
||||||
|
|
||||||
|
// standard headers
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace player
|
namespace player
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/// Name of the default audio device
|
||||||
static constexpr char DEFAULT_DEVICE[] = "default";
|
static constexpr char DEFAULT_DEVICE[] = "default";
|
||||||
|
|
||||||
|
/// DAC identifier
|
||||||
struct PcmDevice
|
struct PcmDevice
|
||||||
{
|
{
|
||||||
|
/// c'tor
|
||||||
PcmDevice() : idx(-1), name(DEFAULT_DEVICE){};
|
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){};
|
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;
|
int idx;
|
||||||
|
/// device name
|
||||||
std::string name;
|
std::string name;
|
||||||
|
/// device description
|
||||||
std::string description;
|
std::string description;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace player
|
} // namespace player
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
// local headers
|
// local headers
|
||||||
#include "common/popl.hpp"
|
#include "common/popl.hpp"
|
||||||
|
#include "common/utils/string_utils.hpp"
|
||||||
#include "controller.hpp"
|
#include "controller.hpp"
|
||||||
|
|
||||||
#ifdef HAS_ALSA
|
#ifdef HAS_ALSA
|
||||||
|
@ -37,6 +38,7 @@
|
||||||
#include "common/aixlog.hpp"
|
#include "common/aixlog.hpp"
|
||||||
#include "common/snap_exception.hpp"
|
#include "common/snap_exception.hpp"
|
||||||
#include "common/str_compat.hpp"
|
#include "common/str_compat.hpp"
|
||||||
|
#include "common/stream_uri.hpp"
|
||||||
#include "common/version.hpp"
|
#include "common/version.hpp"
|
||||||
|
|
||||||
// 3rd party headers
|
// 3rd party headers
|
||||||
|
@ -44,6 +46,7 @@
|
||||||
#include <boost/asio/signal_set.hpp>
|
#include <boost/asio/signal_set.hpp>
|
||||||
|
|
||||||
// standard headers
|
// standard headers
|
||||||
|
#include <filesystem>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#ifndef WINDOWS
|
#ifndef WINDOWS
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
|
@ -133,23 +136,38 @@ int main(int argc, char** argv)
|
||||||
ClientSettings settings;
|
ClientSettings settings;
|
||||||
string pcm_device(player::DEFAULT_DEVICE);
|
string pcm_device(player::DEFAULT_DEVICE);
|
||||||
|
|
||||||
OptionParser op("Allowed options");
|
OptionParser op("Usage: snapclient [options...] [url]\n\n"
|
||||||
auto helpSwitch = op.add<Switch>("", "help", "produce help message");
|
" With 'url' = "
|
||||||
auto groffSwitch = op.add<Switch, Attribute::hidden>("", "groff", "produce groff message");
|
#ifdef HAS_OPENSSL
|
||||||
auto versionSwitch = op.add<Switch>("v", "version", "show version number");
|
"<tcp|ws|wss>"
|
||||||
op.add<Value<string>>("h", "host", "server hostname or ip address", "", &settings.server.host);
|
#else
|
||||||
op.add<Value<size_t>>("p", "port", "server port", 1704, &settings.server.port);
|
"<tcp|ws>"
|
||||||
op.add<Value<size_t>>("i", "instance", "instance id when running multiple instances on the same host", 1, &settings.instance);
|
#endif
|
||||||
op.add<Value<string>>("", "hostID", "unique host id, default is MAC address", "", &settings.host_id);
|
"://<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
|
// PCM device specific
|
||||||
#if defined(HAS_ALSA) || defined(HAS_PULSE) || defined(HAS_WASAPI)
|
#if defined(HAS_ALSA) || defined(HAS_PULSE) || defined(HAS_WASAPI)
|
||||||
auto listSwitch = op.add<Switch>("l", "list", "list PCM devices");
|
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 soundcardValue =*/op.add<Value<string>>("s", "Soundcard", "index or name of the pcm device", pcm_device, &pcm_device);
|
||||||
#endif
|
#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
|
#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
|
#endif
|
||||||
|
|
||||||
auto supported_players = Controller::getSupportedPlayerNames();
|
auto supported_players = Controller::getSupportedPlayerNames();
|
||||||
|
@ -160,7 +178,7 @@ int main(int argc, char** argv)
|
||||||
|
|
||||||
// sharing mode
|
// sharing mode
|
||||||
#if defined(HAS_OBOE) || defined(HAS_WASAPI)
|
#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
|
#endif
|
||||||
|
|
||||||
// mixer
|
// mixer
|
||||||
|
@ -181,12 +199,12 @@ int main(int argc, char** argv)
|
||||||
// daemon settings
|
// daemon settings
|
||||||
#ifdef HAS_DAEMON
|
#ifdef HAS_DAEMON
|
||||||
int processPriority(-3);
|
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");
|
auto userValue = op.add<Value<string>>("", "user", "the user[:group] to run snapclient as when daemonized");
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// logging
|
// 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>>(
|
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]",
|
"", "logfilter", "log filter <tag>:<level>[,<tag>:<level>]* with tag = * or <log tag> and level = [trace,debug,info,notice,warning,error,fatal]",
|
||||||
settings.logging.filter);
|
settings.logging.filter);
|
||||||
|
@ -303,6 +321,82 @@ int main(int argc, char** argv)
|
||||||
else
|
else
|
||||||
throw SnapException("Invalid log sink: " + settings.logging.sink);
|
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 !defined(HAS_AVAHI) && !defined(HAS_BONJOUR)
|
||||||
if (settings.server.host.empty())
|
if (settings.server.host.empty())
|
||||||
throw SnapException("Snapserver host not configured and mDNS not available, please configure with \"--host\".");
|
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;
|
int num_threads = 0;
|
||||||
std::vector<std::thread> threads;
|
std::vector<std::thread> threads;
|
||||||
|
threads.reserve(num_threads);
|
||||||
for (int n = 0; n < num_threads; ++n)
|
for (int n = 0; n < num_threads; ++n)
|
||||||
threads.emplace_back([&] { io_context.run(); });
|
threads.emplace_back([&] { io_context.run(); });
|
||||||
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)
|
utils/string_utils.cpp)
|
||||||
|
|
||||||
if(NOT WIN32 AND NOT ANDROID)
|
if(NOT WIN32 AND NOT ANDROID)
|
||||||
list(APPEND SOURCES daemon.cpp)
|
list(APPEND SOURCES daemon.cpp)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
include_directories(${OPENSSL_INCLUDE_DIR})
|
|
||||||
|
|
||||||
if(SOXR_FOUND)
|
if(SOXR_FOUND)
|
||||||
include_directories(${SOXR_INCLUDE_DIRS})
|
include_directories(${SOXR_INCLUDE_DIRS})
|
||||||
endif(SOXR_FOUND)
|
endif(SOXR_FOUND)
|
||||||
|
@ -18,5 +16,3 @@ if(ANDROID)
|
||||||
elseif(SOXR_FOUND)
|
elseif(SOXR_FOUND)
|
||||||
target_link_libraries(common ${SOXR_LIBRARIES})
|
target_link_libraries(common ${SOXR_LIBRARIES})
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
target_link_libraries(common OpenSSL::Crypto OpenSSL::SSL)
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
***/
|
***/
|
||||||
|
|
||||||
#ifndef MESSAGE_CLIENT_INFO_HPP
|
#pragma once
|
||||||
#define MESSAGE_CLIENT_INFO_HPP
|
|
||||||
|
|
||||||
// local headers
|
// local headers
|
||||||
#include "json_message.hpp"
|
#include "json_message.hpp"
|
||||||
|
@ -27,39 +26,47 @@ namespace msg
|
||||||
{
|
{
|
||||||
|
|
||||||
/// Client information sent from client to server
|
/// 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
|
class ClientInfo : public JsonMessage
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
/// c'tor
|
||||||
ClientInfo() : JsonMessage(message_type::kClientInfo)
|
ClientInfo() : JsonMessage(message_type::kClientInfo)
|
||||||
{
|
{
|
||||||
setVolume(100);
|
setVolume(100);
|
||||||
setMuted(false);
|
setMuted(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// d'tor
|
||||||
~ClientInfo() override = default;
|
~ClientInfo() override = default;
|
||||||
|
|
||||||
|
/// @return the volume in percent
|
||||||
uint16_t getVolume()
|
uint16_t getVolume()
|
||||||
{
|
{
|
||||||
return get("volume", static_cast<uint16_t>(100));
|
return get("volume", static_cast<uint16_t>(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return if muted or not
|
||||||
bool isMuted()
|
bool isMuted()
|
||||||
{
|
{
|
||||||
return get("muted", false);
|
return get("muted", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the volume to @p volume percent
|
||||||
void setVolume(uint16_t volume)
|
void setVolume(uint16_t volume)
|
||||||
{
|
{
|
||||||
msg["volume"] = volume;
|
msg["volume"] = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set muted to @p muted
|
||||||
void setMuted(bool muted)
|
void setMuted(bool muted)
|
||||||
{
|
{
|
||||||
msg["muted"] = muted;
|
msg["muted"] = muted;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace msg
|
} // namespace msg
|
||||||
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
***/
|
***/
|
||||||
|
|
||||||
#ifndef MESSAGE_CODEC_HEADER_HPP
|
#pragma once
|
||||||
#define MESSAGE_CODEC_HEADER_HPP
|
|
||||||
|
|
||||||
// local headers
|
// local headers
|
||||||
#include "message.hpp"
|
#include "message.hpp"
|
||||||
|
@ -31,13 +31,15 @@ namespace msg
|
||||||
class CodecHeader : public BaseMessage
|
class CodecHeader : public BaseMessage
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit CodecHeader(const std::string& codecName = "", uint32_t size = 0)
|
/// c'tor taking the @p codec_name and @p site of the payload
|
||||||
: BaseMessage(message_type::kCodecHeader), payloadSize(size), payload(nullptr), codec(codecName)
|
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)
|
if (size > 0)
|
||||||
payload = static_cast<char*>(malloc(size * sizeof(char)));
|
payload = static_cast<char*>(malloc(size * sizeof(char)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// d'tor
|
||||||
~CodecHeader() override
|
~CodecHeader() override
|
||||||
{
|
{
|
||||||
free(payload);
|
free(payload);
|
||||||
|
@ -54,8 +56,11 @@ public:
|
||||||
return static_cast<uint32_t>(sizeof(uint32_t) + codec.size() + sizeof(uint32_t) + payloadSize);
|
return static_cast<uint32_t>(sizeof(uint32_t) + codec.size() + sizeof(uint32_t) + payloadSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// payload size
|
||||||
uint32_t payloadSize;
|
uint32_t payloadSize;
|
||||||
|
/// the payload
|
||||||
char* payload;
|
char* payload;
|
||||||
|
/// name of the codec
|
||||||
std::string codec;
|
std::string codec;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
@ -65,7 +70,5 @@ protected:
|
||||||
writeVal(stream, payload, payloadSize);
|
writeVal(stream, payload, payloadSize);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace msg
|
} // namespace msg
|
||||||
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
it under the terms of the GNU General Public License as published by
|
||||||
|
@ -30,15 +30,16 @@
|
||||||
namespace msg
|
namespace msg
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/// Cast a BaseMessage @message to type "ToType"
|
||||||
|
/// @return castest message or nullptr, if the cast failed
|
||||||
template <typename ToType>
|
template <typename ToType>
|
||||||
static std::unique_ptr<ToType> message_cast(std::unique_ptr<msg::BaseMessage> message)
|
static std::unique_ptr<ToType> message_cast(std::unique_ptr<msg::BaseMessage> message)
|
||||||
{
|
{
|
||||||
ToType* tmp = dynamic_cast<ToType*>(message.get());
|
auto* tmp = dynamic_cast<ToType*>(message.get());
|
||||||
std::unique_ptr<ToType> result;
|
|
||||||
if (tmp != nullptr)
|
if (tmp != nullptr)
|
||||||
{
|
{
|
||||||
message.release();
|
message.release();
|
||||||
result.reset(tmp);
|
std::unique_ptr<ToType> result(tmp);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
@ -47,6 +48,7 @@ static std::unique_ptr<ToType> message_cast(std::unique_ptr<msg::BaseMessage> me
|
||||||
namespace factory
|
namespace factory
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/// Create a message of type T from @p base_message beaser and payload @p buffer
|
||||||
template <typename T>
|
template <typename T>
|
||||||
static std::unique_ptr<T> createMessage(const BaseMessage& base_message, char* buffer)
|
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;
|
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)
|
static std::unique_ptr<BaseMessage> createMessage(const BaseMessage& base_message, char* buffer)
|
||||||
{
|
{
|
||||||
std::unique_ptr<BaseMessage> result;
|
std::unique_ptr<BaseMessage> result;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
***/
|
***/
|
||||||
|
|
||||||
#ifndef MESSAGE_HELLO_HPP
|
#pragma once
|
||||||
#define MESSAGE_HELLO_HPP
|
|
||||||
|
|
||||||
// local headers
|
// local headers
|
||||||
#include "common/str_compat.hpp"
|
#include "common/str_compat.hpp"
|
||||||
|
@ -31,16 +30,20 @@
|
||||||
namespace msg
|
namespace msg
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/// Hello message
|
||||||
|
/// Initial message, sent from client to server
|
||||||
class Hello : public JsonMessage
|
class Hello : public JsonMessage
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
/// c'tor
|
||||||
Hello() : JsonMessage(message_type::kHello)
|
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["HostName"] = ::getHostName();
|
||||||
msg["Version"] = VERSION;
|
msg["Version"] = VERSION;
|
||||||
msg["ClientName"] = "Snapclient";
|
msg["ClientName"] = "Snapclient";
|
||||||
|
@ -51,53 +54,64 @@ public:
|
||||||
msg["SnapStreamProtocolVersion"] = 2;
|
msg["SnapStreamProtocolVersion"] = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// d'tor
|
||||||
~Hello() override = default;
|
~Hello() override = default;
|
||||||
|
|
||||||
|
/// @return the MAC address
|
||||||
std::string getMacAddress() const
|
std::string getMacAddress() const
|
||||||
{
|
{
|
||||||
return msg["MAC"];
|
return msg["MAC"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return the host name
|
||||||
std::string getHostName() const
|
std::string getHostName() const
|
||||||
{
|
{
|
||||||
return msg["HostName"];
|
return msg["HostName"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return the client version
|
||||||
std::string getVersion() const
|
std::string getVersion() const
|
||||||
{
|
{
|
||||||
return msg["Version"];
|
return msg["Version"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return the client name (e.g. "Snapclient")
|
||||||
std::string getClientName() const
|
std::string getClientName() const
|
||||||
{
|
{
|
||||||
return msg["ClientName"];
|
return msg["ClientName"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return the OS name
|
||||||
std::string getOS() const
|
std::string getOS() const
|
||||||
{
|
{
|
||||||
return msg["OS"];
|
return msg["OS"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return the CPU architecture
|
||||||
std::string getArch() const
|
std::string getArch() const
|
||||||
{
|
{
|
||||||
return msg["Arch"];
|
return msg["Arch"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return the instance id
|
||||||
int getInstance() const
|
int getInstance() const
|
||||||
{
|
{
|
||||||
return get("Instance", 1);
|
return get("Instance", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return the protocol version
|
||||||
int getProtocolVersion() const
|
int getProtocolVersion() const
|
||||||
{
|
{
|
||||||
return get("SnapStreamProtocolVersion", 1);
|
return get("SnapStreamProtocolVersion", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return a unqiue machine ID
|
||||||
std::string getId() const
|
std::string getId() const
|
||||||
{
|
{
|
||||||
return get("ID", getMacAddress());
|
return get("ID", getMacAddress());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return a unqiue client ID
|
||||||
std::string getUniqueId() const
|
std::string getUniqueId() const
|
||||||
{
|
{
|
||||||
std::string id = getId();
|
std::string id = getId();
|
||||||
|
@ -109,7 +123,5 @@ public:
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace msg
|
} // namespace msg
|
||||||
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
***/
|
***/
|
||||||
|
|
||||||
#ifndef MESSAGE_JSON_HPP
|
#pragma once
|
||||||
#define MESSAGE_JSON_HPP
|
|
||||||
|
|
||||||
// local headers
|
// local headers
|
||||||
#include "common/json.hpp"
|
#include "common/json.hpp"
|
||||||
|
@ -30,13 +30,16 @@ using json = nlohmann::json;
|
||||||
namespace msg
|
namespace msg
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/// Base class of a message with json payload
|
||||||
class JsonMessage : public BaseMessage
|
class JsonMessage : public BaseMessage
|
||||||
{
|
{
|
||||||
public:
|
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;
|
~JsonMessage() override = default;
|
||||||
|
|
||||||
void read(std::istream& stream) override
|
void read(std::istream& stream) override
|
||||||
|
@ -60,6 +63,7 @@ protected:
|
||||||
writeVal(stream, msg.dump());
|
writeVal(stream, msg.dump());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return value for key @p what or @p def, if not found
|
||||||
template <typename T>
|
template <typename T>
|
||||||
T get(const std::string& what, const T& def) const
|
T get(const std::string& what, const T& def) const
|
||||||
{
|
{
|
||||||
|
@ -75,7 +79,5 @@ protected:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace msg
|
} // namespace msg
|
||||||
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -38,15 +38,17 @@ namespace msg
|
||||||
class PcmChunk : public WireChunk
|
class PcmChunk : public WireChunk
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
PcmChunk(const SampleFormat& sampleFormat, uint32_t ms)
|
/// c'tor, construct from @p sample_format with duration @p ms
|
||||||
: WireChunk((sampleFormat.rate() * ms / 1000) * sampleFormat.frameSize()), format(sampleFormat), idx_(0)
|
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;
|
~PcmChunk() override = default;
|
||||||
|
|
||||||
#if 0
|
#if 0
|
||||||
|
@ -73,16 +75,18 @@ public:
|
||||||
// return result;
|
// 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";
|
// logd << "read: " << frameCount << ", total: " << (wireChunk->length / format.frameSize()) << ", idx: " << idx;// << "\n";
|
||||||
int result = frameCount;
|
int result = frame_count;
|
||||||
if (idx_ + frameCount > (payloadSize / format.frameSize()))
|
if (idx_ + frame_count > (payloadSize / format.frameSize()))
|
||||||
result = (payloadSize / format.frameSize()) - idx_;
|
result = (payloadSize / format.frameSize()) - idx_;
|
||||||
|
|
||||||
// logd << ", from: " << format.frameSize()*idx << ", to: " << format.frameSize()*idx + format.frameSize()*result;
|
// logd << ", from: " << format.frameSize()*idx << ", to: " << format.frameSize()*idx + format.frameSize()*result;
|
||||||
if (outputBuffer != nullptr)
|
if (output_buffer != nullptr)
|
||||||
memcpy(static_cast<char*>(outputBuffer), static_cast<char*>(payload) + format.frameSize() * idx_, format.frameSize() * result);
|
memcpy(static_cast<char*>(output_buffer), static_cast<char*>(payload) + format.frameSize() * idx_, format.frameSize() * result);
|
||||||
|
|
||||||
idx_ += result;
|
idx_ += result;
|
||||||
// logd << ", new idx: " << idx << ", result: " << result << ", wireChunk->length: " << wireChunk->length << ", format.frameSize(): " <<
|
// logd << ", new idx: " << idx << ", result: " << result << ", wireChunk->length: " << wireChunk->length << ", format.frameSize(): " <<
|
||||||
|
@ -90,6 +94,8 @@ public:
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// seek @p frames forward or backward
|
||||||
|
/// @return the new read position
|
||||||
int seek(int frames)
|
int seek(int frames)
|
||||||
{
|
{
|
||||||
if ((frames < 0) && (-frames > static_cast<int>(idx_)))
|
if ((frames < 0) && (-frames > static_cast<int>(idx_)))
|
||||||
|
@ -102,17 +108,20 @@ public:
|
||||||
return idx_;
|
return idx_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return start time of the current frame
|
||||||
chronos::time_point_clk start() const override
|
chronos::time_point_clk start() const override
|
||||||
{
|
{
|
||||||
return chronos::time_point_clk(chronos::sec(timestamp.sec) + chronos::usec(timestamp.usec) +
|
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()))));
|
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
|
inline chronos::time_point_clk end() const
|
||||||
{
|
{
|
||||||
return start() + durationLeft<chronos::usec>();
|
return start() + durationLeft<chronos::usec>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return duration of this chunk
|
||||||
template <typename T>
|
template <typename T>
|
||||||
inline T duration() const
|
inline T duration() const
|
||||||
{
|
{
|
||||||
|
@ -127,42 +136,51 @@ public:
|
||||||
// payloadSize = newSize;
|
// payloadSize = newSize;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
void setFrameCount(int frameCount)
|
/// Set the @p frame_count, reserve memory
|
||||||
|
void setFrameCount(int frame_count)
|
||||||
{
|
{
|
||||||
auto newSize = format.frameSize() * frameCount;
|
auto new_size = format.frameSize() * frame_count;
|
||||||
payload = static_cast<char*>(realloc(payload, newSize));
|
payload = static_cast<char*>(realloc(payload, new_size));
|
||||||
payloadSize = newSize;
|
payloadSize = new_size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return duration of this chunk in [ms]
|
||||||
double durationMs() const
|
double durationMs() const
|
||||||
{
|
{
|
||||||
return static_cast<double>(getFrameCount()) / format.msRate();
|
return static_cast<double>(getFrameCount()) / format.msRate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return time left, starting from the read pointer
|
||||||
template <typename T>
|
template <typename T>
|
||||||
inline T durationLeft() const
|
inline T durationLeft() const
|
||||||
{
|
{
|
||||||
return std::chrono::duration_cast<T>(chronos::nsec(static_cast<chronos::nsec::rep>(1000000 * (getFrameCount() - idx_) / format.msRate())));
|
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
|
inline bool isEndOfChunk() const
|
||||||
{
|
{
|
||||||
return idx_ >= getFrameCount();
|
return idx_ >= getFrameCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return number of frames
|
||||||
inline uint32_t getFrameCount() const
|
inline uint32_t getFrameCount() const
|
||||||
{
|
{
|
||||||
return (payloadSize / format.frameSize());
|
return (payloadSize / format.frameSize());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return number of samples
|
||||||
inline uint32_t getSampleCount() const
|
inline uint32_t getSampleCount() const
|
||||||
{
|
{
|
||||||
return (payloadSize / format.sampleSize());
|
return (payloadSize / format.sampleSize());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sample format of this chunk
|
||||||
SampleFormat format;
|
SampleFormat format;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
/// current read position (frame idx)
|
||||||
uint32_t idx_ = 0;
|
uint32_t idx_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace msg
|
} // namespace msg
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
***/
|
***/
|
||||||
|
|
||||||
#ifndef MESSAGE_SERVER_SETTINGS_HPP
|
#pragma once
|
||||||
#define MESSAGE_SERVER_SETTINGS_HPP
|
|
||||||
|
|
||||||
// local headers
|
// local headers
|
||||||
#include "json_message.hpp"
|
#include "json_message.hpp"
|
||||||
|
@ -26,9 +25,11 @@
|
||||||
namespace msg
|
namespace msg
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/// Dynamic settings that affect the client
|
||||||
class ServerSettings : public JsonMessage
|
class ServerSettings : public JsonMessage
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
/// c'tor
|
||||||
ServerSettings() : JsonMessage(message_type::kServerSettings)
|
ServerSettings() : JsonMessage(message_type::kServerSettings)
|
||||||
{
|
{
|
||||||
setBufferMs(0);
|
setBufferMs(0);
|
||||||
|
@ -37,51 +38,57 @@ public:
|
||||||
setMuted(false);
|
setMuted(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// d'tor
|
||||||
~ServerSettings() override = default;
|
~ServerSettings() override = default;
|
||||||
|
|
||||||
|
/// @return the end to end delay in [ms]
|
||||||
int32_t getBufferMs()
|
int32_t getBufferMs()
|
||||||
{
|
{
|
||||||
return get("bufferMs", 0);
|
return get("bufferMs", 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return client specific additional latency in [ms]
|
||||||
int32_t getLatency()
|
int32_t getLatency()
|
||||||
{
|
{
|
||||||
return get("latency", 0);
|
return get("latency", 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return the volume in [%]
|
||||||
uint16_t getVolume()
|
uint16_t getVolume()
|
||||||
{
|
{
|
||||||
return get("volume", static_cast<uint16_t>(100));
|
return get("volume", static_cast<uint16_t>(100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @return if muted
|
||||||
bool isMuted()
|
bool isMuted()
|
||||||
{
|
{
|
||||||
return get("muted", false);
|
return get("muted", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Set the end to end delay to @p buffer_ms [ms]
|
||||||
void setBufferMs(int32_t bufferMs)
|
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)
|
void setLatency(int32_t latency)
|
||||||
{
|
{
|
||||||
msg["latency"] = latency;
|
msg["latency"] = latency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the @p volume [%]
|
||||||
void setVolume(uint16_t volume)
|
void setVolume(uint16_t volume)
|
||||||
{
|
{
|
||||||
msg["volume"] = volume;
|
msg["volume"] = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set client to @p muted
|
||||||
void setMuted(bool muted)
|
void setMuted(bool muted)
|
||||||
{
|
{
|
||||||
msg["muted"] = muted;
|
msg["muted"] = muted;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace msg
|
} // namespace msg
|
||||||
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
***/
|
***/
|
||||||
|
|
||||||
#ifndef MESSAGE_TIME_HPP
|
#pragma once
|
||||||
#define MESSAGE_TIME_HPP
|
|
||||||
|
|
||||||
// local headers
|
// local headers
|
||||||
#include "message.hpp"
|
#include "message.hpp"
|
||||||
|
@ -25,13 +24,16 @@
|
||||||
namespace msg
|
namespace msg
|
||||||
{
|
{
|
||||||
|
|
||||||
|
/// Time sync message, send from client to server and back
|
||||||
class Time : public BaseMessage
|
class Time : public BaseMessage
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
/// c'tor
|
||||||
Time() : BaseMessage(message_type::kTime)
|
Time() : BaseMessage(message_type::kTime)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// d'tor
|
||||||
~Time() override = default;
|
~Time() override = default;
|
||||||
|
|
||||||
void read(std::istream& stream) override
|
void read(std::istream& stream) override
|
||||||
|
@ -45,6 +47,7 @@ public:
|
||||||
return sizeof(tv);
|
return sizeof(tv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The latency after round trip "client => server => client"
|
||||||
tv latency;
|
tv latency;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
@ -54,7 +57,5 @@ protected:
|
||||||
writeVal(stream, latency.usec);
|
writeVal(stream, latency.usec);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace msg
|
} // namespace msg
|
||||||
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
( _ \ / \( _ \( )
|
( _ \ / \( _ \( )
|
||||||
) __/( O )) __// (_/\
|
) __/( O )) __// (_/\
|
||||||
(__) \__/(__) \____/
|
(__) \__/(__) \____/
|
||||||
version 1.3.0
|
version 1.3.1
|
||||||
https://github.com/badaix/popl
|
https://github.com/badaix/popl
|
||||||
|
|
||||||
This file is part of popl (program options parser lib)
|
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;
|
std::string print(const Attribute& max_attribute = Attribute::optional) const override;
|
||||||
|
|
||||||
private:
|
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;
|
std::string print(const Attribute& max_attribute = Attribute::optional) const override;
|
||||||
|
|
||||||
private:
|
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;
|
std::stringstream line;
|
||||||
if (option->short_name() != 0)
|
if (option->short_name() != 0)
|
||||||
|
@ -1142,7 +1142,7 @@ inline std::string ConsoleOptionPrinter::to_string(Option_ptr option) const
|
||||||
std::stringstream defaultStr;
|
std::stringstream defaultStr;
|
||||||
if (option->get_default(defaultStr))
|
if (option->get_default(defaultStr))
|
||||||
{
|
{
|
||||||
if (!defaultStr.str().empty())
|
if (!defaultStr.str().empty() && (defaultStr.str() != "\"\""))
|
||||||
line << " (=" << defaultStr.str() << ")";
|
line << " (=" << defaultStr.str() << ")";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1167,7 +1167,7 @@ inline std::string ConsoleOptionPrinter::print(const Attribute& max_attribute) c
|
||||||
|
|
||||||
std::stringstream s;
|
std::stringstream s;
|
||||||
if (!option_parser_->description().empty())
|
if (!option_parser_->description().empty())
|
||||||
s << option_parser_->description() << ":\n";
|
s << option_parser_->description() << "\n";
|
||||||
|
|
||||||
size_t optionRightMargin(20);
|
size_t optionRightMargin(20);
|
||||||
const size_t maxDescriptionLeftMargin(40);
|
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;
|
std::stringstream line;
|
||||||
if (option->short_name() != 0)
|
if (option->short_name() != 0)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
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_;
|
int error_code_;
|
||||||
|
|
||||||
public:
|
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
|
int code() const noexcept
|
||||||
{
|
{
|
||||||
|
|
|
@ -33,8 +33,6 @@ namespace strutils = utils::string;
|
||||||
|
|
||||||
static constexpr auto LOG_TAG = "StreamUri";
|
static constexpr auto LOG_TAG = "StreamUri";
|
||||||
|
|
||||||
namespace streamreader
|
|
||||||
{
|
|
||||||
|
|
||||||
StreamUri::StreamUri(const std::string& uri)
|
StreamUri::StreamUri(const std::string& uri)
|
||||||
{
|
{
|
||||||
|
@ -87,6 +85,12 @@ void StreamUri::parse(const std::string& stream_uri)
|
||||||
// pos: ^ or ^ or ^
|
// pos: ^ or ^ or ^
|
||||||
|
|
||||||
host = strutils::uriDecode(strutils::trim_copy(tmp.substr(0, pos)));
|
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);
|
tmp = tmp.substr(pos);
|
||||||
path = tmp;
|
path = tmp;
|
||||||
pos = std::min(path.find('?'), path.find('#'));
|
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;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
bool StreamUri::operator==(const StreamUri& other) const
|
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
|
// standard headers
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
|
||||||
using json = nlohmann::json;
|
using json = nlohmann::json;
|
||||||
|
|
||||||
namespace streamreader
|
|
||||||
{
|
|
||||||
|
|
||||||
/// URI with the general format:
|
/// URI with the general format:
|
||||||
/// scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
|
/// scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
|
||||||
struct StreamUri
|
struct StreamUri
|
||||||
{
|
{
|
||||||
|
/// c'tor
|
||||||
|
StreamUri() = default;
|
||||||
/// c'tor construct from string @p uri
|
/// c'tor construct from string @p uri
|
||||||
explicit StreamUri(const std::string& uri);
|
explicit StreamUri(const std::string& uri);
|
||||||
|
|
||||||
|
@ -54,6 +54,8 @@ struct StreamUri
|
||||||
|
|
||||||
/// the host component
|
/// the host component
|
||||||
std::string host;
|
std::string host;
|
||||||
|
/// the port
|
||||||
|
std::optional<size_t> port;
|
||||||
/// the path component
|
/// the path component
|
||||||
std::string path;
|
std::string path;
|
||||||
/// the query component: "key = value" pairs
|
/// the query component: "key = value" pairs
|
||||||
|
@ -76,5 +78,3 @@ struct StreamUri
|
||||||
/// @return true if @p other is equal to this
|
/// @return true if @p other is equal to this
|
||||||
bool operator==(const StreamUri& other) const;
|
bool operator==(const StreamUri& other) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace streamreader
|
|
|
@ -1,6 +1,7 @@
|
||||||
# Snapcast binary protocol
|
# Snapcast binary protocol
|
||||||
|
|
||||||
Each message sent with the Snapcast binary protocol is split up into two parts:
|
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 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
|
- 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
|
## Messages
|
||||||
|
|
||||||
| Typed Message ID | Name | Notes |
|
| Typed Message ID | Name | Dir | Notes |
|
||||||
|------------------|--------------------------------------|---------------------------------------------------------------------------|
|
|------------------|--------------------------------------|------|---------------------------------------------------------------------------|
|
||||||
| 0 | [Base](#base) | The beginning of every message containing data about the typed message |
|
| 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 |
|
| 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) | A part of an audio stream |
|
| 2 | [Wire Chunk](#wire-chunk) | S->C | A part of an audio stream |
|
||||||
| 3 | [Server Settings](#server-settings) | Settings set from the server like volume, latency, etc |
|
| 3 | [Server Settings](#server-settings) | S->C | Settings set from the server like volume, latency, etc |
|
||||||
| 4 | [Time](#time) | Used for synchronizing time with the server |
|
| 4 | [Time](#time) | C->S<br>S->C | Used for synchronizing time with the server |
|
||||||
| 5 | [Hello](#hello) | Sent by the client when connecting with the server |
|
| 5 | [Hello](#hello) | C->S | Sent by the client when connecting with the server |
|
||||||
| 6 | [Stream Tags](#stream-tags) | Metadata about the stream for use by the client |
|
| 7 | [Client Info](#client-info) | C->S | Update the server when relevant information changes (e.g. client volume) |
|
||||||
|
|
||||||
### Base
|
### 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
|
- 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)
|
- 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
|
### Wire Chunk
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|
@ -122,3 +122,21 @@ Sample JSON payload (whitespace added for readability):
|
||||||
"Version": "0.17.1"
|
"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
|
### 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.
|
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:
|
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)
|
- [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.
|
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
|
* `track`: the current track will start again from the begining once it has finished playing
|
||||||
* `playlist`: the playback loops through a list of tracks
|
* `playlist`: the playback loops through a list of tracks
|
||||||
* `shuffle`: [bool] play playlist in random order
|
* `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
|
* `mute`: [bool] the current mute state
|
||||||
* `rate`: [float] the current playback rate, valid range (0..)
|
* `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
|
```sh
|
||||||
vlc --no-video --aout afile --audiofile-file /tmp/snapfifo
|
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_tcp.cpp
|
||||||
control_session_http.cpp
|
control_session_http.cpp
|
||||||
control_session_ws.cpp
|
control_session_ws.cpp
|
||||||
|
jwt.cpp
|
||||||
snapserver.cpp
|
snapserver.cpp
|
||||||
server.cpp
|
server.cpp
|
||||||
stream_server.cpp
|
stream_server.cpp
|
||||||
|
@ -17,7 +18,6 @@ set(SERVER_SOURCES
|
||||||
encoder/null_encoder.cpp
|
encoder/null_encoder.cpp
|
||||||
streamreader/control_error.cpp
|
streamreader/control_error.cpp
|
||||||
streamreader/stream_control.cpp
|
streamreader/stream_control.cpp
|
||||||
streamreader/stream_uri.cpp
|
|
||||||
streamreader/stream_manager.cpp
|
streamreader/stream_manager.cpp
|
||||||
streamreader/pcm_stream.cpp
|
streamreader/pcm_stream.cpp
|
||||||
streamreader/tcp_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})
|
include_directories(SYSTEM ${Boost_INCLUDE_DIR})
|
||||||
|
|
||||||
|
find_package(OpenSSL REQUIRED)
|
||||||
|
|
||||||
if(ANDROID)
|
if(ANDROID)
|
||||||
find_package(vorbis REQUIRED CONFIG)
|
find_package(vorbis REQUIRED CONFIG)
|
||||||
list(APPEND SERVER_LIBRARIES boost::boost)
|
list(APPEND SERVER_LIBRARIES boost::boost)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
it under the terms of the GNU General Public License as published by
|
||||||
|
@ -22,8 +22,8 @@
|
||||||
// local headers
|
// local headers
|
||||||
#include "common/aixlog.hpp"
|
#include "common/aixlog.hpp"
|
||||||
#include "common/base64.h"
|
#include "common/base64.h"
|
||||||
#include "common/jwt.hpp"
|
|
||||||
#include "common/utils/string_utils.hpp"
|
#include "common/utils/string_utils.hpp"
|
||||||
|
#include "jwt.hpp"
|
||||||
|
|
||||||
// 3rd party headers
|
// 3rd party headers
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
// local headers
|
// local headers
|
||||||
#include "common/aixlog.hpp"
|
#include "common/aixlog.hpp"
|
||||||
#include "common/json.hpp"
|
#include "common/json.hpp"
|
||||||
|
#include "common/snap_exception.hpp"
|
||||||
#include "control_session_http.hpp"
|
#include "control_session_http.hpp"
|
||||||
#include "control_session_tcp.hpp"
|
#include "control_session_tcp.hpp"
|
||||||
#include "server_settings.hpp"
|
#include "server_settings.hpp"
|
||||||
|
@ -54,10 +55,50 @@ ControlServer::ControlServer(boost::asio::io_context& io_context, const ServerSe
|
||||||
return pw;
|
return pw;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ssl.certificate.empty() && !ssl.certificate_key.empty())
|
if (!ssl.certificate.empty() && !ssl.certificate_key.empty())
|
||||||
{
|
{
|
||||||
ssl_context_.use_certificate_chain_file(ssl.certificate);
|
boost::system::error_code ec;
|
||||||
ssl_context_.use_private_key_file(ssl.certificate_key, boost::asio::ssl::context::pem);
|
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");
|
// 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)
|
# Password for decryption of the certificate_key (only needed for encrypted certificate_key file)
|
||||||
#key_password =
|
#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
|
# and will override the default codec, sampleformat or chunk_ms settings
|
||||||
# Available types are:
|
# Available types are:
|
||||||
# pipe: pipe:///<path/to/pipe>?name=<name>[&mode=create], mode can be "create" or "read"
|
# 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
|
# note that you need to have the librespot binary on your machine
|
||||||
# sampleformat will be set to "44100:16:2"
|
# sampleformat will be set to "44100:16:2"
|
||||||
# file: file:///<path/to/PCM/file>?name=<name>
|
# file: file:///<path/to/PCM/file>?name=<name>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
it under the terms of the GNU General Public License as published by
|
|
@ -1,6 +1,6 @@
|
||||||
/***
|
/***
|
||||||
This file is part of snapcast
|
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
|
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
|
it under the terms of the GNU General Public License as published by
|
|
@ -28,32 +28,49 @@
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
/// Server settings
|
||||||
struct ServerSettings
|
struct ServerSettings
|
||||||
{
|
{
|
||||||
|
/// Launch settings
|
||||||
struct Server
|
struct Server
|
||||||
{
|
{
|
||||||
|
/// Number of worker threads
|
||||||
int threads{-1};
|
int threads{-1};
|
||||||
|
/// PID file, if running as daemon
|
||||||
std::string pid_file{"/var/run/snapserver/pid"};
|
std::string pid_file{"/var/run/snapserver/pid"};
|
||||||
|
/// User when running as deaemon
|
||||||
std::string user{"snapserver"};
|
std::string user{"snapserver"};
|
||||||
|
/// Group when running as deaemon
|
||||||
std::string group;
|
std::string group;
|
||||||
|
/// Server data dir
|
||||||
std::string data_dir;
|
std::string data_dir;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// SSL settings
|
||||||
struct Ssl
|
struct Ssl
|
||||||
{
|
{
|
||||||
|
/// Certificate file
|
||||||
std::filesystem::path certificate;
|
std::filesystem::path certificate;
|
||||||
|
/// Private key file
|
||||||
std::filesystem::path certificate_key;
|
std::filesystem::path certificate_key;
|
||||||
|
/// Password for encrypted key file
|
||||||
std::string key_password;
|
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
|
bool enabled() const
|
||||||
{
|
{
|
||||||
return !certificate.empty() && !certificate_key.empty();
|
return !certificate.empty() && !certificate_key.empty();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// User settings
|
||||||
struct User
|
struct User
|
||||||
{
|
{
|
||||||
|
/// c'tor
|
||||||
explicit User(const std::string& user_permissions_password)
|
explicit User(const std::string& user_permissions_password)
|
||||||
{
|
{
|
||||||
std::string perm;
|
std::string perm;
|
||||||
|
@ -62,62 +79,94 @@ struct ServerSettings
|
||||||
permissions = utils::string::split(perm, ',');
|
permissions = utils::string::split(perm, ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// user name
|
||||||
std::string name;
|
std::string name;
|
||||||
|
/// permissions
|
||||||
std::vector<std::string> permissions;
|
std::vector<std::string> permissions;
|
||||||
|
/// password
|
||||||
std::string password;
|
std::string password;
|
||||||
};
|
};
|
||||||
|
|
||||||
std::vector<User> users;
|
|
||||||
|
|
||||||
|
/// HTTP settings
|
||||||
struct Http
|
struct Http
|
||||||
{
|
{
|
||||||
|
/// enable HTTP server
|
||||||
bool enabled{true};
|
bool enabled{true};
|
||||||
|
/// enable HTTPS
|
||||||
bool ssl_enabled{false};
|
bool ssl_enabled{false};
|
||||||
|
/// HTTP port
|
||||||
size_t port{1780};
|
size_t port{1780};
|
||||||
|
/// HTTPS port
|
||||||
size_t ssl_port{1788};
|
size_t ssl_port{1788};
|
||||||
|
/// HTTP listen address
|
||||||
std::vector<std::string> bind_to_address{{"::"}};
|
std::vector<std::string> bind_to_address{{"::"}};
|
||||||
|
/// HTTPS listen address
|
||||||
std::vector<std::string> ssl_bind_to_address{{"::"}};
|
std::vector<std::string> ssl_bind_to_address{{"::"}};
|
||||||
|
/// doc root directory
|
||||||
std::string doc_root;
|
std::string doc_root;
|
||||||
|
/// HTTP server host name
|
||||||
std::string host{"<hostname>"};
|
std::string host{"<hostname>"};
|
||||||
|
/// URL prefix when serving album art
|
||||||
std::string url_prefix;
|
std::string url_prefix;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// TCP streaming client settings
|
||||||
struct Tcp
|
struct Tcp
|
||||||
{
|
{
|
||||||
|
/// enable plain TCP audio streaming
|
||||||
bool enabled{true};
|
bool enabled{true};
|
||||||
|
/// TCP port
|
||||||
size_t port{1705};
|
size_t port{1705};
|
||||||
|
/// TCP listen addresses
|
||||||
std::vector<std::string> bind_to_address{{"::"}};
|
std::vector<std::string> bind_to_address{{"::"}};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Stream settings
|
||||||
struct Stream
|
struct Stream
|
||||||
{
|
{
|
||||||
|
/// Audio streaming port
|
||||||
size_t port{1704};
|
size_t port{1704};
|
||||||
|
/// Directory for stream plugins
|
||||||
std::filesystem::path plugin_dir{"/usr/share/snapserver/plug-ins"};
|
std::filesystem::path plugin_dir{"/usr/share/snapserver/plug-ins"};
|
||||||
|
/// Stream sources
|
||||||
std::vector<std::string> sources;
|
std::vector<std::string> sources;
|
||||||
|
/// Default codec
|
||||||
std::string codec{"flac"};
|
std::string codec{"flac"};
|
||||||
|
/// Default end to end delay
|
||||||
int32_t bufferMs{1000};
|
int32_t bufferMs{1000};
|
||||||
|
/// Default sample format
|
||||||
std::string sampleFormat{"48000:16:2"};
|
std::string sampleFormat{"48000:16:2"};
|
||||||
|
/// Default read size for stream sources
|
||||||
size_t streamChunkMs{20};
|
size_t streamChunkMs{20};
|
||||||
|
/// Send audio to muted clients?
|
||||||
bool sendAudioToMutedClients{false};
|
bool sendAudioToMutedClients{false};
|
||||||
|
/// Liste addresses
|
||||||
std::vector<std::string> bind_to_address{{"::"}};
|
std::vector<std::string> bind_to_address{{"::"}};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Client settings
|
||||||
struct StreamingClient
|
struct StreamingClient
|
||||||
{
|
{
|
||||||
|
/// Initial volume of new clients
|
||||||
uint16_t initialVolume{100};
|
uint16_t initialVolume{100};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Logging settings
|
||||||
struct Logging
|
struct Logging
|
||||||
{
|
{
|
||||||
|
/// log sing
|
||||||
std::string sink;
|
std::string sink;
|
||||||
|
/// log filter
|
||||||
std::string filter{"*:info"};
|
std::string filter{"*:info"};
|
||||||
};
|
};
|
||||||
|
|
||||||
Server server;
|
Server server; ///< Server settings
|
||||||
Ssl ssl;
|
Ssl ssl; ///< SSL settings
|
||||||
Http http;
|
std::vector<User> users; ///< User settings
|
||||||
Tcp tcp;
|
Http http; ///< HTTP settings
|
||||||
Stream stream;
|
Tcp tcp; ///< TCP settings
|
||||||
StreamingClient streamingclient;
|
Stream stream; ///< Stream settings
|
||||||
Logging logging;
|
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,
|
conf.add<Value<std::filesystem::path>>("", "ssl.certificate_key", "private key file (PEM format)", settings.ssl.certificate_key,
|
||||||
&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<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
|
#if 0 // feature: users
|
||||||
// Users setting
|
// Users setting
|
||||||
|
@ -276,6 +279,13 @@ int main(int argc, char* argv[])
|
||||||
settings.ssl.certificate_key = make_absolute(settings.ssl.certificate_key);
|
settings.ssl.certificate_key = make_absolute(settings.ssl.certificate_key);
|
||||||
if (!fs::exists(settings.ssl.certificate_key))
|
if (!fs::exists(settings.ssl.certificate_key))
|
||||||
throw SnapException("SSL certificate_key file not found: " + settings.ssl.certificate_key.native());
|
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())
|
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 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.
|
* Implements EncoderListener to get the encoded data.
|
||||||
* Data is passed to the PcmStream::Listener
|
* Data is passed to the PcmStream::Listener
|
||||||
* usage:
|
* usage:
|
||||||
|
|
|
@ -24,12 +24,12 @@
|
||||||
#include "common/json.hpp"
|
#include "common/json.hpp"
|
||||||
#include "common/message/codec_header.hpp"
|
#include "common/message/codec_header.hpp"
|
||||||
#include "common/sample_format.hpp"
|
#include "common/sample_format.hpp"
|
||||||
|
#include "common/stream_uri.hpp"
|
||||||
#include "encoder/encoder.hpp"
|
#include "encoder/encoder.hpp"
|
||||||
#include "jsonrpcpp.hpp"
|
#include "jsonrpcpp.hpp"
|
||||||
#include "properties.hpp"
|
#include "properties.hpp"
|
||||||
#include "server_settings.hpp"
|
#include "server_settings.hpp"
|
||||||
#include "stream_control.hpp"
|
#include "stream_control.hpp"
|
||||||
#include "stream_uri.hpp"
|
|
||||||
|
|
||||||
// 3rd party headers
|
// 3rd party headers
|
||||||
#include <boost/asio/io_context.hpp>
|
#include <boost/asio/io_context.hpp>
|
||||||
|
|
|
@ -16,14 +16,16 @@ endif()
|
||||||
# Make test executable
|
# Make test executable
|
||||||
set(TEST_SOURCES
|
set(TEST_SOURCES
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/test_main.cpp
|
${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/base64.cpp
|
||||||
${CMAKE_SOURCE_DIR}/common/utils/string_utils.cpp
|
${CMAKE_SOURCE_DIR}/common/utils/string_utils.cpp
|
||||||
${CMAKE_SOURCE_DIR}/server/authinfo.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/control_error.cpp
|
||||||
${CMAKE_SOURCE_DIR}/server/streamreader/properties.cpp
|
${CMAKE_SOURCE_DIR}/server/streamreader/properties.cpp
|
||||||
${CMAKE_SOURCE_DIR}/server/streamreader/metadata.cpp
|
${CMAKE_SOURCE_DIR}/server/streamreader/metadata.cpp)
|
||||||
${CMAKE_SOURCE_DIR}/server/streamreader/stream_uri.cpp)
|
|
||||||
|
find_package(OpenSSL REQUIRED)
|
||||||
|
|
||||||
include_directories(SYSTEM ${Boost_INCLUDE_DIR})
|
include_directories(SYSTEM ${Boost_INCLUDE_DIR})
|
||||||
|
|
||||||
|
|
|
@ -22,13 +22,13 @@
|
||||||
#include "common/aixlog.hpp"
|
#include "common/aixlog.hpp"
|
||||||
#include "common/base64.h"
|
#include "common/base64.h"
|
||||||
#include "common/error_code.hpp"
|
#include "common/error_code.hpp"
|
||||||
#include "common/jwt.hpp"
|
#include "common/stream_uri.hpp"
|
||||||
#include "common/utils/string_utils.hpp"
|
#include "common/utils/string_utils.hpp"
|
||||||
#include "server/authinfo.hpp"
|
#include "server/authinfo.hpp"
|
||||||
|
#include "server/jwt.hpp"
|
||||||
#include "server/server_settings.hpp"
|
#include "server/server_settings.hpp"
|
||||||
#include "server/streamreader/control_error.hpp"
|
#include "server/streamreader/control_error.hpp"
|
||||||
#include "server/streamreader/properties.hpp"
|
#include "server/streamreader/properties.hpp"
|
||||||
#include "server/streamreader/stream_uri.hpp"
|
|
||||||
|
|
||||||
// 3rd party headers
|
// 3rd party headers
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
@ -224,7 +224,6 @@ TEST_CASE("JWT")
|
||||||
|
|
||||||
TEST_CASE("Uri")
|
TEST_CASE("Uri")
|
||||||
{
|
{
|
||||||
using namespace streamreader;
|
|
||||||
StreamUri uri("pipe:///tmp/snapfifo?name=default&codec=flac");
|
StreamUri uri("pipe:///tmp/snapfifo?name=default&codec=flac");
|
||||||
REQUIRE(uri.scheme == "pipe");
|
REQUIRE(uri.scheme == "pipe");
|
||||||
REQUIRE(uri.path == "/tmp/snapfifo");
|
REQUIRE(uri.path == "/tmp/snapfifo");
|
||||||
|
@ -232,9 +231,11 @@ TEST_CASE("Uri")
|
||||||
|
|
||||||
// uri = StreamUri("scheme:[//host[:port]][/]path[?query=none][#fragment]");
|
// uri = StreamUri("scheme:[//host[:port]][/]path[?query=none][#fragment]");
|
||||||
// Test with all fields
|
// 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.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.path == "/path");
|
||||||
REQUIRE(uri.query["query"] == "none");
|
REQUIRE(uri.query["query"] == "none");
|
||||||
REQUIRE(uri.query["key"] == "value");
|
REQUIRE(uri.query["key"] == "value");
|
||||||
|
@ -243,9 +244,11 @@ TEST_CASE("Uri")
|
||||||
// Test with all fields, url encoded
|
// 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"
|
// "%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.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.path == "/pa+th");
|
||||||
REQUIRE(uri.query["!#$%&'()"] == "*+,/:;=?@[]");
|
REQUIRE(uri.query["!#$%&'()"] == "*+,/:;=?@[]");
|
||||||
REQUIRE(uri.query["key%25"] == "value");
|
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");
|
uri = StreamUri("spotify:///librespot?name=Spotify&username=EMAIL&password=string%26with%26ampersands&devicename=Snapcast&bitrate=320&killall=false");
|
||||||
REQUIRE(uri.scheme == "spotify");
|
REQUIRE(uri.scheme == "spotify");
|
||||||
REQUIRE(uri.host.empty());
|
REQUIRE(uri.host.empty());
|
||||||
|
REQUIRE(!uri.port.has_value());
|
||||||
REQUIRE(uri.path == "/librespot");
|
REQUIRE(uri.path == "/librespot");
|
||||||
REQUIRE(uri.query["name"] == "Spotify");
|
REQUIRE(uri.query["name"] == "Spotify");
|
||||||
REQUIRE(uri.query["username"] == "EMAIL");
|
REQUIRE(uri.query["username"] == "EMAIL");
|
||||||
|
|
Loading…
Add table
Reference in a new issue