LINT+Black+stability fixes for some plugins that hadn't been touched in a while.
continuous-integration/drone/push Build is passing Details

- media.mplayer
- media.omxplayer
- media.vlc
- music.mpd
- music.snapcast
This commit is contained in:
Fabio Manganiello 2023-10-01 16:38:22 +02:00
parent 2aefc4e5c8
commit 3086dd86fc
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
6 changed files with 711 additions and 702 deletions

View File

@ -4,6 +4,7 @@ import select
import subprocess import subprocess
import threading import threading
import time import time
from typing import Any, Collection, Dict, List, Optional
from platypush.context import get_bus from platypush.context import get_bus
from platypush.message.response import Response from platypush.message.response import Response
@ -39,10 +40,9 @@ class MediaMplayerPlugin(MediaPlugin):
def __init__( def __init__(
self, self,
mplayer_bin=None, mplayer_bin: Optional[str] = None,
mplayer_timeout=_mplayer_default_communicate_timeout, mplayer_timeout: float = _mplayer_default_communicate_timeout,
args=None, args: Optional[Collection[str]] = None,
*argv,
**kwargs, **kwargs,
): ):
""" """
@ -52,21 +52,13 @@ class MediaMplayerPlugin(MediaPlugin):
:param mplayer_bin: Path to the MPlayer executable (default: search for :param mplayer_bin: Path to the MPlayer executable (default: search for
the first occurrence in your system PATH environment variable) the first occurrence in your system PATH environment variable)
:type mplayer_bin: str
:param mplayer_timeout: Timeout in seconds to wait for more data :param mplayer_timeout: Timeout in seconds to wait for more data
from MPlayer before considering a response ready (default: 0.5 seconds) from MPlayer before considering a response ready (default: 0.5 seconds)
:type mplayer_timeout: float
:param subtitles: Path to the subtitles file
:type subtitles: str
:param args: Default arguments that will be passed to the MPlayer :param args: Default arguments that will be passed to the MPlayer
executable executable
:type args: list
""" """
super().__init__(*argv, **kwargs) super().__init__(**kwargs)
self.args = args or [] self.args = args or []
self._init_mplayer_bin(mplayer_bin=mplayer_bin) self._init_mplayer_bin(mplayer_bin=mplayer_bin)
@ -106,17 +98,15 @@ class MediaMplayerPlugin(MediaPlugin):
try: try:
self._player.terminate() self._player.terminate()
except Exception as e: except Exception as e:
self.logger.debug( self.logger.debug('Failed to quit mplayer before _exec: %s', e)
'Failed to quit mplayer before _exec: {}'.format(str(e))
)
mplayer_args = mplayer_args or [] m_args = mplayer_args or []
args = [self.mplayer_bin] + self._mplayer_bin_default_args args = [self.mplayer_bin] + self._mplayer_bin_default_args
for arg in self.args + mplayer_args: for arg in (*self.args, *m_args):
if arg not in args: if arg not in args:
args.append(arg) args.append(arg)
popen_args = { popen_args: Dict[str, Any] = {
'stdin': subprocess.PIPE, 'stdin': subprocess.PIPE,
'stdout': subprocess.PIPE, 'stdout': subprocess.PIPE,
} }
@ -140,10 +130,13 @@ class MediaMplayerPlugin(MediaPlugin):
def args_pprint(txt): def args_pprint(txt):
lc = txt.lower() lc = txt.lower()
if lc[0] == '[': if lc[0] == '[':
return '%s=None' % lc[1:-1] return f'{lc[1:-1]}=None'
return lc return lc
while True: while True:
if not mplayer.stdout:
break
line = mplayer.stdout.readline() line = mplayer.stdout.readline()
if not line: if not line:
break break
@ -153,7 +146,7 @@ class MediaMplayerPlugin(MediaPlugin):
args = line.split() args = line.split()
cmd_name = args.pop(0) cmd_name = args.pop(0)
arguments = ', '.join([args_pprint(a) for a in args]) arguments = ', '.join([args_pprint(a) for a in args])
self._actions[cmd_name] = '{}({})'.format(cmd_name, arguments) self._actions[cmd_name] = f'{cmd_name}({arguments})'
def _exec( def _exec(
self, cmd, *args, mplayer_args=None, prefix=None, wait_for_response=False self, cmd, *args, mplayer_args=None, prefix=None, wait_for_response=False
@ -161,22 +154,27 @@ class MediaMplayerPlugin(MediaPlugin):
cmd_name = cmd cmd_name = cmd
response = None response = None
if cmd_name == 'loadfile' or cmd_name == 'loadlist': if cmd_name in {'loadfile', 'loadlist'}:
self._init_mplayer(mplayer_args) self._init_mplayer(mplayer_args)
else: else:
if not self._player: if not self._player:
self.logger.warning('MPlayer is not running') self.logger.warning('MPlayer is not running')
cmd = '{}{}{}{}\n'.format( cmd = (
prefix + ' ' if prefix else '', f'{prefix + " " if prefix else ""}'
cmd_name, + cmd_name
' ' if args else '', + (" " if args else "")
' '.join(repr(a) for a in args), + " ".join(repr(a) for a in args)
+ '\n'
).encode() ).encode()
if not self._player: if not self._player:
self.logger.warning('Cannot send command %s: player unavailable', cmd)
return
if not self._player.stdin:
self.logger.warning( self.logger.warning(
'Cannot send command {}: player unavailable'.format(cmd) 'Could not communicate with the mplayer process: the stdin is closed'
) )
return return
@ -199,6 +197,12 @@ class MediaMplayerPlugin(MediaPlugin):
if not wait_for_response: if not wait_for_response:
return return
if not (self._player and self._player.stdout):
self.logger.warning(
'Could not communicate with the mplayer process: the stdout is closed'
)
return
poll = select.poll() poll = select.poll()
poll.register(self._player.stdout, select.POLLIN) poll.register(self._player.stdout, select.POLLIN)
last_read_time = time.time() last_read_time = time.time()
@ -209,11 +213,16 @@ class MediaMplayerPlugin(MediaPlugin):
if not self._player: if not self._player:
break break
line = self._player.stdout.readline().decode() buf = self._player.stdout.readline()
line = buf.decode() if isinstance(buf, bytes) else buf
last_read_time = time.time() last_read_time = time.time()
if line.startswith('ANS_'): if line.startswith('ANS_'):
m = re.match('^([^=]+)=(.*)$', line[4:]) m = re.match('^([^=]+)=(.*)$', line[4:])
if not m:
self.logger.warning('Unexpected response: %s', line)
break
k, v = m.group(1), m.group(2) k, v = m.group(1), m.group(2)
v = v.strip() v = v.strip()
if v == 'yes': if v == 'yes':
@ -222,7 +231,8 @@ class MediaMplayerPlugin(MediaPlugin):
v = False v = False
try: try:
v = eval(v) if isinstance(v, str):
v = eval(v) # pylint: disable=eval-used
except Exception: except Exception:
pass pass
@ -272,25 +282,26 @@ class MediaMplayerPlugin(MediaPlugin):
bus.post(evt_type(player='local', plugin='media.mplayer', **evt)) bus.post(evt_type(player='local', plugin='media.mplayer', **evt))
@action @action
def play(self, resource, subtitles=None, mplayer_args=None): def play(
self,
resource: str,
subtitles: Optional[str] = None,
mplayer_args: Optional[List[str]] = None,
):
""" """
Play a resource. Play a resource.
:param resource: Resource to play - can be a local file or a remote URL :param resource: Resource to play - can be a local file or a remote URL
:type resource: str
:param subtitles: Path to optional subtitle file :param subtitles: Path to optional subtitle file
:type subtitles: str
:param mplayer_args: Extra runtime arguments that will be passed to the :param mplayer_args: Extra runtime arguments that will be passed to the
MPlayer executable MPlayer executable
:type mplayer_args: list[str]
""" """
self._post_event(MediaPlayRequestEvent, resource=resource) self._post_event(MediaPlayRequestEvent, resource=resource)
if subtitles: if subtitles:
mplayer_args = mplayer_args or [] subs = self.get_subtitles_file(subtitles)
mplayer_args += ['-sub', self.get_subtitles_file(subtitles)] if subs:
mplayer_args = list(mplayer_args or []) + ['-sub', subs]
resource = self._get_resource(resource) resource = self._get_resource(resource)
if resource.startswith('file://'): if resource.startswith('file://'):
@ -305,67 +316,78 @@ class MediaMplayerPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def pause(self): def pause(self, *_, **__):
"""Toggle the paused state""" """Toggle the paused state"""
self._exec('pause') self._exec('pause')
self._post_event(MediaPauseEvent) self._post_event(MediaPauseEvent)
return self.status() return self.status()
@action @action
def stop(self): def stop(self, *_, **__):
"""Stop the playback""" """Stop the playback"""
# return self._exec('stop') # return self._exec('stop')
self.quit() self.quit()
return self.status() return self.status()
@action @action
def quit(self): def quit(self, *_, **__):
"""Quit the player""" """Quit the player"""
self._exec('quit') self._exec('quit')
self._post_event(MediaStopEvent) self._post_event(MediaStopEvent)
return self.status() return self.status()
@action @action
def voldown(self, step=10.0): def voldown(self, *_, step=10.0, **__):
"""Volume down by (default: 10)%""" """Volume down by (default: 10)%"""
self._exec('volume', -step * 10) self._exec('volume', -step * 10)
return self.status() return self.status()
@action @action
def volup(self, step=10.0): def volup(self, *_, step=10.0, **__):
"""Volume up by (default: 10)%""" """Volume up by (default: 10)%"""
self._exec('volume', step * 10) self._exec('volume', step * 10)
return self.status() return self.status()
@action @action
def back(self, offset=30.0): def back(self, *_, offset=30.0, **__):
"""Back by (default: 30) seconds""" """Back by (default: 30) seconds"""
self.step_property('time_pos', -offset) self.step_property('time_pos', -offset)
return self.status() return self.status()
@action @action
def forward(self, offset=30.0): def forward(self, *_, offset=30.0, **__):
"""Forward by (default: 30) seconds""" """Forward by (default: 30) seconds"""
self.step_property('time_pos', offset) self.step_property('time_pos', offset)
return self.status() return self.status()
@action @action
def toggle_subtitles(self): def toggle_subtitles(self, *_, **__):
"""Toggle the subtitles visibility""" """Toggle the subtitles visibility"""
subs = self.get_property('sub_visibility').output.get('sub_visibility') response: dict = (
self.get_property('sub_visibility').output or {} # type: ignore
)
subs = response.get('sub_visibility')
self._exec('sub_visibility', int(not subs)) self._exec('sub_visibility', int(not subs))
return self.status() return self.status()
@action @action
def add_subtitles(self, filename, **__): def add_subtitles(self, filename: str, **__):
"""Sets media subtitles from filename""" """
Sets media subtitles from filename
:param filename: Subtitles file.
"""
self._exec('sub_visibility', 1) self._exec('sub_visibility', 1)
self._exec('sub_load', filename) self._exec('sub_load', filename)
return self.status() return self.status()
@action @action
def remove_subtitles(self, index=None): def remove_subtitles(self, *_, index: Optional[int] = None, **__):
"""Removes the subtitle specified by the index (default: all)""" """
Removes the subtitle specified by the index (default: all)
:param index: (1-based) index of the subtitles track to remove.
"""
if index is None: if index is None:
self._exec('sub_remove') self._exec('sub_remove')
else: else:
@ -374,14 +396,15 @@ class MediaMplayerPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def is_playing(self): def is_playing(self, *_, **__):
""" """
:returns: True if it's playing, False otherwise :returns: True if it's playing, False otherwise
""" """
return self.get_property('pause').output.get('pause') is False response: dict = self.get_property('pause').output or {} # type: ignore
return response.get('pause') is False
@action @action
def load(self, resource, mplayer_args=None, **kwargs): def load(self, resource, *_, mplayer_args: Optional[Collection[str]] = None, **__):
""" """
Load a resource/video in the player. Load a resource/video in the player.
""" """
@ -390,13 +413,13 @@ class MediaMplayerPlugin(MediaPlugin):
return self.play(resource, mplayer_args=mplayer_args) return self.play(resource, mplayer_args=mplayer_args)
@action @action
def mute(self): def mute(self, *_, **__):
"""Toggle mute state""" """Toggle mute state"""
self._exec('mute') self._exec('mute')
return self.status() return self.status()
@action @action
def seek(self, position): def seek(self, position: float, *_, **__):
""" """
Seek backward/forward by the specified number of seconds Seek backward/forward by the specified number of seconds
@ -407,7 +430,7 @@ class MediaMplayerPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def set_position(self, position): def set_position(self, position: float, *_, **__):
""" """
Seek backward/forward to the specified absolute position Seek backward/forward to the specified absolute position
@ -418,7 +441,7 @@ class MediaMplayerPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def set_volume(self, volume): def set_volume(self, volume: float, *_, **__):
""" """
Set the volume Set the volume
@ -463,7 +486,7 @@ class MediaMplayerPlugin(MediaPlugin):
with self._status_lock: with self._status_lock:
for prop, player_prop in props.items(): for prop, player_prop in props.items():
value = self.get_property(player_prop).output value = self.get_property(player_prop).output
if value is not None: if isinstance(value, dict):
status[prop] = value.get(player_prop) status[prop] = value.get(player_prop)
status['seekable'] = bool(status['duration']) status['seekable'] = bool(status['duration'])
@ -480,7 +503,11 @@ class MediaMplayerPlugin(MediaPlugin):
return status return status
@action @action
def get_property(self, property, args=None): def get_property(
self,
property: str, # pylint: disable=redefined-builtin
args: Optional[Collection[str]] = None,
):
""" """
Get a player property (e.g. pause, fullscreen etc.). See Get a player property (e.g. pause, fullscreen etc.). See
https://www.mplayerhq.hu/DOCS/tech/slave.txt for a full list of the https://www.mplayerhq.hu/DOCS/tech/slave.txt for a full list of the
@ -503,14 +530,23 @@ class MediaMplayerPlugin(MediaPlugin):
for k, v in result.items(): for k, v in result.items():
if k == 'ERROR' and v not in response.errors: if k == 'ERROR' and v not in response.errors:
response.errors.append('{}{}: {}'.format(property, args, v)) if not isinstance(response.errors, list):
response.errors = []
response.errors.append(f'{property}{args}: {v}')
else: else:
if not isinstance(response.output, dict):
response.output = {}
response.output[k] = v response.output[k] = v
return response return response
@action @action
def set_property(self, property, value, args=None): def set_property(
self,
property: str, # pylint: disable=redefined-builtin
value: Any,
args: Optional[Collection[str]] = None,
):
""" """
Set a player property (e.g. pause, fullscreen etc.). See Set a player property (e.g. pause, fullscreen etc.). See
https://www.mplayerhq.hu/DOCS/tech/slave.txt for a full list of the https://www.mplayerhq.hu/DOCS/tech/slave.txt for a full list of the
@ -534,14 +570,25 @@ class MediaMplayerPlugin(MediaPlugin):
for k, v in result.items(): for k, v in result.items():
if k == 'ERROR' and v not in response.errors: if k == 'ERROR' and v not in response.errors:
response.errors.append('{} {}{}: {}'.format(property, value, args, v)) if not isinstance(response.errors, list):
response.errors = []
response.errors.append(f'{property} {value}{args}: {v}')
else: else:
if not isinstance(response.output, dict):
response.output = {}
response.output[k] = v response.output[k] = v
return response return response
@action @action
def step_property(self, property, value, args=None): def step_property(
self,
property: str, # pylint: disable=redefined-builtin
value: Any,
*_,
args: Optional[Collection[str]] = None,
**__,
):
""" """
Step a player property (e.g. volume, time_pos etc.). See Step a player property (e.g. volume, time_pos etc.). See
https://www.mplayerhq.hu/DOCS/tech/slave.txt for a full list of the https://www.mplayerhq.hu/DOCS/tech/slave.txt for a full list of the
@ -565,13 +612,18 @@ class MediaMplayerPlugin(MediaPlugin):
for k, v in result.items(): for k, v in result.items():
if k == 'ERROR' and v not in response.errors: if k == 'ERROR' and v not in response.errors:
response.errors.append('{} {}{}: {}'.format(property, value, args, v)) if not isinstance(response.errors, list):
response.errors = []
response.errors.append(f'{property} {value}{args}: {v}')
else: else:
if not isinstance(response.output, dict):
response.output = {}
response.output[k] = v response.output[k] = v
return response return response
def set_subtitles(self, filename, *args, **kwargs): def set_subtitles(self, filename: str, *_, **__):
self.logger.debug('set_subtitles called with filename=%s', filename)
raise NotImplementedError raise NotImplementedError

View File

@ -1,5 +1,7 @@
import enum import enum
import threading import threading
from typing import Collection, Optional
import urllib.parse import urllib.parse
from platypush.context import get_bus from platypush.context import get_bus
@ -16,6 +18,10 @@ from platypush.plugins import action
class PlayerEvent(enum.Enum): class PlayerEvent(enum.Enum):
"""
Supported player events.
"""
STOP = 'stop' STOP = 'stop'
PLAY = 'play' PLAY = 'play'
PAUSE = 'pause' PAUSE = 'pause'
@ -26,17 +32,18 @@ class MediaOmxplayerPlugin(MediaPlugin):
Plugin to control video and media playback using OMXPlayer. Plugin to control video and media playback using OMXPlayer.
""" """
def __init__(self, args=None, *argv, timeout: float = 20.0, **kwargs): def __init__(
self, args: Optional[Collection[str]] = None, timeout: float = 20.0, **kwargs
):
""" """
:param args: Arguments that will be passed to the OMXPlayer constructor :param args: Arguments that will be passed to the OMXPlayer constructor
(e.g. subtitles, volume, start position, window size etc.) see (e.g. subtitles, volume, start position, window size etc.) see
https://github.com/popcornmix/omxplayer#synopsis and https://github.com/popcornmix/omxplayer#synopsis and
https://python-omxplayer-wrapper.readthedocs.io/en/latest/omxplayer/#omxplayer.player.OMXPlayer https://python-omxplayer-wrapper.readthedocs.io/en/latest/omxplayer/#omxplayer.player.OMXPlayer
:type args: list
:param timeout: How long the plugin should wait for a video to start upon play request (default: 20 seconds). :param timeout: How long the plugin should wait for a video to start upon play request (default: 20 seconds).
""" """
super().__init__(*argv, **kwargs) super().__init__(**kwargs)
if args is None: if args is None:
args = [] args = []
@ -48,7 +55,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
self._play_started = threading.Event() self._play_started = threading.Event()
@action @action
def play(self, resource=None, subtitles=None, *args, **kwargs): def play(self, *args, resource=None, subtitles=None, **_):
""" """
Play or resume playing a resource. Play or resume playing a resource.
@ -68,9 +75,8 @@ class MediaOmxplayerPlugin(MediaPlugin):
self._player.play() self._player.play()
return self.status() return self.status()
else:
self._play_started.clear()
self._play_started.clear()
self._post_event(MediaPlayRequestEvent, resource=resource) self._post_event(MediaPlayRequestEvent, resource=resource)
if subtitles: if subtitles:
@ -141,7 +147,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
return {'status': 'stop'} return {'status': 'stop'}
def get_volume(self) -> float: def get_volume(self) -> Optional[float]:
""" """
:return: The player volume in percentage [0, 100]. :return: The player volume in percentage [0, 100].
""" """
@ -157,7 +163,9 @@ class MediaOmxplayerPlugin(MediaPlugin):
:type step: float :type step: float
""" """
if self._player: if self._player:
self.set_volume(max(0, self.get_volume() - step)) vol = self.get_volume()
if vol is not None:
self.set_volume(max(0, vol - step))
return self.status() return self.status()
@action @action
@ -169,7 +177,9 @@ class MediaOmxplayerPlugin(MediaPlugin):
:type step: float :type step: float
""" """
if self._player: if self._player:
self.set_volume(min(100, self.get_volume() + step)) vol = self.get_volume()
if vol is not None:
self.set_volume(min(100, vol + step))
return self.status() return self.status()
@action @action
@ -213,23 +223,19 @@ class MediaOmxplayerPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def is_playing(self): def is_playing(self, *_, **__) -> bool:
""" """
:returns: True if it's playing, False otherwise :returns: True if it's playing, False otherwise
""" """
return self._player.is_playing() if self._player else False
return self._player.is_playing()
@action @action
def load(self, resource, pause=False, **kwargs): def load(self, resource: str, *_, pause: bool = False, **__):
""" """
Load a resource/video in the player. Load a resource/video in the player.
:param resource: URL or filename to load :param resource: URL or filename to load
:type resource: str
:param pause: If set, load the video in paused mode (default: False) :param pause: If set, load the video in paused mode (default: False)
:type pause: bool
""" """
if self._player: if self._player:
@ -244,48 +250,45 @@ class MediaOmxplayerPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def mute(self): def mute(self, *_, **__):
"""Mute the player""" """Mute the player"""
if self._player: if self._player:
self._player.mute() self._player.mute()
return self.status() return self.status()
@action @action
def unmute(self): def unmute(self, *_, **__):
"""Unmute the player""" """Unmute the player"""
if self._player: if self._player:
self._player.unmute() self._player.unmute()
return self.status() return self.status()
@action @action
def seek(self, position): def seek(self, position: float, **__):
""" """
Seek to the specified number of seconds from the start. Seek to the specified number of seconds from the start.
:param position: Number of seconds from the start :param position: Number of seconds from the start
:type position: float
""" """
if self._player: if self._player:
self._player.set_position(position) self._player.set_position(position)
return self.status() return self.status()
@action @action
def set_position(self, position): def set_position(self, position: float, **__):
""" """
Seek to the specified number of seconds from the start (same as :meth:`.seek`). Seek to the specified number of seconds from the start (same as :meth:`.seek`).
:param position: Number of seconds from the start :param position: Number of seconds from the start
:type position: float
""" """
return self.seek(position) return self.seek(position)
@action @action
def set_volume(self, volume): def set_volume(self, volume: float, *_, **__):
""" """
Set the volume Set the volume
:param volume: Volume value between 0 and 100 :param volume: Volume value between 0 and 100
:type volume: float
""" """
if self._player: if self._player:
@ -327,7 +330,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
try: try:
state = self._player.playback_status().lower() state = self._player.playback_status().lower()
except (OMXPlayerDeadError, DBusException) as e: except (OMXPlayerDeadError, DBusException) as e:
self.logger.warning(f'Could not retrieve player status: {e}') self.logger.warning('Could not retrieve player status: %s', e)
if isinstance(e, OMXPlayerDeadError): if isinstance(e, OMXPlayerDeadError):
self._player = None self._player = None
@ -362,9 +365,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
def add_handler(self, event_type, callback): def add_handler(self, event_type, callback):
if event_type not in self._handlers.keys(): if event_type not in self._handlers.keys():
raise AttributeError( raise AttributeError(f'{event_type} is not a valid PlayerEvent type')
'{} is not a valid PlayerEvent type'.format(event_type)
)
self._handlers[event_type].append(callback) self._handlers[event_type].append(callback)
@ -420,13 +421,13 @@ class MediaOmxplayerPlugin(MediaPlugin):
self._player.positionEvent += self.on_seek() self._player.positionEvent += self.on_seek()
self._player.seekEvent += self.on_seek() self._player.seekEvent += self.on_seek()
def toggle_subtitles(self, *args, **kwargs): def toggle_subtitles(self, *_, **__):
raise NotImplementedError raise NotImplementedError
def set_subtitles(self, filename, *args, **kwargs): def set_subtitles(self, *_, **__):
raise NotImplementedError raise NotImplementedError
def remove_subtitles(self, *args, **kwargs): def remove_subtitles(self, *_, **__):
raise NotImplementedError raise NotImplementedError

View File

@ -1,7 +1,7 @@
import os import os
import threading import threading
import urllib.parse import urllib.parse
from typing import Optional from typing import Collection, Optional
from platypush.context import get_bus from platypush.context import get_bus
from platypush.plugins.media import PlayerState, MediaPlugin from platypush.plugins.media import PlayerState, MediaPlugin
@ -24,23 +24,22 @@ class MediaVlcPlugin(MediaPlugin):
Plugin to control VLC instances. Plugin to control VLC instances.
""" """
def __init__(self, args=None, fullscreen=False, volume=100, *argv, **kwargs): def __init__(
self,
args: Optional[Collection[str]] = None,
fullscreen: bool = False,
volume: int = 100,
**kwargs
):
""" """
Create the vlc wrapper.
:param args: List of extra arguments to pass to the VLC executable (e.g. :param args: List of extra arguments to pass to the VLC executable (e.g.
``['--sub-language=en', '--snapshot-path=/mnt/snapshots']``) ``['--sub-language=en', '--snapshot-path=/mnt/snapshots']``)
:type args: list[str]
:param fullscreen: Set to True if you want media files to be opened in :param fullscreen: Set to True if you want media files to be opened in
fullscreen by default (can be overridden by `.play()`) (default: False) fullscreen by default (can be overridden by `.play()`) (default: False)
:type fullscreen: bool
:param volume: Default media volume (default: 100) :param volume: Default media volume (default: 100)
:type volume: int
""" """
super().__init__(*argv, **kwargs) super().__init__(**kwargs)
self._args = args or [] self._args = args or []
self._instance = None self._instance = None
@ -98,6 +97,7 @@ class MediaVlcPlugin(MediaPlugin):
self._monitor_thread = threading.Thread(target=self._player_monitor) self._monitor_thread = threading.Thread(target=self._player_monitor)
self._monitor_thread.start() self._monitor_thread.start()
self._instance = vlc.Instance(*self._args) self._instance = vlc.Instance(*self._args)
assert self._instance, 'Could not create a VLC instance'
self._player = self._instance.media_player_new(resource) self._player = self._instance.media_player_new(resource)
for evt in self._watched_event_types(): for evt in self._watched_event_types():
@ -136,65 +136,67 @@ class MediaVlcPlugin(MediaPlugin):
def callback(event): def callback(event):
from vlc import EventType from vlc import EventType
self.logger.debug('Received vlc event: {}'.format(event)) self.logger.debug('Received vlc event: %s', event)
if event.type == EventType.MediaPlayerPlaying: # type: ignore
if event.type == EventType.MediaPlayerPlaying:
self._post_event(MediaPlayEvent, resource=self._get_current_resource()) self._post_event(MediaPlayEvent, resource=self._get_current_resource())
elif event.type == EventType.MediaPlayerPaused: elif event.type == EventType.MediaPlayerPaused: # type: ignore
self._post_event(MediaPauseEvent) self._post_event(MediaPauseEvent)
elif ( elif (
event.type == EventType.MediaPlayerStopped event.type == EventType.MediaPlayerStopped # type: ignore
or event.type == EventType.MediaPlayerEndReached or event.type == EventType.MediaPlayerEndReached # type: ignore
): ):
self._on_stop_event.set() self._on_stop_event.set()
self._post_event(MediaStopEvent) self._post_event(MediaStopEvent)
for cbk in self._on_stop_callbacks: for cbk in self._on_stop_callbacks:
cbk() cbk()
elif ( elif self._player and (
event.type == EventType.MediaPlayerTitleChanged event.type
or event.type == EventType.MediaPlayerMediaChanged in (
EventType.MediaPlayerTitleChanged, # type: ignore
EventType.MediaPlayerMediaChanged, # type: ignore
)
): ):
self._title = self._player.get_title() or self._filename self._title = self._player.get_title() or self._filename
if event.type == EventType.MediaPlayerMediaChanged: if event.type == EventType.MediaPlayerMediaChanged: # type: ignore
self._post_event(NewPlayingMediaEvent, resource=self._title) self._post_event(NewPlayingMediaEvent, resource=self._title)
elif event.type == EventType.MediaPlayerLengthChanged: elif event.type == EventType.MediaPlayerLengthChanged: # type: ignore
self._post_event( self._post_event(
NewPlayingMediaEvent, resource=self._get_current_resource() NewPlayingMediaEvent, resource=self._get_current_resource()
) )
elif event.type == EventType.MediaPlayerTimeChanged: elif self._player and event.type == EventType.MediaPlayerTimeChanged: # type: ignore
pos = float(self._player.get_time() / 1000) pos = float(self._player.get_time() / 1000)
if self._latest_seek is None or abs(pos - self._latest_seek) > 5: if self._latest_seek is None or abs(pos - self._latest_seek) > 5:
self._post_event(MediaSeekEvent, position=pos) self._post_event(MediaSeekEvent, position=pos)
self._latest_seek = pos self._latest_seek = pos
elif event.type == EventType.MediaPlayerAudioVolume: elif self._player and event.type == EventType.MediaPlayerAudioVolume: # type: ignore
self._post_event( self._post_event(
MediaVolumeChangedEvent, volume=self._player.audio_get_volume() MediaVolumeChangedEvent, volume=self._player.audio_get_volume()
) )
elif event.type == EventType.MediaPlayerMuted: elif event.type == EventType.MediaPlayerMuted: # type: ignore
self._post_event(MediaMuteChangedEvent, mute=True) self._post_event(MediaMuteChangedEvent, mute=True)
elif event.type == EventType.MediaPlayerUnmuted: elif event.type == EventType.MediaPlayerUnmuted: # type: ignore
self._post_event(MediaMuteChangedEvent, mute=False) self._post_event(MediaMuteChangedEvent, mute=False)
return callback return callback
@action @action
def play(self, resource=None, subtitles=None, fullscreen=None, volume=None): def play(
self,
resource: Optional[str] = None,
subtitles: Optional[str] = None,
fullscreen: Optional[bool] = None,
volume: Optional[int] = None,
):
""" """
Play a resource. Play a resource.
:param resource: Resource to play - can be a local file or a remote URL (default: None == toggle play). :param resource: Resource to play - can be a local file or a remote URL
:type resource: str (default: None == toggle play).
:param subtitles: Path to optional subtitle file :param subtitles: Path to optional subtitle file
:type subtitles: str
:param fullscreen: Set to explicitly enable/disable fullscreen (default: :param fullscreen: Set to explicitly enable/disable fullscreen (default:
`fullscreen` configured value or False) `fullscreen` configured value or False)
:type fullscreen: bool
:param volume: Set to explicitly set the playback volume (default: :param volume: Set to explicitly set the playback volume (default:
`volume` configured value or 100) `volume` configured value or 100)
:type fullscreen: bool
""" """
if not resource: if not resource:
@ -208,12 +210,14 @@ class MediaVlcPlugin(MediaPlugin):
self._filename = resource self._filename = resource
self._init_vlc(resource) self._init_vlc(resource)
if subtitles: if subtitles and self._player:
if subtitles.startswith('file://'): if subtitles.startswith('file://'):
subtitles = subtitles[len('file://') :] subtitles = subtitles[len('file://') :]
self._player.video_set_subtitle_file(subtitles) self._player.video_set_subtitle_file(subtitles)
if self._player:
self._player.play() self._player.play()
if self.volume: if self.volume:
self.set_volume(volume=self.volume) self.set_volume(volume=self.volume)
@ -226,71 +230,60 @@ class MediaVlcPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def pause(self): def pause(self, *_, **__):
"""Toggle the paused state""" """Toggle the paused state"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running' assert self._player.can_pause(), 'The specified media type cannot be paused'
if not self._player.can_pause():
return None, 'The specified media type cannot be paused'
self._player.pause() self._player.pause()
return self.status() return self.status()
@action @action
def quit(self): def quit(self, *_, **__):
"""Quit the player (same as `stop`)""" """Quit the player (same as `stop`)"""
with self._stop_lock: with self._stop_lock:
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
self._player.stop() self._player.stop()
self._on_stop_event.wait(timeout=5) self._on_stop_event.wait(timeout=5)
self._reset_state() self._reset_state()
return self.status() return self.status()
@action @action
def stop(self): def stop(self, *_, **__):
"""Stop the application (same as `quit`)""" """Stop the application (same as `quit`)"""
return self.quit() return self.quit()
@action @action
def voldown(self, step=10.0): def voldown(self, *_, step: float = 10.0, **__):
"""Volume down by (default: 10)%""" """Volume down by (default: 10)%"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
return self.set_volume(int(max(0, self._player.audio_get_volume() - step))) return self.set_volume(int(max(0, self._player.audio_get_volume() - step)))
@action @action
def volup(self, step=10.0): def volup(self, *_, step: float = 10.0, **__):
"""Volume up by (default: 10)%""" """Volume up by (default: 10)%"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
return self.set_volume(int(min(100, self._player.audio_get_volume() + step))) return self.set_volume(int(min(100, self._player.audio_get_volume() + step)))
@action @action
def set_volume(self, volume): def set_volume(self, volume: int):
""" """
Set the volume Set the volume
:param volume: Volume value between 0 and 100 :param volume: Volume value between 0 and 100
:type volume: float
""" """
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
volume = max(0, min([100, volume])) volume = max(0, min([100, volume]))
self._player.audio_set_volume(volume) self._player.audio_set_volume(volume)
status = self.status().output status: dict = self.status().output # type: ignore
status['volume'] = volume status['volume'] = volume
return status return status
@action @action
def seek(self, position): def seek(self, position: float):
""" """
Seek backward/forward by the specified number of seconds Seek backward/forward by the specified number of seconds
:param position: Number of seconds relative to the current cursor :param position: Number of seconds relative to the current cursor
:type position: int
""" """
if not self._player: if not self._player:
return None, 'No vlc instance is running' return None, 'No vlc instance is running'
@ -306,7 +299,7 @@ class MediaVlcPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def back(self, offset=30.0): def back(self, *_, offset: float = 30.0, **__):
"""Back by (default: 30) seconds""" """Back by (default: 30) seconds"""
if not self._player: if not self._player:
return None, 'No vlc instance is running' return None, 'No vlc instance is running'
@ -319,7 +312,7 @@ class MediaVlcPlugin(MediaPlugin):
return self.seek(pos) return self.seek(pos)
@action @action
def forward(self, offset=30.0): def forward(self, *_, offset: float = 30.0, **__):
"""Forward by (default: 30) seconds""" """Forward by (default: 30) seconds"""
if not self._player: if not self._player:
return None, 'No vlc instance is running' return None, 'No vlc instance is running'
@ -334,13 +327,12 @@ class MediaVlcPlugin(MediaPlugin):
return self.seek(pos) return self.seek(pos)
@action @action
def toggle_subtitles(self, visibile=None): def toggle_subtitles(self, *_, **__):
"""Toggle the subtitles visibility""" """Toggle the subtitles visibility"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running' assert (
self._player.video_get_spu_count() > 0
if self._player.video_get_spu_count() == 0: ), 'The media file has no subtitles set'
return None, 'The media file has no subtitles set'
if self._player.video_get_spu() is None or self._player.video_get_spu() == -1: if self._player.video_get_spu() is None or self._player.video_get_spu() == -1:
self._player.video_set_spu(0) self._player.video_set_spu(0)
@ -350,36 +342,32 @@ class MediaVlcPlugin(MediaPlugin):
@action @action
def toggle_fullscreen(self): def toggle_fullscreen(self):
"""Toggle the fullscreen mode""" """Toggle the fullscreen mode"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
self._player.toggle_fullscreen() self._player.toggle_fullscreen()
@action @action
def set_fullscreen(self, fullscreen=True): def set_fullscreen(self, fullscreen: bool = True):
"""Set fullscreen mode""" """Set fullscreen mode"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
self._player.set_fullscreen(fullscreen) self._player.set_fullscreen(fullscreen)
@action @action
def set_subtitles(self, filename, **args): def set_subtitles(self, filename: str, *_, **__):
"""Sets media subtitles from filename""" """Sets media subtitles from filename"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
if filename.startswith('file://'): if filename.startswith('file://'):
filename = filename[len('file://') :] filename = filename[len('file://') :]
self._player.video_set_subtitle_file(filename) self._player.video_set_subtitle_file(filename)
@action @action
def remove_subtitles(self): def remove_subtitles(self, *_, **__):
"""Removes (hides) the subtitles""" """Removes (hides) the subtitles"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
self._player.video_set_spu(-1) self._player.video_set_spu(-1)
@action @action
def is_playing(self): def is_playing(self, *_, **__):
""" """
:returns: True if it's playing, False otherwise :returns: True if it's playing, False otherwise
""" """
@ -388,7 +376,7 @@ class MediaVlcPlugin(MediaPlugin):
return self._player.is_playing() return self._player.is_playing()
@action @action
def load(self, resource, **args): def load(self, resource, *_, **args):
""" """
Load/queue a resource/video to the player Load/queue a resource/video to the player
""" """
@ -398,14 +386,13 @@ class MediaVlcPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def mute(self): def mute(self, *_, **__):
"""Toggle mute state""" """Toggle mute state"""
if not self._player: assert self._player, 'No vlc instance is running'
return None, 'No vlc instance is running'
self._player.audio_toggle_mute() self._player.audio_toggle_mute()
@action @action
def set_position(self, position): def set_position(self, position: float, **_):
""" """
Seek backward/forward to the specified absolute position (same as ``seek``) Seek backward/forward to the specified absolute position (same as ``seek``)
""" """
@ -434,9 +421,9 @@ class MediaVlcPlugin(MediaPlugin):
status = {} status = {}
vlc_state = self._player.get_state() vlc_state = self._player.get_state()
if vlc_state == vlc.State.Playing: if vlc_state == vlc.State.Playing: # type: ignore
status['state'] = PlayerState.PLAY.value status['state'] = PlayerState.PLAY.value
elif vlc_state == vlc.State.Paused: elif vlc_state == vlc.State.Paused: # type: ignore
status['state'] = PlayerState.PAUSE.value status['state'] = PlayerState.PAUSE.value
else: else:
status['state'] = PlayerState.STOP.value status['state'] = PlayerState.STOP.value
@ -446,6 +433,7 @@ class MediaVlcPlugin(MediaPlugin):
if self._player.get_media() if self._player.get_media()
else None else None
) )
status['position'] = ( status['position'] = (
float(self._player.get_time() / 1000) float(self._player.get_time() / 1000)
if self._player.get_time() is not None if self._player.get_time() is not None
@ -477,7 +465,7 @@ class MediaVlcPlugin(MediaPlugin):
def _get_current_resource(self): def _get_current_resource(self):
if not self._player or not self._player.get_media(): if not self._player or not self._player.get_media():
return return None
return self._player.get_media().get_mrl() return self._player.get_media().get_mrl()

View File

@ -1,7 +1,7 @@
import re import re
import threading import threading
import time import time
from typing import Optional, Union from typing import Collection, Optional, Union
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.music import MusicPlugin from platypush.plugins.music import MusicPlugin
@ -9,27 +9,29 @@ from platypush.plugins.music import MusicPlugin
class MusicMpdPlugin(MusicPlugin): class MusicMpdPlugin(MusicPlugin):
""" """
This plugin allows you to interact with an MPD/Mopidy music server. MPD This plugin allows you to interact with an MPD/Mopidy music server.
(https://www.musicpd.org/) is a flexible server-side protocol/application
for handling music collections and playing music, mostly aimed to manage `MPD <https://www.musicpd.org/>`_ is a flexible server-side
local libraries. Mopidy (https://www.mopidy.com/) is an evolution of MPD, protocol/application for handling music collections and playing music,
compatible with the original protocol and with support for multiple music mostly aimed to manage local libraries.
sources through plugins (e.g. Spotify, TuneIn, Soundcloud, local files
etc.). `Mopidy <https://www.mopidy.com/>`_ is an evolution of MPD, compatible with
the original protocol and with support for multiple music sources through
plugins (e.g. Spotify, TuneIn, Soundcloud, local files etc.).
.. note:: As of Mopidy 3.0 MPD is an optional interface provided by the
``mopidy-mpd`` extension. Make sure that you have the extension
installed and enabled on your instance to use this plugin with your
server.
**NOTE**: As of Mopidy 3.0 MPD is an optional interface provided by the ``mopidy-mpd`` extension. Make sure that you
have the extension installed and enabled on your instance to use this plugin with your server.
""" """
_client_lock = threading.RLock() _client_lock = threading.RLock()
def __init__(self, host, port=6600): def __init__(self, host: str, port: int = 6600):
""" """
:param host: MPD IP/hostname :param host: MPD IP/hostname
:type host: str
:param port: MPD port (default: 6600) :param port: MPD port (default: 6600)
:type port: int
""" """
super().__init__() super().__init__()
@ -37,12 +39,12 @@ class MusicMpdPlugin(MusicPlugin):
self.port = port self.port = port
self.client = None self.client = None
def _connect(self, n_tries=2): def _connect(self, n_tries: int = 2):
import mpd import mpd
with self._client_lock: with self._client_lock:
if self.client: if self.client:
return return self.client
error = None error = None
while n_tries > 0: while n_tries > 0:
@ -54,9 +56,9 @@ class MusicMpdPlugin(MusicPlugin):
except Exception as e: except Exception as e:
error = e error = e
self.logger.warning( self.logger.warning(
'Connection exception: {}{}'.format( 'Connection exception: %s%s',
str(e), (': Retrying' if n_tries > 0 else '') e,
) (': Retrying' if n_tries > 0 else ''),
) )
time.sleep(0.5) time.sleep(0.5)
@ -64,7 +66,9 @@ class MusicMpdPlugin(MusicPlugin):
if error: if error:
raise error raise error
def _exec(self, method, *args, **kwargs): return self.client
def _exec(self, method: str, *args, **kwargs):
error = None error = None
n_tries = int(kwargs.pop('n_tries')) if 'n_tries' in kwargs else 2 n_tries = int(kwargs.pop('n_tries')) if 'n_tries' in kwargs else 2
return_status = ( return_status = (
@ -84,16 +88,16 @@ class MusicMpdPlugin(MusicPlugin):
except Exception as e: except Exception as e:
error = str(e) error = str(e)
self.logger.warning( self.logger.warning(
'Exception while executing MPD method {}: {}'.format(method, error) 'Exception while executing MPD method %s: %s', method, error
) )
self.client = None self.client = None
return None, error return None, error
@action @action
def play(self, resource=None): def play(self, resource: Optional[str] = None, **__):
""" """
Play a resource by path/URI Play a resource by path/URI.
:param resource: Resource path/URI :param resource: Resource path/URI
:type resource: str :type resource: str
@ -106,213 +110,184 @@ class MusicMpdPlugin(MusicPlugin):
return self._exec('play') return self._exec('play')
@action @action
def play_pos(self, pos): def play_pos(self, pos: int):
""" """
Play a track in the current playlist by position number Play a track in the current playlist by position number.
:param pos: Position number :param pos: Position number.
""" """
return self._exec('play', pos) return self._exec('play', pos)
@action @action
def pause(self): def pause(self, *_, **__):
"""Pause playback""" """Pause playback"""
status = self.status().output['state'] status = self._status()['state']
if status == 'play': return self._exec('pause') if status == 'play' else self._exec('play')
return self._exec('pause')
else:
return self._exec('play')
@action @action
def pause_if_playing(self): def pause_if_playing(self):
"""Pause playback only if it's playing""" """Pause playback only if it's playing"""
status = self._status()['state']
status = self.status().output['state'] return self._exec('pause') if status == 'play' else None
if status == 'play':
return self._exec('pause')
@action @action
def play_if_paused(self): def play_if_paused(self):
"""Play only if it's paused (resume)""" """Play only if it's paused (resume)"""
status = self._status()['state']
status = self.status().output['state'] return self._exec('play') if status == 'pause' else None
if status == 'pause':
return self._exec('play')
@action @action
def play_if_paused_or_stopped(self): def play_if_paused_or_stopped(self):
"""Play only if it's paused or stopped""" """Play only if it's paused or stopped"""
status = self._status()['state']
status = self.status().output['state'] return self._exec('play') if status in ('pause', 'stop') else None
if status == 'pause' or status == 'stop':
return self._exec('play')
@action @action
def stop(self): def stop(self, *_, **__):
"""Stop playback""" """Stop playback"""
return self._exec('stop') return self._exec('stop')
@action @action
def play_or_stop(self): def play_or_stop(self):
"""Play or stop (play state toggle)""" """Play or stop (play state toggle)"""
status = self.status().output['state'] status = self._status()['state']
if status == 'play': if status == 'play':
return self._exec('stop') return self._exec('stop')
else:
return self._exec('play') return self._exec('play')
@action @action
def playid(self, track_id): def playid(self, track_id: str):
""" """
Play a track by ID Play a track by ID.
:param track_id: Track ID :param track_id: Track ID.
:type track_id: str
""" """
return self._exec('playid', track_id) return self._exec('playid', track_id)
@action @action
def next(self): def next(self, *_, **__):
"""Play the next track""" """Play the next track"""
return self._exec('next') return self._exec('next')
@action @action
def previous(self): def previous(self, *_, **__):
"""Play the previous track""" """Play the previous track"""
return self._exec('previous') return self._exec('previous')
@action @action
def setvol(self, vol): def setvol(self, vol: int):
""" """
Set the volume (DEPRECATED, use :meth:`.set_volume` instead). Set the volume.
:param vol: Volume value (range: 0-100) ..warning :: **DEPRECATED**, use :meth:`.set_volume` instead.
:type vol: int
:param vol: Volume value (range: 0-100).
""" """
return self.set_volume(vol) return self.set_volume(vol)
@action @action
def set_volume(self, volume): def set_volume(self, volume: int, *_, **__):
""" """
Set the volume. Set the volume.
:param volume: Volume value (range: 0-100) :param volume: Volume value (range: 0-100).
:type volume: int
""" """
return self._exec('setvol', str(volume)) return self._exec('setvol', str(volume))
@action @action
def volup(self, delta=10): def volup(self, *_, delta: int = 10, **__):
""" """
Turn up the volume Turn up the volume.
:param delta: Volume up delta (default: +10%) :param delta: Volume up delta (default: +10%).
:type delta: int
""" """
volume = int(self._status()['volume'])
volume = int(self.status().output['volume'])
new_volume = min(volume + delta, 100) new_volume = min(volume + delta, 100)
return self.setvol(new_volume) return self.setvol(new_volume)
@action @action
def voldown(self, delta=10): def voldown(self, *_, delta: int = 10, **__):
""" """
Turn down the volume Turn down the volume.
:param delta: Volume down delta (default: -10%) :param delta: Volume down delta (default: -10%).
:type delta: int
""" """
volume = int(self._status()['volume'])
volume = int(self.status().output['volume'])
new_volume = max(volume - delta, 0) new_volume = max(volume - delta, 0)
return self.setvol(new_volume) return self.setvol(new_volume)
@action def _toggle(self, key: str, value: Optional[bool] = None):
def random(self, value=None):
"""
Set random mode
:param value: If set, set the random state this value (true/false). Default: None (toggle current state)
:type value: bool
"""
if value is None: if value is None:
value = int(self.status().output['random']) value = bool(self._status()[key])
value = 1 if value == 0 else 0 return self._exec(key, int(value))
return self._exec('random', value)
@action @action
def consume(self, value=None): def random(self, value: Optional[bool] = None):
""" """
Set consume mode Set random mode.
:param value: If set, set the consume state this value (true/false). Default: None (toggle current state) :param value: If set, set the random state this value (true/false).
:type value: bool Default: None (toggle current state).
""" """
return self._toggle('random', value)
if value is None:
value = int(self.status().output['consume'])
value = 1 if value == 0 else 0
return self._exec('consume', value)
@action @action
def single(self, value=None): def consume(self, value: Optional[bool] = None):
""" """
Set single mode Set consume mode.
:param value: If set, set the consume state this value (true/false). Default: None (toggle current state) :param value: If set, set the consume state this value (true/false).
:type value: bool Default: None (toggle current state)
""" """
return self._toggle('consume', value)
if value is None:
value = int(self.status().output['single'])
value = 1 if value == 0 else 0
return self._exec('single', value)
@action @action
def repeat(self, value=None): def single(self, value: Optional[bool] = None):
""" """
Set repeat mode Set single mode.
:param value: If set, set the repeat state this value (true/false). Default: None (toggle current state) :param value: If set, set the consume state this value (true/false).
:type value: bool Default: None (toggle current state)
""" """
return self._toggle('single', value)
if value is None: @action
value = int(self.status().output['repeat']) def repeat(self, value: Optional[bool] = None):
value = 1 if value == 0 else 0 """
return self._exec('repeat', value) Set repeat mode.
:param value: If set, set the repeat state this value (true/false).
Default: None (toggle current state)
"""
return self._toggle('repeat', value)
@action @action
def shuffle(self): def shuffle(self):
""" """
Shuffles the current playlist Shuffles the current playlist.
""" """
return self._exec('shuffle') return self._exec('shuffle')
@action @action
def save(self, name): def save(self, name: str):
""" """
Save the current tracklist to a new playlist with the specified name Save the current tracklist to a new playlist with the specified name.
:param name: Name of the playlist :param name: Name of the playlist
:type name: str
""" """
return self._exec('save', name) return self._exec('save', name)
@action @action
def add(self, resource, position=None): def add(self, resource: str, *_, position: Optional[int] = None, **__):
""" """
Add a resource (track, album, artist, folder etc.) to the current playlist Add a resource (track, album, artist, folder etc.) to the current
playlist.
:param resource: Resource path or URI :param resource: Resource path or URI.
:type resource: str :param position: Position where the track(s) will be inserted (default:
end of the playlist).
:param position: Position where the track(s) will be inserted (default: end of the playlist)
:type position: int
""" """
if isinstance(resource, list): if isinstance(resource, list):
@ -324,7 +299,7 @@ class MusicMpdPlugin(MusicPlugin):
else: else:
self._exec('addid', r, position) self._exec('addid', r, position)
except Exception as e: except Exception as e:
self.logger.warning('Could not add {}: {}'.format(r, e)) self.logger.warning('Could not add %s: %s', r, e)
return self.status().output return self.status().output
@ -361,7 +336,7 @@ class MusicMpdPlugin(MusicPlugin):
if isinstance(playlist, str): if isinstance(playlist, str):
playlist = [playlist] playlist = [playlist]
elif not isinstance(playlist, list): elif not isinstance(playlist, list):
raise RuntimeError('Invalid type for playlist: {}'.format(type(playlist))) raise RuntimeError(f'Invalid type for playlist: {type(playlist)}')
for p in playlist: for p in playlist:
self._exec('rm', p) self._exec('rm', p)
@ -382,11 +357,11 @@ class MusicMpdPlugin(MusicPlugin):
@classmethod @classmethod
def _parse_resource(cls, resource): def _parse_resource(cls, resource):
if not resource: if not resource:
return return None
m = re.search(r'^https?://open\.spotify\.com/([^?]+)', resource) m = re.search(r'^https?://open\.spotify\.com/([^?]+)', resource)
if m: if m:
resource = 'spotify:{}'.format(m.group(1).replace('/', ':')) resource = 'spotify:' + m.group(1).replace('/', ':')
if resource.startswith('spotify:'): if resource.startswith('spotify:'):
resource = resource.split('?')[0] resource = resource.split('?')[0]
@ -415,46 +390,59 @@ class MusicMpdPlugin(MusicPlugin):
return ret return ret
@action @action
def clear(self): def clear(self, *_, **__):
"""Clear the current playlist""" """Clear the current playlist"""
return self._exec('clear') return self._exec('clear')
@action @action
def seekcur(self, value): def seekcur(self, value: float):
""" """
Seek to the specified position (DEPRECATED, use :meth:`.seek` instead). Seek to the specified position (DEPRECATED, use :meth:`.seek` instead).
:param value: Seek position in seconds, or delta string (e.g. '+15' or '-15') to indicate a seek relative to :param value: Seek position in seconds, or delta string (e.g. '+15' or
the current position :type value: int '-15') to indicate a seek relative to the current position
""" """
return self.seek(value) return self.seek(value)
@action @action
def seek(self, position): def seek(self, position: float, *_, **__):
""" """
Seek to the specified position Seek to the specified position
:param position: Seek position in seconds, or delta string (e.g. '+15' or '-15') to indicate a seek relative :param position: Seek position in seconds, or delta string (e.g. '+15'
to the current position :type position: int or '-15') to indicate a seek relative to the current position
""" """
return self._exec('seekcur', position) return self._exec('seekcur', position)
@action @action
def forward(self): def forward(self):
"""Go forward by 15 seconds""" """Go forward by 15 seconds"""
return self._exec('seekcur', '+15') return self._exec('seekcur', '+15')
@action @action
def back(self): def back(self):
"""Go backward by 15 seconds""" """Go backward by 15 seconds"""
return self._exec('seekcur', '-15') return self._exec('seekcur', '-15')
def _status(self) -> dict:
n_tries = 2
error = None
while n_tries > 0:
try:
n_tries -= 1
self._connect()
if self.client:
return self.client.status() # type: ignore
except Exception as e:
error = e
self.logger.warning('Exception while getting MPD status: %s', e)
self.client = None
raise AssertionError(str(error))
@action @action
def status(self): def status(self, *_, **__):
""" """
:returns: The current state. :returns: The current state.
@ -480,24 +468,7 @@ class MusicMpdPlugin(MusicPlugin):
} }
""" """
return self._status()
n_tries = 2
error = None
while n_tries > 0:
try:
n_tries -= 1
self._connect()
if self.client:
return self.client.status()
except Exception as e:
error = e
self.logger.warning(
'Exception while getting MPD status: {}'.format(str(e))
)
self.client = None
return None, error
@action @action
def currentsong(self): def currentsong(self):
@ -506,9 +477,8 @@ class MusicMpdPlugin(MusicPlugin):
""" """
return self.current_track() return self.current_track()
# noinspection PyTypeChecker
@action @action
def current_track(self): def current_track(self, *_, **__):
""" """
:returns: The currently played track. :returns: The currently played track.
@ -530,6 +500,9 @@ class MusicMpdPlugin(MusicPlugin):
""" """
track = self._exec('currentsong', return_status=False) track = self._exec('currentsong', return_status=False)
if not isinstance(track, dict):
return None
if 'title' in track and ( if 'title' in track and (
'artist' not in track 'artist' not in track
or not track['artist'] or not track['artist']
@ -583,7 +556,7 @@ class MusicMpdPlugin(MusicPlugin):
return self._exec('playlistinfo', return_status=False) return self._exec('playlistinfo', return_status=False)
@action @action
def get_playlists(self): def get_playlists(self, *_, **__):
""" """
:returns: The playlists available on the server as a list of dicts. :returns: The playlists available on the server as a list of dicts.
@ -602,11 +575,12 @@ class MusicMpdPlugin(MusicPlugin):
# ... # ...
} }
] ]
""" """
return sorted( playlists: list = self._exec( # type: ignore
self._exec('listplaylists', return_status=False), 'listplaylists', return_status=False
key=lambda p: p['playlist'],
) )
return sorted(playlists, key=lambda p: p['playlist'])
@action @action
def listplaylists(self): def listplaylists(self):
@ -616,14 +590,13 @@ class MusicMpdPlugin(MusicPlugin):
return self.get_playlists() return self.get_playlists()
@action @action
def get_playlist(self, playlist, with_tracks=False): def get_playlist(self, playlist: str, *_, with_tracks: bool = False, **__):
""" """
List the items in the specified playlist. List the items in the specified playlist.
:param playlist: Name of the playlist :param playlist: Name of the playlist
:type playlist: str :param with_tracks: If True then the list of tracks in the playlist will
:param with_tracks: If True then the list of tracks in the playlist will be returned as well (default: False). be returned as well (default: False).
:type with_tracks: bool
""" """
return self._exec( return self._exec(
'listplaylistinfo' if with_tracks else 'listplaylist', 'listplaylistinfo' if with_tracks else 'listplaylist',
@ -632,29 +605,26 @@ class MusicMpdPlugin(MusicPlugin):
) )
@action @action
def listplaylist(self, name): def listplaylist(self, name: str):
""" """
Deprecated alias for :meth:`.playlist`. Deprecated alias for :meth:`.playlist`.
""" """
return self._exec('listplaylist', name, return_status=False) return self._exec('listplaylist', name, return_status=False)
@action @action
def listplaylistinfo(self, name): def listplaylistinfo(self, name: str):
""" """
Deprecated alias for :meth:`.playlist` with `with_tracks=True`. Deprecated alias for :meth:`.playlist` with ``with_tracks=True``.
""" """
return self.get_playlist(name, with_tracks=True) return self.get_playlist(name, with_tracks=True)
@action @action
def add_to_playlist(self, playlist, resources): def add_to_playlist(self, playlist: str, resources: Union[str, Collection[str]]):
""" """
Add one or multiple resources to a playlist. Add one or multiple resources to a playlist.
:param playlist: Playlist name :param playlist: Playlist name
:type playlist: str
:param resources: URI or path of the resource(s) to be added :param resources: URI or path of the resource(s) to be added
:type resources: str or list[str]
""" """
if isinstance(resources, str): if isinstance(resources, str):
@ -664,22 +634,21 @@ class MusicMpdPlugin(MusicPlugin):
self._exec('playlistadd', playlist, res) self._exec('playlistadd', playlist, res)
@action @action
def playlistadd(self, name, uri): def playlistadd(self, name: str, uri: str):
""" """
Deprecated alias for :meth:`.add_to_playlist`. Deprecated alias for :meth:`.add_to_playlist`.
""" """
return self.add_to_playlist(name, uri) return self.add_to_playlist(name, uri)
@action @action
def remove_from_playlist(self, playlist, resources): def remove_from_playlist(
self, playlist: str, resources: Union[int, Collection[int]], *_, **__
):
""" """
Remove one or multiple tracks from a playlist. Remove one or multiple tracks from a playlist.
:param playlist: Playlist name :param playlist: Playlist name
:type playlist: str
:param resources: Position or list of positions to remove :param resources: Position or list of positions to remove
:type resources: int or list[int]
""" """
if isinstance(resources, str): if isinstance(resources, str):
@ -691,62 +660,53 @@ class MusicMpdPlugin(MusicPlugin):
self._exec('playlistdelete', playlist, p) self._exec('playlistdelete', playlist, p)
@action @action
def playlist_move(self, playlist, from_pos, to_pos): def playlist_move(self, playlist: str, from_pos: int, to_pos: int, *_, **__):
""" """
Change the position of a track in the specified playlist. Change the position of a track in the specified playlist.
:param playlist: Playlist name :param playlist: Playlist name
:type playlist: str
:param from_pos: Original track position :param from_pos: Original track position
:type from_pos: int
:param to_pos: New track position :param to_pos: New track position
:type to_pos: int
""" """
self._exec('playlistmove', playlist, from_pos, to_pos) self._exec('playlistmove', playlist, from_pos, to_pos)
@action @action
def playlistdelete(self, name, pos): def playlistdelete(self, name: str, pos: int):
""" """
Deprecated alias for :meth:`.remove_from_playlist`. Deprecated alias for :meth:`.remove_from_playlist`.
""" """
return self.remove_from_playlist(name, pos) return self.remove_from_playlist(name, pos)
@action @action
def playlistmove(self, name, from_pos, to_pos): def playlistmove(self, name: str, from_pos: int, to_pos: int):
""" """
Deprecated alias for :meth:`.playlist_move`. Deprecated alias for :meth:`.playlist_move`.
""" """
return self.playlist_move(name, from_pos=from_pos, to_pos=to_pos) return self.playlist_move(name, from_pos=from_pos, to_pos=to_pos)
@action @action
def playlistclear(self, name): def playlistclear(self, name: str):
""" """
Clears all the elements from the specified playlist Clears all the elements from the specified playlist.
:param name: Playlist name :param name: Playlist name.
:type name: str
""" """
self._exec('playlistclear', name) self._exec('playlistclear', name)
@action @action
def rename(self, name, new_name): def rename(self, name: str, new_name: str):
""" """
Rename a playlist Rename a playlist.
:param name: Original playlist name :param name: Original playlist name
:type name: str
:param new_name: New playlist name :param new_name: New playlist name
:type name: str
""" """
self._exec('rename', name, new_name) self._exec('rename', name, new_name)
@action @action
def lsinfo(self, uri=None): def lsinfo(self, uri: Optional[str] = None):
""" """
Returns the list of playlists and directories on the server Returns the list of playlists and directories on the server.
""" """
return ( return (
@ -756,37 +716,32 @@ class MusicMpdPlugin(MusicPlugin):
) )
@action @action
def plchanges(self, version): def plchanges(self, version: int):
""" """
Show what has changed on the current playlist since a specified playlist Show what has changed on the current playlist since a specified playlist
version number. version number.
:param version: Version number :param version: Version number
:type version: int
:returns: A list of dicts representing the songs being added since the specified version :returns: A list of dicts representing the songs being added since the specified version
""" """
return self._exec('plchanges', version, return_status=False) return self._exec('plchanges', version, return_status=False)
@action @action
def searchaddplaylist(self, name): def searchaddplaylist(self, name: str):
""" """
Search and add a playlist by (partial or full) name Search and add a playlist by (partial or full) name.
:param name: Playlist name, can be partial :param name: Playlist name, can be partial.
:type name: str
""" """
resp: list = self._exec('listplaylists', return_status=False) # type: ignore
playlists = [ playlists = [
pl['playlist'] pl['playlist'] for pl in resp if name.lower() in pl['playlist'].lower()
for pl in filter(
lambda playlist: name.lower() in playlist['playlist'].lower(),
self._exec('listplaylists', return_status=False),
)
] ]
if len(playlists): if not playlists:
return None
self._exec('clear') self._exec('clear')
self._exec('load', playlists[0]) self._exec('load', playlists[0])
self._exec('play') self._exec('play')
@ -799,40 +754,37 @@ class MusicMpdPlugin(MusicPlugin):
ll.extend([k, v]) ll.extend([k, v])
return ll return ll
# noinspection PyShadowingBuiltins
@action @action
def find(self, filter: dict, *args, **kwargs): def find(self, filter: dict, *args, **kwargs): # pylint: disable=redefined-builtin
""" """
Find in the database/library by filter. Find in the database/library by filter.
:param filter: Search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``) :param filter: Search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``)
:returns: list[dict] :returns: list[dict]
""" """
filter_list = self._make_filter(filter)
return self._exec('find', *filter_list, *args, return_status=False, **kwargs)
filter = self._make_filter(filter)
return self._exec('find', *filter, *args, return_status=False, **kwargs)
# noinspection PyShadowingBuiltins
@action @action
def findadd(self, filter: dict, *args, **kwargs): def findadd(
self, filter: dict, *args, **kwargs # pylint: disable=redefined-builtin
):
""" """
Find in the database/library by filter and add to the current playlist. Find in the database/library by filter and add to the current playlist.
:param filter: Search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``) :param filter: Search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``)
:returns: list[dict] :returns: list[dict]
""" """
filter_list = self._make_filter(filter)
return self._exec('findadd', *filter_list, *args, return_status=False, **kwargs)
filter = self._make_filter(filter)
return self._exec('findadd', *filter, *args, return_status=False, **kwargs)
# noinspection PyShadowingBuiltins
@action @action
def search( def search(
self, self,
query: Optional[Union[str, dict]] = None,
filter: Optional[dict] = None,
*args, *args,
**kwargs query: Optional[Union[str, dict]] = None,
filter: Optional[dict] = None, # pylint: disable=redefined-builtin
**kwargs,
): ):
""" """
Free search by filter. Free search by filter.
@ -842,26 +794,37 @@ class MusicMpdPlugin(MusicPlugin):
``query``, it's still here for back-compatibility reasons. ``query``, it's still here for back-compatibility reasons.
:returns: list[dict] :returns: list[dict]
""" """
filter = self._make_filter(query or filter) assert query or filter, 'Specify either `query` or `filter`'
items = self._exec('search', *filter, *args, return_status=False, **kwargs)
filt = filter
if isinstance(query, str):
filt = query
elif isinstance(query, dict):
filt = {**(filter or {}), **query}
filter_list = self._make_filter(filt) if isinstance(filt, dict) else [query]
items: list = self._exec( # type: ignore
'search', *filter_list, *args, return_status=False, **kwargs
)
# Spotify results first # Spotify results first
return sorted( return sorted(
items, key=lambda item: 0 if item['file'].startswith('spotify:') else 1 items, key=lambda item: 0 if item['file'].startswith('spotify:') else 1
) )
# noinspection PyShadowingBuiltins
@action @action
def searchadd(self, filter, *args, **kwargs): def searchadd(self, filter: dict, *args, **kwargs):
""" """
Free search by filter and add the results to the current playlist. Free search by filter and add the results to the current playlist.
:param filter: Search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``) :param filter: Search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``)
:returns: list[dict] :returns: list[dict]
""" """
filter_list = self._make_filter(filter)
filter = self._make_filter(filter) return self._exec(
return self._exec('searchadd', *filter, *args, return_status=False, **kwargs) 'searchadd', *filter_list, *args, return_status=False, **kwargs
)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -1,6 +1,7 @@
import json import json
import socket import socket
import threading import threading
from typing import Collection, Optional
from platypush.config import Config from platypush.config import Config
from platypush.context import get_backend from platypush.context import get_backend
@ -9,7 +10,7 @@ from platypush.plugins import Plugin, action
class MusicSnapcastPlugin(Plugin): class MusicSnapcastPlugin(Plugin):
""" """
Plugin to interact with a [Snapcast](https://github.com/badaix/snapcast) Plugin to interact with a `Snapcast <https://github.com/badaix/snapcast>`_
instance, control clients mute status, volume, playback etc. instance, control clients mute status, volume, playback etc.
See https://github.com/badaix/snapcast/blob/master/doc/json_rpc_api/v2_0_0.md See https://github.com/badaix/snapcast/blob/master/doc/json_rpc_api/v2_0_0.md
@ -19,15 +20,13 @@ class MusicSnapcastPlugin(Plugin):
_DEFAULT_SNAPCAST_PORT = 1705 _DEFAULT_SNAPCAST_PORT = 1705
_SOCKET_EOL = '\r\n'.encode() _SOCKET_EOL = '\r\n'.encode()
def __init__(self, host='localhost', port=_DEFAULT_SNAPCAST_PORT, **kwargs): def __init__(
self, host: str = 'localhost', port: int = _DEFAULT_SNAPCAST_PORT, **kwargs
):
""" """
:param host: Default Snapcast server host (default: localhost) :param host: Default Snapcast server host (default: localhost)
:type host: str
:param port: Default Snapcast server control port (default: 1705) :param port: Default Snapcast server control port (default: 1705)
:type port: int
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self.host = host self.host = host
@ -46,23 +45,24 @@ class MusicSnapcastPlugin(Plugin):
self._latest_req_id += 1 self._latest_req_id += 1
return self._latest_req_id return self._latest_req_id
def _connect(self, host=None, port=None): def _connect(self, host: Optional[str] = None, port: Optional[int] = None):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.logger.info('Connecting to Snapcast host {}:{}'.format(host, port)) self.logger.info('Connecting to Snapcast host %s:%d', host, port)
sock.connect((host or self.host, port or self.port)) sock.connect((host or self.host, port or self.port))
return sock return sock
@classmethod @classmethod
def _send(cls, sock, req): def _send(cls, sock: socket.socket, req: dict):
if isinstance(req, dict): if isinstance(req, dict):
req = json.dumps(req) r = json.dumps(req)
if isinstance(req, str): if isinstance(req, str):
req = req.encode() r = req.encode()
if not isinstance(req, bytes): if not isinstance(r, bytes):
raise RuntimeError('Unsupported type {} for Snapcast request: {}'. raise RuntimeError(
format(type(req), req)) f'Unsupported type {type(req)} for Snapcast request: {req}'
)
sock.send(req + cls._SOCKET_EOL) sock.send(r + cls._SOCKET_EOL)
@classmethod @classmethod
def _recv(cls, sock): def _recv(cls, sock):
@ -71,54 +71,58 @@ class MusicSnapcastPlugin(Plugin):
buf += sock.recv(1) buf += sock.recv(1)
return json.loads(buf.decode().strip()).get('result') return json.loads(buf.decode().strip()).get('result')
def _get_group(self, sock, group): def _get_group(self, sock: socket.socket, group: str):
for g in self._status(sock).get('groups', []): for g in self._status(sock).get('groups', []):
if group == g.get('id') or group == g.get('name'): if group == g.get('id') or group == g.get('name'):
return g return g
def _get_client(self, sock, client): return None
def _get_client(self, sock: socket.socket, client: str):
for g in self._status(sock).get('groups', []): for g in self._status(sock).get('groups', []):
clients = g.get('clients', []) clients = g.get('clients', [])
for c in clients: for c in clients:
if client == c.get('id') or \ if (
client == c.get('name') or \ client == c.get('id')
client == c.get('host', {}).get('name') or \ or client == c.get('name')
client == c.get('host', {}).get('ip'): or client == c.get('host', {}).get('name')
or client == c.get('host', {}).get('ip')
):
c['group_id'] = g.get('id') c['group_id'] = g.get('id')
return c return c
def _status(self, sock): return None
def _status(self, sock: socket.socket):
request = { request = {
'id': self._get_req_id(), 'id': self._get_req_id(),
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'Server.GetStatus' 'method': 'Server.GetStatus',
} }
# noinspection PyTypeChecker
self._send(sock, request) self._send(sock, request)
return (self._recv(sock) or {}).get('server', {}) return (self._recv(sock) or {}).get('server', {})
@action @action
def status(self, host=None, port=None, client=None, group=None): def status(
self,
host: Optional[str] = None,
port: Optional[int] = None,
client: Optional[str] = None,
group: Optional[str] = None,
):
""" """
Get the status either of a Snapcast server, client or group Get the status either of a Snapcast server, client or group
:param host: Snapcast server to query (default: default configured host) :param host: Snapcast server to query (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
:param client: Client ID or name (default: None) :param client: Client ID or name (default: None)
:type client: str
:param group: Group ID or name (default: None) :param group: Group ID or name (default: None)
:type group: str
:returns: dict. :returns: dict. Example:
Example:: .. code-block:: json
"output": { "output": {
"groups": [ "groups": [
@ -192,7 +196,7 @@ class MusicSnapcastPlugin(Plugin):
"name": "mopidy", "name": "mopidy",
"sampleformat": "48000:16:2" "sampleformat": "48000:16:2"
}, },
"raw": "pipe:////tmp/snapfifo?buffer_ms=20&codec=pcm&name=mopidy&sampleformat=48000:16:2", "raw": "pipe:////tmp/fifo?buffer_ms=20&codec=pcm&name=mopidy&sampleformat=48000:16:2",
"scheme": "pipe" "scheme": "pipe"
} }
} }
@ -213,33 +217,32 @@ class MusicSnapcastPlugin(Plugin):
return self._status(sock) return self._status(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning(f'Error on socket close: {e}') self.logger.warning('Error on socket close: %s', e)
@action @action
def mute(self, client=None, group=None, mute=None, host=None, port=None): def mute(
self,
client: Optional[str] = None,
group: Optional[str] = None,
mute: Optional[bool] = None,
host: Optional[str] = None,
port: Optional[int] = None,
):
""" """
Set the mute status of a connected client or group Set the mute status of a connected client or group
:param client: Client name or ID to mute :param client: Client name or ID to mute
:type client: str
:param group: Group ID to mute :param group: Group ID to mute
:type group: str
:param mute: Mute status. If not set, the mute status of the selected :param mute: Mute status. If not set, the mute status of the selected
client/group will be toggled. client/group will be toggled.
:type mute: bool
:param host: Snapcast server to query (default: default configured host) :param host: Snapcast server to query (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
""" """
if not client and not group: if not (client and group):
raise RuntimeError('Please specify either a client or a group') raise RuntimeError('Please specify either a client or a group')
sock = None sock = None
@ -250,59 +253,62 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(), 'id': self._get_req_id(),
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'Group.SetMute' if group else 'Client.SetVolume', 'method': 'Group.SetMute' if group else 'Client.SetVolume',
'params': {} 'params': {},
} }
if group: if group:
group = self._get_group(sock, group) g = self._get_group(sock, group)
cur_muted = group['muted'] assert g, f'No such group: {group}'
request['params']['id'] = group['id'] cur_muted = g['muted']
request['params']['id'] = g['id']
request['params']['mute'] = not cur_muted if mute is None else mute request['params']['mute'] = not cur_muted if mute is None else mute
else: elif client:
client = self._get_client(sock, client) c = self._get_client(sock, client)
cur_muted = client['config']['volume']['muted'] assert c, f'No such client: {client}'
request['params']['id'] = client['id'] cur_muted = c['config']['volume']['muted']
request['params']['id'] = c['id']
request['params']['volume'] = {} request['params']['volume'] = {}
request['params']['volume']['percent'] = client['config']['volume']['percent'] request['params']['volume']['percent'] = c['config']['volume'][
request['params']['volume']['muted'] = not cur_muted if mute is None else mute 'percent'
]
request['params']['volume']['muted'] = (
not cur_muted if mute is None else mute
)
# noinspection PyTypeChecker
self._send(sock, request) self._send(sock, request)
return self._recv(sock) return self._recv(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error on socket close', e) self.logger.warning('Error on socket close: %s', e)
@action @action
def volume(self, client, volume=None, delta=None, mute=None, host=None, def volume(
port=None): self,
client: str,
volume: Optional[int] = None,
delta: Optional[int] = None,
mute: Optional[bool] = None,
host: Optional[str] = None,
port: Optional[int] = None,
):
""" """
Set the volume of a connected client Set the volume of a connected client.
:param client: Client name or ID :param client: Client name or ID
:type client: str
:param volume: Absolute volume to set between 0 and 100 :param volume: Absolute volume to set between 0 and 100
:type volume: int
:param delta: Relative volume change in percentage (e.g. +10 or -10) :param delta: Relative volume change in percentage (e.g. +10 or -10)
:type delta: int
:param mute: Set to true or false if you want to toggle the muted status :param mute: Set to true or false if you want to toggle the muted status
:type mute: bool
:param host: Snapcast server (default: default configured host) :param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
""" """
if volume is None and delta is None and mute is None: if volume is None and delta is None and mute is None:
raise RuntimeError('Please specify either an absolute volume or ' + raise RuntimeError(
'relative delta') 'Please specify either an absolute volume or ' + 'relative delta'
)
sock = None sock = None
@ -312,56 +318,51 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(), 'id': self._get_req_id(),
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'Client.SetVolume', 'method': 'Client.SetVolume',
'params': {} 'params': {},
} }
client = self._get_client(sock, client) c = self._get_client(sock, client)
cur_volume = int(client['config']['volume']['percent']) assert c, f'No such client: {client}'
cur_mute = bool(client['config']['volume']['muted']) cur_volume = int(c['config']['volume']['percent'])
cur_mute = bool(c['config']['volume']['muted'])
if volume is not None: if volume is not None:
volume = int(volume) volume = int(volume)
elif delta is not None: elif delta is not None:
volume = cur_volume + int(delta) volume = cur_volume + int(delta)
if volume is not None: volume = max(0, min(100, volume)) if volume is not None else cur_volume
if volume > 100: volume = 100
if volume < 0: volume = 0
else:
volume = cur_volume
if mute is None: if mute is None:
mute = cur_mute mute = cur_mute
request['params']['id'] = client['id'] request['params']['id'] = c['id']
request['params']['volume'] = {} request['params']['volume'] = {}
request['params']['volume']['percent'] = volume request['params']['volume']['percent'] = volume
request['params']['volume']['muted'] = mute request['params']['volume']['muted'] = mute
# noinspection PyTypeChecker
self._send(sock, request) self._send(sock, request)
return self._recv(sock) return self._recv(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error on socket close', e) self.logger.warning('Error on socket close: %s', e)
@action @action
def set_client_name(self, client, name, host=None, port=None): def set_client_name(
self,
client: str,
name: str,
host: Optional[str] = None,
port: Optional[int] = None,
):
""" """
Set/change the name of a connected client Set/change the name of a connected client
:param client: Current client name or ID to rename :param client: Current client name or ID to rename
:type client: str
:param name: New name :param name: New name
:type name: str
:param host: Snapcast server (default: default configured host) :param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
""" """
sock = None sock = None
@ -372,37 +373,37 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(), 'id': self._get_req_id(),
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'Client.SetName', 'method': 'Client.SetName',
'params': {} 'params': {},
} }
client = self._get_client(sock, client) c = self._get_client(sock, client)
request['params']['id'] = client['id'] assert c, f'No such client: {client}'
request['params']['id'] = c['id']
request['params']['name'] = name request['params']['name'] = name
# noinspection PyTypeChecker
self._send(sock, request) self._send(sock, request)
return self._recv(sock) return self._recv(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error on socket close', e) self.logger.warning('Error on socket close: %s', e)
@action @action
def set_group_name(self, group, name, host=None, port=None): def set_group_name(
self,
group: str,
name: str,
host: Optional[str] = None,
port: Optional[int] = None,
):
""" """
Set/change the name of a group Set/change the name of a group
:param group: Group ID to rename :param group: Group ID to rename
:type group: str
:param name: New name :param name: New name
:type name: str
:param host: Snapcast server (default: default configured host) :param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
""" """
sock = None sock = None
@ -416,34 +417,33 @@ class MusicSnapcastPlugin(Plugin):
'params': { 'params': {
'id': group, 'id': group,
'name': name, 'name': name,
} },
} }
# noinspection PyTypeChecker
self._send(sock, request) self._send(sock, request)
return self._recv(sock) return self._recv(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error on socket close', e) self.logger.warning('Error on socket close: %s', e)
@action @action
def set_latency(self, client, latency, host=None, port=None): def set_latency(
self,
client: str,
latency: float,
host: Optional[str] = None,
port: Optional[int] = None,
):
""" """
Set/change the latency of a connected client Set/change the latency of a connected client
:param client: Client name or ID :param client: Client name or ID
:type client: str
:param latency: New latency in milliseconds :param latency: New latency in milliseconds
:type latency: float
:param host: Snapcast server (default: default configured host) :param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
""" """
sock = None sock = None
@ -454,35 +454,31 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(), 'id': self._get_req_id(),
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'Client.SetLatency', 'method': 'Client.SetLatency',
'params': { 'params': {'latency': latency},
'latency': latency
}
} }
client = self._get_client(sock, client) c = self._get_client(sock, client)
request['params']['id'] = client['id'] assert c, f'No such client: {client}'
# noinspection PyTypeChecker request['params']['id'] = c['id']
self._send(sock, request) self._send(sock, request)
return self._recv(sock) return self._recv(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error on socket close', e) self.logger.warning('Error on socket close: %s', e)
@action @action
def delete_client(self, client, host=None, port=None): def delete_client(
self, client: str, host: Optional[str] = None, port: Optional[int] = None
):
""" """
Delete a client from the Snapcast server Delete a client from the Snapcast server
:param client: Client name or ID :param client: Client name or ID
:type client: str
:param host: Snapcast server (default: default configured host) :param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
""" """
sock = None sock = None
@ -493,132 +489,129 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(), 'id': self._get_req_id(),
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'Server.DeleteClient', 'method': 'Server.DeleteClient',
'params': {} 'params': {},
} }
client = self._get_client(sock, client) c = self._get_client(sock, client)
request['params']['id'] = client['id'] assert c, f'No such client: {client}'
# noinspection PyTypeChecker request['params']['id'] = c['id']
self._send(sock, request) self._send(sock, request)
return self._recv(sock) return self._recv(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error on socket close', e) self.logger.warning('Error on socket close: %s', e)
@action @action
def group_set_clients(self, group, clients, host=None, port=None): def group_set_clients(
self,
group: str,
clients: Collection[str],
host: Optional[str] = None,
port: Optional[int] = None,
):
""" """
Sets the clients for a group on a Snapcast server Sets the clients for a group on a Snapcast server
:param group: Group name or ID :param group: Group name or ID
:type group: str
:param clients: List of client names or IDs :param clients: List of client names or IDs
:type clients: list[str]
:param host: Snapcast server (default: default configured host) :param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
""" """
sock = None sock = None
try: try:
sock = self._connect(host or self.host, port or self.port) sock = self._connect(host or self.host, port or self.port)
group = self._get_group(sock, group) g = self._get_group(sock, group)
assert g, f'No such group: {group}'
request = { request = {
'id': self._get_req_id(), 'id': self._get_req_id(),
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'Group.SetClients', 'method': 'Group.SetClients',
'params': { 'params': {'id': g['id'], 'clients': []},
'id': group['id'],
'clients': []
}
} }
for client in clients: for client in clients:
client = self._get_client(sock, client) c = self._get_client(sock, client)
request['params']['clients'].append(client['id']) assert c, f'No such client: {client}'
request['params']['clients'].append(c['id'])
# noinspection PyTypeChecker
self._send(sock, request) self._send(sock, request)
return self._recv(sock) return self._recv(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error on socket close', e) self.logger.warning('Error on socket close: %s', e)
@action @action
def group_set_stream(self, group, stream_id, host=None, port=None): def group_set_stream(
self,
group: str,
stream_id: str,
host: Optional[str] = None,
port: Optional[int] = None,
):
""" """
Sets the active stream for a group. Sets the active stream for a group.
:param group: Group name or ID :param group: Group name or ID
:type group: str
:param stream_id: Stream ID :param stream_id: Stream ID
:type stream_id: str
:param host: Snapcast server (default: default configured host) :param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port) :param port: Snapcast server port (default: default configured port)
:type port: int
""" """
sock = None sock = None
try: try:
sock = self._connect(host or self.host, port or self.port) sock = self._connect(host or self.host, port or self.port)
group = self._get_group(sock, group) g = self._get_group(sock, group)
assert g, f'No such group: {group}'
request = { request = {
'id': self._get_req_id(), 'id': self._get_req_id(),
'jsonrpc': '2.0', 'jsonrpc': '2.0',
'method': 'Group.SetStream', 'method': 'Group.SetStream',
'params': { 'params': {
'id': group['id'], 'id': g['id'],
'stream_id': stream_id, 'stream_id': stream_id,
} },
} }
# noinspection PyTypeChecker
self._send(sock, request) self._send(sock, request)
return self._recv(sock) return self._recv(sock)
finally: finally:
try: try:
if sock:
sock.close() sock.close()
except Exception as e: except Exception as e:
self.logger.warning('Error on socket close', e) self.logger.warning('Error on socket close: %s', e)
@action @action
def get_backend_hosts(self): def get_backend_hosts(self):
""" """
:return: A dict with the Snapcast hosts configured on the backend :return: A dict with the Snapcast hosts configured on the backend
in the format host -> port in the format ``host -> port``.
""" """
hosts = {} return {
for i in range(len(self.backend_hosts)): host: self.backend_ports[i] for i, host in enumerate(self.backend_hosts)
hosts[self.backend_hosts[i]] = self.backend_ports[i] }
return hosts
@action @action
def get_playing_streams(self, exclude_local=False): def get_playing_streams(self, exclude_local: bool = False):
""" """
Returns the remote streams configured in the `music.snapcast` backend Returns the remote streams configured in the `music.snapcast` backend
that are currently active and unmuted. that are currently active and unmuted.
:param exclude_local: Exclude localhost connections (default: False) :param exclude_local: Exclude localhost connections (default: False)
:type exclude_local: bool
:returns: dict with the host->port mapping. :returns: dict with the host->port mapping. Example:
Example:: .. code-block:: json
{ {
"hosts": { "hosts": {
@ -630,39 +623,54 @@ class MusicSnapcastPlugin(Plugin):
""" """
backend_hosts = self.get_backend_hosts().output backend_hosts: dict = self.get_backend_hosts().output # type: ignore
playing_hosts = {} playing_hosts = {}
def _worker(host, port): def _worker(host, port):
try: try:
if exclude_local and (host == 'localhost' if exclude_local and (
or host == Config.get('device_id')): host == 'localhost' or host == Config.get('device_id')
):
return return
server_status = self.status(host=host, port=port).output server_status: dict = self.status(host=host, port=port).output # type: ignore
client_status = self.status(host=host, port=port, client_status: dict = self.status( # type: ignore
client=Config.get('device_id')).output host=host, port=port, client=Config.get('device_id')
).output
if client_status.get('config', {}).get('volume', {}).get('muted'): if client_status.get('config', {}).get('volume', {}).get('muted'):
return return
group = [g for g in server_status.get('groups', {}) group = next(
if g.get('id') == client_status.get('group_id')].pop(0) iter(
g
for g in server_status.get('groups', {})
if g.get('id') == client_status.get('group_id')
)
)
if group.get('muted'): if group.get('muted'):
return return
stream = [s for s in server_status.get('streams') stream = next(
if s.get('id') == group.get('stream_id')].pop(0) iter(
s
for s in server_status.get('streams', {})
if s.get('id') == group.get('stream_id')
)
)
if stream.get('status') != 'playing': if stream.get('status') != 'playing':
return return
playing_hosts[host] = port playing_hosts[host] = port
except Exception as e: except Exception as e:
self.logger.warning(('Error while retrieving the status of ' + self.logger.warning(
'Snapcast host at {}:{}: {}').format( 'Error while retrieving the status of Snapcast host at %s:%d: %s',
host, port, str(e))) host,
port,
e,
)
workers = [] workers = []
@ -677,4 +685,5 @@ class MusicSnapcastPlugin(Plugin):
return {'hosts': playing_hosts} return {'hosts': playing_hosts}
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -79,10 +79,6 @@ class ZwaveMqttPlugin(
This plugin allows you to manage a Z-Wave network over MQTT through This plugin allows you to manage a Z-Wave network over MQTT through
`zwave-js-ui <https://github.com/zwave-js/zwave-js-ui>`_. `zwave-js-ui <https://github.com/zwave-js/zwave-js-ui>`_.
For historical reasons, it is advised to enable this plugin together
with the ``zwave.mqtt`` backend, or you may lose the ability to listen
to asynchronous events.
Configuration required on the zwave-js-ui gateway: Configuration required on the zwave-js-ui gateway:
* Install the gateway following the instructions reported * Install the gateway following the instructions reported