platypush/platypush/plugins/media/mplayer/__init__.py

642 lines
18 KiB
Python

from dataclasses import asdict
import os
import re
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
from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.media import (
MediaPlayEvent,
MediaPlayRequestEvent,
MediaPauseEvent,
MediaStopEvent,
NewPlayingMediaEvent,
)
from platypush.plugins import action
from platypush.utils import find_bins_in_path
class MediaMplayerPlugin(MediaPlugin):
"""
Plugin to control MPlayer instances.
"""
_mplayer_default_communicate_timeout = 0.5
_mplayer_bin_default_args = [
'-slave',
'-quiet',
'-idle',
'-input',
'nodefault-bindings',
'-noconfig',
'all',
]
def __init__(
self,
mplayer_bin: Optional[str] = None,
mplayer_timeout: float = _mplayer_default_communicate_timeout,
args: Optional[Collection[str]] = None,
**kwargs,
):
"""
Create the MPlayer wrapper. Note that the plugin methods are populated
dynamically by introspecting the mplayer executable. You can verify the
supported methods at runtime by using the `list_actions` method.
:param mplayer_bin: Path to the MPlayer executable (default: search for
the first occurrence in your system PATH environment variable)
:param mplayer_timeout: Timeout in seconds to wait for more data
from MPlayer before considering a response ready (default: 0.5 seconds)
:param args: Default arguments that will be passed to the MPlayer
executable
"""
super().__init__(**kwargs)
self.args = args or []
self._init_mplayer_bin(mplayer_bin=mplayer_bin)
self._build_actions()
self._player = None
self._mplayer_timeout = mplayer_timeout
self._mplayer_stopped_event = threading.Event()
self._status_lock = threading.Lock()
def _init_mplayer_bin(self, mplayer_bin=None):
if not mplayer_bin:
bin_name = 'mplayer.exe' if os.name == 'nt' else 'mplayer'
bins = find_bins_in_path(bin_name)
if not bins:
raise RuntimeError(
'MPlayer executable not specified and not '
+ 'found in your PATH. Make sure that mplayer'
+ 'is either installed or configured'
)
self.mplayer_bin = bins[0]
else:
mplayer_bin = os.path.expanduser(mplayer_bin)
if not (
os.path.isfile(mplayer_bin)
and (os.name == 'nt' or os.access(mplayer_bin, os.X_OK))
):
raise RuntimeError(
f'{mplayer_bin} is does not exist or is not a valid executable file'
)
self.mplayer_bin = mplayer_bin
def _init_mplayer(self, mplayer_args=None):
if self._player:
try:
self._player.terminate()
except Exception as e:
self.logger.debug('Failed to quit mplayer before _exec: %s', e)
m_args = mplayer_args or []
args = [self.mplayer_bin] + self._mplayer_bin_default_args
for arg in (*self.args, *m_args):
if arg not in args:
args.append(arg)
popen_args: Dict[str, Any] = {
'stdin': subprocess.PIPE,
'stdout': subprocess.PIPE,
}
if self._env:
popen_args['env'] = self._env
self._player = subprocess.Popen(args, **popen_args)
threading.Thread(target=self._process_monitor()).start()
def _build_actions(self):
"""Populates the actions list by introspecting the mplayer executable"""
self._actions = {}
mplayer = subprocess.Popen(
[self.mplayer_bin, '-input', 'cmdlist'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
def args_pprint(txt):
lc = txt.lower()
if lc[0] == '[':
return f'{lc[1:-1]}=None'
return lc
while True:
if not mplayer.stdout:
break
line = mplayer.stdout.readline()
if not line:
break
line = line.decode()
if line[0].isupper():
continue
args = line.split()
cmd_name = args.pop(0)
arguments = ', '.join([args_pprint(a) for a in args])
self._actions[cmd_name] = f'{cmd_name}({arguments})'
def _exec(
self, cmd, *args, mplayer_args=None, prefix=None, wait_for_response=False
):
cmd_name = cmd
response = None
if cmd_name in {'loadfile', 'loadlist'}:
self._init_mplayer(mplayer_args)
else:
if not self._player:
self.logger.warning('MPlayer is not running')
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(
'Could not communicate with the mplayer process: the stdin is closed'
)
return
self._player.stdin.write(cmd)
self._player.stdin.flush()
if cmd_name in {'loadfile', 'loadlist'}:
self._post_event(NewPlayingMediaEvent, resource=args[0])
elif cmd_name == 'pause':
self._post_event(MediaPauseEvent)
elif cmd_name == 'quit':
self._player.terminate()
self._player.wait()
try:
self._player.kill()
except Exception:
pass
self._player = None
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()
while time.time() - last_read_time < self._mplayer_timeout:
result = poll.poll(0)
if result:
if not self._player:
break
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':
v = True
elif v == 'no':
v = False
try:
if isinstance(v, str):
v = eval(v) # pylint: disable=eval-used
except Exception:
pass
response = {k: v}
return response
@action
def execute(self, cmd, args=None):
"""
Execute a raw MPlayer command. See
https://www.mplayerhq.hu/DOCS/tech/slave.txt for a reference or call
:meth:`.list_actions` to get a list
"""
args = args or []
return self._exec(cmd, *args)
@action
def list_actions(self):
return [
{'action': a, 'args': self._actions[a]}
for a in sorted(self._actions.keys())
]
def _process_monitor(self):
def _thread():
if not self._player:
return
self._mplayer_stopped_event.clear()
self._player.wait()
try:
self.quit()
except Exception:
pass
self._post_event(MediaStopEvent)
self._mplayer_stopped_event.set()
self._player = None
return _thread
@staticmethod
def _post_event(evt_type, **evt):
bus = get_bus()
bus.post(evt_type(player='local', plugin='media.mplayer', **evt))
@action
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
:param subtitles: Path to optional subtitle file
:param mplayer_args: Extra runtime arguments that will be passed to the
MPlayer executable
"""
self._post_event(MediaPlayRequestEvent, resource=resource)
if 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://'):
resource = resource[7:]
self._exec('loadfile', resource, mplayer_args=mplayer_args)
self._post_event(MediaPlayEvent, resource=resource)
if self.volume:
self.set_volume(volume=self.volume)
return self.status()
@action
def pause(self, *_, **__):
"""Toggle the paused state"""
self._exec('pause')
self._post_event(MediaPauseEvent)
return self.status()
@action
def stop(self, *_, **__):
"""Stop the playback"""
# return self._exec('stop')
self.quit()
return self.status()
@action
def quit(self, *_, **__):
"""Quit the player"""
self._exec('quit')
self._post_event(MediaStopEvent)
return self.status()
@action
def voldown(self, *_, step=5.0, **__):
"""Volume down by (default: 10)%"""
self._exec('volume', -step * 10)
return self.status()
@action
def volup(self, *_, step=5.0, **__):
"""Volume up by (default: 10)%"""
self._exec('volume', step * 10)
return self.status()
@action
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, **__):
"""Forward by (default: 30) seconds"""
self.step_property('time_pos', offset)
return self.status()
@action
def toggle_subtitles(self, *_, **__):
"""Toggle the subtitles 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: 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: 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:
self._exec('sub_remove', index)
return self.status()
@action
def is_playing(self, *_, **__):
"""
:returns: True if it's playing, False otherwise
"""
response: dict = self.get_property('pause').output or {} # type: ignore
return response.get('pause') is False
@action
def load(self, resource, *_, mplayer_args: Optional[Collection[str]] = None, **__):
"""
Load a resource/video in the player.
"""
if mplayer_args is None:
mplayer_args = {}
return self.play(resource, mplayer_args=mplayer_args)
@action
def mute(self, *_, **__):
"""Toggle mute state"""
self._exec('mute')
return self.status()
@action
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
"""
self.step_property('time_pos', position)
return self.status()
@action
def set_position(self, position: float, *_, **__):
"""
Seek backward/forward to the specified absolute position
:param position: Number of seconds from the start
:type position: int
"""
self.set_property('time_pos', position)
return self.status()
@action
def set_volume(self, volume: float, *_, **__):
"""
Set the volume
:param volume: Volume value between 0 and 100
:type volume: float
"""
self._exec('volume', volume)
return self.status()
@action
def status(self):
"""
Get the current player state.
:returns: A dictionary containing the current state.
Example::
output = {
"state": "play" # or "stop" or "pause"
}
"""
if not self._player:
return {'state': PlayerState.STOP.value}
status = {}
props = {
'duration': 'length',
'filename': 'filename',
'fullscreen': 'fullscreen',
'mute': 'mute',
'name': 'filename',
'path': 'path',
'pause': 'pause',
'percent_pos': 'percent_pos',
'position': 'time_pos',
'title': 'filename',
'volume': 'volume',
}
with self._status_lock:
for prop, player_prop in props.items():
value = self.get_property(player_prop).output
if isinstance(value, dict):
status[prop] = value.get(player_prop)
status['seekable'] = bool(status['duration'])
status['state'] = (
PlayerState.PAUSE.value if status['pause'] else PlayerState.PLAY.value
)
if status['path']:
status['url'] = (
'file://' if os.path.isfile(status['path']) else ''
) + status['path']
status['volume_max'] = 100
if self._latest_resource:
status.update(
{
k: v
for k, v in asdict(self._latest_resource).items()
if v is not None
}
)
return status
@action
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
available properties
"""
args = args or []
response = Response(output={})
result = (
self._exec(
'get_property',
property,
prefix='pausing_keep_force',
wait_for_response=True,
*args,
)
or {}
)
for k, v in result.items():
if k == 'ERROR' and v not in response.errors:
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: 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
available properties
"""
args = args or []
response = Response(output={})
result = (
self._exec(
'set_property',
property,
value,
prefix='pausing_keep_force' if property != 'pause' else None,
wait_for_response=True,
*args,
)
or {}
)
for k, v in result.items():
if k == 'ERROR' and v not in response.errors:
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: 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
available steppable properties
"""
args = args or []
response = Response(output={})
result = (
self._exec(
'step_property',
property,
value,
prefix='pausing_keep_force',
wait_for_response=True,
*args,
)
or {}
)
for k, v in result.items():
if k == 'ERROR' and v not in response.errors:
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: str, *_, **__):
self.logger.debug('set_subtitles called with filename=%s', filename)
raise NotImplementedError
# vim:sw=4:ts=4:et: