# -*- coding: utf-8 -*-
"""
Display song/video and control MPRIS compatible players.

There are two ways to control the media player. Either by clicking with a mouse
button in the text information or by using buttons. For former you have
to define the button parameters in the i3status config.

Configuration parameters:
    button_next: mouse button to play the next entry (default 4)
    button_previous: mouse button to play the previous entry (default 5)
    button_stop: mouse button to stop the player (default None)
    button_toggle: mouse button to toggle between play and pause mode (default 1)
    format: see placeholders below
        (default '{previous}{toggle}{next} {state} [{artist} - ][{title}]')
    format_none: define output if no player is running
        (default 'no player running')
    icon_next: text for the next button in the button control panel (default '»')
    icon_pause: text for the pause button in the button control panel (default '▮')
    icon_play: text for the play button in the button control panel (default '▶')
    icon_previous: text for the previous button in the button control panel (default '«')
    icon_stop: text for the stop button in the button control panel (default '◾')
    player_priority: priority of the players.
        Keep in mind that the state has a higher priority than
        player_priority. So when player_priority is "[mpd, bomi]" and mpd is
        paused and bomi is playing than bomi wins. (default [])
    state_pause: text for placeholder {state} when song is paused (default '▮')
    state_play: text for placeholder {state} when song is playing (default '▶')
    state_stop: text for placeholder {state} when song is stopped (default '◾')

Format of status string placeholders:
    {album} album name
    {artist} artiste name (first one)
    {length} time duration of the song
    {player} show name of the player
    {state} playback status of the player
    {time} played time of the song
    {title} name of the song

Format of button placeholders:
    {next} play the next title
    {pause} pause the player
    {play} play the player
    {previous} play the previous title
    {stop} stop the player
    {toggle} toggle between play and pause

Color options:
    color_control_inactive: button is not clickable
    color_control_active: button is clickable
    color_paused: song is paused, defaults to color_degraded
    color_playing: song is playing, defaults to color_good
    color_stopped: song is stopped, defaults to color_bad

Requires:
    pydbus: python library module

i3status.conf example:

```
mpris {
    format = "{previous}{play}{next} {player}: {state} [[{artist} - {title}]|[{title}]]"
    format_none = "no player"
    player_priority = "[mpd, cantata, vlc, bomi, *]"
}
```

only show information from mpd and vlc, but mpd has a higher priority:

```
mpris {
    player_priority = "[mpd, vlc]"
}
```

show information of all players, but mpd and vlc have the highest priority:

```
mpris {
    player_priority = "[mpd, vlc, *]"
}
```

vlc has the lowest priority:

```
mpris {
    player_priority = "[*, vlc]"
}
```

Tested players:
    bomi
    Cantata
    mpDris2 (mpris extension for mpd)
    vlc

@author Moritz Lüdecke, tobes, valdur55

SAMPLE OUTPUT
[
    {'color': '#00FF00', 'index': 'previous', 'min_width': 20,
     'align': 'center', 'full_text': u'\xab'},
    {'color': '#00FF00', 'index': 'toggle', 'min_width': 20,
     'align': 'center', 'full_text': u'\u25ae'},
    {'color': '#00FF00', 'index': 'next', 'min_width': 20,
     'align': 'center', 'full_text': u'\xbb '},
    {'full_text': u'\u25b6 Happy Mondays - Fat Lady Wrestlers'}
]
"""

from datetime import timedelta
from time import time
from gi.repository import GObject
from gi.repository.GLib import GError
from threading import Thread
import re
import sys
from pydbus import SessionBus


SERVICE_BUS = 'org.mpris.MediaPlayer2'
SERVICE_BUS_URL = '/org/mpris/MediaPlayer2'

WORKING_STATES = ['Playing', 'Paused', 'Stopped']

PLAYING = 0
PAUSED = 1
STOPPED = 2


def _get_time_str(microseconds):
    seconds = int(microseconds / 1000000)
    time = str(timedelta(seconds=seconds))
    if time[0] == '0':
        time = time[2:]
        if time[0] == '0':
            time = time[1:]

    return time


class Py3status:
    """
    """
    # available configuration parameters
    button_next = 4
    button_previous = 5
    button_stop = None
    button_toggle = 1
    format = '{previous}{toggle}{next} {state} [{artist} - ][{title}]'
    format_none = 'no player running'
    icon_next = u'»'
    icon_pause = u'▮'
    icon_play = u'▶'
    icon_previous = u'«'
    icon_stop = u'◾'
    player_priority = []
    state_pause = u'▮'
    state_play = u'▶'
    state_stop = u'◾'

    def post_config_hook(self):
        self._dbus = None
        self._data = {}
        self._control_states = {}
        self._kill = False
        self._mpris_players = {}
        self._mpris_names = {}
        self._mpris_name_index = {}
        self._player = None
        self._player_details = {}
        self._tries = 0
        # start last
        self._dbus = SessionBus()
        self._start_listener()
        self._states = {
            'pause': {
                'action': 'Pause',
                'clickable': 'CanPause',
                'icon': self.icon_pause
            },
            'play': {
                'action': 'Play',
                'clickable': 'CanPlay',
                'icon': self.icon_play
            },
            'stop': {
                'action': 'Stop',
                'clickable': 'True',  # The MPRIS API lacks 'CanStop' function.
                'icon': self.icon_stop
            },
            'next': {
                'action': 'Next',
                'clickable': 'CanGoNext',
                'icon': self.icon_next
            },
            'previous': {
                'action': 'Previous',
                'clickable': 'CanGoPrevious',
                'icon': self.icon_previous
            }
        }

    def _init_data(self):
        self._data = {
            'album': None,
            'artist': None,
            'error_occurred': False,
            'length': None,
            'player': None,
            'state': STOPPED,
            'title': None
        }

        if self._player is None:
            self._control_states = {}
            return

        try:
            self._data['player'] = self._player.Identity
            playback_status = self._player.PlaybackStatus
            self._data['state'] = self._get_state(playback_status)
            metadata = self._player.Metadata
            self._update_metadata(metadata)
        except Exception:
            self._data['error_occurred'] = True

    def _get_button_state(self, control_state):
        try:
            # Workaround: The last parameter returns True for the Stop button.
            clickable = getattr(self._player, control_state['clickable'], True)
        except Exception:
            clickable = False

        state = self._data.get('state')
        if control_state['action'] == 'Play' and state == PLAYING:
            clickable = False
        elif control_state['action'] == 'Pause' and state in [STOPPED, PAUSED]:
            clickable = False
        elif control_state['action'] == 'Stop' and state == STOPPED:
            clickable = False

        return clickable

    def _get_state(self, playback_status):
        if playback_status == 'Playing':
            return PLAYING
        elif playback_status == 'Paused':
            return PAUSED
        else:
            return STOPPED

    def _get_text(self):
        """
        Get the current metadata
        """
        if self._data.get('state') == PLAYING:
            color = self.py3.COLOR_PLAYING or self.py3.COLOR_GOOD
            state_symbol = self.state_play
        elif self._data.get('state') == PAUSED:
            color = self.py3.COLOR_PAUSED or self.py3.COLOR_DEGRADED
            state_symbol = self.state_pause
        else:
            color = self.py3.COLOR_STOPPED or self.py3.COLOR_BAD
            state_symbol = self.state_stop

        if self._data.get('error_occurred'):
            color = self.py3.COLOR_BAD

        try:
            ptime_ms = self._player.Position
            ptime = _get_time_str(ptime_ms)
        except Exception:
            ptime = None

        if self.py3.format_contains(self.format, 'time') and self._data.get('state') == PLAYING:
            # Don't get trapped in aliasing errors!
            update = time() + 0.5
        else:
            update = self.py3.CACHE_FOREVER

        placeholders = {
            'player': self._data.get('player'),
            'state': state_symbol,
            'album': self._data.get('album'),
            'artist': self._data.get('artist'),
            'length': self._data.get('length'),
            'time': ptime,
            'title': self._data.get('title') or 'No Track',
            'full_name': self._player_details.get('full_name'),  # for debugging ;p
        }

        return (placeholders, color, update)

    def _get_control_states(self):
        state = 'pause' if self._data.get('state') == PLAYING else 'play'
        self._states['toggle'] = self._states[state]
        return self._states

    def _get_response_buttons(self):
        response = {}

        for button in self._control_states.keys():
            control_state = self._control_states[button]

            if self._get_button_state(control_state):
                color = self.py3.COLOR_CONTROL_ACTIVE or self.py3.COLOR_GOOD
            else:
                color = self.py3.COLOR_CONTROL_INACTIVE or self.py3.COLOR_BAD

            response[button] = {
                'color': color,
                'full_text': control_state['icon'],
                'index': button,
                'min_width': 20,
                'align': 'center'
            }

        return response

    def _start_loop(self):
        self._loop = GObject.MainLoop()
        GObject.timeout_add(1000, self._timeout)
        try:
            self._loop.run()
        except KeyboardInterrupt:
            # This branch is only needed for the test mode
            self._kill = True

    def _name_owner_changed(self, *args):
        player_id = args[5][0]
        player_add = args[5][2]
        player_remove = args[5][1]
        if player_add:
            self._add_player(player_id)
        if player_remove:
            self._remove_player(player_id)
        self._set_player()

    def _set_player(self):
        """
        Sort the current players into priority order and set self._player
        Players are ordered by working state then prefernce supplied by user
        and finally by instance if a player has more than one running.
        """
        players = []
        for name, p in self._mpris_players.items():
            # we set the priority here as we need to establish the player name
            # which might not be immediately available.
            if '_priority' not in p:
                if self.player_priority:
                    try:
                        priority = self.player_priority.index(p['name'])
                    except ValueError:
                        try:
                            priority = self.player_priority.index('*')
                        except ValueError:
                            priority = None
                else:
                    priority = 0
                if priority is not None:
                    p['_priority'] = priority
            if p.get('_priority') is not None:
                players.append(
                    (p['_state_priority'], p['_priority'], p['index'], name)
                )
        if players:
            top_player = self._mpris_players.get(sorted(players)[0][3])
        else:
            top_player = {}

        self._player = top_player.get('_dbus_player')
        self._player_details = top_player

        self.py3.update()

    def _player_monitor(self, player_id):
        def player_on_change(*args):
            """
            Monitor a player and update its status.
            """
            data = args[1]
            status = data.get('PlaybackStatus')
            if status:
                player = self._mpris_players[player_id]

                # Note: Workaround. Since all players get noted if playback status
                #       has been changed we have to check if we are the choosen one
                try:
                    dbus_status = player['_dbus_player'].PlaybackStatus
                except GError:
                    # Prevent errors when calling methods of deleted dbus objects
                    return
                if status != dbus_status:
                    # FIXME: WE DON'T RECOGNIZE ANY TITLE CHANGE
                    return

                player['status'] = status
                player['_state_priority'] = WORKING_STATES.index(status)
            self._set_player()
        return player_on_change

    def _add_player(self, player_id):
        """
        Add player to mpris_players
        """
        if not player_id.startswith(SERVICE_BUS):
            return False

        player = self._dbus.get(player_id, SERVICE_BUS_URL)

        if player.Identity not in self._mpris_names:
            self._mpris_names[player.Identity] = player_id.split('.')[-1]
            for p in self._mpris_players.values():
                if not p['name'] and p['identity'] in self._mpris_names:
                    p['name'] = self._mpris_names[p['identity']]
                    p['full_name'] = u'{} {}'.format(p['name'], p['index'])

        identity = player.Identity
        name = self._mpris_names.get(identity)
        if self.player_priority != [] and name not in self.player_priority \
                and '*' not in self.player_priority:
            return False

        if identity not in self._mpris_name_index:
            self._mpris_name_index[identity] = 0

        status = player.PlaybackStatus
        state_priority = WORKING_STATES.index(status)
        index = self._mpris_name_index[identity]
        self._mpris_name_index[identity] += 1
        subscription = player.PropertiesChanged.connect(self._player_monitor(player_id))

        self._mpris_players[player_id] = {
            '_dbus_player': player,
            '_id': player_id,
            '_state_priority': state_priority,
            'index': index,
            'identity': identity,
            'name': name,
            'full_name': u'{} {}'.format(name, index),
            'status': status,
            'subscription': subscription,
        }

        return True

    def _remove_player(self, player_id):
        """
        Remove player from mpris_players
        """
        player = self._mpris_players.get(player_id)
        if player:
            if player.get('subscription'):
                player['subscription'].disconnect()
            del self._mpris_players[player_id]

    def _get_players(self):
        bus = self._dbus.get('org.freedesktop.DBus')
        for player in bus.ListNames():
            self._add_player(player)

        self._set_player()

    def _start_listener(self):
        self._dbus.con.signal_subscribe(
            None,
            'org.freedesktop.DBus',
            'NameOwnerChanged',
            None,
            None,
            0,
            self._name_owner_changed,
        )
        self._get_players()
        t = Thread(target=self._start_loop)
        t.daemon = True
        t.start()

    def _timeout(self):
        if self._kill:
            self._loop.quit()
            sys.exit(0)

    def _update_metadata(self, metadata):
        is_stream = False

        try:
            if len(metadata) > 0:
                url = metadata.get('xesam:url')
                is_stream = url is not None and 'file://' not in url
                self._data['title'] = metadata.get('xesam:title')
                self._data['album'] = metadata.get('xesam:album')

                if metadata.get('xesam:artist') is not None:
                    self._data['artist'] = metadata.get('xesam:artist')[0]
                else:
                    # we assume here that we playing a video and these types of
                    # media we handle just like streams
                    is_stream = True

                length_ms = metadata.get('mpris:length')
                if length_ms is not None:
                    self._data['length'] = _get_time_str(length_ms)
            else:
                # use stream format if no metadata is available
                is_stream = True
        except Exception:
            self._data['error_occurred'] = True

        if is_stream and self._data.get('title'):
            # delete the file extension
            self._data['title'] = re.sub(r'\....$', '', self._data.get('title'))

    def kill(self):
        self._kill = True

    def mpris(self):
        """
        Get the current output format and return it.
        """
        if self._kill:
            raise KeyboardInterrupt
        current_player_id = self._player_details.get('id')
        cached_until = self.py3.CACHE_FOREVER

        if self._player is None:
            text = self.format_none
            color = self.py3.COLOR_BAD
            composite = [{
                'full_text': text,
                'color': color,
            }]
            self._data = {}
        else:
            self._init_data()
            (text, color, cached_until) = self._get_text()
            self._control_states = self._get_control_states()
            buttons = self._get_response_buttons()
            composite = self.py3.safe_format(self.format, dict(text, **buttons))

        if (self._data.get('error_occurred') or
                current_player_id != self._player_details.get('id')):
            # Something went wrong or the player changed during our processing
            # This is usually due to something like a player being killed
            # whilst we are checking its details
            # Retry but limit the number of attempts
            self._tries += 1
            if self._tries < 3:
                return self.mpris()
            # Max retries hit we need to output something
            composite = [{
                'full_text': 'Something went wrong',
                'color': self.py3.COLOR_BAD,
            }]
            cached_until = self.py3.time_in(1)

        response = {
            'cached_until': cached_until,
            'color': color,
            'composite': composite
        }

        # we are outputing so reset tries
        self._tries = 0
        return response

    def on_click(self, event):
        """
        Handles click events
        """
        index = event['index']
        button = event['button']

        if index not in self._control_states.keys():
            if button == self.button_toggle:
                index = 'toggle'
            elif button == self.button_stop:
                index = 'stop'
            elif button == self.button_next:
                index = 'next'
            elif button == self.button_previous:
                index = 'previous'
            else:
                return
        elif button != 1:
            return

        control_state = self._control_states.get(index)
        if self._player and self._get_button_state(control_state):
            getattr(self._player, self._control_states[index]['action'])()


if __name__ == "__main__":
    """
    Run module in test mode.
    """
    from py3status.module_test import module_test
    module_test(Py3status)
