#!/usr/bin/python3
#
#    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/>.
#
# Authors: terual (https://github.com/terual), Peter Oliver <git@mavit.org.uk>
#
# Heavily based on mpDris2 by Erik Karlsson <pilo@ayeon.org> and
#  Jean-Philippe Braun <eon@patapon.info>
# Some bits taken from quodlibet mpris plugin by <christoph.reiter@gmx.at>


from dbus.mainloop.glib import DBusGMainLoop
import dbus
import dbus.service
import getopt
import gettext
import gi
import inspect
import json
import logging
import math
import os
import re
import signal
import simplejson
import six
import six.moves.configparser
import six.moves.http_client
import socket
import ssl
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import xdg.BaseDirectory

gi.require_version('Soup', '3.0')
from gi.repository import GLib, Gio, Soup


app_name = "slimpris2"
app_id = f"io.github.mavit.{app_name}"

gettext.bindtextdomain(app_name, '/usr/share/locale')
gettext.textdomain(app_name)
_ = gettext.gettext

params = {
    'scheme': 'http',
    'host': None,
    'port': 9000,
    'username': None,
    'password': None,
    'progname': sys.argv[0],
    'mmkeys': True,
    'insecure_https': False,
}


# MPRIS allowed metadata tags
allowed_tags = {
    'mpris:trackid': dbus.ObjectPath,
    'mpris:length': dbus.Int64,
    'mpris:artUrl': str,
    'xesam:album': str,
    'xesam:albumArtist': list,
    'xesam:artist': list,
    'xesam:asText': str,
    'xesam:audioBPM': int,
    'xesam:comment': list,
    'xesam:composer': list,
    'xesam:contentCreated': str,
    'xesam:discNumber': int,
    'xesam:firstUsed': str,
    'xesam:genre': list,
    'xesam:lastUsed': str,
    'xesam:lyricist': str,
    'xesam:title': str,
    'xesam:trackNumber': int,
    'xesam:url': str,
    'xesam:useCount': int,
    'xesam:userRating': float,
}

# python dbus bindings don't include annotations and properties
MPRIS2_INTROSPECTION = \
    """<node name="/org/mpris/MediaPlayer2">
  <interface name="org.freedesktop.DBus.Introspectable">
    <method name="Introspect">
      <arg direction="out" name="xml_data" type="s"/>
    </method>
  </interface>
  <interface name="org.freedesktop.DBus.Properties">
    <method name="Get">
      <arg direction="in" name="interface_name" type="s"/>
      <arg direction="in" name="property_name" type="s"/>
      <arg direction="out" name="value" type="v"/>
    </method>
    <method name="GetAll">
      <arg direction="in" name="interface_name" type="s"/>
      <arg direction="out" name="properties" type="a{sv}"/>
    </method>
    <method name="Set">
      <arg direction="in" name="interface_name" type="s"/>
      <arg direction="in" name="property_name" type="s"/>
      <arg direction="in" name="value" type="v"/>
    </method>
    <signal name="PropertiesChanged">
      <arg name="interface_name" type="s"/>
      <arg name="changed_properties" type="a{sv}"/>
      <arg name="invalidated_properties" type="as"/>
    </signal>
  </interface>
  <interface name="org.mpris.MediaPlayer2">
    <method name="Raise"/>
    <method name="Quit"/>
    <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
    <property name="CanQuit" type="b" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="Fullscreen" type="b" access="read"/>
    <property name="CanSetFullscreen" type="b" access="read"/>
    <property name="CanRaise" type="b" access="read"/>
    <property name="HasTrackList" type="b" access="read"/>
    <property name="Identity" type="s" access="read"/>
    <property name="DesktopEntry" type="s" access="read"/>
    <property name="SupportedUriSchemes" type="as" access="read"/>
    <property name="SupportedMimeTypes" type="as" access="read"/>
  </interface>
  <interface name="org.mpris.MediaPlayer2.Player">
    <method name="Next"/>
    <method name="Previous"/>
    <method name="Pause"/>
    <method name="PlayPause"/>
    <method name="Stop"/>
    <method name="Play"/>
    <method name="Seek">
      <arg direction="in" name="Offset" type="x"/>
    </method>
    <method name="SetPosition">
      <arg direction="in" name="TrackId" type="o"/>
      <arg direction="in" name="Position" type="x"/>
    </method>
    <method name="OpenUri">
      <arg direction="in" name="Uri" type="s"/>
    </method>
    <signal name="Seeked">
      <arg name="Position" type="x"/>
    </signal>
    <property name="PlaybackStatus" type="s" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="LoopStatus" type="s" access="readwrite">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="Rate" type="d" access="readwrite">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
    </property>
    <property name="Shuffle" type="b" access="readwrite">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="Metadata" type="a{sv}" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="Volume" type="d" access="readwrite">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="false"/>
    </property>
    <property name="Position" type="x" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="false"/>
    </property>
    <property name="MinimumRate" type="d" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
    </property>
    <property name="MaximumRate" type="d" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
    </property>
    <property name="CanGoNext" type="b" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="CanGoPrevious" type="b" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="CanPlay" type="b" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="CanPause" type="b" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
    </property>
    <property name="CanSeek" type="b" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="CanControl" type="b" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
    </property>
  </interface>
  <interface name="org.mpris.MediaPlayer2.TrackList">
    <method name="GetTracksMetadata">
      <arg direction="in" name="TrackIds" type="ao"/>
      <arg direction="out" name="Metadata" type="aa{sv}"/>
    </method>
<!--
    <method name="AddTrack">
      <arg direction="in" name="Uri" type="s"/>
      <arg direction="in" name="AfterTrack" type="o"/>
      <arg direction="in" name="SetAsCurrent" type="b"/>
    </method>
-->
    <method name="RemoveTrack">
      <arg direction="in" name="TrackId" type="o"/>
    </method>
    <method name="GoTo">
      <arg direction="in" name="TrackId" type="o"/>
    </method>
    <signal name="TrackListReplaced">
      <arg name="Tracks" type="ao"/>
      <arg name="CurrentTrack" type="o"/>
    </signal>
    <property name="Tracks" type="ao" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
    <property name="CanEditTracks" type="b" access="read">
      <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
    </property>
  </interface>
</node>"""


# Wrapper to handle socket errors and similar
class SBWrapper():
    # By how many seconds can the time step by before we consider a seek
    # to have happened?  Needs to be long enough to account for
    # scheduling and network jitter, but small enough that the user
    # won't notice and/or care that they're being shown the wrong time.
    seek_tolerance = 1.0

    def __init__(self, params):
        self.sc = None
        self._dbus = dbus
        self._params = params
        self._monitor = False
        self._status = False
        self._position = 0
        self._dbus_service = False
        self._errors = 0
        self._retry_period = 1
        # init mmkeys, if configured
        if self._params['mmkeys']:
            try:
                gsd_object = dbus.SessionBus().get_object('org.gnome.SettingsDaemon.MediaKeys',
                                                          '/org/gnome/SettingsDaemon/MediaKeys')
                # this is what gives us the multi media keys.
                gsd_object.GrabMediaPlayerKeys('Squeezebox', 0,
                                               dbus_interface='org.gnome.SettingsDaemon.MediaKeys')
                # connect_to_signal registers our callback function.
                gsd_object.connect_to_signal(
                    'MediaPlayerKeyPressed', self.on_mediakey)
            except:
                logger.warning('Failed to connect to GNOME Settings Daemon.'
                               ' Disabling multimedia key support')
                params['mmkeys'] = False

    def run(self):
        """ Try to connect to LMS """
        GLib.timeout_add(0, self.my_connect)

    def my_connect(self):
        """ Init LMS connection """
        try:
            if self._params['host'] is None:
                self.discover_lms()

            lms.detect_player()
            lms.isplayer()
            lms.get_playing_info()

            # Export our DBUS service
            if not self._dbus_service:
                self._dbus_service = MPRISInterface()
            else:
                # Add our service to the session bus
                self._dbus_service.add_to_connection(dbus.SessionBus(),
                                                     '/org/mpris/MediaPlayer2')
                self._dbus_service.aquire_name()

            # Init internal state to throw events at start
            self.init_state()

            # Subscribe to LMS status changes
            lms.run()

            # Reset error counter
            self._errors = 0
            self._retry_period = 1
        except (
                LMSDiscoveryError,
                six.moves.http_client.BadStatusLine,
                socket.timeout,
                urllib.error.URLError
        ) as e:
            self._errors += 1
            if self._errors <= 6:
                logger.error('Could not connect to LMS: %s' % e)
            if self._errors == 6:
                logger.info('Continue to connect but going silent')
            GLib.timeout_add_seconds(self._retry_period, self.my_connect)
            if self._retry_period < 60:
                self._retry_period = self._retry_period * 2

        # Return False to stop trying to connect
        return False

    def discover_lms(self):
        """Attempt to locate LMS on the local network"""
        logger.debug("Sending discovery packet")
        udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        udp_sock.sendto(
            b'eNAME\0JSON\0UUID\0VERS\0',
            ('255.255.255.255', 3483)
        )

        udp_sock.settimeout(self._retry_period)
        (msg, (lms.host, _)) = udp_sock.recvfrom(1500)
        logger.debug(f"Discovered LMS at {lms.host}")

        if msg[0:1] == b'E':
            msg = msg[1:]
        else:
            raise LMSDiscoveryError("Bad discovery packet")

        length = len(msg)
        while length > 0:
            tag = msg[0:4]
            (length2,) = struct.unpack("B", msg[4:5])
            val = msg[5:5 + length2] if length2 else None

            logger.debug(f"Discovery tag {tag}: {val}")
            if tag == 'JSON':
                lms.port = int(val)

            msg = msg[length2 + 5:]
            length = length - length2 - 5


    def reconnect(self):
        logger.warning("Disconnected")
        # Release the Dbus name
        self._dbus_service.release_name()
        # Disconnect service from the session bus
        self._dbus_service.remove_from_connection()
        # Stop monitoring
        GLib.source_remove(self._monitor)
        # Clean mpd client state
        # self.disconnect()
        # Try to reconnect
        self.run()

    def init_state(self):
        # Get current state
        self._status = lms.get_playing_info()
        # Invalid some fields to throw events at start
        self._status['mode'] = 'invalid'
        if 'playlist_loop' in self._status:
            self._status['playlist_loop'][
                int(self._status['playlist_cur_index'])
            ]['title'] = 'invalid'
        self._status['playlist_cur_index'] = '-1'
        self._position = 0
        self.monitor()

    def monitor(self, status=None):
        old_status = self._status
        old_position = self._position

        self._status = status or lms.get_playing_info()

        if type(self._status) == type(dict()):

            if 'time' in self._status:
                self._position = float(self._status['time'])
            else:
                self._position = 0

            if (
                self._status['mode'] == 'play'
                and 'playlist_cur_index' in self._status
                and 'playlist_loop' in self._status
                and (
                    'playlist_cur_index' not in old_status
                    or (old_status['playlist_cur_index'] != self._status['playlist_cur_index'])
                    or (old_status['playlist_loop'][int(old_status['playlist_cur_index'])]['id'] != self._status['playlist_loop'][int(self._status['playlist_cur_index'])]['id'])
                    or (old_status['playlist_loop'][int(old_status['playlist_cur_index'])]['title'] != self._status['playlist_loop'][int(self._status['playlist_cur_index'])]['title'])
                 )
            ):
                metadata = self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.Player',
                    'Metadata'
                )
                self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.Player',
                    'CanSeek'
                )
            elif 'playlist_loop' in old_status \
                 and 'playlist_loop' not in self._status:
                metadata = self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.Player',
                    'Metadata'
                )
                self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.Player',
                    'CanSeek'
                )

            if self._status['mode'] != 'stop' and old_status['mode'] != 'stop':
                if int(old_status['playlist_cur_index']) == -1 \
                   or (old_status['playlist_cur_index']
                       == self._status['playlist_cur_index']):
                    expected_delta = self._status['last_fetch'] \
                        - old_status['last_fetch']
                    actual_delta = self._position - old_position
                    if abs(actual_delta - expected_delta) > self.seek_tolerance:
                        self._dbus_service.Seeked(self._position * 1000000)

            if old_status['mixer volume'] != self._status['mixer volume']:
                self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.Player', 'Volume')

            if old_status['power'] != self._status['power']:
                self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2', 'CanQuit'
                )

            if old_status['mode'] != self._status['mode']:
                self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.Player', 'PlaybackStatus')

            if old_status['playlist shuffle'] != self._status['playlist shuffle']:
                self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.Player', 'Shuffle')

            if old_status['playlist repeat'] != self._status['playlist repeat']:
                self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.Player', 'LoopStatus')

            if (
                bool('playlist_loop' in old_status)
                    != bool('playlist_loop' in self._status)
            ) or (
                'playlist_loop' in old_status
                and 'playlist_loop' in self._status
                and old_status['playlist_loop'] != self._status['playlist_loop']
            ):
                self._dbus_service.update_property(
                    'org.mpris.MediaPlayer2.TrackList',
                    'Tracks'
                )
                self._dbus_service.update_tracklist()
                for prop in ['CanGoNext', 'CanGoPrevious', 'CanPlay']:
                    self._dbus_service.update_property(
                        'org.mpris.MediaPlayer2.Player',
                        prop
                    )
            elif (
                'playlist_cur_index' in old_status
                and 'playlist_cur_index' in self._status
            ):
                for prop in ['CanGoNext', 'CanGoPrevious']:
                    self._dbus_service.update_property(
                        'org.mpris.MediaPlayer2.Player',
                        prop
                    )

        # Return True to continue polling
        return True

    def on_mediakey(self, appname, key):
        """ GNOME media key handler """
        logger.debug('Got GNOME mmkey "%s" for "%s"' % (key, appname))
        if key == 'Play':
            if self._status['mode'] == 'play':
                lms.pause()
            else:
                lms.play()
        elif key == 'Next':
            next(lms)
        elif key == 'Previous':
            lms.prev()
        elif key == 'Stop':
            lms.stop()

    def get_current_track(self):
        try:
            index = int(self._status['playlist_cur_index'])
        except KeyError:
            return
        if index >= 0:
            return self._status['playlist_loop'][index]
        else:
            return


class MPRISInterface(dbus.service.Object):
    ''' The base object of an MPRIS player '''

    __path = "/org/mpris/MediaPlayer2"
    __root_interface = "org.mpris.MediaPlayer2"
    __name = f"{__root_interface}.{app_name}"
    __introspect_interface = "org.freedesktop.DBus.Introspectable"
    __prop_interface = dbus.PROPERTIES_IFACE

    def __init__(self):
        dbus.service.Object.__init__(self, dbus.SessionBus(),
                                     MPRISInterface.__path)
        self.aquire_name()

    def aquire_name(self):
        self._bus_name = dbus.service.BusName(MPRISInterface.__name,
                                              bus=dbus.SessionBus())

    def release_name(self):
        del self._bus_name

    def __can_quit():
        return bool(int(sb_wrapper._status['power']))

    def __get_identity():
        return lms.player_name

    __root_props = {
        "CanQuit": (__can_quit, None),
        "CanRaise": (False, None),
        "Fullscreen": (False, None),
        "CanSetFullscreen": (False, None),
        "DesktopEntry": (app_id, None),
        "HasTrackList": (True, None),
        "Identity": (__get_identity, None),
        "SupportedUriSchemes": (dbus.Array(signature="s"), None),
        "SupportedMimeTypes": (dbus.Array(signature="s"), None)
    }

    def __get_playback_status():
        status = sb_wrapper._status
        return {
            'play': 'Playing',
            'pause': 'Paused',
            'stop': 'Stopped',
        }[status['mode']]

    def __set_loop_status(value):
        if str(value) == "Playlist":
            lms.set_repeat(2)
        elif str(value) == "Track":
            lms.set_repeat(1)
        elif str(value) == "None":
            lms.set_repeat(0)
        else:
            raise dbus.exceptions.DBusException("Loop mode not supported")
        return

    def __get_loop_status():
        # if lms.get_repeat() == '0':

        playlist_repeat = int(sb_wrapper._status['playlist repeat'])
        if playlist_repeat == 2:
            return "Playlist"
        elif playlist_repeat == 1:
            return "Track"
        else:
            return "None"

    def __set_shuffle(value):
        lms.set_shuffle(1 if value else 0)
        return

    def __get_shuffle():
        # if lms.get_shuffle() == '0':
        return bool(int(sb_wrapper._status['playlist shuffle']))

    def __get_metadata():
        track = sb_wrapper.get_current_track()
        if track:
            if 'playcount' in track or ('remote' in track and track['remote']):
                # Comet gives us all the data we need.
                metadata = [{k: v} for k, v in track.items()]
            else:
                # We didn't request everything we need, so get it now.
                metadata = lms.metadata(track['id'])
            return format_metadata(metadata, "Track")
        else:
            return format_metadata([], "Track")

    def __get_volume():
        #vol = float(lms.get_volume())

        volume_percent = float(sb_wrapper._status['mixer volume'])
        if volume_percent > 0:
            return volume_percent / 100
        else:
            # Muted when negative.
            return 0.0

    def __set_volume(value):
        if value >= 0 and value <= 1:
            lms.set_volume(int(value * 100))
        return

    def __get_position():
        try:
            # position = lms.get_position()
            position = sb_wrapper._status['time'] \
                + time.monotonic() - sb_wrapper._status['last_fetch']
        except KeyError:
            position = 0
        return dbus.Int64(int(position * 1000000))

    def __can_go_next():
        track = sb_wrapper.get_current_track()
        try:
            return bool(track['buttons']['fwd'])
        except (KeyError, TypeError):
            return bool(track)

    def __can_go_previous():
        track = sb_wrapper.get_current_track()
        try:
            return bool(track['buttons']['rew'])
        except (KeyError, TypeError):
            return bool(track)

    def __can_play():
        if sb_wrapper._status['playlist_tracks'] > 0:
            return True
        else:
            return False

    def __can_seek():
        status = sb_wrapper._status  # lms.get_playing_info()
        if 'can_seek' in status:
            if status['can_seek'] == 1:
                return True
        return False

    __player_interface = f"{__root_interface}.Player"
    __player_props = {
        "PlaybackStatus": (__get_playback_status, None),
        "LoopStatus": (__get_loop_status, __set_loop_status),
        "Rate": (1.0, None),
        "Shuffle": (__get_shuffle, __set_shuffle),
        "Metadata": (__get_metadata, None),
        "Volume": (__get_volume, __set_volume),
        "Position": (__get_position, None),
        "MinimumRate": (1.0, None),
        "MaximumRate": (1.0, None),
        "CanGoNext": (__can_go_next, None),
        "CanGoPrevious": (__can_go_previous, None),
        "CanPlay": (__can_play, None),
        "CanPause": (True, None),
        "CanSeek": (__can_seek, None),
        "CanControl": (True, None),
    }

    def __get_tracks():
        status = sb_wrapper._status
        tracklist = []
        if 'playlist_loop' in status:
            for track in status['playlist_loop']:
                tracklist.append(mpris_track_id_to_object_path(track['id']))
        return dbus.Array(tracklist, signature="o")

    @staticmethod
    def __get_current_track():
        track = sb_wrapper.get_current_track()
        if track:
            return mpris_track_id_to_object_path(track['id'])
        else:
            return '/org/mpris/MediaPlayer2/TrackList/NoTrack'

    __tracklist_interface = f"{__root_interface}.TrackList"
    __tracklist_props = {
        "Tracks": (__get_tracks, None),
        "CanEditTracks": (True, None),
    }

    __prop_mapping = {
        __tracklist_interface: __tracklist_props,
        __player_interface: __player_props,
        __root_interface: __root_props}

    @dbus.service.method(__introspect_interface)
    def Introspect(self):
        return MPRIS2_INTROSPECTION

    @dbus.service.signal(__prop_interface, signature="sa{sv}as")
    def PropertiesChanged(self, interface, changed_properties,
                          invalidated_properties):
        pass

    @dbus.service.method(__prop_interface,
                         in_signature="ss", out_signature="v")
    def Get(self, interface, prop):
        getter, setter = self.__prop_mapping[interface][prop]
        if callable(getter):
            return getter()
        return getter

    @dbus.service.method(__prop_interface,
                         in_signature="ssv", out_signature="")
    def Set(self, interface, prop, value):
        getter, setter = self.__prop_mapping[interface][prop]
        if setter is not None:
            setter(value)

    @dbus.service.method(__prop_interface,
                         in_signature="s", out_signature="a{sv}")
    def GetAll(self, interface):
        read_props = {}
        props = self.__prop_mapping[interface]
        for key, (getter, setter) in six.iteritems(props):
            if callable(getter):
                getter = getter()
            read_props[key] = getter
        return read_props

    def update_property(self, interface, prop):
        getter, setter = self.__prop_mapping[interface][prop]
        if callable(getter):
            value = getter()
        else:
            value = getter
        logger.debug('Updated property: %s = %s' % (prop, value))
        self.PropertiesChanged(interface, {prop: value}, [])
        return value

    def update_tracklist(self):
        self.TrackListReplaced(
            self.__tracklist_props['Tracks'][0](),
            self.__get_current_track(),
        )

    # Root methods

    @dbus.service.method(__root_interface, in_signature='', out_signature='')
    def Raise(self):
        return

    @dbus.service.method(__root_interface, in_signature='', out_signature='')
    def Quit(self):
        lms.power(0)
        return

    # Player methods
    @dbus.service.method(__player_interface, in_signature='', out_signature='')
    def Next(self):
        next(lms)
        return

    @dbus.service.method(__player_interface, in_signature='', out_signature='')
    def Previous(self):
        lms.prev()
        return

    @dbus.service.method(__player_interface, in_signature='', out_signature='')
    def Pause(self):
        status = lms.get_mode()
        if status == 'play':
            lms.pause()
        return

    @dbus.service.method(__player_interface, in_signature='', out_signature='')
    def PlayPause(self):
        status = lms.get_mode()
        if status == 'play':
            lms.pause()
        else:
            lms.play()
        return

    @dbus.service.method(__player_interface, in_signature='', out_signature='')
    def Stop(self):
        lms.stop()
        return

    @dbus.service.method(__player_interface, in_signature='', out_signature='')
    def Play(self):
        lms.play()
        return

    @dbus.service.method(__player_interface, in_signature='x', out_signature='')
    def Seek(self, offset):
        lms.seek(offset/1000000)
        return

    @dbus.service.method(__player_interface, in_signature='ox', out_signature='')
    def SetPosition(self, trackid, position):
        # lms.get_playing_info()

        if trackid != self.__get_current_track():
            return

        position = float(position) / 1000000
        track = sb_wrapper.get_current_track()
        if 0 <= position and position <= track['duration']:
            lms.set_position(position)
        else:
            logger.debug(
                f'SetPosition {position} out of range [0, {track["duration"]}]'
            )
        return

    @dbus.service.signal(__player_interface, signature='x')
    def Seeked(self, position):
        logger.debug("Seeked to %i" % position)
        return dbus.Int64(position)

    @dbus.service.method(__player_interface, in_signature='s', out_signature='')
    def OpenUri(self, uri):
        lms.playlist_add(uri)
        lms.play()
        return

    # Tracklist methods
    @dbus.service.method(__tracklist_interface, in_signature='ao', out_signature='aa{sv}')
    def GetTracksMetadata(self, track_ids):
        metadata = []
        for track_id in track_ids:
            metadata.append(
                format_metadata(
                    lms.metadata(mpris_track_object_path_to_id(track_id)),
                    "TrackList"
                )
            )
        return dbus.Array(metadata, signature="a{sv}")

    # # Worth implementing?  https://github.com/mavit/slimpris2/issues/14
    # @dbus.service.method(
    #     __tracklist_interface, in_signature='sob', out_signature=''
    # )
    # def AddTrack(self, uri, after_track, set_as_current):
    #     return

    @dbus.service.method(__tracklist_interface, in_signature='o', out_signature='')
    def RemoveTrack(self, track_id):
        for track in sb_wrapper._status['playlist_loop']:
            if int(track['id']) == mpris_track_object_path_to_id(track_id):
                lms.playlist_remove(int(track['playlist index']))
        return

    @dbus.service.method(__tracklist_interface, in_signature='o', out_signature='')
    def GoTo(self, track_id):
        for track in sb_wrapper._status['playlist_loop']:
            if int(track['id']) == mpris_track_object_path_to_id(track_id):
                lms.playlist_goto(int(track['playlist index']))
        return

    @dbus.service.signal(__tracklist_interface, signature='aoo')
    def TrackListReplaced(self, tracks, current_track):
        logger.debug("Tracklist replaced: %s", tracks)
        return

# Handle signals more gracefully


def handle_sigint(signum, frame):
    logger.debug(f'{signal.strsignal(signum)}.  Disconnecting.')
    lms.disconnect()


def dbus_encode_id(track_id):
    return str(track_id).replace('-', '_')


def dbus_decode_id(string):
    return int(string.replace('_', '-'))


def mpris_track_id_to_object_path(track_id):
    return dbus.ObjectPath(
        f"/{app_id.replace('.', '/')}/{dbus_encode_id(track_id)}"
    )


def mpris_track_object_path_to_id(object_path):
    return dbus_decode_id(
        str(object_path).replace(
            f"/{app_id.replace('.', '/')}/", ''
        )
    )


def format_metadata(data, context):
    """http://xmms2.org/wiki/MPRIS_Metadata"""

    metadata = {}

    for item in data:
        for key in item:
            metadata[key] = item[key]

    if 'id' in metadata:
        metadata['mpris:trackid'] = mpris_track_id_to_object_path(
            metadata['id']
        )

    if 'duration' in metadata:
        metadata['mpris:length'] = float(metadata['duration']) * 1000000

    if 'year' in metadata:
        if not int(metadata['year']) == 0:
            metadata['xesam:contentCreated'] = metadata['year']

    if 'tracknum' in metadata:
        metadata['xesam:trackNumber'] = metadata['tracknum']

    if 'disc' in metadata:
        metadata['xesam:discNumber'] = metadata['disc']

    if 'playcount' in metadata:
        metadata['xesam:useCount'] = metadata['playcount']

    if 'bpm' in metadata:
        metadata['xesam:audioBPM'] = metadata['bpm']

    if 'lyrics' in metadata:
        metadata['xesam:asText'] = metadata['lyrics']

    for tag in ('album', 'title', 'url'):
        if tag in metadata:
            metadata['xesam:%s' % tag] = metadata[tag]

    for tag in ('artist', 'comment', 'composer', 'genre'):
        if tag in metadata:
            metadata['xesam:%s' % tag] = [metadata[tag]]

    if 'artwork_url' in metadata:
        # Work around for https://gitlab.gnome.org/GNOME/gvfs/issues/54
        artwork_url = re.sub(r'(?i)%2f', '/', metadata['artwork_url'])

        metadata['mpris:artUrl'] = lms.get_absolute_url(artwork_url)
    elif 'coverart' in metadata and 'coverid' in metadata:
        if int(metadata['coverart']) == 1:
            metadata['mpris:artUrl'] = lms.get_coverart_url(metadata['coverid'])

    # Stream: populate some missings tags with stream's name
    if 'name' in metadata:
        if 'xesam:title' not in metadata:
            metadata['xesam:title'] = metadata['name']
        elif 'xesam:album' not in metadata:
            metadata['xesam:album'] = metadata['name']

    surplus_tags = set(metadata.keys()).difference(set(allowed_tags.keys()))
    # Remove surplus tags
    for tag in surplus_tags:
        del metadata[tag]

    # Cast metadata to the correct type, or discard it
    for key, value in list(metadata.items()):
        try:
            metadata[key] = allowed_tags[key](value)
        except ValueError:
            del metadata[key]
            logger.error("Can't cast value %s to %s" %
                         (value, allowed_tags[key]))

    return dbus.Dictionary(metadata, signature='sv')


class LMSConnection:

    def get_coverart_url(self, track_id, size=None):
        if size:
            return self.get_absolute_url(
                "/music/%s/cover_%sx%s_o" % (track_id, size, size)
            )
        else:
            return self.get_absolute_url(
                "/music/%s/cover.jpg" % (track_id)
            )

    def get_absolute_url(self, relative_url):
        if urllib.parse.urlparse(relative_url).scheme == '':
            if relative_url[0] != '/':
                relative_url = '/' + relative_url
            return "%s://%s:%s%s" % (
                self.scheme, self.host, self.port, relative_url
            )
        else:
            return relative_url

    def get_playing_info(self):
        #{'signalstrength': 0, 'mixer volume': 100, 'player_name': 'Studeerkamer', 'playlist mode': 'off', 'power': 1, 'playlist repeat': 0, 'player_ip': '192.168.1.58:49078', 'playlist_timestamp': 1338368677.0128, 'playlist_cur_index': '0', 'playlist shuffle': 0, 'rate': 1, 'seq_no': 0, 'mode': 'play', 'time': 161.490672870636, 'duration': 314.232, 'can_seek': 1, 'playlist_tracks': 12, 'player_connected': 1}
        info = self.query(self.player_id, "status", "0", None, "tags:")
        info['last_fetch'] = time.monotonic()
        return info or {"mode": "invalid"}

    def metadata(self, track_id):
        #path = self.get_track_path()
        songinfo = self.query(
            self.player_id, "songinfo", "0", None,
            "tags:acdgijkKlmOtuwxy",
            "track_id:%s" % track_id
        )

        return songinfo['songinfo_loop'] if 'songinfo_loop' in songinfo else []

    def get_track_path(self):
        return self.query(self.player_id, "path", "?")['_path']

    def get_track_id(self, path):
        return self.query(self.player_id, "songinfo", "0", "1", "url:%s" % path)['songinfo_loop'][0]['id']

    def get_track_title(self):
        return self.query(self.player_id, "title", "?")['_title']

    def get_track_artist(self):
        return self.query(self.player_id, "artist", "?")['_artist']

    def get_track_album(self):
        return self.query(self.player_id, "album", "?")['_album']

    def get_track_duration(self):
        return self.query(self.player_id, "duration", "?")['_duration']

    def get_mode(self):
        return self.query(self.player_id, "mode", "?")['_mode']

    def play(self):
        return self.query(self.player_id, "play")

    def pause(self):
        return self.query(self.player_id, "pause")

    def stop(self):
        return self.query(self.player_id, "stop")

    def power(self, off_on):
        return self.query(self.player_id, "power", off_on)

    def __next__(self):
        if self.isplayer:
            return self.query(self.player_id, "button", "jump_fwd")
        else:
            return self.query(self.player_id, "playlist", "jump", "+1")

    def prev(self):
        if self.isplayer:
            return self.query(self.player_id, "button", "jump_rew")
        else:
            return self.query(self.player_id, "playlist", "jump", "-1")

    def get_volume(self):
        # Get volume (0-100)
        return float(self.query(self.player_id, "mixer", "volume", "?")['_volume'])

    def set_volume(self, volume):
        # Set volume (0-100)
        return self.query(self.player_id, "mixer", "volume", volume)

    def get_position(self):
        return float(self.query(self.player_id, "time", "?")['_time'])

    def set_position(self, pos):
        # set in seconds
        return self.query(self.player_id, "time", pos)

    def seek(self, pos):
        return self.query(self.player_id, "time", "%+.6f" % pos)

    def set_repeat(self, num):
        # 0 no repeat, 1 repeat song, 2 repeat playlist.
        return self.query(self.player_id, "playlist", "repeat", num)

    def get_repeat(self):
        # 0 no repeat, 1 repeat song, 2 repeat playlist.
        return self.query(self.player_id, "playlist", "repeat", "?")['_repeat']

    def get_shuffle(self):
        # 0 no shuffle, 1 shuffle songs, 2 shuffle albums.
        return self.query(self.player_id, "playlist", "shuffle", "?")['_shuffle']

    def set_shuffle(self, num):
        # 0 no shuffle, 1 shuffle songs, 2 shuffle albums.
        return self.query(self.player_id, "playlist", "shuffle", num)

    def playlist_goto(self, playlist_index):
        return self.query(self.player_id, "playlist", "index", playlist_index)

    def playlist_add(self, uri):
        return self.query(self.player_id, "playlist", "add", uri)

    def playlist_remove(self, playlist_index):
        return self.query(self.player_id, "playlist", "delete", playlist_index)


class JSONConnection(LMSConnection):

    def __init__(self, params):

        self.scheme = params['scheme']
        self.host = params['host']
        self.port = params['port']
        self.username = params['username']
        self.password = params['password']
        self.insecure_https = params['insecure_https']

        handlers = []

        if self.scheme == 'https' and self.insecure_https:
            context = ssl._create_unverified_context()
            handlers.append(urllib.request.HTTPSHandler(context=context))

        if self.username is not None and self.password is not None:
            auth_handler = urllib.request.HTTPBasicAuthHandler()
            for brand in ['Lyrion Music Server', 'Logitech Media Server']:
                auth_handler.add_password(
                    realm=brand,
                    uri=("%s://%s:%s/" % (self.scheme, self.host, self.port)),
                    user=self.username,
                    passwd=self.password
                )
            handlers.append(auth_handler)

        opener = urllib.request.build_opener(*handlers)
        urllib.request.install_opener(opener)

        if 'player_id' in params:
            self.player_id = params['player_id']

    def detect_player(self):
        if not hasattr(self, 'player_id'):
            lms.player_id = lms.query(None, 'player', 'id', 0, '?')['_id']
        lms.player_name = lms.query(self.player_id, 'name', '?')['_value']

    def isplayer(self):
        lms.isplayer = lms.query(
            self.player_id, 'player', 'isplayer', '?'
        )['_isplayer']

    def get_coverart_url(self, track_id, size=None):
        login = ""
        if self.username is not None and self.password is not None:
            login = "%s:%s@" % (self.username, self.password)

        if size:
            return "%s://%s%s:%s/music/%s/cover_%sx%s_o" % (self.scheme, login, self.host, self.port, track_id, size, size)
        else:
            return "%s://%s%s:%s/music/%s/cover.jpg" % (self.scheme, login, self.host, self.port, track_id)

    def query(self, player_id, *args):
        params = simplejson.dumps(
            {'id': 1, 'method': 'slim.request', 'params': [player_id, list(args)]})
        req = urllib.request.Request(
            f"{self.scheme}://{self.host}:{self.port}/jsonrpc.js",
            params.encode('utf8')
        )
        req.add_header(
            'User-Agent',
            '%s/4.0.2 Python-urllib/%d.%d' % (
                app_name,
                *sys.version_info[:2],
            )
        )

        try:
            response = urllib.request.urlopen(req)
        except urllib.error.HTTPError as error:
            logger.error("%s. Query was %s", error.reason, list(args))
            return
        response_txt = response.read()
        result = simplejson.loads(response_txt)['result']
        logger.debug("query: %s, result: %s", list(args), result)
        return result


class CometConnection(JSONConnection):
    def __init__(self, params):
        super().__init__(params)

        self.disconnecting = False
        self.comet_id = 0
        self.retry_period = 1

    def run(self):
        self.session = Soup.Session()
        self.session.set_user_agent(f"{app_name}/4.0.2 ")
        if logger.isEnabledFor(logging.DEBUG):
            self.session.add_feature(
                Soup.Logger.new(Soup.LoggerLogLevel.BODY)
            )

        self.handshake()

    def get_comet_id(self):
        self.comet_id += 1
        return self.comet_id

    def handshake(self):
        self.send_comet_requests([{
            'channel': '/meta/handshake',
            'version': '1.0',
            'minimumVersion': '1.0',
            'supportedConnectionTypes': ['long-polling'],
            'id': self.get_comet_id(),
        }])
        return False

    def meta_subscribe(self):
        self.send_comet_requests([{
            'channel': '/meta/subscribe',
            'clientId': self.comet_client_id,
            'id': self.get_comet_id(),
            'subscription': f'/{self.comet_client_id}/**',
        }])
        return False

    def slim_subscribe(self):
        self.send_comet_requests([{
            'channel': '/slim/subscribe',
            'clientId': self.comet_client_id,
            'data': {
                'request': [
                    self.player_id,
                    [
                        "status",
                        "0",
                        None,
                        "tags:acdgijkKlmOtuwxy",
                        "subscribe:0"
                    ],
                ],
                'response': f'/{self.comet_client_id}/slim/status/{self.player_id}',
            },
            'id': self.get_comet_id(),
        }])
        return False

    def connect(self):
        self.send_comet_requests([{
            'channel': f'/meta/connect',
            'clientId': self.comet_client_id,
            'connectionType': 'long-polling',
            'id': self.get_comet_id(),
        }])
        return False

    def disconnect(self):
        if hasattr(self, 'comet_client_id'):
            self.send_comet_requests([{
                'channel': f'/meta/disconnect',
                'clientId': self.comet_client_id,
                'id': self.get_comet_id(),
            }])
            self.disconnecting = True
            GLib.timeout_add_seconds(10, loop.quit)
        else:
            loop.quit()
        return False

    def send_comet_requests(self, requests):
        message = Soup.Message.new(
            "POST",
            f"{self.scheme}://{self.host}:{self.port}/cometd/{inspect.currentframe().f_back.f_code.co_name}",
        )

        def on_starting(message):
            message.set_request_body_from_bytes(
                'application/json; charset=UTF-8',
                GLib.Bytes.new(json.dumps(requests).encode())
            )
        message.connect('starting', on_starting)

        if self.username is not None and self.password is not None:
            def on_authenticate(message, auth, retrying):
                if retrying:
                    return False
                if (
                    auth.get_authority() == f"{self.host}:{self.port}"
                    and (auth.get_realm()
                         in ['Lyrion Music Server', 'Logitech Media Server'])
                ):
                    auth.authenticate(self.username, self.password)
                    message.set_request_body_from_bytes(
                        'application/json; charset=UTF-8',
                        GLib.Bytes.new(json.dumps(requests).encode())
                    )
            message.connect('authenticate', on_authenticate)

        self.session.send_async(
            message,
            GLib.PRIORITY_DEFAULT,
            None,
            self.receive_comet_responses,
            message
        )

    def retry(self, message):
        if not self.disconnecting:
            self.session.send_async(
                message,
                GLib.PRIORITY_DEFAULT,
                None,
                self.receive_comet_responses,
                message
            )

        return False

    def receive_comet_responses(self, session, result, message):
        try:
            input_stream = session.send_finish(result)
            status_code = message.get_status()
            if not (200 <= status_code and status_code < 300):
                raise HttpNonSuccessException(
                    status_code,
                    message.get_reason_phrase(),
                )
        except (GLib.Error, HttpNonSuccessException) as e:
            if isinstance(e, GLib.Error) \
               and e.matches(Gio.io_error_quark(), Gio.IOErrorEnum.TIMED_OUT):
                # We expect long polling to time out eventually, so
                # retry immediately:
                retry_period = 0
            else:
                retry_period = self.retry_period
                if self.retry_period < 60:
                    self.retry_period = self.retry_period * 2
                logger.error(
                    f"{message.get_uri().to_string()}: {e.code} {e.message}"
                )
                logger.info(f"Will retry in {retry_period} seconds")
            GLib.timeout_add_seconds(retry_period, self.retry, message)
            return

        self.retry_period = 1

        # I couldn't get Gio.InputStream.read_all() to work :-(
        content, length = Gio.DataInputStream.new(input_stream) \
                                             .read_upto("\00", 1, None)
        for msg in json.loads(content):
            logger.debug(f"Received comet response: {msg}")
            if msg['channel'] == '/meta/handshake':
                if msg['successful']:
                    self.comet_client_id = msg['clientId']
                    GLib.timeout_add(0, self.meta_subscribe)
                    GLib.timeout_add(0, self.connect)
                else:
                    logger.critical(f"Handshake failed: {msg}")
                    raise CometError(msg)
            elif msg['channel'] == '/meta/subscribe':
                if msg['successful']:
                    GLib.timeout_add(0, self.slim_subscribe)
                else:
                    logger.critical(f"Subscription failed: {msg}")
                    raise CometError(msg)
            elif msg['channel'] == '/slim/subscribe':
                if not msg['successful']:
                    logger.critical(f"Subscription failed: {msg}")
                    raise CometError(msg)
            elif msg['channel'] == '/meta/connect':
                if not msg['successful']:
                    logger.warning(f"Connection failed: {msg}")
                if 'reconnect' in msg['advice']:
                    if msg['advice']['reconnect'] == 'none':
                        logger.critical(f"The server advises us not to reconnect.")
                        raise CometError(msg)
                    elif msg['advice']['reconnect'] == 'handshake':
                        GLib.timeout_add_seconds(
                            math.ceil(int(msg['advice']['interval'])/1000),
                            self.handshake,
                        )
                        continue
                if not self.disconnecting:
                    GLib.timeout_add_seconds(
                        math.ceil(int(msg['advice']['interval'])/1000),
                        self.connect,
                    )
            elif re.search(
                    f"^/{re.escape(self.comet_client_id)}/slim/status/{re.escape(self.player_id)}$",
                    msg['channel']
            ):
                info = msg['data']
                info['last_fetch'] = time.monotonic()
                sb_wrapper.monitor(info)
            elif msg['channel'] == '/meta/disconnect':
                if not msg['successful']:
                    logger.critical(f"Disconnection failed: {msg}")
                loop.quit()
            else:
                logger.warning(f"Unexpected comet response: {msg}")


class HttpNonSuccessException(Exception):
    def __init__(self, code, message):
        self.code = code
        self.message = message


# FIXME: handle these exceptions.
class CometError(Exception):
    def __init__(self, message):
        self.message = message


class LMSDiscoveryError(Exception):
    def __init__(self, message):
        self.message = message


def usage():
    print(f"""\
Usage: {params['progname']} [OPTION]... [LMS_HOST LMS_PORT [PLAYER]]

     -d, --debug            Run in debug mode
     -V, --version          Display version and exit
     -?, --help             Display this message
""")

if __name__ == '__main__':
    DBusGMainLoop(set_as_default=True)

    path = ""

    try:
        (opts, args) = getopt.getopt(
            sys.argv[1:],
            '?dDV', [
                'debug',
                'discover',
                'help',
                'version',
            ]
        )
    except getopt.GetoptError as e:
        (msg, opt) = e
        print(sys.argv[0] + ': ' + msg)
        print()
        usage()
        sys.exit(2)

    log_format = '%(asctime)s %(module)s %(levelname)s: %(message)s'
    log_level = logging.INFO

    for (opt, arg) in opts:
        if opt in ['-d', '--debug']:
            log_level = logging.DEBUG
        elif opt in ['-?', '--help']:
            usage()
            sys.exit()
        elif opt in ['-V', '--version']:
            print("""slimpris2 4.0.2

Copyright (C) 2011-2021 Peter Oliver, Bart Lauret, Jean-Philippe Braun.
This program comes with ABSOLUTELY NO WARRANTY.  This is free software,
and you are welcome to redistribute it under certain conditions.

Report bugs to https://github.com/mavit/slimpris2/issues""")
            sys.exit()

    logging.basicConfig(format=log_format, level=log_level)
    logger = logging.getLogger(app_name)

    config = six.moves.configparser.ConfigParser()
    config_filenames = [
        f'{dir}/slimpris2.conf'
        for dir in xdg.BaseDirectory.load_config_paths(app_name)
    ]
    config_filenames.append(f'/etc/{app_name}.conf')
    config_filenames.reverse()
    config.read(config_filenames)

    if config.has_option('Connection', 'scheme'):
        params['scheme'] = config.get('Connection', 'scheme')
    if config.has_option('Connection', 'host'):
        params['host'] = config.get('Connection', 'host')
    if config.has_option('Connection', 'port'):
        params['port'] = config.get('Connection', 'port')
    if config.has_option('Connection', 'mac'):
        params['player_id'] = config.get('Connection', 'mac')
    if config.has_option('Connection', 'player'):
        params['player_id'] = config.get('Connection', 'player')
    if config.has_option('Connection', 'username'):
        params['username'] = config.get('Connection', 'username')
    if config.has_option('Connection', 'password'):
        params['password'] = config.get('Connection', 'password')
    if config.has_option('Connection', 'insecure_https'):
        params['insecure_https'] = config.getboolean('Connection',
                                                     'insecure_https')

    if 2 <= len(args) and len(args) <= 3:
        params['host'] = args[0]
        params['port'] = args[1]
        if len(args) == 3:
            params['player_id'] = args[2]
    elif len(args) == 0:
        pass
    else:
        usage()
        sys.exit(2)

    try:
        params['port'] = int(params['port'])
    except ValueError:
        params['port'] = socket.getservbyname(params['port'], 'tcp')

    if 'player_id' in params:
        if re.search(r'^(?:\d\d.){5}\d\d$', params['player_id']):
            params['player_id'] = params['player_id'].replace('-', ':')

    for bling in ['mmkeys']:
        if config.has_option('Bling', bling):
            params[bling] = config.getboolean('Bling', bling)

    for (opt, arg) in opts:
        if opt in ['-D', '--discover']:
            params['host'] = None

    if params['host'] is None and (
            params['username'] is not None
            or params['password'] is not None
    ):
        logger.warning("For safety, usernames and passwords are not supported with auto-discovery")
    elif params['username'] is not None and params['password'] is None:
        logger.warning(
            "A username, '%s', is set, but there is no corresponding password",
            params['username']
        )
    elif params['password'] is not None and params['username'] is None:
        logger.warning(
            "A password is set, but there is no corresponding username"
        )

    loop = GLib.MainLoop()
    signal.signal(signal.SIGINT, handle_sigint)
    signal.signal(signal.SIGTERM, handle_sigint)

    # Used to communicate with LMS
    lms = CometConnection(params)

    # Create wrapper to handle connection failures with LMS more gracefully
    sb_wrapper = SBWrapper(params)
    sb_wrapper.run()

    # Run idle loop
    loop.run()
