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

View File

@ -1,5 +1,7 @@
import enum
import threading
from typing import Collection, Optional
import urllib.parse
from platypush.context import get_bus
@ -16,6 +18,10 @@ from platypush.plugins import action
class PlayerEvent(enum.Enum):
"""
Supported player events.
"""
STOP = 'stop'
PLAY = 'play'
PAUSE = 'pause'
@ -26,17 +32,18 @@ class MediaOmxplayerPlugin(MediaPlugin):
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
(e.g. subtitles, volume, start position, window size etc.) see
https://github.com/popcornmix/omxplayer#synopsis and
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).
"""
super().__init__(*argv, **kwargs)
super().__init__(**kwargs)
if args is None:
args = []
@ -48,7 +55,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
self._play_started = threading.Event()
@action
def play(self, resource=None, subtitles=None, *args, **kwargs):
def play(self, *args, resource=None, subtitles=None, **_):
"""
Play or resume playing a resource.
@ -68,9 +75,8 @@ class MediaOmxplayerPlugin(MediaPlugin):
self._player.play()
return self.status()
else:
self._play_started.clear()
self._play_started.clear()
self._post_event(MediaPlayRequestEvent, resource=resource)
if subtitles:
@ -141,7 +147,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
return {'status': 'stop'}
def get_volume(self) -> float:
def get_volume(self) -> Optional[float]:
"""
:return: The player volume in percentage [0, 100].
"""
@ -157,7 +163,9 @@ class MediaOmxplayerPlugin(MediaPlugin):
:type step: float
"""
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()
@action
@ -169,7 +177,9 @@ class MediaOmxplayerPlugin(MediaPlugin):
:type step: float
"""
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()
@action
@ -213,23 +223,19 @@ class MediaOmxplayerPlugin(MediaPlugin):
return self.status()
@action
def is_playing(self):
def is_playing(self, *_, **__) -> bool:
"""
:returns: True if it's playing, False otherwise
"""
return self._player.is_playing()
return self._player.is_playing() if self._player else False
@action
def load(self, resource, pause=False, **kwargs):
def load(self, resource: str, *_, pause: bool = False, **__):
"""
Load a resource/video in the player.
:param resource: URL or filename to load
:type resource: str
:param pause: If set, load the video in paused mode (default: False)
:type pause: bool
"""
if self._player:
@ -244,48 +250,45 @@ class MediaOmxplayerPlugin(MediaPlugin):
return self.status()
@action
def mute(self):
def mute(self, *_, **__):
"""Mute the player"""
if self._player:
self._player.mute()
return self.status()
@action
def unmute(self):
def unmute(self, *_, **__):
"""Unmute the player"""
if self._player:
self._player.unmute()
return self.status()
@action
def seek(self, position):
def seek(self, position: float, **__):
"""
Seek to the specified number of seconds from the start.
:param position: Number of seconds from the start
:type position: float
"""
if self._player:
self._player.set_position(position)
return self.status()
@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`).
:param position: Number of seconds from the start
:type position: float
"""
return self.seek(position)
@action
def set_volume(self, volume):
def set_volume(self, volume: float, *_, **__):
"""
Set the volume
:param volume: Volume value between 0 and 100
:type volume: float
"""
if self._player:
@ -327,7 +330,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
try:
state = self._player.playback_status().lower()
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):
self._player = None
@ -362,9 +365,7 @@ class MediaOmxplayerPlugin(MediaPlugin):
def add_handler(self, event_type, callback):
if event_type not in self._handlers.keys():
raise AttributeError(
'{} is not a valid PlayerEvent type'.format(event_type)
)
raise AttributeError(f'{event_type} is not a valid PlayerEvent type')
self._handlers[event_type].append(callback)
@ -420,13 +421,13 @@ class MediaOmxplayerPlugin(MediaPlugin):
self._player.positionEvent += self.on_seek()
self._player.seekEvent += self.on_seek()
def toggle_subtitles(self, *args, **kwargs):
def toggle_subtitles(self, *_, **__):
raise NotImplementedError
def set_subtitles(self, filename, *args, **kwargs):
def set_subtitles(self, *_, **__):
raise NotImplementedError
def remove_subtitles(self, *args, **kwargs):
def remove_subtitles(self, *_, **__):
raise NotImplementedError

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import json
import socket
import threading
from typing import Collection, Optional
from platypush.config import Config
from platypush.context import get_backend
@ -9,7 +10,7 @@ from platypush.plugins import Plugin, action
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.
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
_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)
:type host: str
:param port: Default Snapcast server control port (default: 1705)
:type port: int
"""
super().__init__(**kwargs)
self.host = host
@ -46,23 +45,24 @@ class MusicSnapcastPlugin(Plugin):
self._latest_req_id += 1
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)
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))
return sock
@classmethod
def _send(cls, sock, req):
def _send(cls, sock: socket.socket, req: dict):
if isinstance(req, dict):
req = json.dumps(req)
r = json.dumps(req)
if isinstance(req, str):
req = req.encode()
if not isinstance(req, bytes):
raise RuntimeError('Unsupported type {} for Snapcast request: {}'.
format(type(req), req))
r = req.encode()
if not isinstance(r, bytes):
raise RuntimeError(
f'Unsupported type {type(req)} for Snapcast request: {req}'
)
sock.send(req + cls._SOCKET_EOL)
sock.send(r + cls._SOCKET_EOL)
@classmethod
def _recv(cls, sock):
@ -71,133 +71,137 @@ class MusicSnapcastPlugin(Plugin):
buf += sock.recv(1)
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', []):
if group == g.get('id') or group == g.get('name'):
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', []):
clients = g.get('clients', [])
for c in clients:
if client == c.get('id') or \
client == c.get('name') or \
client == c.get('host', {}).get('name') or \
client == c.get('host', {}).get('ip'):
if (
client == c.get('id')
or client == c.get('name')
or client == c.get('host', {}).get('name')
or client == c.get('host', {}).get('ip')
):
c['group_id'] = g.get('id')
return c
def _status(self, sock):
return None
def _status(self, sock: socket.socket):
request = {
'id': self._get_req_id(),
'jsonrpc': '2.0',
'method': 'Server.GetStatus'
'method': 'Server.GetStatus',
}
# noinspection PyTypeChecker
self._send(sock, request)
return (self._recv(sock) or {}).get('server', {})
@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
:param host: Snapcast server to query (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port)
:type port: int
:param client: Client ID or name (default: None)
:type client: str
:param group: Group ID or name (default: None)
:type group: str
:returns: dict.
:returns: dict. Example:
Example::
.. code-block:: json
"output": {
"groups": [
{
"clients": [
"output": {
"groups": [
{
"config": {
"instance": 1,
"latency": 0,
"name": "",
"volume": {
"muted": false,
"percent": 96
"clients": [
{
"config": {
"instance": 1,
"latency": 0,
"name": "",
"volume": {
"muted": false,
"percent": 96
}
},
"connected": true,
"host": {
"arch": "x86_64",
"ip": "YOUR_IP",
"mac": "YOUR_MAC",
"name": "YOUR_NAME",
"os": "YOUR_OS"
},
"id": "YOUR_ID",
"lastSeen": {
"sec": 1546648311,
"usec": 86011
},
"snapclient": {
"name": "Snapclient",
"protocolVersion": 2,
"version": "0.15.0"
}
},
"connected": true,
"host": {
"arch": "x86_64",
"ip": "YOUR_IP",
"mac": "YOUR_MAC",
"name": "YOUR_NAME",
"os": "YOUR_OS"
},
}
],
"id": "YOUR_ID",
"lastSeen": {
"sec": 1546648311,
"usec": 86011
},
"snapclient": {
"name": "Snapclient",
"protocolVersion": 2,
"version": "0.15.0"
}
"muted": false,
"name": "",
"stream_id": "mopidy"
}
],
"id": "YOUR_ID",
"muted": false,
"name": "",
"stream_id": "mopidy"
}
],
"server": {
"host": {
"arch": "armv7l",
"ip": "",
"mac": "",
"name": "YOUR_NAME",
"os": "YOUR_OS"
},
"snapserver": {
"controlProtocolVersion": 1,
"name": "Snapserver",
"protocolVersion": 1,
"version": "0.15.0"
}
},
"streams": [
{
"id": "mopidy",
"meta": {
"STREAM": "mopidy"
"server": {
"host": {
"arch": "armv7l",
"ip": "",
"mac": "",
"name": "YOUR_NAME",
"os": "YOUR_OS"
},
"status": "playing",
"uri": {
"fragment": "",
"host": "",
"path": "/tmp/snapfifo",
"query": {
"buffer_ms": "20",
"codec": "pcm",
"name": "mopidy",
"sampleformat": "48000:16:2"
},
"raw": "pipe:////tmp/snapfifo?buffer_ms=20&codec=pcm&name=mopidy&sampleformat=48000:16:2",
"scheme": "pipe"
"snapserver": {
"controlProtocolVersion": 1,
"name": "Snapserver",
"protocolVersion": 1,
"version": "0.15.0"
}
}
]
}
},
"streams": [
{
"id": "mopidy",
"meta": {
"STREAM": "mopidy"
},
"status": "playing",
"uri": {
"fragment": "",
"host": "",
"path": "/tmp/snapfifo",
"query": {
"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"
}
}
]
}
"""
@ -213,33 +217,32 @@ class MusicSnapcastPlugin(Plugin):
return self._status(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning(f'Error on socket close: {e}')
self.logger.warning('Error on socket close: %s', e)
@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
:param client: Client name or ID to mute
:type client: str
:param group: Group ID to mute
:type group: str
:param mute: Mute status. If not set, the mute status of the selected
client/group will be toggled.
:type mute: bool
:param host: Snapcast server to query (default: default configured host)
:type host: str
: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')
sock = None
@ -250,59 +253,62 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(),
'jsonrpc': '2.0',
'method': 'Group.SetMute' if group else 'Client.SetVolume',
'params': {}
'params': {},
}
if group:
group = self._get_group(sock, group)
cur_muted = group['muted']
request['params']['id'] = group['id']
g = self._get_group(sock, group)
assert g, f'No such group: {group}'
cur_muted = g['muted']
request['params']['id'] = g['id']
request['params']['mute'] = not cur_muted if mute is None else mute
else:
client = self._get_client(sock, client)
cur_muted = client['config']['volume']['muted']
request['params']['id'] = client['id']
elif client:
c = self._get_client(sock, client)
assert c, f'No such client: {client}'
cur_muted = c['config']['volume']['muted']
request['params']['id'] = c['id']
request['params']['volume'] = {}
request['params']['volume']['percent'] = client['config']['volume']['percent']
request['params']['volume']['muted'] = not cur_muted if mute is None else mute
request['params']['volume']['percent'] = c['config']['volume'][
'percent'
]
request['params']['volume']['muted'] = (
not cur_muted if mute is None else mute
)
# noinspection PyTypeChecker
self._send(sock, request)
return self._recv(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning('Error on socket close', e)
self.logger.warning('Error on socket close: %s', e)
@action
def volume(self, client, volume=None, delta=None, mute=None, host=None,
port=None):
def volume(
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
:type client: str
: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)
:type delta: int
: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)
:type host: str
:param port: Snapcast server port (default: default configured port)
:type port: int
"""
if volume is None and delta is None and mute is None:
raise RuntimeError('Please specify either an absolute volume or ' +
'relative delta')
raise RuntimeError(
'Please specify either an absolute volume or ' + 'relative delta'
)
sock = None
@ -312,56 +318,51 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(),
'jsonrpc': '2.0',
'method': 'Client.SetVolume',
'params': {}
'params': {},
}
client = self._get_client(sock, client)
cur_volume = int(client['config']['volume']['percent'])
cur_mute = bool(client['config']['volume']['muted'])
c = self._get_client(sock, client)
assert c, f'No such client: {client}'
cur_volume = int(c['config']['volume']['percent'])
cur_mute = bool(c['config']['volume']['muted'])
if volume is not None:
volume = int(volume)
elif delta is not None:
volume = cur_volume + int(delta)
if volume is not None:
if volume > 100: volume = 100
if volume < 0: volume = 0
else:
volume = cur_volume
volume = max(0, min(100, volume)) if volume is not None else cur_volume
if mute is None:
mute = cur_mute
request['params']['id'] = client['id']
request['params']['id'] = c['id']
request['params']['volume'] = {}
request['params']['volume']['percent'] = volume
request['params']['volume']['muted'] = mute
# noinspection PyTypeChecker
self._send(sock, request)
return self._recv(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning('Error on socket close', e)
self.logger.warning('Error on socket close: %s', e)
@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
:param client: Current client name or ID to rename
:type client: str
:param name: New name
:type name: str
:param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port)
:type port: int
"""
sock = None
@ -372,37 +373,37 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(),
'jsonrpc': '2.0',
'method': 'Client.SetName',
'params': {}
'params': {},
}
client = self._get_client(sock, client)
request['params']['id'] = client['id']
c = self._get_client(sock, client)
assert c, f'No such client: {client}'
request['params']['id'] = c['id']
request['params']['name'] = name
# noinspection PyTypeChecker
self._send(sock, request)
return self._recv(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning('Error on socket close', e)
self.logger.warning('Error on socket close: %s', e)
@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
:param group: Group ID to rename
:type group: str
:param name: New name
:type name: str
:param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port)
:type port: int
"""
sock = None
@ -416,34 +417,33 @@ class MusicSnapcastPlugin(Plugin):
'params': {
'id': group,
'name': name,
}
},
}
# noinspection PyTypeChecker
self._send(sock, request)
return self._recv(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning('Error on socket close', e)
self.logger.warning('Error on socket close: %s', e)
@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
:param client: Client name or ID
:type client: str
:param latency: New latency in milliseconds
:type latency: float
:param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port)
:type port: int
"""
sock = None
@ -454,35 +454,31 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(),
'jsonrpc': '2.0',
'method': 'Client.SetLatency',
'params': {
'latency': latency
}
'params': {'latency': latency},
}
client = self._get_client(sock, client)
request['params']['id'] = client['id']
# noinspection PyTypeChecker
c = self._get_client(sock, client)
assert c, f'No such client: {client}'
request['params']['id'] = c['id']
self._send(sock, request)
return self._recv(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning('Error on socket close', e)
self.logger.warning('Error on socket close: %s', e)
@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
:param client: Client name or ID
:type client: str
:param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port)
:type port: int
"""
sock = None
@ -493,176 +489,188 @@ class MusicSnapcastPlugin(Plugin):
'id': self._get_req_id(),
'jsonrpc': '2.0',
'method': 'Server.DeleteClient',
'params': {}
'params': {},
}
client = self._get_client(sock, client)
request['params']['id'] = client['id']
# noinspection PyTypeChecker
c = self._get_client(sock, client)
assert c, f'No such client: {client}'
request['params']['id'] = c['id']
self._send(sock, request)
return self._recv(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning('Error on socket close', e)
self.logger.warning('Error on socket close: %s', e)
@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
:param group: Group name or ID
:type group: str
:param clients: List of client names or IDs
:type clients: list[str]
:param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port)
:type port: int
"""
sock = None
try:
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 = {
'id': self._get_req_id(),
'jsonrpc': '2.0',
'method': 'Group.SetClients',
'params': {
'id': group['id'],
'clients': []
}
'params': {'id': g['id'], 'clients': []},
}
for client in clients:
client = self._get_client(sock, client)
request['params']['clients'].append(client['id'])
c = self._get_client(sock, client)
assert c, f'No such client: {client}'
request['params']['clients'].append(c['id'])
# noinspection PyTypeChecker
self._send(sock, request)
return self._recv(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning('Error on socket close', e)
self.logger.warning('Error on socket close: %s', e)
@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.
:param group: Group name or ID
:type group: str
:param stream_id: Stream ID
:type stream_id: str
:param host: Snapcast server (default: default configured host)
:type host: str
:param port: Snapcast server port (default: default configured port)
:type port: int
"""
sock = None
try:
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 = {
'id': self._get_req_id(),
'jsonrpc': '2.0',
'method': 'Group.SetStream',
'params': {
'id': group['id'],
'id': g['id'],
'stream_id': stream_id,
}
},
}
# noinspection PyTypeChecker
self._send(sock, request)
return self._recv(sock)
finally:
try:
sock.close()
if sock:
sock.close()
except Exception as e:
self.logger.warning('Error on socket close', e)
self.logger.warning('Error on socket close: %s', e)
@action
def get_backend_hosts(self):
"""
:return: A dict with the Snapcast hosts configured on the backend
in the format host -> port
in the format ``host -> port``.
"""
hosts = {}
for i in range(len(self.backend_hosts)):
hosts[self.backend_hosts[i]] = self.backend_ports[i]
return hosts
return {
host: self.backend_ports[i] for i, host in enumerate(self.backend_hosts)
}
@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
that are currently active and unmuted.
: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": {
"server_1": 1705,
"server_2": 1705,
"server_3": 1705
{
"hosts": {
"server_1": 1705,
"server_2": 1705,
"server_3": 1705
}
}
}
"""
backend_hosts = self.get_backend_hosts().output
backend_hosts: dict = self.get_backend_hosts().output # type: ignore
playing_hosts = {}
def _worker(host, port):
try:
if exclude_local and (host == 'localhost'
or host == Config.get('device_id')):
if exclude_local and (
host == 'localhost' or host == Config.get('device_id')
):
return
server_status = self.status(host=host, port=port).output
client_status = self.status(host=host, port=port,
client=Config.get('device_id')).output
server_status: dict = self.status(host=host, port=port).output # type: ignore
client_status: dict = self.status( # type: ignore
host=host, port=port, client=Config.get('device_id')
).output
if client_status.get('config', {}).get('volume', {}).get('muted'):
return
group = [g for g in server_status.get('groups', {})
if g.get('id') == client_status.get('group_id')].pop(0)
group = next(
iter(
g
for g in server_status.get('groups', {})
if g.get('id') == client_status.get('group_id')
)
)
if group.get('muted'):
return
stream = [s for s in server_status.get('streams')
if s.get('id') == group.get('stream_id')].pop(0)
stream = next(
iter(
s
for s in server_status.get('streams', {})
if s.get('id') == group.get('stream_id')
)
)
if stream.get('status') != 'playing':
return
playing_hosts[host] = port
except Exception as e:
self.logger.warning(('Error while retrieving the status of ' +
'Snapcast host at {}:{}: {}').format(
host, port, str(e)))
self.logger.warning(
'Error while retrieving the status of Snapcast host at %s:%d: %s',
host,
port,
e,
)
workers = []
@ -677,4 +685,5 @@ class MusicSnapcastPlugin(Plugin):
return {'hosts': playing_hosts}
# 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
`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:
* Install the gateway following the instructions reported