platypush/platypush/plugins/media/vlc.py
Fabio Manganiello 833f810d4b Fixed stop handler for vlc plugin.
For some reason, vlc event handlers are not re-entrant (https://github.com/oaubert/python-vlc/issues/44#issuecomment-378520074).

This means that the vlc API can't be used from an event handler,
and that an event handler that reacts to stop/end-of-stream by
releasing the player and the vlc instance will likely get stuck
and the app may eventually die with SIGSEGV.

Because of this design limitation on the vlc side, the plugin has
to run another thread in the main app that monitors the stop event
set by the event handler and releases the resources appropriately.
2021-02-28 13:03:10 +01:00

443 lines
15 KiB
Python

import os
import threading
import urllib.parse
from typing import Optional
from platypush.context import get_bus
from platypush.plugins.media import PlayerState, MediaPlugin
from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \
MediaPauseEvent, MediaStopEvent, MediaSeekEvent, MediaVolumeChangedEvent, \
MediaMuteChangedEvent, NewPlayingMediaEvent
from platypush.plugins import action
class MediaVlcPlugin(MediaPlugin):
"""
Plugin to control vlc instances
Requires:
* **python-vlc** (``pip install python-vlc``)
* **vlc** executable on your system
"""
def __init__(self, args=None, fullscreen=False, volume=100, *argv, **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)
self._args = args or []
self._instance = None
self._player = None
self._latest_seek = None
self._default_fullscreen = fullscreen
self._default_volume = volume
self._on_stop_callbacks = []
self._title = None
self._filename = None
self._monitor_thread: Optional[threading.Thread] = None
self._on_stop_event = threading.Event()
@classmethod
def _watched_event_types(cls):
import vlc
return [getattr(vlc.EventType, evt) for evt in [
'MediaPlayerLengthChanged', 'MediaPlayerMediaChanged',
'MediaDurationChanged', 'MediaPlayerMuted',
'MediaPlayerUnmuted', 'MediaPlayerOpening', 'MediaPlayerPaused',
'MediaPlayerPlaying', 'MediaPlayerPositionChanged',
'MediaPlayerStopped', 'MediaPlayerTimeChanged', 'MediaStateChanged',
'MediaPlayerForward', 'MediaPlayerBackward',
'MediaPlayerEndReached', 'MediaPlayerTitleChanged',
'MediaPlayerAudioVolume',
] if hasattr(vlc.EventType, evt)]
def _init_vlc(self, resource):
import vlc
if self._instance:
self.logger.info('Another instance is running, waiting for it to terminate')
self._on_stop_event.wait()
self._reset_state()
for k, v in self._env.items():
os.environ[k] = v
self._monitor_thread = threading.Thread(target=self._player_monitor)
self._monitor_thread.start()
self._instance = vlc.Instance(*self._args)
self._player = self._instance.media_player_new(resource)
for evt in self._watched_event_types():
self._player.event_manager().event_attach(
eventtype=evt, callback=self._event_callback())
def _player_monitor(self):
self._on_stop_event.wait()
self.logger.info('VLC stream terminated')
self._reset_state()
def _reset_state(self):
self._latest_seek = None
self._title = None
self._filename = None
self._on_stop_event.clear()
if self._player:
self.logger.info('Releasing VLC player resource')
self._player.release()
self._player = None
if self._instance:
self.logger.info('Releasing VLC instance resource')
self._instance.release()
self._instance = None
@staticmethod
def _post_event(evt_type, **evt):
bus = get_bus()
bus.post(evt_type(player='local', plugin='media.vlc', **evt))
def _event_callback(self):
def callback(event):
from vlc import EventType
self.logger.debug('Received vlc event: {}'.format(event))
if event.type == EventType.MediaPlayerPlaying:
self._post_event(MediaPlayEvent, resource=self._get_current_resource())
elif event.type == EventType.MediaPlayerPaused:
self._post_event(MediaPauseEvent)
elif event.type == EventType.MediaPlayerStopped or \
event.type == EventType.MediaPlayerEndReached:
self._on_stop_event.set()
self._post_event(MediaStopEvent)
for cbk in self._on_stop_callbacks:
cbk()
elif event.type == EventType.MediaPlayerTitleChanged:
self._filename = event.u.filename
self._title = event.u.new_title
self._post_event(NewPlayingMediaEvent, resource=event.u.new_title)
elif event.type == EventType.MediaPlayerMediaChanged:
self._filename = event.u.filename
self._title = event.u.new_title
self._post_event(NewPlayingMediaEvent, resource=event.u.filename)
elif event.type == EventType.MediaPlayerLengthChanged:
self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource())
elif event.type == EventType.MediaPlayerTimeChanged:
pos = float(event.u.new_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:
self._post_event(MediaVolumeChangedEvent, volume=self._player.audio_get_volume())
elif event.type == EventType.MediaPlayerMuted:
self._post_event(MediaMuteChangedEvent, mute=True)
elif event.type == EventType.MediaPlayerUnmuted:
self._post_event(MediaMuteChangedEvent, mute=False)
return callback
@action
def play(self, resource=None, subtitles=None, fullscreen=None, volume=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 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:
return self.pause()
self._post_event(MediaPlayRequestEvent, resource=resource)
resource = self._get_resource(resource)
if resource.startswith('file://'):
resource = resource[len('file://'):]
self._init_vlc(resource)
if subtitles:
if subtitles.startswith('file://'):
subtitles = subtitles[len('file://'):]
self._player.video_set_subtitle_file(subtitles)
self._player.play()
if self.volume:
self.set_volume(volume=self.volume)
if fullscreen or self._default_fullscreen:
self.set_fullscreen(True)
if volume is not None or self._default_volume is not None:
self.set_volume(volume if volume is not None
else self._default_volume)
return self.status()
@action
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'
self._player.pause()
return self.status()
@action
def quit(self):
""" Quit the player (same as `stop`) """
if not self._player:
return None, 'No vlc instance is running'
self._player.stop()
self._player = None
return self.status()
@action
def stop(self):
""" Stop the application (same as `quit`) """
return self.quit()
@action
def voldown(self, step=10.0):
""" Volume down by (default: 10)% """
if not self._player:
return None, '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):
""" Volume up by (default: 10)% """
if not self._player:
return None, 'No vlc instance is running'
return self.set_volume(int(min(100, self._player.audio_get_volume()+step)))
@action
def set_volume(self, volume):
"""
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'
volume = max(0, min([100, volume]))
self._player.audio_set_volume(volume)
status = self.status().output
status['volume'] = volume
return status
@action
def seek(self, position):
"""
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'
if not self._player.is_seekable():
return None, 'The resource is not seekable'
media = self._player.get_media()
if not media:
return None, 'No media loaded'
pos = min(media.get_duration()/1000, max(0, position))
self._player.set_time(int(pos*1000))
return self.status()
@action
def back(self, offset=30.0):
""" Back by (default: 30) seconds """
if not self._player:
return None, 'No vlc instance is running'
media = self._player.get_media()
if not media:
return None, 'No media loaded'
pos = max(0, (self._player.get_time()/1000)-offset)
return self.seek(pos)
@action
def forward(self, offset=30.0):
""" Forward by (default: 30) seconds """
if not self._player:
return None, 'No vlc instance is running'
media = self._player.get_media()
if not media:
return None, 'No media loaded'
pos = min(media.get_duration()/1000, (self._player.get_time()/1000)+offset)
return self.seek(pos)
@action
def toggle_subtitles(self, visibile=None):
""" 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'
if self._player.video_get_spu() is None or \
self._player.video_get_spu() == -1:
self._player.video_set_spu(0)
else:
self._player.video_set_spu(-1)
@action
def toggle_fullscreen(self):
""" Toggle the fullscreen mode """
if not self._player:
return None, 'No vlc instance is running'
self._player.toggle_fullscreen()
@action
def set_fullscreen(self, fullscreen=True):
""" Set fullscreen mode """
if not self._player:
return None, 'No vlc instance is running'
self._player.set_fullscreen(fullscreen)
@action
def set_subtitles(self, filename, **args):
""" Sets media subtitles from filename """
if not self._player:
return None, '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):
""" Removes (hides) the subtitles """
if not self._player:
return None, 'No vlc instance is running'
self._player.video_set_spu(-1)
@action
def is_playing(self):
"""
:returns: True if it's playing, False otherwise
"""
if not self._player:
return False
return self._player.is_playing()
@action
def load(self, resource, **args):
"""
Load/queue a resource/video to the player
"""
if not self._player:
return self.play(resource, **args)
self._player.set_media(resource)
return self.status()
@action
def mute(self):
""" Toggle mute state """
if not self._player:
return None, 'No vlc instance is running'
self._player.audio_toggle_mute()
@action
def set_position(self, position):
"""
Seek backward/forward to the specified absolute position (same as ``seek``)
"""
return self.seek(position)
@action
def status(self):
"""
Get the current player state.
:returns: A dictionary containing the current state.
Example::
output = {
"filename": "filename or stream URL",
"state": "play" # or "stop" or "pause"
}
"""
import vlc
if not self._player:
return {'state': PlayerState.STOP.value}
status = {}
vlc_state = self._player.get_state()
if vlc_state == vlc.State.Playing:
status['state'] = PlayerState.PLAY.value
elif vlc_state == vlc.State.Paused:
status['state'] = PlayerState.PAUSE.value
else:
status['state'] = PlayerState.STOP.value
status['url'] = urllib.parse.unquote(self._player.get_media().get_mrl()) if self._player.get_media() else None
status['position'] = float(self._player.get_time()/1000) if self._player.get_time() is not None else None
media = self._player.get_media()
status['duration'] = media.get_duration()/1000 if media and media.get_duration() is not None else None
status['seekable'] = status['duration'] is not None
status['fullscreen'] = self._player.get_fullscreen()
status['mute'] = self._player.audio_get_mute()
status['path'] = status['url']
status['pause'] = status['state'] == PlayerState.PAUSE.value
status['percent_pos'] = self._player.get_position()*100
status['filename'] = self._filename
status['title'] = self._title
status['volume'] = self._player.audio_get_volume()
status['volume_max'] = 100
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.get_media():
return
return self._player.get_media().get_mrl()
# vim:sw=4:ts=4:et: