Make metadata part of the properties

This commit is contained in:
badaix 2021-09-01 18:18:36 +02:00
parent 5cebc64e15
commit 004ea21e3f
13 changed files with 134 additions and 153 deletions

View file

@ -26,6 +26,8 @@
#include "common/aixlog.hpp"
#include "common/json.hpp"
#include "common/metatags.hpp"
using json = nlohmann::json;
@ -152,6 +154,8 @@ public:
fromJson(j);
}
/// Meta data
std::optional<Metatags> metatags;
/// https://www.musicpd.org/doc/html/protocol.html#tags
/// The current playback status
std::optional<PlaybackStatus> playback_status;
@ -202,6 +206,8 @@ public:
addTag(j, "canPause", can_pause);
addTag(j, "canSeek", can_seek);
addTag(j, "canControl", can_control);
if (metatags.has_value())
addTag(j, "metadata", metatags->toJson());
return j;
}
@ -209,7 +215,7 @@ public:
{
static std::set<std::string> rw_props = {"loopStatus", "shuffle", "volume", "rate"};
static std::set<std::string> ro_props = {"playbackStatus", "loopStatus", "shuffle", "volume", "position", "minimumRate", "maximumRate",
"canGoNext", "canGoPrevious", "canPlay", "canPause", "canSeek", "canControl"};
"canGoNext", "canGoPrevious", "canPlay", "canPause", "canSeek", "canControl", "metadata"};
for (const auto& element : j.items())
{
bool is_rw = (rw_props.find(element.key()) != rw_props.end());
@ -243,12 +249,38 @@ public:
readTag(j, "canPause", can_pause, false);
readTag(j, "canSeek", can_seek, false);
readTag(j, "canControl", can_control, false);
if (j.contains("metadata"))
{
Metatags m;
m.fromJson(j["metadata"]);
metatags = m;
}
else
metatags = std::nullopt;
}
bool operator==(const Properties& other) const
{
// expensive, but not called ofetn and less typing
return (toJson() == other.toJson());
// clang-format off
// return (playback_status == other.playback_status &&
// loop_status == other.loop_status &&
// rate == other.rate &&
// shuffle == other.shuffle &&
// volume == other.volume &&
// position == other.position &&
// minimum_rate == other.minimum_rate &&
// maximum_rate == other.maximum_rate &&
// can_go_next == other.can_go_next &&
// can_go_previous == other.can_go_previous &&
// can_play == other.can_play &&
// can_pause == other.can_pause &&
// can_seek == other.can_seek &&
// can_control == other.can_control);
// clang-format on
}
private:

View file

@ -376,7 +376,8 @@ class MPDWrapper(object):
success = True
command = request['params']['command']
params = request['params'].get('params', {})
logger.debug(f'Control command: {command}, params: {params}')
logger.debug(
f'Control command: {command}, params: {params}')
if command == 'Next':
self.next()
elif command == 'Previous':
@ -495,8 +496,20 @@ class MPDWrapper(object):
def __track_key(self, snapmeta):
return hash(snapmeta.get('artist', [''])[0] + snapmeta.get('album', snapmeta.get('title', '')))
def update_albumart(self, snapmeta):
def get_albumart(self, snapmeta, cached):
album_key = 'musicbrainzAlbumId'
track_key = self.__track_key(snapmeta)
album_art = self._album_art_map.get(track_key)
if album_art is not None:
if album_art == '':
return None
else:
return album_art
if cached:
return None
self._album_art_map[track_key] = ''
try:
if not album_key in snapmeta:
mbartist = None
@ -521,22 +534,21 @@ class MPDWrapper(object):
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"]
album_art = image["thumbnails"]["small"]
logger.debug(
f'{snapmeta["artUrl"]} is an approved front image')
logger.info(f'Snapmeta: {snapmeta}')
self._album_art_map[self.__track_key(
snapmeta)] = snapmeta['artUrl']
send(
{"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Metadata", "params": snapmeta})
f'{album_art} is an approved front image')
self._album_art_map[track_key] = album_art
break
except musicbrainzngs.musicbrainz.ResponseError as e:
logger.error(
f'Error while getting cover for {snapmeta[album_key]}: {e}')
self._album_art_map[self.__track_key(snapmeta)] = ''
album_art = self._album_art_map[track_key]
if album_art == '':
return None
return album_art
def update_metadata(self):
def get_metadata(self):
"""
Translate metadata returned by MPD to the MPRIS v2 syntax.
http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata
@ -586,19 +598,12 @@ class MPDWrapper(object):
snapmeta['artist'] = [fields[0]]
snapmeta['title'] = fields[1]
track_key = self.__track_key(snapmeta)
if track_key in self._album_art_map:
art_url = self._album_art_map[track_key]
art_url = self.get_albumart(snapmeta, True)
if art_url is not None:
logger.info(f'album art cache hit: "{art_url}"')
if art_url != '':
snapmeta['artUrl'] = art_url
snapmeta['artUrl'] = art_url
send({"jsonrpc": "2.0",
"method": "Plugin.Stream.Player.Metadata", "params": snapmeta})
if not track_key in self._album_art_map:
# t = threading.Thread(target=self.update_albumart, args=[snapmeta])
# t.start()
self.update_albumart(snapmeta)
return snapmeta
def __diff_map(self, old_map, new_map):
diff = {}
@ -662,14 +667,11 @@ class MPDWrapper(object):
logger.debug('nothing to do')
return
logger.info(f'new status: {new_status}')
logger.info(f'changed_status: {changed_status}')
logger.info(f'changed_song: {changed_song}')
logger.info(
f'new status: {new_status}, changed_status: {changed_status}, changed_song: {changed_song}')
self._time = new_time = int(time.time())
snapstatus = self._get_properties(new_status)
send({"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Properties",
"params": snapstatus})
if 'elapsed' in new_status:
new_position = float(new_status['elapsed'])
@ -682,9 +684,9 @@ class MPDWrapper(object):
# "player" subsystem
force = len(changed_song) > 0
new_song = len(changed_song) > 0
if not force:
if not new_song:
if new_status['state'] == 'play':
expected_position = old_position + (new_time - old_time)
else:
@ -698,7 +700,18 @@ class MPDWrapper(object):
else:
# Update current song metadata
self.update_metadata()
snapstatus["metadata"] = self.get_metadata()
send({"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Properties",
"params": snapstatus})
if new_song:
if 'artUrl' not in snapstatus['metadata']:
album_art = self.get_albumart(snapstatus['metadata'], False)
if album_art is not None:
snapstatus['metadata']['artUrl'] = album_art
send(
{"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Properties", "params": snapstatus})
# Compatibility functions

View file

@ -331,12 +331,12 @@ class SnapcastWrapper(object):
if not 'mpris:artUrl' in self._metadata:
self._metadata['mpris:artUrl'] = f'http://{self._params["host"]}:{self._params["port"]}/snapcast-512.png'
logger.info(f'mpris meta: {self._metadata}')
logger.debug(f'mpris meta: {self._metadata}')
self.notify_about_track(self._metadata)
new_meta = self._dbus_service.update_property('org.mpris.MediaPlayer2.Player',
'Metadata')
logger.info(f'new meta {new_meta}')
logger.debug(f'new meta {new_meta}')
except Exception as e:
logger.error(f'Error in update_metadata: {str(e)}')
@ -344,11 +344,10 @@ class SnapcastWrapper(object):
try:
if props is None:
props = {}
logger.info(f'Properties: "{props}"')
logger.debug(f'Properties: "{props}"')
# store the last receive time stamp for better position estimation
if 'position' in props:
props['_received'] = time.time()
# ignore "internal" properties, starting with "_"
changed_properties = {}
for key, value in props.items():
@ -369,6 +368,8 @@ class SnapcastWrapper(object):
if key in property_mapping:
self._dbus_service.update_property(
'org.mpris.MediaPlayer2.Player', property_mapping[key])
if 'metadata' in changed_properties:
self.__update_metadata(props.get('metadata', None))
except Exception as e:
logger.error(f'Error in update_properties: {str(e)}')
@ -388,8 +389,6 @@ class SnapcastWrapper(object):
logger.info(f'Stream id: {self._stream_id}')
for stream in jmsg['result']['server']['streams']:
if stream['id'] == self._stream_id:
if 'metadata' in stream:
self.__update_metadata(stream['metadata'])
if 'properties' in stream:
self.__update_properties(stream['properties'])
break
@ -399,14 +398,6 @@ class SnapcastWrapper(object):
logger.info(f'Stream id: {self._stream_id}')
elif jmsg['method'] == "Group.OnStreamChanged":
self.send_request("Server.GetStatus")
elif jmsg["method"] == "Stream.OnMetadata":
stream_id = jmsg["params"]["id"]
logger.info(f'Stream meta changed for "{stream_id}"')
if self._stream_id != stream_id:
return
meta = jmsg["params"]["metadata"]
self.__update_metadata(meta)
elif jmsg["method"] == "Stream.OnProperties":
stream_id = jmsg["params"]["id"]
logger.info(

View file

@ -4,11 +4,11 @@
A stream plugin is (this might change in future) an executable binary or script that is started by the server for a specific stream and offers playback control capabilities and provides information about the stream's state, as well as metadata for the currently playing track.
The Snapcast server communicates via stdin/stdout with the plugin and sends newline delimited JSON-RPC commands and receives responses and notifications from the plugin, as described below. In upcoming releases shared library plugins might be supported as well.
## Requests:
## Requests
A Stream plugin must support and handle the following requests, sent by the Snapcast server
### Plugin.Stream.Player.Control:
### Plugin.Stream.Player.Control
Used to control the player
@ -19,33 +19,33 @@ Used to control the player
Supported `command`s:
- `Play`: Start playback
- `params`: none
- `params`: none
- `Pause`: Stop playback
- `params`: none
- `params`: none
- `PlayPause`: Toggle play/pause
- `params`: none
- `params`: none
- `Stop`: Stop playback
- `params`: none
- `params`: none
- `Next`: Skip to next track
- `params`: none
- `params`: none
- `Previous`: Skip to previous track
- `params`: none
- `params`: none
- `Seek`: Seek forward or backward in the current track
- `params`:
- `Offset`: [float] seek offset in seconds
- `params`:
- `Offset`: [float] seek offset in seconds
- `SetPosition`: Set the current track position in seconds
- `params`:
- `Position`: [float] the new track position
- `TrackId`: [string] the optional currently playing track's identifier
- `params`:
- `Position`: [float] the new track position
- `TrackId`: [string] the optional currently playing track's identifier
#### Example:
#### Example
```json
{"id": 1, "jsonrpc": "2.0", "method": "Plugin.Stream.Player.Control", "params": {"command": "SetPosition", "params": { "Position": 170966827, "TrackId": "/org/mpris/MediaPlayer2/Track/2"}}}
```
#### Expected response:
#### Expected response
Success:
@ -59,23 +59,23 @@ Error:
todo
```
### Plugin.Stream.Player.SetProperty:
### Plugin.Stream.Player.SetProperty
```json
{"id": 1, "jsonrpc": "2.0", "method": "Plugin.Stream.Player.SetProperty", "params": {"<property>", <value>}}
```
#### Supported `property`s:
#### Supported `property`s
- `loopStatus`: [string] the current repeat status, one of:
- `none`: the playback will stop when there are no more tracks to play
- `track`: the current track will start again from the begining once it has finished playing
- `playlist`: the playback loops through a list of tracks
- `none`: the playback will stop when there are no more tracks to play
- `track`: the current track will start again from the begining once it has finished playing
- `playlist`: the playback loops through a list of tracks
- `shuffle`: [bool] play playlist in random order
- `volume`: [int] voume in percent, valid range [0..100]
- `rate`: [float] The current playback rate, valid range (0..)
#### Expected response:
#### Expected response
Success:
@ -95,7 +95,7 @@ todo
{"id": 1, "jsonrpc": "2.0", "method": "Plugin.Stream.Player.GetProperties"}
```
#### Expected response:
#### Expected response
Success:
@ -106,13 +106,13 @@ Success:
#### Supported `property`s
- `playbackStatus`: [string] The current playback status, one of:
- `playing`
- `paused`
- `stopped`
- `playing`
- `paused`
- `stopped`
- `loopStatus`: [string] The current repeat status, one of:
- `none`: The playback will stop when there are no more tracks to play
- `track`: The current track will start again from the begining once it has finished playing
- `playlist`: The playback loops through a list of tracks
- `none`: The playback will stop when there are no more tracks to play
- `track`: The current track will start again from the begining once it has finished playing
- `playlist`: The playback loops through a list of tracks
- `rate`: [float] The current playback rate, valid range (0..)
- `shuffle`: [bool] Traverse through the playlist in random order
- `volume`: [int] Voume in percent, valid range [0..100]
@ -132,11 +132,13 @@ todo
### Plugin.Stream.Player.GetMetadata
TODO: Metadata are part of the properties
```json
{"id": 1, "jsonrpc": "2.0", "method": "Plugin.Stream.Player.GetMetadata"}
```
#### Expected response:
#### Expected response
Success:
@ -144,10 +146,12 @@ Success:
{"id": 1, "jsonrpc": "2.0", "result": {"artist":["Travis Scott & HVME"],"file":"http://wdr-1live-live.icecast.wdr.de/wdr/1live/live/mp3/128/stream.mp3","name":"1Live, Westdeutscher Rundfunk Koeln","title":"Goosebumps (Remix)","trackId":"3"}}
```
## Notifications:
## Notifications
### Plugin.Stream.Player.Metadata
TODO: Metadata are part of the properties
```json
{"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Metadata", "params": {"artist":["Travis Scott & HVME"],"file":"http://wdr-1live-live.icecast.wdr.de/wdr/1live/live/mp3/128/stream.mp3","name":"1Live, Westdeutscher Rundfunk Koeln","title":"Goosebumps (Remix)","trackId":"3"}}
```
@ -199,7 +203,6 @@ Success:
- `spotifyArtistId`: [string] The [Spotify Artist ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids)
- `spotifyTrackId`: [string] The [Spotify Track ID](https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids)
### Plugin.Stream.Player.Properties
```json
@ -234,12 +237,11 @@ The plugin shall send this notification when it's up and ready to receive comman
{"jsonrpc": "2.0", "method": "Plugin.Stream.Ready"}
```
# Server:
# Server
TODO: this belongs to doc/json_rpc_api/v2_0_0.md
## Requests:
## Requests
To control the stream state, the following commands can be sent to the Snapcast server and will be forwarded to the respective stream:
@ -251,7 +253,7 @@ To control the stream state, the following commands can be sent to the Snapcast
{"id": 1, "jsonrpc": "2.0", "method": "Stream.SetProperty", "params": {"id": "Pipe", "property": property, "value": value}}
```
## Notifications:
## Notifications
```json
{"jsonrpc": "2.0", "method": "Stream.OnMetadata", "params": {"id": "Pipe", "metadata": {}}}

View file

@ -49,23 +49,6 @@ void Server::onNewSession(std::shared_ptr<StreamSession> session)
}
void Server::onMetadataChanged(const PcmStream* pcmStream, const Metatags& metadata)
{
// clang-format off
// Notification: {"jsonrpc":"2.0","method":"Stream.OnMetadata","params":{"id":"stream 1", "metadata": {"album": "some album", "artist": "some artist", "track": "some track"...}}}
// clang-format on
LOG(DEBUG, LOG_TAG) << "Metadata changed, stream: " << pcmStream->getName() << ", meta: " << metadata.toJson().dump(3) << "\n";
// streamServer_->onMetadataChanged(pcmStream, meta);
// Send meta to all connected clients
json notification = jsonrpcpp::Notification("Stream.OnMetadata", jsonrpcpp::Parameter("id", pcmStream->getId(), "metadata", metadata.toJson())).to_json();
controlServer_->send(notification.dump(), nullptr);
// cout << "Notification: " << notification.dump() << "\n";
}
void Server::onPropertiesChanged(const PcmStream* pcmStream, const Properties& properties)
{
LOG(DEBUG, LOG_TAG) << "Properties changed, stream: " << pcmStream->getName() << ", properties: " << properties.toJson().dump(3) << "\n";

View file

@ -78,7 +78,6 @@ private:
void onNewSession(std::shared_ptr<StreamSession> session) override;
/// Implementation of PcmListener
void onMetadataChanged(const PcmStream* pcmStream, const Metatags& metadata) override;
void onPropertiesChanged(const PcmStream* pcmStream, const Properties& properties) override;
void onStateChanged(const PcmStream* pcmStream, ReaderState state) override;
void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) override;

View file

@ -183,7 +183,7 @@ void AirplayStream::push()
// mden = metadata end, pcen == picture end
if (metadata_dirty_ && entry_->type == "ssnc" && (entry_->code == "mden" || entry_->code == "pcen"))
{
setMetadata(metadata_);
// setMetadata(metadata_);
metadata_dirty_ = false;
}
}

View file

@ -161,7 +161,7 @@ void LibrespotStream::onStderrMsg(const std::string& line)
Metatags meta;
meta.artist = std::vector<std::string>{j["ARTIST"].get<std::string>()};
meta.title = j["TITLE"].get<std::string>();
setMetadata(meta);
// TODO setMetadata(meta);
}
else if (regex_search(line, m, re_track_loaded))
{
@ -169,7 +169,7 @@ void LibrespotStream::onStderrMsg(const std::string& line)
Metatags meta;
meta.title = string(m[1]);
meta.duration = cpt::stod(m[2]) / 1000.;
setMetadata(meta);
// TODO setMetadata(meta);
Properties properties;
// properties.can_seek = true;
// properties.can_control = true;

View file

@ -84,16 +84,6 @@ void MetaStream::stop()
}
void MetaStream::onMetadataChanged(const PcmStream* pcmStream, const Metatags& metadata)
{
LOG(DEBUG, LOG_TAG) << "onMetadataChanged: " << pcmStream->getName() << "\n";
std::lock_guard<std::recursive_mutex> lock(mutex_);
if (pcmStream != active_stream_.get())
return;
setMetadata(metadata);
}
void MetaStream::onPropertiesChanged(const PcmStream* pcmStream, const Properties& properties)
{
LOG(DEBUG, LOG_TAG) << "onPropertiesChanged: " << pcmStream->getName() << "\n";
@ -118,7 +108,6 @@ void MetaStream::onStateChanged(const PcmStream* pcmStream, ReaderState state)
LOG(INFO, LOG_TAG) << "Stream: " << name_ << ", switching active stream: " << (active_stream_ ? active_stream_->getName() : "<null>") << " => "
<< new_stream->getName() << "\n";
active_stream_ = new_stream;
setMetadata(active_stream_->getMetadata());
setProperties(active_stream_->getProperties());
resampler_ = make_unique<Resampler>(active_stream_->getSampleFormat(), sampleFormat_);
};

View file

@ -62,7 +62,6 @@ public:
protected:
/// Implementation of PcmListener
void onMetadataChanged(const PcmStream* pcmStream, const Metatags& metadata) override;
void onPropertiesChanged(const PcmStream* pcmStream, const Properties& properties) override;
void onStateChanged(const PcmStream* pcmStream, ReaderState state) override;
void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) override;

View file

@ -137,12 +137,7 @@ void PcmStream::onControlNotification(const jsonrpcpp::Notification& notificatio
try
{
LOG(INFO, LOG_TAG) << "Notification method: " << notification.method() << ", params: " << notification.params().to_json() << "\n";
if (notification.method() == "Plugin.Stream.Player.Metadata")
{
LOG(DEBUG, LOG_TAG) << "Received metadata notification\n";
setMetadata(notification.params().to_json());
}
else if (notification.method() == "Plugin.Stream.Player.Properties")
if (notification.method() == "Plugin.Stream.Player.Properties")
{
LOG(DEBUG, LOG_TAG) << "Received properties notification\n";
setProperties(notification.params().to_json());
@ -155,15 +150,10 @@ void PcmStream::onControlNotification(const jsonrpcpp::Notification& notificatio
if (response.error().code() == 0)
setProperties(response.result());
});
stream_ctrl_->command({++req_id_, "Plugin.Stream.Player.GetMetadata"}, [this](const jsonrpcpp::Response& response) {
LOG(INFO, LOG_TAG) << "Response for Plugin.Stream.Player.GetMetadata: " << response.to_json() << "\n";
if (response.error().code() == 0)
setMetadata(response.result());
});
// TODO: Add capabilities or settings?
// {"jsonrpc": "2.0", "method": "Plugin.Stream.Ready", "params": {"pollProperties": 10, "responseTimeout": 5}}
pollProperties();
// pollProperties();
}
else if (notification.method() == "Plugin.Stream.Log")
{
@ -452,44 +442,31 @@ void PcmStream::play(ResultHandler handler)
}
void PcmStream::setMetadata(const Metatags& metadata)
{
std::lock_guard<std::recursive_mutex> lock(mutex_);
if (metadata == metadata_)
{
LOG(DEBUG, LOG_TAG) << "setMetadata: Metadata did not change\n";
return;
}
metadata_ = metadata;
LOG(INFO, LOG_TAG) << "setMetadata, stream: " << getId() << ", metadata: " << metadata_.toJson() << "\n";
// Trigger a stream update
for (auto* listener : pcmListeners_)
{
if (listener != nullptr)
listener->onMetadataChanged(this, metadata_);
}
}
void PcmStream::setProperties(const Properties& properties)
{
std::lock_guard<std::recursive_mutex> lock(mutex_);
if (properties == properties_)
Properties props = properties;
// Missing metadata means, they didn't change, so
// enrich the new properites with old metadata
if (!props.metatags.has_value() && properties_.metatags.has_value())
props.metatags = properties_.metatags;
if (props == properties_)
{
LOG(DEBUG, LOG_TAG) << "setProperties: Properties did not change\n";
return;
}
properties_ = properties;
properties_ = std::move(props);
LOG(INFO, LOG_TAG) << "setProperties, stream: " << getId() << ", properties: " << properties_.toJson() << "\n";
// Trigger a stream update
for (auto* listener : pcmListeners_)
{
if (listener != nullptr)
listener->onPropertiesChanged(this, properties);
listener->onPropertiesChanged(this, properties_);
}
}

View file

@ -30,7 +30,6 @@
#include "common/error_code.hpp"
#include "common/json.hpp"
#include "common/metatags.hpp"
#include "common/properties.hpp"
#include "common/sample_format.hpp"
#include "encoder/encoder.hpp"
@ -98,7 +97,6 @@ static constexpr auto kControlScript = "controlscript";
class PcmListener
{
public:
virtual void onMetadataChanged(const PcmStream* pcmStream, const Metatags& metadata) = 0;
virtual void onPropertiesChanged(const PcmStream* pcmStream, const Properties& properties) = 0;
virtual void onStateChanged(const PcmStream* pcmStream, ReaderState state) = 0;
virtual void onChunkRead(const PcmStream* pcmStream, const msg::PcmChunk& chunk) = 0;
@ -166,7 +164,6 @@ protected:
void resync(const std::chrono::nanoseconds& duration);
void chunkEncoded(const encoder::Encoder& encoder, std::shared_ptr<msg::PcmChunk> chunk, double duration);
void setMetadata(const Metatags& metadata);
void setProperties(const Properties& properties);
void pollProperties();

View file

@ -22,7 +22,6 @@
#include <regex>
#include "common/aixlog.hpp"
#include "common/metatags.hpp"
#include "common/properties.hpp"
#include "common/utils/string_utils.hpp"
#include "server/streamreader/control_error.hpp"