[mpv] Refactored plugin.

I hadn't paid attention to this plugin in a while and the newest mpv API
has diverged so much that it was entirely incompatible with recent mpv
versions.
This commit is contained in:
Fabio Manganiello 2024-01-13 22:36:42 +01:00
parent 641a2fd135
commit cf314f2615
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774

View file

@ -1,10 +1,12 @@
from dataclasses import asdict
import os import os
import threading from dataclasses import asdict
from typing import Any, Dict, Optional, Type
from urllib.parse import quote
from platypush.context import get_bus from platypush.plugins import action
from platypush.plugins.media import PlayerState, MediaPlugin from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.media import ( from platypush.message.event.media import (
MediaEvent,
MediaPlayEvent, MediaPlayEvent,
MediaPlayRequestEvent, MediaPlayRequestEvent,
MediaPauseEvent, MediaPauseEvent,
@ -14,8 +16,6 @@ from platypush.message.event.media import (
MediaResumeEvent, MediaResumeEvent,
) )
from platypush.plugins import action
class MediaMpvPlugin(MediaPlugin): class MediaMpvPlugin(MediaPlugin):
""" """
@ -27,26 +27,29 @@ class MediaMpvPlugin(MediaPlugin):
'start_event_thread': True, 'start_event_thread': True,
} }
def __init__(self, args=None, *argv, **kwargs): def __init__(
self, args: Optional[Dict[str, Any]] = None, fullscreen: bool = False, **kwargs
):
""" """
Create the MPV wrapper. Create the MPV wrapper.
:param args: Default arguments that will be passed to the mpv executable :param args: Default arguments that will be passed to the mpv executable
as a key-value dict (names without the `--` prefix). See `man mpv` as a key-value dict (names without the `--` prefix). See `man mpv`
for available options. for available options.
:type args: dict[str, 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)
""" """
super().__init__(*argv, **kwargs) super().__init__(**kwargs)
self.args = self._default_mpv_args self.args = {**self._default_mpv_args}
if args: if args:
# noinspection PyTypeChecker
self.args.update(args) self.args.update(args)
if fullscreen:
self.args['fs'] = True
self._player = None self._player = None
self._playback_rebounce_event = threading.Event() self._latest_state = PlayerState.STOP
self._on_stop_callbacks = []
def _init_mpv(self, args=None): def _init_mpv(self, args=None):
import mpv import mpv
@ -59,80 +62,85 @@ class MediaMpvPlugin(MediaPlugin):
os.environ[k] = v os.environ[k] = v
self._player = mpv.MPV(**mpv_args) self._player = mpv.MPV(**mpv_args)
# noinspection PyProtectedMember
self._player._event_callbacks += [self._event_callback()] self._player._event_callbacks += [self._event_callback()]
@staticmethod def _post_event(self, evt_type: Type[MediaEvent], **evt):
def _post_event(evt_type, **evt): self._bus.post(
bus = get_bus() evt_type(
bus.post(evt_type(player='local', plugin='media.mpv', **evt)) player='local',
plugin='media.mpv',
resource=evt.pop('resource', self._resource),
title=self._filename,
**evt,
)
)
@property
def _cur_player(self):
if self._player and not self._player.core_shutdown:
return self._player
return None
@property
def _state(self):
player = self._cur_player
if not player:
return PlayerState.STOP
return PlayerState.PAUSE if player.pause else PlayerState.PLAY
@property
def _resource(self):
if not self._cur_player:
return None
cur_resource = self._cur_player.stream_path
if not cur_resource:
return None
return quote(
('file://' if os.path.isfile(cur_resource) else '') + str(cur_resource)
)
@property
def _filename(self):
if not self._cur_player:
return None
return self._cur_player.filename
def _event_callback(self): def _event_callback(self):
def callback(event): def callback(event):
from mpv import ( from mpv import MpvEvent
MpvEvent,
MpvEventID as Event,
MpvEventEndFile as EndFile,
)
self.logger.info('Received mpv event: {}'.format(event)) self.logger.info('Received mpv event: %s', event)
if isinstance(event, MpvEvent): if isinstance(event, MpvEvent):
event = event.as_dict() event = event.as_dict()
if not isinstance(event, dict):
evt = event.get('event_id')
if not evt:
return return
if ( evt_type = event.get('event', b'').decode()
evt == Event.FILE_LOADED or evt == Event.START_FILE if not evt_type:
) and self._get_current_resource():
self._playback_rebounce_event.set()
self._post_event(
NewPlayingMediaEvent,
resource=self._get_current_resource(),
title=self._player.filename,
)
elif evt == Event.PLAYBACK_RESTART:
self._playback_rebounce_event.set()
self._post_event(
MediaPlayEvent,
resource=self._get_current_resource(),
title=self._player.filename,
)
elif evt == Event.PAUSE:
self._post_event(
MediaPauseEvent,
resource=self._get_current_resource(),
title=self._player.filename,
)
elif evt == Event.UNPAUSE:
self._post_event(
MediaResumeEvent,
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 [EndFile.EOF, EndFile.ABORTED, EndFile.QUIT]
)
):
playback_rebounced = self._playback_rebounce_event.wait(timeout=0.5)
if playback_rebounced:
self._playback_rebounce_event.clear()
return return
self._player = None if evt_type == 'start-file':
self._post_event(NewPlayingMediaEvent)
elif evt_type == 'playback-restart':
self._post_event(MediaPlayEvent)
elif evt_type in ('shutdown', 'idle', 'end-file'):
if self._state != PlayerState.PLAY:
self._post_event(MediaStopEvent) self._post_event(MediaStopEvent)
for cbk in self._on_stop_callbacks: if evt_type == 'shutdown' and self._player:
cbk() self._player = None
elif evt == Event.SEEK: elif evt_type == 'seek' and self._cur_player:
self._post_event(MediaSeekEvent, position=self._player.playback_time) self._post_event(
MediaSeekEvent, position=self._cur_player.playback_time
)
self._latest_state = self._state
return callback return callback
@ -141,35 +149,41 @@ class MediaMpvPlugin(MediaPlugin):
""" """
Execute a raw mpv command. Execute a raw mpv command.
""" """
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
return self._player.command(cmd, *args)
return self._cur_player.command(cmd, *args)
@action @action
def play(self, resource, subtitles=None, **args): def play(
self,
resource: str,
*_,
subtitles: Optional[str] = None,
fullscreen: Optional[bool] = None,
**args,
):
""" """
Play a resource. Play a resource.
:param resource: Resource to play - can be a local file or a remote URL :param resource: Resource to play - can be a local file or a remote URL
:type resource: str
:param subtitles: Path to optional subtitle file :param subtitles: Path to optional subtitle file
:type subtitles: str
:param args: Extra runtime arguments that will be passed to the :param args: Extra runtime arguments that will be passed to the
mpv executable as a key-value dict (keys without `--` prefix) mpv executable as a key-value dict (keys without `--` prefix)
:type args: dict[str,str]
""" """
self._post_event(MediaPlayRequestEvent, resource=resource) self._post_event(MediaPlayRequestEvent, resource=resource)
if fullscreen is not None:
args['fs'] = fullscreen
self._init_mpv(args) self._init_mpv(args)
resource = self._get_resource(resource) resource = self._get_resource(resource)
if resource.startswith('file://'): if resource.startswith('file://'):
resource = resource[7:] resource = resource[7:]
assert self._player, 'The player is not ready' assert self._cur_player, 'The player is not ready'
self._player.play(resource) self._cur_player.play(resource)
if self.volume: if self.volume:
self.set_volume(volume=self.volume) self.set_volume(volume=self.volume)
if subtitles: if subtitles:
@ -178,43 +192,54 @@ class MediaMpvPlugin(MediaPlugin):
return self.status() return self.status()
@action @action
def pause(self): def pause(self, *_, **__):
"""Toggle the paused state""" """Toggle the paused state"""
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
self._player.pause = not self._player.pause self._cur_player.pause = not self._cur_player.pause
return self.status() return self.status()
@action @action
def quit(self): def quit(self, *_, **__):
"""Stop and quit the player""" """Stop and quit the player"""
if not self._player: player = self._cur_player
return None, 'No mpv instance is running' if not player:
return None
self._player.quit() player.stop()
self._player.terminate() player.quit(code=0)
player.wait_for_shutdown(timeout=10)
player.terminate()
self._player = None self._player = None
return {'state': PlayerState.STOP.value} return self.status()
@action @action
def stop(self): def stop(self, *_, **__):
"""Stop and quit the player""" """Stop and quit the player"""
return self.quit() return self.quit()
@action def _set_vol(self, *_, step=10.0, **__):
def voldown(self, step=10.0): if not self._cur_player:
"""Volume down by (default: 10)%""" return None
if not self._player:
return None, 'No mpv instance is running' return self.set_volume(float(self._cur_player.volume or 0) - step)
return self.set_volume(self._player.volume - step)
@action @action
def volup(self, step=10.0): def voldown(self, *_, step: float = 10.0, **__):
"""Volume down by (default: 10)%"""
if not self._cur_player:
return None
return self.set_volume(float(self._cur_player.volume or 0) - step)
@action
def volup(self, step: float = 10.0, **_):
"""Volume up by (default: 10)%""" """Volume up by (default: 10)%"""
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
return self.set_volume(self._player.volume + step)
return self.set_volume(float(self._cur_player.volume or 0) + step)
@action @action
def set_volume(self, volume): def set_volume(self, volume):
@ -224,82 +249,92 @@ class MediaMpvPlugin(MediaPlugin):
:param volume: Volume value between 0 and 100 :param volume: Volume value between 0 and 100
:type volume: float :type volume: float
""" """
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
volume = max(0, min([self._player.volume_max, volume])) max_vol = (
self._player.volume = volume self._cur_player.volume_max
if self._cur_player.volume_max is not None
else 100
)
volume = max(0, min([max_vol, volume]))
self._cur_player.volume = volume
return self.status() return self.status()
@action @action
def seek(self, position): def seek(self, position: float, **_):
""" """
Seek backward/forward by the specified number of seconds Seek backward/forward by the specified number of seconds
:param position: Number of seconds relative to the current cursor :param position: Number of seconds relative to the current cursor
:type position: int
""" """
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
if not self._player.seekable:
return None, 'The resource is not seekable' assert self._cur_player.seekable, 'The resource is not seekable'
pos = min(self._player.time_pos + self._player.time_remaining, max(0, position)) self._cur_player.time_pos = min(
self._player.time_pos = pos float(self._cur_player.time_pos or 0)
+ float(self._cur_player.time_remaining or 0),
max(0, position),
)
return self.status() return self.status()
@action @action
def back(self, offset=30.0): def back(self, offset=30.0, **_):
"""Back by (default: 30) seconds""" """Back by (default: 30) seconds"""
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
if not self._player.seekable:
return None, 'The resource is not seekable' assert self._cur_player.seekable, 'The resource is not seekable'
pos = max(0, self._player.time_pos - offset) cur_pos = float(self._cur_player.time_pos or 0)
return self.seek(pos) return self.seek(cur_pos - offset)
@action @action
def forward(self, offset=30.0): def forward(self, offset=30.0, **_):
"""Forward by (default: 30) seconds""" """Forward by (default: 30) seconds"""
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
if not self._player.seekable:
return None, 'The resource is not seekable' assert self._cur_player.seekable, 'The resource is not seekable'
pos = min( cur_pos = float(self._cur_player.time_pos or 0)
self._player.time_pos + self._player.time_remaining, return self.seek(cur_pos + offset)
self._player.time_pos + offset,
)
return self.seek(pos)
@action @action
def next(self): def next(self, **_):
"""Play the next item in the queue""" """Play the next item in the queue"""
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
self._player.playlist_next()
self._cur_player.playlist_next()
return self.status()
@action @action
def prev(self): def prev(self, **_):
"""Play the previous item in the queue""" """Play the previous item in the queue"""
if not self._player: if not self._cur_player:
return None, 'No mpv instance is running' return None
self._player.playlist_prev()
self._cur_player.playlist_prev()
return self.status()
@action @action
def toggle_subtitles(self, visible=None): def toggle_subtitles(self, *_, **__):
"""Toggle the subtitles visibility""" """Toggle the subtitles visibility"""
return self.toggle_property('sub_visibility') return self.toggle_property('sub_visibility')
@action @action
def add_subtitles(self, filename): def add_subtitles(self, filename):
"""Add a subtitles file""" """Add a subtitles file"""
return self._player.sub_add(filename) if not self._cur_player:
return None
return self._cur_player.sub_add(filename)
@action @action
def toggle_fullscreen(self): def toggle_fullscreen(self):
"""Toggle the fullscreen mode""" """Toggle the fullscreen mode"""
return self.toggle_property('fullscreen') return self.toggle_property('fullscreen')
# noinspection PyShadowingBuiltins
@action @action
def toggle_property(self, property): def toggle_property(self, property):
""" """
@ -309,7 +344,7 @@ class MediaMpvPlugin(MediaPlugin):
:param property: Property to toggle :param property: Property to toggle
""" """
if not self._player: if not self._player:
return None, 'No mpv instance is running' return None
if not hasattr(self._player, property): if not hasattr(self._player, property):
self.logger.warning('No such mpv property: {}'.format(property)) self.logger.warning('No such mpv property: {}'.format(property))
@ -318,7 +353,6 @@ class MediaMpvPlugin(MediaPlugin):
setattr(self._player, property, value) setattr(self._player, property, value)
return {property: value} return {property: value}
# noinspection PyShadowingBuiltins
@action @action
def get_property(self, property): def get_property(self, property):
""" """
@ -326,7 +360,7 @@ class MediaMpvPlugin(MediaPlugin):
``man mpv`` for a full list of the available properties ``man mpv`` for a full list of the available properties
""" """
if not self._player: if not self._player:
return None, 'No mpv instance is running' return None
return getattr(self._player, property) return getattr(self._player, property)
@action @action
@ -339,29 +373,28 @@ class MediaMpvPlugin(MediaPlugin):
:type props: dict :type props: dict
""" """
if not self._player: if not self._player:
return None, 'No mpv instance is running' return None
for k, v in props.items(): for k, v in props.items():
setattr(self._player, k, v) setattr(self._player, k, v)
return props return props
@action @action
def set_subtitles(self, filename, *args, **kwargs): def set_subtitles(self, filename, *_, **__):
"""Sets media subtitles from filename""" """Sets media subtitles from filename"""
# noinspection PyTypeChecker
return self.set_property(subfile=filename, sub_visibility=True) return self.set_property(subfile=filename, sub_visibility=True)
@action @action
def remove_subtitles(self, sub_id=None): def remove_subtitles(self, sub_id=None, **_):
"""Removes (hides) the subtitles""" """Removes (hides) the subtitles"""
if not self._player: if not self._player:
return None, 'No mpv instance is running' return None
if sub_id: if sub_id:
return self._player.sub_remove(sub_id) return self._player.sub_remove(sub_id)
self._player.sub_visibility = False self._player.sub_visibility = False
@action @action
def is_playing(self): def is_playing(self, **_):
""" """
:returns: True if it's playing, False otherwise :returns: True if it's playing, False otherwise
""" """
@ -379,36 +412,58 @@ class MediaMpvPlugin(MediaPlugin):
return self._player.loadfile(resource, mode='append-play') return self._player.loadfile(resource, mode='append-play')
@action @action
def mute(self): def mute(self, **_):
"""Toggle mute state""" """Toggle mute state"""
if not self._player: if not self._player:
return None, 'No mpv instance is running' return None
mute = not self._player.mute mute = not self._player.mute
self._player.mute = mute self._player.mute = mute
return {'muted': mute} return {'muted': mute}
@action @action
def set_position(self, position): def set_position(self, position: float, **_):
""" """
Seek backward/forward to the specified absolute position (same as ``seek``) Seek backward/forward to the specified absolute position (same as ``seek``)
""" """
return self.seek(position) return self.seek(position)
@action @action
def status(self): def status(self, **_):
""" """
Get the current player state. Get the current player state.
:returns: A dictionary containing the current state. :returns: A dictionary containing the current state.
Example:: Example:
output = { .. code-block:: javascript
{
"audio_channels": 2,
"audio_codec": "mp3",
"delay": 0,
"duration": 300.0,
"file_size": 123456,
"filename": "filename or stream URL", "filename": "filename or stream URL",
"state": "play" # or "stop" or "pause" "fullscreen": false,
"mute": false,
"name": "mpv",
"pause": false,
"percent_pos": 10.0,
"position": 30.0,
"seekable": true,
"state": "play", // or "stop" or "pause"
"title": "filename or stream URL",
"url": "file:///path/to/file.mp3",
"video_codec": "h264",
"video_format": "avc1",
"volume": 50.0,
"volume_max": 100.0,
"width": 1280
} }
""" """
if not self._player or not hasattr(self._player, 'pause'): if not self._cur_player:
return {'state': PlayerState.STOP.value} return {'state': PlayerState.STOP.value}
status = { status = {
@ -428,14 +483,10 @@ class MediaMpvPlugin(MediaPlugin):
'percent_pos': getattr(self._player, 'percent_pos', None), 'percent_pos': getattr(self._player, 'percent_pos', None),
'position': getattr(self._player, 'playback_time', None), 'position': getattr(self._player, 'playback_time', None),
'seekable': getattr(self._player, 'seekable', None), 'seekable': getattr(self._player, 'seekable', None),
'state': ( 'state': self._state.value,
PlayerState.PAUSE.value
if self._player.pause
else PlayerState.PLAY.value
),
'title': getattr(self._player, 'media_title', None) 'title': getattr(self._player, 'media_title', None)
or getattr(self._player, 'filename', None), or getattr(self._player, 'filename', None),
'url': self._get_current_resource(), 'url': self._resource,
'video_codec': getattr(self._player, 'video_codec', None), 'video_codec': getattr(self._player, 'video_codec', None),
'video_format': getattr(self._player, 'video_format', None), 'video_format': getattr(self._player, 'video_format', None),
'volume': getattr(self._player, 'volume', None), 'volume': getattr(self._player, 'volume', None),
@ -452,19 +503,19 @@ class MediaMpvPlugin(MediaPlugin):
} }
) )
if self._state != self._latest_state:
if not self._cur_player:
self._post_event(MediaStopEvent)
else:
self._post_event(
MediaPauseEvent
if self._state == PlayerState.PAUSE
else MediaResumeEvent
)
self._latest_state = self._state
return status return status
def on_stop(self, callback):
self._on_stop_callbacks.append(callback)
def _get_current_resource(self):
if not self._player or not self._player.stream_path:
return
return (
'file://' if os.path.isfile(self._player.stream_path) else ''
) + self._player.stream_path
def _get_resource(self, resource): def _get_resource(self, resource):
if self._is_youtube_resource(resource): if self._is_youtube_resource(resource):
return resource # mpv can handle YouTube streaming natively return resource # mpv can handle YouTube streaming natively