player-mpris-tail: rewrite script (fix #35)

This commit is contained in:
Christian Dannie Storgaard 2018-09-23 20:05:09 +03:00 committed by x70b1
parent dc44f7b512
commit 2021edb8de
2 changed files with 501 additions and 101 deletions

View File

@ -2,23 +2,154 @@
This script displays the current track and the play-pause status without polling. Information is obtained by listening to MPRIS events, so it is updated instantaneously on change. This script displays the current track and the play-pause status without polling. Information is obtained by listening to MPRIS events, so it is updated instantaneously on change.
The format of the output can be defined by passing an `-f` or `--format` argument. This argument supports metadata replacement using `{tag}` (e.g. `{title}`) as well as more advanced formatting, described below.
Players can be blacklisted by passing a `-b` or `--blacklist` argument. As an example, VLC can be blacklisted by passing `-b vlc`. To get a list of the current running players (and their status), run the script as `player-mpris-tail.py list`.
![player-mpris-tail](screenshots/1.png) ![player-mpris-tail](screenshots/1.png)
![player-mpris-tail](screenshots/2.png) ![player-mpris-tail](screenshots/2.png)
## Commands
The current player can be controlled by passing one of the following commands:
Command | Description
---|---
play | Play the current track
pause | Pause the currently playing track
play-pause | Play the current track or unpause it if currently paused
stop | Stop playback
previous | Move to the previous track
next | Move to the next track
raise | Tell the current player to focus its window
General information about the current state can be printed using the following commands:
Command | Description
---|---
status | Print the normal output and exit immediately
current | Print the currently detected player and its status
list | List the detected players and their status
metadata | Print the metadata object for the current track
## Arguments
The following arguments are supported:
Argument | Description | Default
---|---|---
-b or --blacklist | Blacklist / Ignore the given player
-f or --format | Use the given `format` string | `{icon} {artist} - {title}`
--truncate-text | Use the given string as the end of truncated text | `…`
--icon-playing | Use the given text as the playing icon | `⏵`
--icon-paused | Use the given text as the paused icon | `⏸`
--icon-stopped | Use the given text as the stopped icon | `⏹`
--icon-none | Use the given text as the icon for when no player is active | ``
## Formatting
Tags can be printed by surrounding them with `{` and `}`. Polybar formatting can also be given and will be passed through, including substituted tags and formatters.
### Tags
The supported tags are:
Tag | Description
---|---
artist | The artist of the current track
album | The album of the current track
title | The title of the current track
track | The track number of the current track
length | The length of the current track
genre | The genre of the current track
disc | The disc number of the current track
date | The date of the current track
year | The year of the current track
cover | The URL of the cover of the current track
icon | The icon for the current status (playing / paused / stopped / none)
icon-reversed | The pause icon when playing, else the play icon
### String formatters
Parts of the `format` string can be manipulated by surrounding them with `{:` and `:}` and prepending a formatter followed by a `:` (e.g. `{:t20:by {artist}:}`)
The following formatters are supported:
Formatter | Argument | Description | Example | Output
---|---|---|---|---
`tag` | | Only pring the string if `tag` exists | `{:album: on {album}:}` | ` on Album Name`
w | Number | Limit the width of the string to `number` | `{:w3:Hello:}` | `Hel`
t | Number | Truncate width of the string to `number`. If the string is shorter than or equal to `number` it is printed as given, else the string is truncated and appended a truncator text | `{:t3:Hello:}` | `He…`
## Dependencies ## Dependencies
* [playerctl](https://github.com/acrisci/playerctl) * [dbus-python](https://pypi.org/project/dbus-python/)
* [pygobject](https://pypi.org/project/PyGObject/)
## Module ## Module
### Basic output
```ini ```ini
[module/player-mpris-tail] [module/player-mpris-tail]
type = custom/script type = custom/script
exec = ~/polybar-scripts/player-mpris-tail.py exec = ~/polybar-scripts/player-mpris-tail.py -f '{icon} {artist} - {title}'
tail = true tail = true
click-left = ~/polybar-scripts/player-ctrl.sh previous label = %output%
click-right = ~/polybar-scripts/player-ctrl.sh next
click-middle = ~/polybar-scripts/player-ctrl.sh play-pause
``` ```
Example: `⏵ Artist - Title`
### Basic output + mouse controls
```ini
[module/player-mpris-tail]
type = custom/script
exec = ~/polybar-scripts/player-mpris-tail.py -f '{icon} {artist} - {title}'
tail = true
label = %output%
click-left = ~/polybar-scripts/player-mpris-tail.py previous
click-right = ~/polybar-scripts/player-mpris-tail.py next
click-middle = ~/polybar-scripts/player-mpris-tail.py play-pause
```
Example: `⏵ Artist - Title`
### Output using formatters
```ini
[module/player-mpris-tail]
type = custom/script
exec = ~/polybar-scripts/player-mpris-tail.py -f '{icon} {:artist:t5:{artist}:}{:artist: - :}{:t4:{title}:}'
tail = true
label = %output%
click-left = ~/polybar-scripts/player-mpris-tail.py previous
click-right = ~/polybar-scripts/player-mpris-tail.py next
click-middle = ~/polybar-scripts/player-mpris-tail.py play-pause
```
Example: `⏵ Artis… - Titl…` or `⏵ Titl…`
### Output using formatters and Polybar action handlers
```ini
[module/player-mpris-tail]
type = custom/script
exec = ~/polybar-scripts/player-mpris-tail.py -f '{icon} {:artist:t18:{artist}:}{:artist: - :}{:t20:{title}:} %{A1:~/polybar-scripts/player-mpris-tail.py previous:} ⏮ %{A} %{A1:~/polybar-scripts/player-mpris-tail.py play-pause:} {icon-reversed} %{A} %{A1:~/polybar-scripts/player-mpris-tail.py next:} ⏭ %{A}'
tail = true
label = %output%
```
Example: `⏵ Artis… - Titl… ⏮ ⏸ ⏭ ` or `⏵ Titl… ⏮ ⏸ ⏭ ` or `⏸ Titl… ⏮ ⏵ ⏭ `
### Output using formatters, Polybar action handlers and blacklisting
```ini
[module/player-mpris-tail]
type = custom/script
exec = ~/polybar-scripts/player-mpris-tail.py -f '{icon} {:artist:t18:{artist}:}{:artist: - :}{:t20:{title}:} %{A1:~/polybar-scripts/player-mpris-tail.py previous -b vlc -b plasma-browser-integration:} ⏮ %{A} %{A1:~/polybar-scripts/player-mpris-tail.py play-pause -b vlc -b plasma-browser-integration:} {icon-reversed} %{A} %{A1:~/polybar-scripts/player-mpris-tail.py next -b vlc -b plasma-browser-integration:} ⏭ %{A}' -b vlc -b plasma-browser-integration
tail = true
label = %output%
```
Example: `⏵ Artis… - Titl… ⏮ ⏸ ⏭ ` or `⏵ Titl… ⏮ ⏸ ⏭ ` or `⏸ Titl… ⏮ ⏵ ⏭ `

View File

@ -1,115 +1,384 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import time
import sys import sys
import subprocess import dbus
from operator import itemgetter
import argparse
import re
from urllib.parse import unquote
import gi from dbus.mainloop.glib import DBusGMainLoop
gi.require_version('Playerctl', '1.0') from gi.repository import GLib
from gi.repository import Playerctl, GLib DBusGMainLoop(set_as_default=True)
MUSIC_ICON = '#1'
PAUSE_ICON = '#2'
PLAYER_CLOSED_ICON = '#3'
def listPlayers(): FORMAT_STRING = '{icon} {artist} - {title}'
return [ FORMAT_REGEX = re.compile(r'(\{:(?P<tag>.*?)(:(?P<format>[wt])(?P<formatlen>\d+))?:(?P<text>.*?):\})', re.I)
playername.split('"')[1].split('.')[-1] FORMAT_TAG_REGEX = re.compile(r'(?P<format>[wt])(?P<formatlen>\d+)')
for playername SAFE_TAG_REGEX = re.compile(r'[{}]')
in subprocess.getoutput(
'dbus-send --session --dest=org.freedesktop.DBus --type=method_call --print-reply /org/freedesktop/DBus org.freedesktop.DBus.ListNames | grep org.mpris.MediaPlayer2' class PlayerManager:
).split("\n") def __init__(self, blacklist = [], connect = True):
self.blacklist = blacklist
self._connect = connect
self._session_bus = dbus.SessionBus()
self._last_status = ''
self.players = {}
self.refreshPlayerList()
if self._connect:
self.connect()
loop = GLib.MainLoop()
try:
loop.run()
except KeyboardInterrupt:
print("interrupt received, stopping…")
def connect(self):
self._session_bus.add_signal_receiver(self.onOwnerChangedName, 'NameOwnerChanged')
def onOwnerChangedName(self, bus_name, old_owner, new_owner):
if self.busNameIsAPlayer(bus_name):
if new_owner and not old_owner:
self.addPlayer(bus_name, new_owner)
elif old_owner and not new_owner:
self.removePlayer(old_owner)
else:
self.changePlayerOwner(bus_name, old_owner, new_owner)
def busNameIsAPlayer(self, bus_name):
return bus_name.startswith('org.mpris.MediaPlayer2') and bus_name.split('.')[-1] not in self.blacklist
def refreshPlayerList(self):
player_bus_names = [ bus_name for bus_name in self._session_bus.list_names() if self.busNameIsAPlayer(bus_name) ]
for player_bus_name in player_bus_names:
self.addPlayer(player_bus_name)
def addPlayer(self, bus_name, owner = None):
player = Player(self._session_bus, bus_name, owner = owner, connect = self._connect)
self.players[player.owner] = player
def removePlayer(self, owner):
self.players[owner].disconnect()
del self.players[owner]
if len(self.players) == 0:
_printFlush(ICON_NONE)
def changePlayerOwner(self, bus_name, old_owner, new_owner):
player = Player(self._session_bus, bus_name, owner = new_owner, connect = self._connect)
self.players[new_owner] = player
del self.players[old_owner]
# Get a list of player owners sorted by current status and age
def getSortedPlayerOwnerList(self):
players = [
{
'number': int(owner.split('.')[-1]),
'status': 2 if player.status == 'playing' else 1 if player.status == 'paused' else 0,
'owner': owner
}
for owner, player in self.players.items()
] ]
return [ info['owner'] for info in reversed(sorted(players, key=itemgetter('status', 'number'))) ]
def getPlayerStatus(playername): # Get latest player that's currently playing
return subprocess.getoutput( def getCurrentPlayer(self):
'playerctl --player="%s" status' % playername playing_players = [
player_owner for player_owner in self.getSortedPlayerOwnerList()
if
self.players[player_owner].status == 'playing' or
self.players[player_owner].status == 'paused'
]
return self.players[playing_players[0]] if playing_players else None
class Player:
def __init__(self, session_bus, bus_name, owner = None, connect = True):
self._session_bus = session_bus
self.bus_name = bus_name
self._disconnecting = False
self.metadata = {
'artist' : '',
'album' : '',
'title' : '',
'track' : 0
}
self._metadata = None
self.status = 'stopped'
self.icon = ICON_NONE
self.icon_reversed = ICON_PLAYING
if owner is not None:
self.owner = owner
else:
self.owner = self._session_bus.get_name_owner(bus_name)
self._obj = self._session_bus.get_object(self.bus_name, '/org/mpris/MediaPlayer2')
self._properties_interface = dbus.Interface(self._obj, dbus_interface='org.freedesktop.DBus.Properties')
self._introspect_interface = dbus.Interface(self._obj, dbus_interface='org.freedesktop.DBus.Introspectable')
self._media_interface = dbus.Interface(self._obj, dbus_interface='org.mpris.MediaPlayer2')
self._player_interface = dbus.Interface(self._obj, dbus_interface='org.mpris.MediaPlayer2.Player')
self._introspect = self._introspect_interface.get_dbus_method('Introspect', dbus_interface=None)
self._getProperty = self._properties_interface.get_dbus_method('Get', dbus_interface=None)
self._playerPlay = self._player_interface.get_dbus_method('Play', dbus_interface=None)
self._playerPause = self._player_interface.get_dbus_method('Pause', dbus_interface=None)
self._playerPlayPause = self._player_interface.get_dbus_method('PlayPause', dbus_interface=None)
self._playerStop = self._player_interface.get_dbus_method('Stop', dbus_interface=None)
self._playerPrevious = self._player_interface.get_dbus_method('Previous', dbus_interface=None)
self._playerNext = self._player_interface.get_dbus_method('Next', dbus_interface=None)
self._playerRaise = self._media_interface.get_dbus_method('Raise', dbus_interface=None)
self._signals = {}
self.refreshStatus()
self.refreshMetadata()
if connect:
self.printStatus()
self.connect()
def play(self):
self._playerPlay()
def pause(self):
self._playerPause()
def playpause(self):
self._playerPlayPause()
def stop(self):
self._playerStop()
def previous(self):
self._playerPrevious()
def next(self):
self._playerNext()
def raisePlayer(self):
self._playerRaise()
def connect(self):
if self._disconnecting is not True:
introspect_xml = self._introspect(self.bus_name, '/')
if 'TrackMetadataChanged' in introspect_xml:
self._signals['track_metadata_changed'] = self._session_bus.add_signal_receiver(self.onMetadataChanged, 'TrackMetadataChanged', self.bus_name)
self._signals['properties_changed'] = self._properties_interface.connect_to_signal('PropertiesChanged', self.onPropertiesChanged)
def disconnect(self):
self._disconnecting = True
for signal_name, signal_handler in list(self._signals.items()):
signal_handler.remove()
del self._signals[signal_name]
def refreshStatus(self):
# Some clients (VLC) will momentarily create a new player before removing it again
# so we can't be sure the interface still exists
try:
self.status = str(self._getProperty('org.mpris.MediaPlayer2.Player', 'PlaybackStatus')).lower()
self.updateIcon()
except dbus.exceptions.DBusException:
self.disconnect()
def refreshMetadata(self):
# Some clients (VLC) will momentarily create a new player before removing it again
# so we can't be sure the interface still exists
try:
self._metadata = self._getProperty('org.mpris.MediaPlayer2.Player', 'Metadata')
self._parseMetadata()
except dbus.exceptions.DBusException:
self.disconnect()
def updateIcon(self):
self.icon = (
ICON_PLAYING if self.status == 'playing' else
ICON_PAUSED if self.status == 'paused' else
ICON_STOPPED if self.status == 'stopped' else
ICON_NONE
)
self.icon_reversed = (
ICON_PAUSED if self.status == 'playing' else
ICON_PLAYING
) )
def getActivePlayer(): def _parseMetadata(self):
players = [ { 'name': player, 'status': getPlayerStatus(player) } for player in listPlayers() ] if self._metadata != None:
playing = [ player['name'] for player in players if player['status'] == 'Playing' ] self.metadata['artist'] = re.sub(SAFE_TAG_REGEX, """\1\1""", _getProperty(self._metadata, 'xesam:artist', [''])[0])
paused = [ player['name'] for player in players if player['status'] == 'Paused' ] self.metadata['album'] = re.sub(SAFE_TAG_REGEX, """\1\1""", _getProperty(self._metadata, 'xesam:album', ''))
if len(playing): self.metadata['title'] = re.sub(SAFE_TAG_REGEX, """\1\1""", _getProperty(self._metadata, 'xesam:title', ''))
return playing[-1] self.metadata['track'] = _getProperty(self._metadata, 'xesam:trackNumber', '')
if len(paused): self.metadata['length'] = _getProperty(self._metadata, 'xesam:length', '')
return paused[-1] self.metadata['genre'] = _getProperty(self._metadata, 'xesam:genre', '')
if len(players): self.metadata['disc'] = _getProperty(self._metadata, 'xesam:discNumber', '')
return players[-1]['name'] self.metadata['date'] = re.sub(SAFE_TAG_REGEX, """\1\1""", _getProperty(self._metadata, 'xesam:contentCreated', ''))
self.metadata['year'] = re.sub(SAFE_TAG_REGEX, """\1\1""", self.metadata['date'][0:4])
self.metadata['cover'] = re.sub(SAFE_TAG_REGEX, """\1\1""", _getProperty(self._metadata, 'xesam:artUrl', ''))
class PlayerStatus: def onMetadataChanged(self, track_id, metadata):
def __init__(self): self.refreshMetadata()
self._player = None self.printStatus()
self._player_class = None
self._player_name = None
self._icon = PAUSE_ICON
self._last_artist = None def onPropertiesChanged(self, interface, properties, signature):
self._last_title = None updated = False
if dbus.String('Metadata') in properties:
_metadata = properties[dbus.String('Metadata')]
if _metadata != self._metadata:
self._metadata = _metadata
self._parseMetadata()
updated = True
if dbus.String('PlaybackStatus') in properties:
status = str(properties[dbus.String('PlaybackStatus')]).lower()
if status != self.status:
self.status = status
self.updateIcon()
updated = True
self._last_status = '' if updated:
self.printStatus()
def show(self): def _statusReplace(self, match, metadata):
self._init_player() tag = match.group('tag')
format = match.group('format')
formatlen = match.group('formatlen')
text = match.group('text')
tag_found = False
if format is None:
tag_is_format_match = re.match(FORMAT_TAG_REGEX, tag)
if tag_is_format_match:
format = tag_is_format_match.group('format')
formatlen = tag_is_format_match.group('formatlen')
tag_found = True
if format is not None:
text = text.format_map(CleanSafeDict(**metadata))
if format == 'w':
formatlen = int(formatlen)
text = text[:formatlen]
elif format == 't':
formatlen = int(formatlen)
if len(text) > formatlen:
text = text[:max(formatlen - len(TRUNCATE_STRING), 0)] + TRUNCATE_STRING
if tag_found is False and tag in metadata and len(metadata[tag]):
tag_found = True
# Wait for events if tag_found:
main = GLib.MainLoop() return text
main.run()
def _init_player(self):
while True:
try:
self._player_name = getActivePlayer()
self._player_class = Playerctl.Player()
if self._player_name:
self._player = self._player_class.new(self._player_name)
else: else:
self._player = self._player_class.new() return ''
self._player.on('metadata', self._on_metadata)
self._player.on('play', self._on_play)
self._player.on('pause', self._on_pause)
self._player.on('exit', self._on_exit)
status = self._player.get_property('status')
if status == 'Playing':
self._icon = MUSIC_ICON
elif status == 'Paused':
self._icon = PAUSE_ICON
self._on_metadata(self._player, self._player.get_property('metadata'))
break
def printStatus(self):
if self.status in [ 'playing', 'paused' ]:
if self.metadata['title']:
metadata = { **self.metadata, 'icon': self.icon, 'icon-reversed': self.icon_reversed }
# replace metadata tags in text
text = re.sub(FORMAT_REGEX, lambda match: self._statusReplace(match, metadata), FORMAT_STRING)
# restore polybar tag formatting and replace any remaining metadata tags after that
try:
text = re.sub(r'􏿿p􏿿(.*?)􏿿p􏿿(.*?)􏿿p􏿿(.*?)􏿿p􏿿', r'%{\1}\2%{\3}', text.format_map(CleanSafeDict(**metadata)))
except: except:
self._print_flush(PLAYER_CLOSED_ICON) print("Invalid format string")
time.sleep(2) _printFlush(text)
return
_printFlush(ICON_STOPPED)
def _on_metadata(self, player, e):
if 'xesam:artist' in e.keys() and 'xesam:title' in e.keys():
self._artist = e['xesam:artist'][0]
self._title = e['xesam:title']
self._print_song()
def _on_play(self, player): def _dbusValueToPython(value):
self._icon = MUSIC_ICON if isinstance(value, dbus.Dictionary):
self._print_song() return {_dbusValueToPython(key): _dbusValueToPython(value) for key, value in value.items()}
elif isinstance(value, dbus.Array):
return [ _dbusValueToPython(item) for item in value ]
elif isinstance(value, dbus.Boolean):
return int(value) == 1
elif (
isinstance(value, dbus.Byte) or
isinstance(value, dbus.Int16) or
isinstance(value, dbus.UInt16) or
isinstance(value, dbus.Int32) or
isinstance(value, dbus.UInt32) or
isinstance(value, dbus.Int64) or
isinstance(value, dbus.UInt64)
):
return int(value)
elif isinstance(value, dbus.Double):
return float(value)
elif (
isinstance(value, dbus.ObjectPath) or
isinstance(value, dbus.Signature) or
isinstance(value, dbus.String)
):
return unquote(str(value))
def _on_pause(self, player): def _getProperty(properties, property, default = None):
self._icon = PAUSE_ICON value = default
self._print_song() if not isinstance(property, dbus.String):
property = dbus.String(property)
if property in properties:
value = properties[property]
return _dbusValueToPython(value)
else:
return value
def _on_exit(self, player):
self._init_player()
def _print_song(self): class CleanSafeDict(dict):
self._print_flush( def __missing__(self, key):
'{} {} - {}'.format(self._icon, self._artist, self._title)) return '{{{}}}'.format(key)
"""
Seems to assure print() actually prints when no terminal is connected
"""
def _print_flush(self, status, **kwargs): """
if status != self._last_status: Seems to assure print() actually prints when no terminal is connected
"""
_last_status = ''
def _printFlush(status, **kwargs):
global _last_status
if status != _last_status:
print(status, **kwargs) print(status, **kwargs)
sys.stdout.flush() sys.stdout.flush()
self._last_status = status _last_status = status
PlayerStatus().show()
parser = argparse.ArgumentParser()
parser.add_argument('command', help="send the given command to the active player",
choices=[ 'play', 'pause', 'play-pause', 'stop', 'previous', 'next', 'status', 'list', 'current', 'metadata', 'raise' ],
default=None,
nargs='?')
parser.add_argument('-b', '--blacklist', help="ignore a player by it's bus name. Can be be given multiple times (e.g. -b vlc -b audacious)",
action='append',
metavar="BUS_NAME",
default=[])
parser.add_argument('-f', '--format', default='{icon} {artist} - {title}')
parser.add_argument('--truncate-text', default='')
parser.add_argument('--icon-playing', default='')
parser.add_argument('--icon-paused', default='')
parser.add_argument('--icon-stopped', default='')
parser.add_argument('--icon-none', default='')
args = parser.parse_args()
FORMAT_STRING = re.sub(r'%\{(.*?)\}(.*?)%\{(.*?)\}', r'􏿿p􏿿\1􏿿p􏿿\2􏿿p􏿿\3􏿿p􏿿', args.format)
TRUNCATE_STRING = args.truncate_text
ICON_PLAYING = args.icon_playing
ICON_PAUSED = args.icon_paused
ICON_STOPPED = args.icon_stopped
ICON_NONE = args.icon_none
if args.command is None:
PlayerManager(blacklist = args.blacklist)
else:
player_manager = PlayerManager(blacklist = args.blacklist, connect = False)
current_player = player_manager.getCurrentPlayer()
if args.command == 'play' and current_player:
current_player.play()
elif args.command == 'pause' and current_player:
current_player.pause()
elif args.command == 'play-pause' and current_player:
current_player.playpause()
elif args.command == 'stop' and current_player:
current_player.stop()
elif args.command == 'previous' and current_player:
current_player.previous()
elif args.command == 'next' and current_player:
current_player.next()
elif args.command == 'status' and current_player:
current_player.printStatus()
elif args.command == 'list':
print("\n".join(sorted([
"{} : {}".format(player.bus_name.split('.')[-1], player.status)
for player in player_manager.players.values() ])))
elif args.command == 'current' and current_player:
print("{} : {}".format(current_player.bus_name.split('.')[-1], current_player.status))
elif args.command == 'metadata' and current_player:
print(_dbusValueToPython(current_player._metadata))
elif args.command == 'raise' and current_player:
current_player.raisePlayer()