mirror of
https://github.com/badaix/snapcast.git
synced 2025-05-18 03:26:15 +02:00
Add support for Player.Properties
This commit is contained in:
parent
283c3d2c9b
commit
7c11cb7559
11 changed files with 577 additions and 154 deletions
|
@ -268,7 +268,10 @@ private:
|
|||
try
|
||||
{
|
||||
if (!source.has_value())
|
||||
{
|
||||
if (j.contains(tag))
|
||||
j.erase(tag);
|
||||
}
|
||||
else
|
||||
j[tag] = source.value();
|
||||
}
|
||||
|
|
232
common/properties.hpp
Normal file
232
common/properties.hpp
Normal file
|
@ -0,0 +1,232 @@
|
|||
/***
|
||||
This file is part of snapcast
|
||||
Copyright (C) 2014-2021 Johannes Pohl
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
***/
|
||||
|
||||
#ifndef PROPERTIES_HPP
|
||||
#define PROPERTIES_HPP
|
||||
|
||||
#include <boost/optional.hpp>
|
||||
#include <string>
|
||||
|
||||
#include "common/aixlog.hpp"
|
||||
#include "common/json.hpp"
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
enum class PlaybackStatus
|
||||
{
|
||||
kPlaying = 0,
|
||||
kPaused = 1,
|
||||
kStopped = 2,
|
||||
kUnknown = 3
|
||||
};
|
||||
|
||||
|
||||
static std::string to_string(PlaybackStatus playback_status)
|
||||
{
|
||||
switch (playback_status)
|
||||
{
|
||||
case PlaybackStatus::kPlaying:
|
||||
return "playing";
|
||||
case PlaybackStatus::kPaused:
|
||||
return "paused";
|
||||
case PlaybackStatus::kStopped:
|
||||
return "stopped";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static std::ostream& operator<<(std::ostream& os, PlaybackStatus playback_status)
|
||||
{
|
||||
os << to_string(playback_status);
|
||||
return os;
|
||||
}
|
||||
|
||||
|
||||
enum class LoopStatus
|
||||
{
|
||||
kNone = 0,
|
||||
kTrack = 1,
|
||||
kPlaylist = 2,
|
||||
kUnknown = 3
|
||||
};
|
||||
|
||||
|
||||
static std::string to_string(LoopStatus loop_status)
|
||||
{
|
||||
switch (loop_status)
|
||||
{
|
||||
case LoopStatus::kNone:
|
||||
return "none";
|
||||
case LoopStatus::kTrack:
|
||||
return "track";
|
||||
case LoopStatus::kPlaylist:
|
||||
return "playlist";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static std::ostream& operator<<(std::ostream& os, LoopStatus loop_status)
|
||||
{
|
||||
os << to_string(loop_status);
|
||||
return os;
|
||||
}
|
||||
|
||||
|
||||
|
||||
class Properties
|
||||
{
|
||||
public:
|
||||
Properties() = default;
|
||||
Properties(const json& j)
|
||||
{
|
||||
fromJson(j);
|
||||
}
|
||||
|
||||
/// https://www.musicpd.org/doc/html/protocol.html#tags
|
||||
/// The current playback status
|
||||
boost::optional<PlaybackStatus> playback_status;
|
||||
/// The current loop / repeat status
|
||||
boost::optional<LoopStatus> loop_status;
|
||||
/// A value of false indicates that playback is progressing linearly through a playlist, while true means playback is progressing through a playlist in some
|
||||
/// other order.
|
||||
boost::optional<bool> shuffle;
|
||||
/// The volume level between 0-100
|
||||
boost::optional<int> volume;
|
||||
/// The current track position in seconds
|
||||
boost::optional<float> position;
|
||||
/// The current track duration in seconds
|
||||
boost::optional<float> duration;
|
||||
/// Whether the client can call the Next method on this interface and expect the current track to change
|
||||
boost::optional<bool> can_go_next;
|
||||
/// Whether the client can call the Previous method on this interface and expect the current track to change
|
||||
boost::optional<bool> can_go_previous;
|
||||
/// Whether playback can be started using "play" or "playPause"
|
||||
boost::optional<bool> can_play;
|
||||
/// Whether playback can be paused using "pause" or "playPause"
|
||||
boost::optional<bool> can_pause;
|
||||
/// Whether the client can control the playback position using "seek" and "setPosition". This may be different for different tracks
|
||||
boost::optional<bool> can_seek;
|
||||
/// Whether the media player may be controlled over this interface
|
||||
boost::optional<bool> can_control;
|
||||
|
||||
json toJson() const
|
||||
{
|
||||
json j;
|
||||
if (playback_status.has_value())
|
||||
addTag(j, "playbackStatus", boost::optional<std::string>(to_string(playback_status.value())));
|
||||
if (loop_status.has_value())
|
||||
addTag(j, "loopStatus", boost::optional<std::string>(to_string(loop_status.value())));
|
||||
addTag(j, "shuffle", shuffle);
|
||||
addTag(j, "volume", volume);
|
||||
addTag(j, "position", position);
|
||||
addTag(j, "duration", duration);
|
||||
addTag(j, "canGoNext", can_go_next);
|
||||
addTag(j, "canGoPrevious", can_go_previous);
|
||||
addTag(j, "canPlay", can_play);
|
||||
addTag(j, "canPause", can_pause);
|
||||
addTag(j, "canSeek", can_seek);
|
||||
addTag(j, "canControl", can_control);
|
||||
return j;
|
||||
}
|
||||
|
||||
void fromJson(const json& j)
|
||||
{
|
||||
boost::optional<std::string> opt;
|
||||
readTag(j, "playbackStatus", opt);
|
||||
if (opt.has_value())
|
||||
{
|
||||
if (*opt == "playing")
|
||||
playback_status = PlaybackStatus::kPlaying;
|
||||
else if (*opt == "paused")
|
||||
playback_status = PlaybackStatus::kPaused;
|
||||
else if (*opt == "stopped")
|
||||
playback_status = PlaybackStatus::kStopped;
|
||||
else
|
||||
playback_status = PlaybackStatus::kUnknown;
|
||||
}
|
||||
else
|
||||
playback_status = boost::none;
|
||||
readTag(j, "loopStatus", opt);
|
||||
if (opt.has_value())
|
||||
{
|
||||
if (*opt == "none")
|
||||
loop_status = LoopStatus::kNone;
|
||||
else if (*opt == "track")
|
||||
loop_status = LoopStatus::kTrack;
|
||||
else if (*opt == "playlist")
|
||||
loop_status = LoopStatus::kPlaylist;
|
||||
else
|
||||
loop_status = LoopStatus::kUnknown;
|
||||
}
|
||||
else
|
||||
loop_status = boost::none;
|
||||
readTag(j, "shuffle", shuffle);
|
||||
readTag(j, "volume", volume);
|
||||
readTag(j, "position", position);
|
||||
readTag(j, "duration", duration);
|
||||
readTag(j, "canGoNext", can_go_next);
|
||||
readTag(j, "canGoPrevious", can_go_previous);
|
||||
readTag(j, "canPlay", can_play);
|
||||
readTag(j, "canPause", can_pause);
|
||||
readTag(j, "canSeek", can_seek);
|
||||
readTag(j, "canControl", can_control);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename T>
|
||||
void readTag(const json& j, const std::string& tag, boost::optional<T>& dest) const
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!j.contains(tag))
|
||||
dest = boost::none;
|
||||
else
|
||||
dest = j[tag].get<T>();
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
LOG(ERROR) << "failed to read tag: '" << tag << "': " << e.what() << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void addTag(json& j, const std::string& tag, const boost::optional<T>& source) const
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!source.has_value())
|
||||
{
|
||||
if (j.contains(tag))
|
||||
j.erase(tag);
|
||||
}
|
||||
else
|
||||
j[tag] = source.value();
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
LOG(ERROR) << "failed to add tag: '" << tag << "': " << e.what() << '\n';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
#endif
|
|
@ -82,10 +82,13 @@ defaults = {
|
|||
# Player.Status
|
||||
status_mapping = {
|
||||
# https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#properties
|
||||
'state': ['playbackStatus', lambda val: {'play': 'playing', 'pause': 'paused', 'stop': 'stopped'}[val]], # R/O - play => playing, pause => paused, stop => stopped
|
||||
'repeat': ['loopStatus', lambda val: {'0': 'none', '1': 'track', '2': 'playlist'}[val]], # R/W - 0 => none, 1 => track, n/a => playlist
|
||||
# R/O - play => playing, pause => paused, stop => stopped
|
||||
'state': ['playbackStatus', lambda val: {'play': 'playing', 'pause': 'paused', 'stop': 'stopped'}[val]],
|
||||
# R/W - 0 => none, 1 => track, n/a => playlist
|
||||
'repeat': ['loopStatus', lambda val: {'0': 'none', '1': 'track', '2': 'playlist'}[val]],
|
||||
# 'Rate d (Playback_Rate) R/W
|
||||
'random': ['shuffle', lambda val: {'0': False, '1': True}[val]], # R/W - 0 => false, 1 => true
|
||||
# R/W - 0 => false, 1 => true
|
||||
'random': ['shuffle', lambda val: {'0': False, '1': True}[val]],
|
||||
# 'Metadata a{sv} (Metadata_Map) Read only
|
||||
'volume': ['volume', int], # R/W - 0-100 => 0-100
|
||||
'elapsed': ['position', float], # R/O - seconds? ms?
|
||||
|
@ -109,7 +112,8 @@ status_mapping = {
|
|||
# nextsong 2: playlist song number of the next song to be played
|
||||
# nextsongid 2: playlist songid of the next song to be played
|
||||
# time: total time elapsed (of current playing/paused song) in seconds (deprecated, use elapsed instead)
|
||||
'duration': ['duration', float], # duration 5: Duration of the current song in seconds.
|
||||
# duration 5: Duration of the current song in seconds.
|
||||
'duration': ['duration', float],
|
||||
# bitrate: instantaneous bitrate in kbps
|
||||
# xfade: crossfade in seconds
|
||||
# mixrampdb: mixramp threshold in dB
|
||||
|
@ -119,7 +123,8 @@ status_mapping = {
|
|||
# error: if there is an error, returns message here
|
||||
|
||||
# Snapcast
|
||||
'mute': ['mute', lambda val: {'0': False, '1': True}[val]] # R/W true/false
|
||||
# R/W true/false
|
||||
'mute': ['mute', lambda val: {'0': False, '1': True}[val]]
|
||||
}
|
||||
|
||||
# Player.Metadata
|
||||
|
@ -207,6 +212,7 @@ class MPDWrapper(object):
|
|||
self._temp_cover = None
|
||||
self._position = 0
|
||||
self._time = 0
|
||||
self._currentsong = None
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
|
@ -370,8 +376,9 @@ class MPDWrapper(object):
|
|||
def control(self, cmd):
|
||||
try:
|
||||
request = json.loads(cmd)
|
||||
cmd = request['method']
|
||||
id = request['id']
|
||||
[interface, cmd] = request['method'].split('.', 1)
|
||||
if interface == 'Player':
|
||||
success = True
|
||||
if cmd == 'Next':
|
||||
self.next()
|
||||
|
@ -401,6 +408,29 @@ class MPDWrapper(object):
|
|||
if offset >= 0:
|
||||
strOffset = "+" + strOffset
|
||||
self.seekcur(strOffset)
|
||||
elif cmd == 'SetProperties':
|
||||
properties = request['params']
|
||||
logger.info(f'SetProperties: {properties}')
|
||||
if 'shuffle' in properties:
|
||||
self.random(int(properties['shuffle']))
|
||||
if 'loopStatus' in properties:
|
||||
value = properties['loopStatus']
|
||||
if value == "playlist":
|
||||
self.repeat(1)
|
||||
if self._can_single:
|
||||
self.single(0)
|
||||
elif value == "track":
|
||||
if self._can_single:
|
||||
self.repeat(1)
|
||||
self.single(1)
|
||||
elif value == "none":
|
||||
self.repeat(0)
|
||||
if self._can_single:
|
||||
self.single(0)
|
||||
else:
|
||||
send({"jsonrpc": "2.0", "error": {"code": -32601,
|
||||
"message": "Method not found"}, "id": id})
|
||||
success = False
|
||||
else:
|
||||
send({"jsonrpc": "2.0", "error": {"code": -32601,
|
||||
"message": "Method not found"}, "id": id})
|
||||
|
@ -446,7 +476,8 @@ class MPDWrapper(object):
|
|||
if subsystem in ("player", "mixer", "options", "playlist"):
|
||||
if not updated:
|
||||
logger.info(f'Subsystem: {subsystem}')
|
||||
self._update_properties(force=True)
|
||||
self._update_properties(
|
||||
force=subsystem == 'player')
|
||||
updated = True
|
||||
self.idle_enter()
|
||||
return True
|
||||
|
@ -455,6 +486,44 @@ class MPDWrapper(object):
|
|||
self.reconnect()
|
||||
return True
|
||||
|
||||
def update_albumart(self, snapmeta):
|
||||
album_key = 'musicbrainzAlbumId'
|
||||
try:
|
||||
if not album_key in snapmeta:
|
||||
mbartist = None
|
||||
mbrelease = None
|
||||
if 'artist' in snapmeta:
|
||||
mbartist = snapmeta['artist'][0]
|
||||
if 'album' in snapmeta:
|
||||
mbrelease = snapmeta['album']
|
||||
else:
|
||||
if 'title' in snapmeta:
|
||||
mbrelease = snapmeta['title']
|
||||
|
||||
if mbartist is not None and mbrelease is not None:
|
||||
logger.info(
|
||||
f'Querying album art for artist "{mbartist}", release: "{mbrelease}"')
|
||||
result = musicbrainzngs.search_releases(artist=mbartist, release=mbrelease,
|
||||
limit=1)
|
||||
if result['release-list']:
|
||||
snapmeta[album_key] = result['release-list'][0]['id']
|
||||
|
||||
if album_key in snapmeta:
|
||||
data = musicbrainzngs.get_image_list(snapmeta[album_key])
|
||||
for image in data["images"]:
|
||||
if "Front" in image["types"] and image["approved"]:
|
||||
snapmeta['artUrl'] = image["thumbnails"]["small"]
|
||||
logger.debug(
|
||||
f'{snapmeta["artUrl"]} is an approved front image')
|
||||
logger.info(f'Snapmeta: {snapmeta}')
|
||||
send(
|
||||
{"jsonrpc": "2.0", "method": "Player.Metadata", "params": snapmeta})
|
||||
break
|
||||
|
||||
except musicbrainzngs.musicbrainz.ResponseError as e:
|
||||
logger.error(
|
||||
f'Error while getting cover for {snapmeta[album_key]}: {e}')
|
||||
|
||||
def update_metadata(self):
|
||||
"""
|
||||
Translate metadata returned by MPD to the MPRIS v2 syntax.
|
||||
|
@ -505,49 +574,16 @@ class MPDWrapper(object):
|
|||
snapmeta['title'] = fields[1]
|
||||
|
||||
send({"jsonrpc": "2.0", "method": "Player.Metadata", "params": snapmeta})
|
||||
|
||||
album_key = 'musicbrainzAlbumId'
|
||||
try:
|
||||
if not album_key in snapmeta:
|
||||
mbartist = None
|
||||
mbrelease = None
|
||||
if 'artist' in snapmeta:
|
||||
mbartist = snapmeta['artist'][0]
|
||||
if 'album' in snapmeta:
|
||||
mbrelease = snapmeta['album']
|
||||
else:
|
||||
if 'title' in snapmeta:
|
||||
mbrelease = snapmeta['title']
|
||||
|
||||
if mbartist is not None and mbrelease is not None:
|
||||
logger.info(
|
||||
f'Querying album art for artist "{mbartist}", release: "{mbrelease}"')
|
||||
result = musicbrainzngs.search_releases(artist=mbartist, release=mbrelease,
|
||||
limit=1)
|
||||
if result['release-list']:
|
||||
snapmeta[album_key] = result['release-list'][0]['id']
|
||||
|
||||
if album_key in snapmeta:
|
||||
data = musicbrainzngs.get_image_list(snapmeta[album_key])
|
||||
for image in data["images"]:
|
||||
if "Front" in image["types"] and image["approved"]:
|
||||
snapmeta['artUrl'] = image["thumbnails"]["small"]
|
||||
logger.debug(
|
||||
f'{snapmeta["artUrl"]} is an approved front image')
|
||||
break
|
||||
|
||||
except musicbrainzngs.musicbrainz.ResponseError as e:
|
||||
logger.error(
|
||||
f'Error while getting cover for {snapmeta[album_key]}: {e}')
|
||||
|
||||
logger.info(f'Snapmeta: {snapmeta}')
|
||||
send({"jsonrpc": "2.0", "method": "Player.Metadata", "params": snapmeta})
|
||||
self.update_albumart(snapmeta)
|
||||
|
||||
def _update_properties(self, force=False):
|
||||
old_status = self._status
|
||||
old_position = self._position
|
||||
old_time = self._time
|
||||
self._currentsong = self.client.currentsong()
|
||||
currentsong = self.client.currentsong()
|
||||
if self._currentsong != currentsong:
|
||||
self._currentsong = currentsong
|
||||
force = True
|
||||
new_status = self.client.status()
|
||||
logger.info(f'new status: {new_status}')
|
||||
self._time = new_time = int(time.time())
|
||||
|
@ -566,7 +602,13 @@ class MPDWrapper(object):
|
|||
logger.warning("Can't cast value %r to %s" %
|
||||
(value, status_mapping[key][1]))
|
||||
|
||||
send({"jsonrpc": "2.0", "method": "Player.Status", "params": snapstatus})
|
||||
snapstatus['canGoNext'] = True
|
||||
snapstatus['canGoPrevious'] = True
|
||||
snapstatus['canPlay'] = True
|
||||
snapstatus['canPause'] = True
|
||||
snapstatus['canSeek'] = True
|
||||
snapstatus['canControl'] = True
|
||||
send({"jsonrpc": "2.0", "method": "Player.Properties", "params": snapstatus})
|
||||
|
||||
if not new_status:
|
||||
logger.debug("_update_properties: failed to get new status")
|
||||
|
|
|
@ -32,6 +32,7 @@ import sys
|
|||
import re
|
||||
import shlex
|
||||
import getopt
|
||||
import time
|
||||
import socket
|
||||
import dbus
|
||||
import dbus.service
|
||||
|
@ -306,7 +307,7 @@ class MPDWrapper(object):
|
|||
'repeat': None,
|
||||
}
|
||||
self._metadata = {}
|
||||
self._position = 0
|
||||
self._properties = {}
|
||||
self._time = 0
|
||||
self._req_id = 0
|
||||
|
||||
|
@ -366,6 +367,30 @@ class MPDWrapper(object):
|
|||
'Metadata')
|
||||
logger.info(f'new meta {new_meta}')
|
||||
|
||||
elif jmsg["method"] == "Stream.OnProperties":
|
||||
logger.info(
|
||||
f'Stream properties changed for "{jmsg["params"]["id"]}"')
|
||||
props = jmsg["params"]["properties"]
|
||||
logger.info(f'Properties: "{props}"')
|
||||
props['received'] = time.time()
|
||||
changed_properties = self.update_properties(props)
|
||||
logger.info(f'Changed properties: "{changed_properties}"')
|
||||
if 'playbackStatus' in changed_properties:
|
||||
self._dbus_service.update_property(
|
||||
'org.mpris.MediaPlayer2.Player', 'PlaybackStatus')
|
||||
if 'loopStatus' in changed_properties:
|
||||
self._dbus_service.update_property(
|
||||
'org.mpris.MediaPlayer2.Player', 'LoopStatus')
|
||||
if 'shuffle' in changed_properties:
|
||||
self._dbus_service.update_property(
|
||||
'org.mpris.MediaPlayer2.Player', 'Shuffle')
|
||||
if 'volume' in changed_properties:
|
||||
self._dbus_service.update_property(
|
||||
'org.mpris.MediaPlayer2.Player', 'Volume')
|
||||
if 'canGoNext' in changed_properties:
|
||||
self._dbus_service.update_property(
|
||||
'org.mpris.MediaPlayer2.Player', 'CanGoNext')
|
||||
|
||||
def on_ws_error(self, ws, error):
|
||||
logger.error("Snapcast RPC websocket error")
|
||||
logger.error(error)
|
||||
|
@ -382,8 +407,7 @@ class MPDWrapper(object):
|
|||
# '/org/mpris/MediaPlayer2')
|
||||
self._dbus_service.acquire_name()
|
||||
|
||||
self.websocket.send(json.dumps(
|
||||
{"id": 1, "jsonrpc": "2.0", "method": "Server.GetStatus"}))
|
||||
self.send({"id": 1, "jsonrpc": "2.0", "method": "Server.GetStatus"})
|
||||
|
||||
def on_ws_close(self, ws):
|
||||
logger.info("Snapcast RPC websocket closed")
|
||||
|
@ -391,6 +415,9 @@ class MPDWrapper(object):
|
|||
self._dbus_service.release_name()
|
||||
# self._dbus_service.remove_from_connection()
|
||||
|
||||
def send(self, json_msg):
|
||||
self.websocket.send(json.dumps(json_msg))
|
||||
|
||||
def stop(self):
|
||||
self.websocket.keep_running = False
|
||||
logger.info("Waiting for websocket thread to exit")
|
||||
|
@ -469,12 +496,54 @@ class MPDWrapper(object):
|
|||
url = f'http://{self._params["host"]}:{self._params["port"]}/jsonrpc'
|
||||
logger.info(f'url: {url}')
|
||||
self._req_id += 1
|
||||
self.websocket.send(json.dumps(j))
|
||||
self.send(j)
|
||||
|
||||
def set_property(self, property, value):
|
||||
properties = {}
|
||||
properties[property] = value
|
||||
logger.info(f'set_properties {properties}')
|
||||
j = {"id": self._req_id, "jsonrpc": "2.0", "method": "Stream.SetProperties",
|
||||
"params": {"id": "Pipe", "properties": properties}}
|
||||
logger.info(f'Set properties: {properties}, json: {j}')
|
||||
url = f'http://{self._params["host"]}:{self._params["port"]}/jsonrpc'
|
||||
logger.info(f'url: {url}')
|
||||
self._req_id += 1
|
||||
self.send(j)
|
||||
|
||||
@property
|
||||
def metadata(self):
|
||||
return self._metadata
|
||||
|
||||
@property
|
||||
def properties(self):
|
||||
return self._properties
|
||||
|
||||
def update_properties(self, new_properties):
|
||||
changed_properties = {}
|
||||
for key, value in new_properties.items():
|
||||
if not key in self._properties:
|
||||
changed_properties[key] = [None, value]
|
||||
elif value != self._properties[key]:
|
||||
changed_properties[key] = [self._properties[key], value]
|
||||
for key, value in self._properties.items():
|
||||
if not key in self._properties:
|
||||
changed_properties[key] = [value, None]
|
||||
self._properties = new_properties
|
||||
return changed_properties
|
||||
|
||||
def position(self):
|
||||
if not 'position' in self._properties:
|
||||
return 0
|
||||
if not 'duration' in self._properties:
|
||||
return 0
|
||||
position = self._properties['position']
|
||||
if 'received' in self._properties:
|
||||
position += (time.time() - self._properties['received'])
|
||||
return position * 1000000
|
||||
|
||||
def property(self, name, default):
|
||||
return self._properties.get(name, default)
|
||||
|
||||
def notify_about_track(self, meta, state='play'):
|
||||
uri = 'sound'
|
||||
# if 'mpris:artUrl' in meta:
|
||||
|
@ -763,82 +832,53 @@ class MPRISInterface(dbus.service.Object):
|
|||
}
|
||||
|
||||
def __get_playback_status():
|
||||
# status = mpd_wrapper.last_status()
|
||||
# return {'play': 'Playing', 'pause': 'Paused', 'stop': 'Stopped'}[status['state']]
|
||||
return 'Playing'
|
||||
status = snapcast_wrapper.property('playbackStatus', 'stopped')
|
||||
logger.debug(f'get_playback_status "{status}"')
|
||||
return {'playing': 'Playing', 'paused': 'Paused', 'stopped': 'Stopped'}[status]
|
||||
|
||||
def __set_loop_status(value):
|
||||
logger.debug(f'set_loop_status "{value}"')
|
||||
# if value == "Playlist":
|
||||
# mpd_wrapper.repeat(1)
|
||||
# if mpd_wrapper._can_single:
|
||||
# mpd_wrapper.single(0)
|
||||
# elif value == "Track":
|
||||
# if mpd_wrapper._can_single:
|
||||
# mpd_wrapper.repeat(1)
|
||||
# mpd_wrapper.single(1)
|
||||
# elif value == "None":
|
||||
# mpd_wrapper.repeat(0)
|
||||
# if mpd_wrapper._can_single:
|
||||
# mpd_wrapper.single(0)
|
||||
# else:
|
||||
# raise dbus.exceptions.DBusException("Loop mode %r not supported" %
|
||||
# value)
|
||||
snapcast_wrapper.set_property(
|
||||
'loopStatus', {'None': 'none', 'Track': 'track', 'Playlist': 'playlist'}[value])
|
||||
return
|
||||
|
||||
def __get_loop_status():
|
||||
logger.debug(f'get_loop_status')
|
||||
# status = mpd_wrapper.last_status()
|
||||
# if int(status['repeat']) == 1:
|
||||
# if int(status.get('single', 0)) == 1:
|
||||
# return "Track"
|
||||
# else:
|
||||
# return "Playlist"
|
||||
# else:
|
||||
# return "None"
|
||||
return "None"
|
||||
status = snapcast_wrapper.property('loopStatus', 'none')
|
||||
logger.debug(f'get_loop_status "{status}"')
|
||||
return {'none': 'None', 'track': 'Track', 'playlist': 'Playlist'}[status]
|
||||
|
||||
def __set_shuffle(value):
|
||||
logger.debug(f'set_shuffle "{value}"')
|
||||
# mpd_wrapper.random(value)
|
||||
snapcast_wrapper.set_property('shuffle', bool(value))
|
||||
return
|
||||
|
||||
def __get_shuffle():
|
||||
logger.debug(f'get_shuffle')
|
||||
# if int(mpd_wrapper.last_status()['random']) == 1:
|
||||
# return True
|
||||
# else:
|
||||
# return False
|
||||
return False
|
||||
shuffle = snapcast_wrapper.property('shuffle', False)
|
||||
logger.debug(f'get_shuffle "{shuffle}"')
|
||||
return shuffle
|
||||
|
||||
def __get_metadata():
|
||||
logger.debug(f'get_metadata: {snapcast_wrapper.metadata}')
|
||||
return dbus.Dictionary(snapcast_wrapper.metadata, signature='sv')
|
||||
|
||||
def __get_volume():
|
||||
logger.debug(f'get_volume')
|
||||
# vol = float(mpd_wrapper.last_status().get('volume', 0))
|
||||
# if vol > 0:
|
||||
# return vol / 100.0
|
||||
# else:
|
||||
# return 0.0
|
||||
volume = snapcast_wrapper.property('volume', 100)
|
||||
logger.debug(f'get_volume "{volume}"')
|
||||
if volume > 0:
|
||||
return volume / 100.0
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
def __set_volume(value):
|
||||
logger.debug(f'set_voume: {value}')
|
||||
# if value >= 0 and value <= 1:
|
||||
# mpd_wrapper.setvol(int(value * 100))
|
||||
if value >= 0 and value <= 1:
|
||||
snapcast_wrapper.set_property('volume', int(value * 100))
|
||||
return
|
||||
|
||||
def __get_position():
|
||||
logger.debug(f'get_position')
|
||||
# status = mpd_wrapper.last_status()
|
||||
# if 'time' in status:
|
||||
# current, end = status['time'].split(':')
|
||||
# return dbus.Int64((int(current) * 1000000))
|
||||
# else:
|
||||
# return dbus.Int64(0)
|
||||
return dbus.Int64(0)
|
||||
position = snapcast_wrapper.position()
|
||||
logger.debug(f'get_position: {position}')
|
||||
return dbus.Int64(position)
|
||||
|
||||
__player_interface = "org.mpris.MediaPlayer2.Player"
|
||||
__player_props = {
|
||||
|
@ -995,7 +1035,8 @@ class MPRISInterface(dbus.service.Object):
|
|||
# Player signals
|
||||
@ dbus.service.signal(__player_interface, signature='x')
|
||||
def Seeked(self, position):
|
||||
logger.debug("Seeked to %i" % position)
|
||||
logger.debug(f'Seeked to {position}')
|
||||
snapcast_wrapper.properties['position'] = float(position) / 1000000
|
||||
return float(position)
|
||||
|
||||
|
||||
|
|
|
@ -68,6 +68,17 @@ void Server::onMetaChanged(const PcmStream* pcmStream)
|
|||
}
|
||||
|
||||
|
||||
void Server::onPropertiesChanged(const PcmStream* pcmStream)
|
||||
{
|
||||
LOG(INFO, LOG_TAG) << "onPropertiesChanged (" << pcmStream->getName() << ")\n";
|
||||
const auto props = pcmStream->getProperties();
|
||||
|
||||
// Send propeties to all connected control clients
|
||||
json notification = jsonrpcpp::Notification("Stream.OnProperties", jsonrpcpp::Parameter("id", pcmStream->getId(), "properties", props->toJson())).to_json();
|
||||
controlServer_->send(notification.dump(), nullptr);
|
||||
}
|
||||
|
||||
|
||||
void Server::onStateChanged(const PcmStream* pcmStream, ReaderState state)
|
||||
{
|
||||
// clang-format off
|
||||
|
@ -452,6 +463,28 @@ void Server::processRequest(const jsonrpcpp::request_ptr request, jsonrpcpp::ent
|
|||
// Setup response
|
||||
result["id"] = streamId;
|
||||
}
|
||||
else if (request->method().find("Stream.SetProperties") == 0)
|
||||
{
|
||||
// clang-format off
|
||||
// clang-format on
|
||||
|
||||
LOG(INFO, LOG_TAG) << "Stream.SetProperties id: " << request->params().get<std::string>("id")
|
||||
<< ", properties: " << request->params().get("properties") << "\n";
|
||||
|
||||
// Find stream
|
||||
string streamId = request->params().get<std::string>("id");
|
||||
PcmStreamPtr stream = streamManager_->getStream(streamId);
|
||||
if (stream == nullptr)
|
||||
throw jsonrpcpp::InternalErrorException("Stream not found", request->id());
|
||||
|
||||
Properties props(request->params().get("properties"));
|
||||
|
||||
// Set metadata from request
|
||||
stream->setProperties(props);
|
||||
|
||||
// Setup response
|
||||
result["id"] = streamId;
|
||||
}
|
||||
else if (request->method() == "Stream.AddStream")
|
||||
{
|
||||
// clang-format off
|
||||
|
|
|
@ -76,6 +76,7 @@ private:
|
|||
|
||||
/// Implementation of PcmListener
|
||||
void onMetaChanged(const PcmStream* pcmStream) override;
|
||||
void onPropertiesChanged(const PcmStream* pcmStream) override;
|
||||
void onStateChanged(const PcmStream* pcmStream, ReaderState state) override;
|
||||
void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) override;
|
||||
void onChunkEncoded(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) override;
|
||||
|
|
|
@ -93,6 +93,12 @@ void MetaStream::onMetaChanged(const PcmStream* pcmStream)
|
|||
}
|
||||
|
||||
|
||||
void MetaStream::onPropertiesChanged(const PcmStream* pcmStream)
|
||||
{
|
||||
LOG(DEBUG, LOG_TAG) << "onPropertiesChanged: " << pcmStream->getName() << "\n";
|
||||
}
|
||||
|
||||
|
||||
void MetaStream::onStateChanged(const PcmStream* pcmStream, ReaderState state)
|
||||
{
|
||||
LOG(DEBUG, LOG_TAG) << "onStateChanged: " << pcmStream->getName() << ", state: " << state << "\n";
|
||||
|
|
|
@ -47,6 +47,7 @@ public:
|
|||
protected:
|
||||
/// Implementation of PcmListener
|
||||
void onMetaChanged(const PcmStream* pcmStream) override;
|
||||
void onPropertiesChanged(const PcmStream* pcmStream) override;
|
||||
void onStateChanged(const PcmStream* pcmStream, ReaderState state) override;
|
||||
void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) override;
|
||||
void onChunkEncoded(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) override;
|
||||
|
|
|
@ -257,38 +257,33 @@ void PcmStream::onControlMsg(const std::string& msg)
|
|||
LOG(INFO, LOG_TAG) << "Notification method: " << notification->method() << ", params: " << notification->params().to_json() << "\n";
|
||||
if (notification->method() == "Player.Metadata")
|
||||
{
|
||||
LOG(DEBUG, LOG_TAG) << "Received metadata notification\n";
|
||||
setMeta(notification->params().to_json());
|
||||
}
|
||||
else if (notification->method() == "Player.Properties")
|
||||
{
|
||||
LOG(DEBUG, LOG_TAG) << "Received properties notification\n";
|
||||
properties_ = std::make_shared<Properties>(notification->params().to_json());
|
||||
// Trigger a stream update
|
||||
for (auto* listener : pcmListeners_)
|
||||
{
|
||||
if (listener != nullptr)
|
||||
listener->onPropertiesChanged(this);
|
||||
}
|
||||
}
|
||||
else
|
||||
LOG(WARNING, LOG_TAG) << "Received unknown notification method: '" << notification->method() << "'\n";
|
||||
}
|
||||
else if (entity->is_request())
|
||||
{
|
||||
LOG(INFO, LOG_TAG) << "Request\n";
|
||||
// jsonrpcpp::entity_ptr response(nullptr);
|
||||
// jsonrpcpp::notification_ptr notification(nullptr);
|
||||
// jsonrpcpp::request_ptr request = dynamic_pointer_cast<jsonrpcpp::Request>(entity);
|
||||
// processRequest(request, response, notification);
|
||||
// saveConfig();
|
||||
// ////cout << "Request: " << request->to_json().dump() << "\n";
|
||||
// if (notification)
|
||||
// {
|
||||
// ////cout << "Notification: " << notification->to_json().dump() << "\n";
|
||||
// controlServer_->send(notification->to_json().dump(), controlSession);
|
||||
// }
|
||||
// if (response)
|
||||
// {
|
||||
// ////cout << "Response: " << response->to_json().dump() << "\n";
|
||||
// return response->to_json().dump();
|
||||
// }
|
||||
// return "";
|
||||
jsonrpcpp::request_ptr request = dynamic_pointer_cast<jsonrpcpp::Request>(entity);
|
||||
LOG(INFO, LOG_TAG) << "Request: " << request->method() << ", id: " << request->id() << ", params: " << request->params().to_json() << "\n";
|
||||
}
|
||||
else if (entity->is_response())
|
||||
{
|
||||
jsonrpcpp::response_ptr response = dynamic_pointer_cast<jsonrpcpp::Response>(entity);
|
||||
LOG(INFO, LOG_TAG) << "Response: " << response->result().dump() << ", id: " << response->id() << "\n";
|
||||
}
|
||||
|
||||
// json j = json::parse(msg);
|
||||
// setMeta(j["params"]["meta"]);
|
||||
}
|
||||
|
||||
|
||||
|
@ -403,6 +398,24 @@ std::shared_ptr<msg::StreamTags> PcmStream::getMeta() const
|
|||
}
|
||||
|
||||
|
||||
std::shared_ptr<Properties> PcmStream::getProperties() const
|
||||
{
|
||||
return properties_;
|
||||
}
|
||||
|
||||
|
||||
void PcmStream::setProperties(const Properties& props)
|
||||
{
|
||||
LOG(INFO, LOG_TAG) << "Stream '" << getId() << "' set properties: " << props.toJson() << "\n";
|
||||
// TODO: queue commands, send next on timeout or after reception of the last command's response
|
||||
if (ctrl_script_)
|
||||
{
|
||||
jsonrpcpp::Request request(++req_id_, "Player.SetProperties", props.toJson());
|
||||
ctrl_script_->send(request.to_json().dump() + "\n"); //, params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void PcmStream::control(const std::string& command, const json& params)
|
||||
{
|
||||
LOG(INFO, LOG_TAG) << "Stream '" << getId() << "' received command: '" << command << "', params: '" << params << "'\n";
|
||||
|
@ -410,9 +423,12 @@ void PcmStream::control(const std::string& command, const json& params)
|
|||
{
|
||||
LOG(INFO, LOG_TAG) << "Stream " << getId() << " key: '" << it.key() << "', param: '" << it.value() << "'\n";
|
||||
}
|
||||
jsonrpcpp::Request request(++req_id_, command, params);
|
||||
// TODO: queue commands, send next on timeout or after reception of the last command's response
|
||||
if (ctrl_script_)
|
||||
{
|
||||
jsonrpcpp::Request request(++req_id_, "Player." + command, params);
|
||||
ctrl_script_->send(request.to_json().dump() + "\n"); //, params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
#include <boost/optional.hpp>
|
||||
|
||||
#include "common/json.hpp"
|
||||
#include "common/properties.hpp"
|
||||
#include "common/sample_format.hpp"
|
||||
#include "encoder/encoder.hpp"
|
||||
#include "message/codec_header.hpp"
|
||||
|
@ -102,6 +103,7 @@ class PcmListener
|
|||
{
|
||||
public:
|
||||
virtual void onMetaChanged(const PcmStream* pcmStream) = 0;
|
||||
virtual void onPropertiesChanged(const PcmStream* pcmStream) = 0;
|
||||
virtual void onStateChanged(const PcmStream* pcmStream, ReaderState state) = 0;
|
||||
virtual void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) = 0;
|
||||
virtual void onChunkEncoded(const PcmStream* pcmStream, std::shared_ptr<msg::PcmChunk> chunk, double duration) = 0;
|
||||
|
@ -169,6 +171,9 @@ public:
|
|||
std::shared_ptr<msg::StreamTags> getMeta() const;
|
||||
void setMeta(const json& j);
|
||||
|
||||
std::shared_ptr<Properties> getProperties() const;
|
||||
void setProperties(const Properties& props);
|
||||
|
||||
virtual void control(const std::string& command, const json& params);
|
||||
|
||||
virtual ReaderState getState() const;
|
||||
|
@ -194,6 +199,7 @@ protected:
|
|||
std::string name_;
|
||||
ReaderState state_;
|
||||
std::shared_ptr<msg::StreamTags> meta_;
|
||||
std::shared_ptr<Properties> properties_;
|
||||
boost::asio::io_context& ioc_;
|
||||
ServerSettings server_settings_;
|
||||
std::unique_ptr<CtrlScript> ctrl_script_;
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
#include "catch.hpp"
|
||||
#include "common/aixlog.hpp"
|
||||
#include "common/metatags.hpp"
|
||||
#include "common/properties.hpp"
|
||||
#include "common/utils/string_utils.hpp"
|
||||
#include "server/streamreader/stream_uri.hpp"
|
||||
|
||||
|
@ -153,3 +154,44 @@ TEST_CASE("Metatags")
|
|||
std::cout << out_json.dump(4) << "\n";
|
||||
REQUIRE(in_json == out_json);
|
||||
}
|
||||
|
||||
|
||||
TEST_CASE("Properties")
|
||||
{
|
||||
REQUIRE(to_string(PlaybackStatus::kPaused) == "paused");
|
||||
auto in_json = json::parse(R"(
|
||||
{
|
||||
"playbackStatus": "playing",
|
||||
"loopStatus": "track",
|
||||
"shuffle": false,
|
||||
"volume": 42,
|
||||
"elapsed": 23.0
|
||||
}
|
||||
)");
|
||||
std::cout << in_json.dump(4) << "\n";
|
||||
|
||||
Properties props(in_json);
|
||||
std::cout << props.toJson().dump(4) << "\n";
|
||||
|
||||
REQUIRE(props.playback_status.has_value());
|
||||
|
||||
auto out_json = props.toJson();
|
||||
std::cout << out_json.dump(4) << "\n";
|
||||
REQUIRE(in_json == out_json);
|
||||
|
||||
in_json = json::parse(R"(
|
||||
{
|
||||
"volume": 42
|
||||
}
|
||||
)");
|
||||
std::cout << in_json.dump(4) << "\n";
|
||||
|
||||
props.fromJson(in_json);
|
||||
std::cout << props.toJson().dump(4) << "\n";
|
||||
|
||||
REQUIRE(!props.playback_status.has_value());
|
||||
|
||||
out_json = props.toJson();
|
||||
std::cout << out_json.dump(4) << "\n";
|
||||
REQUIRE(in_json == out_json);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue