mirror of
https://github.com/badaix/snapcast
synced 2025-02-23 07:54:34 +01:00
904 lines
33 KiB
Python
Executable file
904 lines
33 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
#
|
|
# 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/>.
|
|
#
|
|
# Author: Johannes Pohl <snapcast@badaix.de>
|
|
# Based on mpDris2 by
|
|
# Jean-Philippe Braun <eon@patapon.info>,
|
|
# Mantas Mikulėnas <grawity@gmail.com>
|
|
# Based on mpDris by:
|
|
# Erik Karlsson <pilo@ayeon.org>
|
|
# Some bits taken from quodlibet mpris plugin by:
|
|
# <christoph.reiter@gmx.at>
|
|
|
|
|
|
# Dependencies:
|
|
# - python-mpd2
|
|
# - musicbrainzngs
|
|
# - PyGObject
|
|
# - dbus-python
|
|
|
|
import os
|
|
import sys
|
|
import socket
|
|
import getopt
|
|
import mpd
|
|
from dbus.mainloop.glib import DBusGMainLoop
|
|
import logging
|
|
import gettext
|
|
import time
|
|
import json
|
|
import musicbrainzngs
|
|
import fcntl
|
|
|
|
__version__ = "@version@"
|
|
__git_version__ = "@gitversion@"
|
|
|
|
musicbrainzngs.set_useragent(
|
|
"snapcast-mtea-mpd",
|
|
"0.1",
|
|
"https://github.com/badaix/snapcast",
|
|
)
|
|
|
|
using_gi_glib = False
|
|
|
|
try:
|
|
from gi.repository import GLib
|
|
using_gi_glib = True
|
|
except ImportError:
|
|
import glib as GLib
|
|
|
|
|
|
# _ = gettext.gettext
|
|
|
|
|
|
params = {
|
|
'progname': sys.argv[0],
|
|
# Connection
|
|
'mpd-host': None,
|
|
'mpd-port': None,
|
|
'mpd-password': None,
|
|
'snapcast-host': None,
|
|
'snapcast-port': None,
|
|
'stream': None,
|
|
}
|
|
|
|
defaults = {
|
|
# Connection
|
|
'mpd-host': 'localhost',
|
|
'mpd-port': 6600,
|
|
'mpd-password': None,
|
|
'snapcast-host': 'localhost',
|
|
'snapcast-port': 1780,
|
|
'stream': 'default',
|
|
}
|
|
|
|
# Player.Status
|
|
status_mapping = {
|
|
# https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#properties
|
|
# 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
|
|
# 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?
|
|
# 'MinimumRate d (Playback_Rate) Read only
|
|
# 'MaximumRate d (Playback_Rate) Read only
|
|
# 'CanGoNext b Read only
|
|
# 'CanGoPrevious b Read only
|
|
# 'CanPlay b Read only
|
|
# 'CanPause b Read only
|
|
# 'CanSeek b Read only
|
|
# 'CanControl b Read only
|
|
|
|
# https://mpd.readthedocs.io/en/stable/protocol.html#status
|
|
# partition: the name of the current partition (see Partition commands)
|
|
# single 2: 0, 1, or oneshot 6
|
|
# consume 2: 0 or 1
|
|
# playlist: 31-bit unsigned integer, the playlist version number
|
|
# playlistlength: integer, the length of the playlist
|
|
# song: playlist song number of the current song stopped on or playing
|
|
# songid: playlist songid of the current song stopped on or playing
|
|
# 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 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
|
|
# mixrampdelay: mixrampdelay in seconds
|
|
# audio: The format emitted by the decoder plugin during playback, format: samplerate:bits:channels. See Global Audio Format for a detailed explanation.
|
|
# updating_db: job id
|
|
# error: if there is an error, returns message here
|
|
|
|
# Snapcast
|
|
# R/W true/false
|
|
'mute': ['mute', lambda val: {'0': False, '1': True}[val]]
|
|
}
|
|
|
|
# Player.Metadata
|
|
# MPD to Snapcast tag mapping: <mpd tag>: [<snapcast tag>, <type>, <is list?>]
|
|
tag_mapping = {
|
|
'file': ['url', str, False],
|
|
'id': ['trackId', str, False],
|
|
'album': ['album', str, False],
|
|
'albumsort': ['albumSort', str, False],
|
|
'albumartist': ['albumArtist', str, True],
|
|
'albumartistsort': ['albumArtistSort', str, True],
|
|
'artist': ['artist', str, True],
|
|
'artistsort': ['artistSort', str, True],
|
|
'astext': ['asText', str, True],
|
|
'audiobpm': ['audioBPM', int, False],
|
|
'autorating': ['autoRating', float, False],
|
|
'comment': ['comment', str, True],
|
|
'composer': ['composer', str, True],
|
|
'date': ['contentCreated', str, False],
|
|
'disc': ['discNumber', int, False],
|
|
'firstused': ['firstUsed', str, False],
|
|
'genre': ['genre', str, True],
|
|
'lastused': ['lastUsed', str, False],
|
|
'lyricist': ['lyricist', str, True],
|
|
'title': ['title', str, False],
|
|
'track': ['trackNumber', int, False],
|
|
'arturl': ['artUrl', str, False],
|
|
'usecount': ['useCount', int, False],
|
|
'userrating': ['userRating', float, False],
|
|
|
|
'duration': ['duration', float, False],
|
|
'name': ['name', str, False],
|
|
'originaldate': ['originalDate', str, False],
|
|
'performer': ['performer', str, True],
|
|
'conductor': ['conductor', str, True],
|
|
'work': ['work', str, False],
|
|
'grouping': ['grouping', str, False],
|
|
'label': ['label', str, False],
|
|
'musicbrainz_artistid': ['musicbrainzArtistId', str, False],
|
|
'musicbrainz_albumid': ['musicbrainzAlbumId', str, False],
|
|
'musicbrainz_albumartistid': ['musicbrainzAlbumArtistId', str, False],
|
|
'musicbrainz_trackid': ['musicbrainzTrackId', str, False],
|
|
'musicbrainz_releasetrackid': ['musicbrainzReleaseTrackId', str, False],
|
|
'musicbrainz_workid': ['musicbrainzWorkId', str, False],
|
|
}
|
|
|
|
|
|
# Default url handlers if MPD doesn't support 'urlhandlers' command
|
|
urlhandlers = ['http://']
|
|
|
|
|
|
def send(json_msg):
|
|
print(json.dumps(json_msg))
|
|
sys.stdout.flush()
|
|
|
|
|
|
class MPDWrapper(object):
|
|
""" Wrapper of mpd.MPDClient to handle socket
|
|
errors and similar
|
|
"""
|
|
|
|
def __init__(self, params):
|
|
self.client = mpd.MPDClient()
|
|
|
|
self._params = params
|
|
|
|
self._can_single = False
|
|
self._can_idle = False
|
|
|
|
self._errors = 0
|
|
self._poll_id = None
|
|
self._watch_id = None
|
|
self._idling = False
|
|
|
|
self._status = {}
|
|
self._currentsong = {}
|
|
self._position = 0
|
|
self._time = 0
|
|
self._album_art_map = {}
|
|
|
|
def run(self):
|
|
"""
|
|
Try to connect to MPD; retry every 5 seconds on failure.
|
|
"""
|
|
if self.my_connect():
|
|
GLib.timeout_add_seconds(5, self.my_connect)
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
@property
|
|
def connected(self):
|
|
return self.client._sock is not None
|
|
|
|
def my_connect(self):
|
|
""" Init MPD connection """
|
|
try:
|
|
self._idling = False
|
|
self._can_idle = False
|
|
self._can_single = False
|
|
self._buffer = ''
|
|
|
|
self.client.connect(
|
|
self._params['mpd-host'], self._params['mpd-port'])
|
|
if self._params['mpd-password']:
|
|
try:
|
|
self.client.password(self._params['mpd-password'])
|
|
except mpd.CommandError as e:
|
|
logger.error(e)
|
|
sys.exit(1)
|
|
|
|
commands = self.commands()
|
|
# added in 0.11
|
|
if 'urlhandlers' in commands:
|
|
global urlhandlers
|
|
urlhandlers = self.urlhandlers()
|
|
# added in 0.14
|
|
if 'idle' in commands:
|
|
self._can_idle = True
|
|
# added in 0.15
|
|
if 'single' in commands:
|
|
self._can_single = True
|
|
|
|
if self._errors > 0:
|
|
logger.info('Reconnected to MPD server.')
|
|
else:
|
|
logger.debug('Connected to MPD server.')
|
|
|
|
# Make the socket non blocking to detect deconnections
|
|
self.client._sock.settimeout(5.0)
|
|
|
|
# Init internal state to throw events at start
|
|
self.init_state()
|
|
|
|
# Add periodic status check for sending MPRIS events
|
|
if not self._poll_id:
|
|
interval = 15 if self._can_idle else 1
|
|
self._poll_id = GLib.timeout_add_seconds(interval,
|
|
self.timer_callback)
|
|
if self._can_idle and not self._watch_id:
|
|
if using_gi_glib:
|
|
self._watch_id = GLib.io_add_watch(self,
|
|
GLib.PRIORITY_DEFAULT,
|
|
GLib.IO_IN | GLib.IO_HUP,
|
|
self.socket_callback)
|
|
else:
|
|
self._watch_id = GLib.io_add_watch(self,
|
|
GLib.IO_IN | GLib.IO_HUP,
|
|
self.socket_callback)
|
|
|
|
flags = fcntl.fcntl(sys.stdin.fileno(), fcntl.F_GETFL)
|
|
flags |= os.O_NONBLOCK
|
|
fcntl.fcntl(sys.stdin.fileno(), fcntl.F_SETFL, flags)
|
|
GLib.io_add_watch(sys.stdin, GLib.IO_IN |
|
|
GLib.IO_HUP, self.io_callback)
|
|
|
|
# Reset error counter
|
|
self._errors = 0
|
|
|
|
self.timer_callback()
|
|
self.idle_enter()
|
|
send({"jsonrpc": "2.0", "method": "Plugin.Stream.Ready"})
|
|
|
|
# Return False to stop trying to connect
|
|
return False
|
|
except socket.error as e:
|
|
self._errors += 1
|
|
if self._errors < 6:
|
|
logger.error('Could not connect to MPD: %s' % e)
|
|
if self._errors == 6:
|
|
logger.info('Continue to connect but going silent')
|
|
return True
|
|
|
|
def reconnect(self):
|
|
logger.warning("Disconnected")
|
|
|
|
# Clean mpd client state
|
|
try:
|
|
self.disconnect()
|
|
except:
|
|
self.disconnect()
|
|
|
|
# Try to reconnect
|
|
self.run()
|
|
|
|
def disconnect(self):
|
|
self.client.disconnect()
|
|
|
|
def init_state(self):
|
|
# Get current state
|
|
self._status = self.client.status()
|
|
# Invalid some fields to throw events at start
|
|
self._status['state'] = 'invalid'
|
|
self._status['songid'] = '-1'
|
|
self._position = 0
|
|
|
|
def idle_enter(self):
|
|
if not self._can_idle:
|
|
return False
|
|
if not self._idling:
|
|
# NOTE: do not use MPDClient.idle(), which waits for an event
|
|
self._write_command("idle", [])
|
|
self._idling = True
|
|
logger.debug("Entered idle")
|
|
return True
|
|
else:
|
|
logger.warning("Nested idle_enter()!")
|
|
return False
|
|
|
|
def idle_leave(self):
|
|
if not self._can_idle:
|
|
return False
|
|
if self._idling:
|
|
# NOTE: don't use noidle() or _execute() to avoid infinite recursion
|
|
self._write_command("noidle", [])
|
|
self._fetch_object()
|
|
self._idling = False
|
|
logger.debug("Left idle")
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
# Events
|
|
|
|
def timer_callback(self):
|
|
try:
|
|
was_idle = self.idle_leave()
|
|
except (socket.error, mpd.MPDError, socket.timeout):
|
|
logger.error('')
|
|
self.reconnect()
|
|
return False
|
|
self._update_properties(force=False)
|
|
if was_idle:
|
|
self.idle_enter()
|
|
return True
|
|
|
|
def control(self, cmd):
|
|
try:
|
|
request = json.loads(cmd)
|
|
id = request['id']
|
|
[interface, cmd] = request['method'].rsplit('.', 1)
|
|
if interface == 'Plugin.Stream.Player':
|
|
if cmd == 'Control':
|
|
success = True
|
|
command = request['params']['command']
|
|
params = request['params'].get('params', {})
|
|
logger.debug(
|
|
f'Control command: {command}, params: {params}')
|
|
if command == 'next':
|
|
self.next()
|
|
elif command == 'previous':
|
|
self.previous()
|
|
elif command == 'play':
|
|
self.play()
|
|
elif command == 'pause':
|
|
self.pause(1)
|
|
elif command == 'playPause':
|
|
if self.status()['state'] == 'play':
|
|
self.pause(1)
|
|
else:
|
|
self.play()
|
|
elif command == 'stop':
|
|
self.stop()
|
|
elif command == 'setPosition':
|
|
position = float(params['position'])
|
|
logger.info(f'setPosition {position}')
|
|
self.seekcur(position)
|
|
elif command == 'seek':
|
|
offset = float(params['offset'])
|
|
strOffset = str(offset)
|
|
if offset >= 0:
|
|
strOffset = "+" + strOffset
|
|
self.seekcur(strOffset)
|
|
elif cmd == 'SetProperty':
|
|
property = request['params']
|
|
logger.info(f'SetProperty: {property}')
|
|
if 'shuffle' in property:
|
|
self.random(int(property['shuffle']))
|
|
if 'loopStatus' in property:
|
|
value = property['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)
|
|
if 'volume' in property:
|
|
self.setvol(int(property['volume']))
|
|
elif cmd == 'GetProperties':
|
|
snapstatus = self._get_properties(self.status())
|
|
logger.info(f'Snapstatus: {snapstatus}')
|
|
return send({"jsonrpc": "2.0", "id": id, "result": snapstatus})
|
|
# return send({"jsonrpc": "2.0", "error": {"code": -32601,
|
|
# "message": "TODO: GetProperties not yet implemented"}, "id": id})
|
|
elif cmd == 'GetMetadata':
|
|
send({"jsonrpc": "2.0", "method": "Plugin.Stream.Log", "params": {
|
|
"severity": "Info", "message": "Logmessage"}})
|
|
return send({"jsonrpc": "2.0", "error": {"code": -32601,
|
|
"message": "TODO: GetMetadata not yet implemented"}, "id": id})
|
|
else:
|
|
return send({"jsonrpc": "2.0", "error": {"code": -32601,
|
|
"message": "Method not found"}, "id": id})
|
|
else:
|
|
return send({"jsonrpc": "2.0", "error": {"code": -32601,
|
|
"message": "Method not found"}, "id": id})
|
|
send({"jsonrpc": "2.0", "result": "ok", "id": id})
|
|
except Exception as e:
|
|
send({"jsonrpc": "2.0", "error": {
|
|
"code": -32700, "message": "Parse error", "data": str(e)}, "id": id})
|
|
|
|
def io_callback(self, fd, event):
|
|
try:
|
|
logger.error(
|
|
f'IO event "{event}" on fd "{fd}" (type: "{type(fd)}"')
|
|
if event & GLib.IO_HUP:
|
|
logger.debug("IO_HUP")
|
|
return True
|
|
elif event & GLib.IO_IN:
|
|
chunk = fd.read()
|
|
for char in chunk:
|
|
if char == '\n':
|
|
logger.info(f'Received: {self._buffer}')
|
|
self.control(self._buffer)
|
|
self._buffer = ''
|
|
else:
|
|
self._buffer += char
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f'Exception in io_callback: "{str(e)}"')
|
|
return True
|
|
|
|
def socket_callback(self, fd, event):
|
|
try:
|
|
logger.debug(f'Socket event "{event}" on fd "{fd}"')
|
|
if event & GLib.IO_HUP:
|
|
self.reconnect()
|
|
return True
|
|
elif event & GLib.IO_IN:
|
|
if self._idling:
|
|
self._idling = False
|
|
data = fd._fetch_objects("changed")
|
|
logger.debug("Idle events: %r" % data)
|
|
updated = False
|
|
for item in data:
|
|
subsystem = item["changed"]
|
|
# subsystem list: <http://www.musicpd.org/doc/protocol/ch03.html>
|
|
if subsystem in ("player", "mixer", "options", "playlist"):
|
|
if not updated:
|
|
logger.info(f'Subsystem: {subsystem}')
|
|
self._update_properties(force=True)
|
|
updated = True
|
|
self.idle_enter()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f'Exception in socket_callback: "{str(e)}"')
|
|
self.reconnect()
|
|
return True
|
|
|
|
def __track_key(self, snapmeta):
|
|
return hash(snapmeta.get('artist', [''])[0] + snapmeta.get('album', snapmeta.get('title', '')))
|
|
|
|
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
|
|
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"]:
|
|
album_art = image["thumbnails"]["small"]
|
|
logger.debug(
|
|
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}')
|
|
album_art = self._album_art_map[track_key]
|
|
if album_art == '':
|
|
return None
|
|
return album_art
|
|
|
|
def get_metadata(self):
|
|
"""
|
|
Translate metadata returned by MPD to the MPRIS v2 syntax.
|
|
http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata
|
|
"""
|
|
|
|
mpd_meta = self._currentsong.copy()
|
|
logger.debug(f'mpd meta: {mpd_meta}')
|
|
snapmeta = {}
|
|
for key, values in mpd_meta.items():
|
|
try:
|
|
value = {}
|
|
if type(values) == list:
|
|
if len(values) == 0:
|
|
continue
|
|
if tag_mapping[key][2]:
|
|
value = list(map(type(tag_mapping[key][1]), values))
|
|
else:
|
|
value = tag_mapping[key][1](values[0])
|
|
else:
|
|
if tag_mapping[key][2]:
|
|
value = [tag_mapping[key][1](values)]
|
|
else:
|
|
value = tag_mapping[key][1](values)
|
|
snapmeta[tag_mapping[key][0]] = value
|
|
logger.debug(
|
|
f'key: {key}, value: {value}, mapped key: {tag_mapping[key][0]}, mapped value: {snapmeta[tag_mapping[key][0]]}')
|
|
except KeyError:
|
|
logger.debug(f'tag "{key}" not supported')
|
|
except (ValueError, TypeError):
|
|
logger.warning(
|
|
f"Can't cast value {value} to {tag_mapping[key][1]}")
|
|
logger.debug(f'snapcast meta: {snapmeta}')
|
|
|
|
# Hack for web radio:
|
|
# "name" and "title" are set, but not "album" and not "artist"
|
|
# where
|
|
# - "name" containts the name of the radio station
|
|
# - "title" has the format "<artist> - <title>"
|
|
# {'url': 'http://wdr-wdr2-aachenundregion.icecast.wdr.de/wdr/wdr2/aachenundregion/mp3/128/stream.mp3', 'title': 'Johannes Oerding - An guten Tagen', 'name': 'WDR 2 Aachen und die Region aktuell, Westdeutscher Rundfunk Koeln', 'trackId': '1'}
|
|
|
|
if 'title' in snapmeta and 'name' in snapmeta and 'url' in snapmeta and not 'album' in snapmeta and not 'artist' in snapmeta:
|
|
if snapmeta['url'].find('http') == 0:
|
|
fields = snapmeta['title'].split(' - ', 1)
|
|
if len(fields) == 1:
|
|
fields = snapmeta['title'].split(' / ', 1)
|
|
if len(fields) == 2:
|
|
snapmeta['artist'] = [fields[0]]
|
|
snapmeta['title'] = fields[1]
|
|
|
|
art_url = self.get_albumart(snapmeta, True)
|
|
if art_url is not None:
|
|
logger.info(f'album art cache hit: "{art_url}"')
|
|
snapmeta['artUrl'] = art_url
|
|
return snapmeta
|
|
|
|
def __diff_map(self, old_map, new_map):
|
|
diff = {}
|
|
for key, value in new_map.items():
|
|
if not key in old_map:
|
|
diff[key] = [None, value]
|
|
elif value != old_map[key]:
|
|
diff[key] = [old_map[key], value]
|
|
for key, value in old_map.items():
|
|
if not key in new_map:
|
|
diff[key] = [value, None]
|
|
return diff
|
|
|
|
def _get_properties(self, mpd_status):
|
|
snapstatus = {}
|
|
for key, value in mpd_status.items():
|
|
try:
|
|
mapped_key = status_mapping[key][0]
|
|
mapped_val = status_mapping[key][1](value)
|
|
snapstatus[mapped_key] = mapped_val
|
|
logger.debug(
|
|
f'key: {key}, value: {value}, mapped key: {mapped_key}, mapped value: {mapped_val}')
|
|
except KeyError:
|
|
logger.debug(f'property "{key}" not supported')
|
|
except (ValueError, TypeError):
|
|
logger.warning(
|
|
f"Can't cast value {value} to {status_mapping[key][1]}")
|
|
|
|
snapstatus['canGoNext'] = True
|
|
snapstatus['canGoPrevious'] = True
|
|
snapstatus['canPlay'] = True
|
|
snapstatus['canPause'] = True
|
|
snapstatus['canSeek'] = 'duration' in snapstatus
|
|
snapstatus['canControl'] = True
|
|
return snapstatus
|
|
|
|
def _update_properties(self, force=False):
|
|
logger.debug(f'update_properties force: {force}')
|
|
old_position = self._position
|
|
old_time = self._time
|
|
|
|
new_song = self.client.currentsong()
|
|
if not new_song:
|
|
logger.warning("_update_properties: failed to get current song")
|
|
new_song = {}
|
|
|
|
new_status = self.client.status()
|
|
if not new_status:
|
|
logger.warning("_update_properties: failed to get new status")
|
|
new_status = {}
|
|
|
|
changed_status = self.__diff_map(self._status, new_status)
|
|
if len(changed_status) > 0:
|
|
self._status = new_status
|
|
|
|
changed_song = self.__diff_map(self._currentsong, new_song)
|
|
if len(changed_song) > 0:
|
|
self._currentsong = new_song
|
|
|
|
if len(changed_song) == 0 and len(changed_status) == 0:
|
|
logger.debug('nothing to do')
|
|
return
|
|
|
|
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)
|
|
|
|
if 'elapsed' in new_status:
|
|
new_position = float(new_status['elapsed'])
|
|
elif 'time' in new_status:
|
|
new_position = int(new_status['time'].split(':')[0])
|
|
else:
|
|
new_position = 0
|
|
|
|
self._position = new_position
|
|
|
|
# "player" subsystem
|
|
|
|
new_song = len(changed_song) > 0
|
|
|
|
if not new_song:
|
|
if new_status['state'] == 'play':
|
|
expected_position = old_position + (new_time - old_time)
|
|
else:
|
|
expected_position = old_position
|
|
if abs(new_position - expected_position) > 0.6:
|
|
logger.debug("Expected pos %r, actual %r, diff %r" % (
|
|
expected_position, new_position, new_position - expected_position))
|
|
logger.debug("Old position was %r at %r (%r seconds ago)" % (
|
|
old_position, old_time, new_time - old_time))
|
|
# self._dbus_service.Seeked(new_position * 1000000)
|
|
|
|
else:
|
|
# Update current song 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
|
|
|
|
# Fedora 17 still has python-mpd 0.2, which lacks fileno().
|
|
if hasattr(mpd.MPDClient, "fileno"):
|
|
def fileno(self):
|
|
return self.client.fileno()
|
|
else:
|
|
def fileno(self):
|
|
if not self.connected:
|
|
raise mpd.ConnectionError("Not connected")
|
|
return self.client._sock.fileno()
|
|
|
|
# Access to python-mpd internal APIs
|
|
|
|
# We use _write_command("idle") to manually enter idle mode, as it has no
|
|
# immediate response to fetch.
|
|
#
|
|
# Similarly, we use _write_command("noidle") + _fetch_object() to manually
|
|
# leave idle mode (for reasons I don't quite remember). The result of
|
|
# _fetch_object() is not used.
|
|
|
|
if hasattr(mpd.MPDClient, "_write_command"):
|
|
def _write_command(self, *args):
|
|
return self.client._write_command(*args)
|
|
elif hasattr(mpd.MPDClient, "_writecommand"):
|
|
def _write_command(self, *args):
|
|
return self.client._writecommand(*args)
|
|
|
|
if hasattr(mpd.MPDClient, "_parse_objects_direct"):
|
|
def _fetch_object(self):
|
|
objs = self._fetch_objects()
|
|
if not objs:
|
|
return {}
|
|
return objs[0]
|
|
elif hasattr(mpd.MPDClient, "_fetch_object"):
|
|
def _fetch_object(self):
|
|
return self.client._fetch_object()
|
|
elif hasattr(mpd.MPDClient, "_getobject"):
|
|
def _fetch_object(self):
|
|
return self.client._getobject()
|
|
|
|
# We use _fetch_objects("changed") to receive unprompted idle events on
|
|
# socket activity.
|
|
|
|
if hasattr(mpd.MPDClient, "_parse_objects_direct"):
|
|
def _fetch_objects(self, *args):
|
|
return list(self.client._parse_objects_direct(self.client._read_lines(), *args))
|
|
elif hasattr(mpd.MPDClient, "_fetch_objects"):
|
|
def _fetch_objects(self, *args):
|
|
return self.client._fetch_objects(*args)
|
|
elif hasattr(mpd.MPDClient, "_getobjects"):
|
|
def _fetch_objects(self, *args):
|
|
return self.client._getobjects(*args)
|
|
|
|
# Wrapper to catch connection errors when calling mpd client methods.
|
|
|
|
def __getattr__(self, attr):
|
|
if attr[0] == "_":
|
|
raise AttributeError(attr)
|
|
return lambda *a, **kw: self.call(attr, *a, **kw)
|
|
|
|
def call(self, command, *args):
|
|
fn = getattr(self.client, command)
|
|
try:
|
|
was_idle = self.idle_leave()
|
|
logger.debug("Sending command %r (was idle? %r)" %
|
|
(command, was_idle))
|
|
r = fn(*args)
|
|
if was_idle:
|
|
self.idle_enter()
|
|
return r
|
|
except (socket.error, mpd.MPDError, socket.timeout) as ex:
|
|
logger.debug("Trying to reconnect, got %r" % ex)
|
|
self.reconnect()
|
|
return False
|
|
|
|
|
|
def usage(params):
|
|
print("""\
|
|
Usage: %(progname)s [OPTION]...
|
|
|
|
--mpd-host=ADDR Set the mpd server address
|
|
--mpd-port=PORT Set the TCP port
|
|
--snapcast-host=ADDR Set the snapcast server address
|
|
--snapcast-port=PORT Set the snapcast server port
|
|
--stream=ID Set the stream id
|
|
|
|
-h, --help Show this help message
|
|
-d, --debug Run in debug mode
|
|
-v, --version meta_mpd version
|
|
|
|
Report bugs to https://github.com/badaix/snapcast/issues""" % params)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
DBusGMainLoop(set_as_default=True)
|
|
|
|
gettext.bindtextdomain('meta_mpd', '@datadir@/locale')
|
|
gettext.textdomain('meta_mpd')
|
|
|
|
log_format_stderr = '%(asctime)s %(module)s %(levelname)s: %(message)s'
|
|
|
|
log_level = logging.INFO
|
|
|
|
# Parse command line
|
|
try:
|
|
(opts, args) = getopt.getopt(sys.argv[1:], 'hdjv',
|
|
['help', 'mpd-host=', 'mpd-port=', 'snapcast-host=', 'snapcast-port=', 'stream=', 'debug', 'version'])
|
|
except getopt.GetoptError as ex:
|
|
(msg, opt) = ex.args
|
|
print("%s: %s" % (sys.argv[0], msg), file=sys.stderr)
|
|
print(file=sys.stderr)
|
|
usage(params)
|
|
sys.exit(2)
|
|
|
|
for (opt, arg) in opts:
|
|
if opt in ['-h', '--help']:
|
|
usage(params)
|
|
sys.exit()
|
|
elif opt in ['--mpd-host']:
|
|
params['mpd-host'] = arg
|
|
elif opt in ['--mpd-port']:
|
|
params['mpd-port'] = int(arg)
|
|
elif opt in ['--snapcast-host']:
|
|
params['snapcast-host'] = arg
|
|
elif opt in ['--snapcast-port']:
|
|
params['snapcast-port'] = int(arg)
|
|
elif opt in ['--stream']:
|
|
params['stream'] = arg
|
|
elif opt in ['-d', '--debug']:
|
|
log_level = logging.DEBUG
|
|
elif opt in ['-v', '--version']:
|
|
v = __version__
|
|
if __git_version__:
|
|
v = __git_version__
|
|
print("meta_mpd version %s" % v)
|
|
sys.exit()
|
|
|
|
if len(args) > 2:
|
|
usage(params)
|
|
sys.exit()
|
|
|
|
logger = logging.getLogger('meta_mpd')
|
|
logger.propagate = False
|
|
logger.setLevel(log_level)
|
|
|
|
# Log to stderr
|
|
log_handler = logging.StreamHandler()
|
|
log_handler.setFormatter(logging.Formatter(log_format_stderr))
|
|
|
|
logger.addHandler(log_handler)
|
|
|
|
for p in ['mpd-host', 'mpd-port', 'snapcast-host', 'snapcast-port', 'mpd-password', 'stream']:
|
|
if not params[p]:
|
|
params[p] = defaults[p]
|
|
|
|
if '@' in params['mpd-host']:
|
|
params['mpd-password'], params['mpd-host'] = params['mpd-host'].rsplit(
|
|
'@', 1)
|
|
|
|
params['mpd-host'] = os.path.expanduser(params['mpd-host'])
|
|
|
|
logger.debug(f'Parameters: {params}')
|
|
|
|
# Set up the main loop
|
|
if using_gi_glib:
|
|
logger.debug('Using GObject-Introspection main loop.')
|
|
else:
|
|
logger.debug('Using legacy pygobject2 main loop.')
|
|
loop = GLib.MainLoop()
|
|
|
|
# Create wrapper to handle connection failures with MPD more gracefully
|
|
mpd_wrapper = MPDWrapper(params)
|
|
mpd_wrapper.run()
|
|
|
|
# Run idle loop
|
|
try:
|
|
loop.run()
|
|
except KeyboardInterrupt:
|
|
logger.debug('Caught SIGINT, exiting.')
|
|
|
|
# Clean up
|
|
try:
|
|
mpd_wrapper.client.close()
|
|
mpd_wrapper.client.disconnect()
|
|
logger.debug('Exiting')
|
|
except mpd.ConnectionError:
|
|
logger.error('Failed to disconnect properly')
|