platypush/platypush/plugins/media/mpv.py

421 lines
13 KiB
Python
Raw Normal View History

2019-02-18 01:17:21 +01:00
import os
import threading
2019-07-01 19:32:22 +02:00
from platypush.context import get_bus
2019-02-18 01:17:21 +01:00
from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \
2019-06-21 02:13:14 +02:00
MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent, MediaSeekEvent
2019-02-18 01:17:21 +01:00
from platypush.plugins import action
class MediaMpvPlugin(MediaPlugin):
"""
Plugin to control MPV instances
Requires:
* **python-mpv** (``pip install python-mpv``)
* **mpv** executable on your system
"""
2019-02-19 00:15:03 +01:00
_default_mpv_args = {
'ytdl': True,
'start_event_thread': True,
}
def __init__(self, args=None, *argv, **kwargs):
2019-02-18 01:17:21 +01:00
"""
Create the MPV wrapper.
:param args: Default arguments that will be passed to the mpv executable
2019-02-19 00:15:03 +01:00
as a key-value dict (names without the `--` prefix). See `man mpv`
for available options.
:type args: dict[str, str]
2019-02-18 01:17:21 +01:00
"""
super().__init__(*argv, **kwargs)
2019-02-19 00:15:03 +01:00
self.args = self._default_mpv_args
if args:
# noinspection PyTypeChecker
2019-02-19 00:15:03 +01:00
self.args.update(args)
2019-02-18 01:17:21 +01:00
self._player = None
self._playback_rebounce_event = threading.Event()
self._on_stop_callbacks = []
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
def _init_mpv(self, args=None):
import mpv
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
mpv_args = self.args.copy()
if args:
mpv_args.update(args)
2019-02-18 01:17:21 +01:00
2019-07-01 19:32:22 +02:00
for k, v in self._env.items():
os.environ[k] = v
2019-02-19 00:15:03 +01:00
self._player = mpv.MPV(**mpv_args)
# noinspection PyProtectedMember
self._player._event_callbacks += [self._event_callback()]
2019-02-18 01:17:21 +01:00
2019-06-22 00:15:32 +02:00
@staticmethod
def _post_event(evt_type, **evt):
bus = get_bus()
bus.post(evt_type(player='local', plugin='media.mpv', **evt))
2019-02-19 00:15:03 +01:00
def _event_callback(self):
def callback(event):
from mpv import MpvEventID as Event
from mpv import MpvEventEndFile as EndFile
self.logger.info('Received mpv event: {}'.format(event))
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
evt = event.get('event_id')
if not evt:
return
2019-02-18 01:17:21 +01:00
2019-06-21 02:13:14 +02:00
if (evt == Event.FILE_LOADED or evt == Event.START_FILE) and self._get_current_resource():
self._playback_rebounce_event.set()
2019-07-01 19:32:22 +02:00
self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource(),
title=self._player.filename)
elif evt == Event.PLAYBACK_RESTART:
self._playback_rebounce_event.set()
2019-02-19 00:15:03 +01:00
elif evt == Event.PAUSE:
2019-06-22 00:15:32 +02:00
self._post_event(MediaPauseEvent, resource=self._get_current_resource(), title=self._player.filename)
2019-02-19 00:15:03 +01:00
elif evt == Event.UNPAUSE:
2019-06-22 00:15:32 +02:00
self._post_event(MediaPlayEvent, resource=self._get_current_resource(), title=self._player.filename)
elif evt == Event.SHUTDOWN or evt == Event.IDLE or (
evt == Event.END_FILE and event.get('event', {}).get('reason') in
2019-07-01 19:32:22 +02:00
[EndFile.EOF_OR_INIT_FAILURE, EndFile.ABORTED, EndFile.QUIT]):
playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5)
if playback_rebounced:
self._playback_rebounce_event.clear()
return
self._player = None
2019-06-22 00:15:32 +02:00
self._post_event(MediaStopEvent)
2019-07-01 19:32:22 +02:00
for cbk in self._on_stop_callbacks:
cbk()
2019-06-21 02:13:14 +02:00
elif evt == Event.SEEK:
2019-06-22 00:15:32 +02:00
self._post_event(MediaSeekEvent, position=self._player.playback_time)
2019-06-21 02:13:14 +02:00
2019-02-19 00:15:03 +01:00
return callback
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def execute(self, cmd, **args):
2019-02-18 01:17:21 +01:00
"""
Execute a raw mpv command.
"""
2019-02-19 00:15:03 +01:00
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
return self._player.command(cmd, *args)
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def play(self, resource, subtitles=None, **args):
2019-02-18 01:17:21 +01:00
"""
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
2019-02-19 00:15:03 +01:00
:param args: Extra runtime arguments that will be passed to the
mpv executable as a key-value dict (keys without `--` prefix)
:type args: dict[str,str]
2019-02-18 01:17:21 +01:00
"""
get_bus().post(MediaPlayRequestEvent(resource=resource))
2019-02-19 00:15:03 +01:00
self._init_mpv(args)
2019-02-18 01:17:21 +01:00
if subtitles:
2019-02-19 00:15:03 +01:00
args['sub_file'] = self.get_subtitles_file(subtitles)
2019-02-18 01:17:21 +01:00
resource = self._get_resource(resource)
2019-02-18 01:17:21 +01:00
if resource.startswith('file://'):
resource = resource[7:]
2019-06-21 02:13:14 +02:00
self._player.play(resource)
if self.volume:
self.set_volume(volume=self.volume)
2019-02-19 00:15:03 +01:00
return self.status()
2019-02-18 01:17:21 +01:00
@action
def pause(self):
""" Toggle the paused state """
2019-02-19 00:15:03 +01:00
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
self._player.pause = not self._player.pause
return self.status()
2019-02-18 01:17:21 +01:00
@action
def quit(self):
2019-06-21 02:13:14 +02:00
""" Stop and quit the player """
2019-02-18 01:17:21 +01:00
self._stop_torrent()
2019-02-19 00:15:03 +01:00
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
self._player.quit()
2019-06-21 02:13:14 +02:00
self._player.terminate()
2019-02-19 00:15:03 +01:00
self._player = None
2019-06-21 02:13:14 +02:00
return {'state': PlayerState.STOP.value}
2019-02-19 00:15:03 +01:00
@action
def stop(self):
2019-06-21 02:13:14 +02:00
""" Stop and quit the player """
2019-02-19 00:15:03 +01:00
return self.quit()
2019-02-18 01:17:21 +01:00
@action
def voldown(self, step=10.0):
""" Volume down by (default: 10)% """
2019-02-19 00:15:03 +01:00
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
return self.set_volume(self._player.volume - step)
2019-02-18 01:17:21 +01:00
@action
def volup(self, step=10.0):
""" Volume up by (default: 10)% """
2019-02-19 00:15:03 +01:00
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
return self.set_volume(self._player.volume + step)
2019-02-19 00:15:03 +01:00
@action
def set_volume(self, volume):
"""
Set the volume
:param volume: Volume value between 0 and 100
:type volume: float
"""
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
2019-06-21 02:13:14 +02:00
volume = max(0, min([self._player.volume_max, volume]))
2019-02-19 00:15:03 +01:00
self._player.volume = volume
2019-06-22 00:15:32 +02:00
return self.status()
2019-02-19 00:15:03 +01:00
@action
def seek(self, position):
"""
Seek backward/forward by the specified number of seconds
2019-06-21 02:13:14 +02:00
:param position: Number of seconds relative to the current cursor
:type position: int
2019-02-19 00:15:03 +01:00
"""
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
if not self._player.seekable:
2019-06-21 02:13:14 +02:00
return None, 'The resource is not seekable'
pos = min(self._player.time_pos + self._player.time_remaining,
2019-02-19 00:15:03 +01:00
max(0, position))
self._player.time_pos = pos
return self.status()
2019-02-18 01:17:21 +01:00
@action
def back(self, offset=60.0):
""" Back by (default: 60) seconds """
2019-02-19 00:15:03 +01:00
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
if not self._player.seekable:
2019-06-21 02:13:14 +02:00
return None, 'The resource is not seekable'
pos = max(0, self._player.time_pos - offset)
2019-02-19 00:15:03 +01:00
return self.seek(pos)
2019-02-18 01:17:21 +01:00
@action
def forward(self, offset=60.0):
""" Forward by (default: 60) seconds """
2019-02-19 00:15:03 +01:00
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
if not self._player.seekable:
2019-06-21 02:13:14 +02:00
return None, 'The resource is not seekable'
pos = min(self._player.time_pos + self._player.time_remaining,
self._player.time_pos + offset)
2019-02-19 00:15:03 +01:00
return self.seek(pos)
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def next(self):
""" Play the next item in the queue """
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
self._player.playlist_next()
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def prev(self):
""" Play the previous item in the queue """
if not self._player:
2019-06-21 02:13:14 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
self._player.playlist_prev()
2019-02-18 01:17:21 +01:00
@action
2019-06-21 02:13:14 +02:00
def toggle_subtitles(self, visible=None):
2019-02-19 00:15:03 +01:00
""" Toggle the subtitles visibility """
return self.toggle_property('sub_visibility')
2019-02-18 01:17:21 +01:00
@action
def add_subtitles(self, filename):
""" Add a subtitles file """
return self._player.sub_add(filename)
@action
def remove_subtitles(self, sub_id):
""" Remove a subtitles track by id """
return self._player.sub_remove(sub_id)
2019-02-18 01:17:21 +01:00
@action
2019-07-01 19:32:22 +02:00
def toggle_fullscreen(self):
2019-02-19 00:15:03 +01:00
""" Toggle the fullscreen mode """
return self.toggle_property('fullscreen')
2019-02-18 01:17:21 +01:00
# noinspection PyShadowingBuiltins
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def toggle_property(self, property):
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
Toggle or sets the value of an mpv property (e.g. fullscreen,
sub_visibility etc.). See ``man mpv`` for a full list of properties
:param property: Property to toggle
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
if not self._player:
2019-07-01 19:32:22 +02:00
return None, 'No mpv instance is running'
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
if not hasattr(self._player, property):
self.logger.warning('No such mpv property: {}'.format(property))
value = not getattr(self._player, property)
setattr(self._player, property, value)
2019-07-01 19:32:22 +02:00
return {property: value}
2019-02-18 01:17:21 +01:00
# noinspection PyShadowingBuiltins
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def get_property(self, property):
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
Get a player property (e.g. pause, fullscreen etc.). See
``man mpv`` for a full list of the available properties
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
if not self._player:
2019-07-01 19:32:22 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
return getattr(self._player, property)
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def set_property(self, **props):
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
Set the value of an mpv property (e.g. fullscreen, sub_visibility
etc.). See ``man mpv`` for a full list of properties
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
:param props: Key-value args for the properties to set
:type props: dict
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
if not self._player:
2019-07-01 19:32:22 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
for k, v in props.items():
2019-02-19 00:15:03 +01:00
setattr(self._player, k, v)
return props
2019-02-18 01:17:21 +01:00
@action
2019-06-21 02:13:14 +02:00
def set_subtitles(self, filename, *args, **kwargs):
2019-02-19 00:15:03 +01:00
""" Sets media subtitles from filename """
# noinspection PyTypeChecker
2019-02-19 00:15:03 +01:00
return self.set_property(subfile=filename, sub_visibility=True)
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
@action
def remove_subtitles(self):
""" Removes (hides) the subtitles """
if not self._player:
2019-07-01 19:32:22 +02:00
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
self._player.sub_visibility = False
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def is_playing(self):
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
:returns: True if it's playing, False otherwise
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
if not self._player:
return False
return not self._player.pause
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def load(self, resource, **args):
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
Load/queue a resource/video to the player
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
if not self._player:
return self.play(resource, **args)
return self._player.loadfile(resource, mode='append-play')
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
@action
def mute(self):
""" Toggle mute state """
if not self._player:
return None, 'No mpv instance is running'
2019-02-19 00:15:03 +01:00
mute = not self._player.mute
self._player.mute = mute
2019-07-01 19:32:22 +02:00
return {'muted': mute}
2019-02-18 01:17:21 +01:00
@action
2019-02-19 00:15:03 +01:00
def set_position(self, position):
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
Seek backward/forward to the specified absolute position (same as ``seek``)
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
return self.seek(position)
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
@action
def status(self):
"""
Get the current player state.
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
:returns: A dictionary containing the current state.
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
Example::
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
output = {
"filename": "filename or stream URL",
"state": "play" # or "stop" or "pause"
}
2019-02-18 01:17:21 +01:00
"""
2019-02-19 00:15:03 +01:00
if not self._player or not hasattr(self._player, 'pause'):
2019-06-21 02:13:14 +02:00
return {'state': PlayerState.STOP.value}
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
return {
2019-06-21 02:13:14 +02:00
'audio_channels': getattr(self._player, 'audio_channels'),
2019-06-22 00:15:32 +02:00
'audio_codec': getattr(self._player, 'audio_codec_name'),
2019-06-21 02:13:14 +02:00
'delay': getattr(self._player, 'delay'),
'duration': getattr(self._player, 'playback_time', 0) + getattr(self._player, 'playtime_remaining', 0)
2019-06-22 00:15:32 +02:00
if getattr(self._player, 'playtime_remaining') else None,
2019-06-21 02:13:14 +02:00
'filename': getattr(self._player, 'filename'),
'file_size': getattr(self._player, 'file_size'),
'fullscreen': getattr(self._player, 'fs'),
'mute': getattr(self._player, 'mute'),
'name': getattr(self._player, 'name'),
'pause': getattr(self._player, 'pause'),
'percent_pos': getattr(self._player, 'percent_pos'),
'position': getattr(self._player, 'playback_time'),
'seekable': getattr(self._player, 'seekable'),
'state': (PlayerState.PAUSE.value if self._player.pause else PlayerState.PLAY.value),
'title': getattr(self._player, 'media_title') or getattr(self._player, 'filename'),
2019-06-21 02:13:14 +02:00
'url': self._get_current_resource(),
'video_codec': getattr(self._player, 'video_codec'),
'video_format': getattr(self._player, 'video_format'),
'volume': getattr(self._player, 'volume'),
'volume_max': getattr(self._player, 'volume_max'),
'width': getattr(self._player, 'width'),
2019-02-19 00:15:03 +01:00
}
2019-02-18 01:17:21 +01:00
def on_stop(self, callback):
self._on_stop_callbacks.append(callback)
2019-02-19 00:15:03 +01:00
def _get_current_resource(self):
if not self._player or not self._player.stream_path:
return
2019-02-18 01:17:21 +01:00
2019-02-19 00:15:03 +01:00
return ('file://' if os.path.isfile(self._player.stream_path)
else '') + self._player.stream_path
2019-02-18 01:17:21 +01:00
# vim:sw=4:ts=4:et: