diff --git a/platypush/plugins/media/__init__.py b/platypush/plugins/media/__init__.py index 241ca138..4acb62d8 100644 --- a/platypush/plugins/media/__init__.py +++ b/platypush/plugins/media/__init__.py @@ -25,7 +25,7 @@ class MediaPlugin(Plugin): Requires: - * A media player installed (supported so far: mplayer, omxplayer, chromecast) + * A media player installed (supported so far: mplayer, mpv, omxplayer, chromecast) * The :class:`platypush.plugins.media.webtorrent` plugin for optional torrent support through webtorrent (recommented) * **python-libtorrent** (``pip install python-libtorrent``), optional, for torrent support through the native Python plugin * **youtube-dl** installed on your system (see your distro instructions), optional for YouTube support @@ -62,7 +62,7 @@ class MediaPlugin(Plugin): } _supported_media_plugins = {'media.mplayer', 'media.omxplayer', - 'media.chromecast'} + 'media.mpv', 'media.chromecast'} _supported_media_types = ['file', 'torrent', 'youtube'] _default_search_timeout = 60 # 60 seconds @@ -145,8 +145,9 @@ class MediaPlugin(Plugin): if resource.startswith('youtube:') \ or resource.startswith('https://www.youtube.com/watch?v='): - if self.__class__.__name__ == 'MediaChromecastPlugin': - # The Chromecast has already its way to handle YouTube + if self.__class__.__name__ == 'MediaChromecastPlugin' or \ + self.__class__.__name__ == 'MediaMpvPlugin': + # The Chromecast and mpv have already their way to handle YouTube return resource resource = self._get_youtube_content(resource) @@ -451,4 +452,23 @@ class MediaPlugin(Plugin): return self._is_local + def get_subtitles_file(self, subtitles): + if not subtitles: + return + + if subtitles.startswith('file://'): + subtitles = subtitles[len('file://'):] + if os.path.isfile(subtitles): + return os.path.abspath(subtitles) + else: + import requests + content = requests.get(subtitles).content + f = tempfile.NamedTemporaryFile(prefix='media_subs_', + suffix='.srt', delete=False) + + with f: + f.write(content) + return f.name + + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/media/mplayer.py b/platypush/plugins/media/mplayer.py index ff7a0163..d3a699c9 100644 --- a/platypush/plugins/media/mplayer.py +++ b/platypush/plugins/media/mplayer.py @@ -241,24 +241,6 @@ class MediaMplayerPlugin(MediaPlugin): return _thread - def _get_subtitles_file(self, subtitles): - if not subtitles: - return - - if subtitles.startswith('file://'): - subtitles = subtitles[len('file://'):] - if os.path.isfile(subtitles): - return os.path.abspath(subtitles) - else: - import requests - content = requests.get(subtitles).content - f = tempfile.NamedTemporaryFile(prefix='media_subs_', - suffix='.srt', delete=False) - - with f: - f.write(content) - return f.name - @action def play(self, resource, subtitles=None, mplayer_args=None): @@ -279,7 +261,7 @@ class MediaMplayerPlugin(MediaPlugin): get_bus().post(MediaPlayRequestEvent(resource=resource)) if subtitles: mplayer_args = mplayer_args or [] - mplayer_args += ['-sub', self._get_subtitles_file(subtitles)] + mplayer_args += ['-sub', self.get_subtitles_file(subtitles)] resource = self._get_resource(resource) if resource.startswith('file://'): diff --git a/platypush/plugins/media/mpv.py b/platypush/plugins/media/mpv.py index b82abea9..3508dc12 100644 --- a/platypush/plugins/media/mpv.py +++ b/platypush/plugins/media/mpv.py @@ -1,10 +1,8 @@ import os -import tempfile +import re import threading -import time from platypush.context import get_bus, get_plugin -from platypush.message.response import Response from platypush.plugins.media import PlayerState, MediaPlugin from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ MediaPauseEvent, MediaStopEvent, NewPlayingMediaEvent @@ -12,7 +10,6 @@ from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, from platypush.plugins import action -# XXX WORK IN PROGRESS class MediaMpvPlugin(MediaPlugin): """ Plugin to control MPV instances @@ -23,208 +20,98 @@ class MediaMpvPlugin(MediaPlugin): * **mpv** executable on your system """ - _mpv_default_communicate_timeout = 0.5 + _default_mpv_args = { + 'ytdl': True, + 'start_event_thread': True, + } - _mpv_properties = [ - 'osdlevel', 'speed', 'loop', 'pause', 'filename', 'path', 'demuxer', - 'stream_pos', 'stream_start', 'stream_end', 'stream_length', - 'stream_time_pos', 'titles', 'chapter', 'chapters', 'angle', 'length', - 'percent_pos', 'time_pos', 'metadata', 'metadata', 'volume', 'balance', - 'mute', 'audio_delay', 'audio_format', 'audio_codec', 'audio_bitrate', - 'samplerate', 'channels', 'switch_audio', 'switch_angle', - 'switch_title', 'capturing', 'fullscreen', 'deinterlace', 'ontop', - 'rootwin', 'border', 'framedropping', 'gamma', 'brightness', 'contrast', - 'saturation', 'hue', 'panscan', 'vsync', 'video_format', 'video_codec', - 'video_bitrate', 'width', 'height', 'fps', 'aspect', 'switch_video', - 'switch_program', 'sub', 'sub_source', 'sub_file', 'sub_vob', - 'sub_demux', 'sub_delay', 'sub_pos', 'sub_alignment', 'sub_visibility', - 'sub_forced_only', 'sub_scale', 'tv_brightness', 'tv_contrast', - 'tv_saturation', 'tv_hue', 'teletext_page', 'teletext_subpage', - 'teletext_mode', 'teletext_format', - ] - - def __init__(self, mpv_bin=None, - mpv_timeout=_mpv_default_communicate_timeout, - args=None, *argv, **kwargs): + def __init__(self, args=None, *argv, **kwargs): """ Create the MPV wrapper. - :param mpv_bin: Path to the mpv executable (default: search for - the first occurrence in your system PATH environment variable) - :type mpv_bin: str - - :param mpv_timeout: Timeout in seconds to wait for more data - from MPV before considering a response ready (default: 0.5 seconds) - :type mpv_timeout: float - :param args: Default arguments that will be passed to the mpv executable - :type args: list + as a key-value dict (names without the `--` prefix). See `man mpv` + for available options. + :type args: dict[str, str] """ super().__init__(*argv, **kwargs) - self.args = args or [] - self._init_mpv_bin() - self._build_actions() + self.args = self._default_mpv_args + if args: + self.args.update(args) + self._player = None - self._mpv_timeout = mpv_timeout - self._mpv_stopped_event = threading.Event() self._is_playing_torrent = False + self._mpv_stopped_event = threading.Event() - def _init_mpv_bin(self, mpv_bin=None): - if not mpv_bin: - bin_name = 'mpv.exe' if os.name == 'nt' else 'mpv' - bins = find_bins_in_path(bin_name) + def _init_mpv(self, args=None): + import mpv - if not bins: - raise RuntimeError('mpv executable not specified and not ' + - 'found in your PATH. Make sure that mpv' + - 'is either installed or configured') - - self.mpv_bin = bins[0] - else: - mpv_bin = os.path.expanduser(mpv_bin) - if not (os.path.isfile(mpv_bin) - and (os.name == 'nt' or os.access(mpv_bin, os.X_OK))): - raise RuntimeError('{} is does not exist or is not a valid ' + - 'executable file'.format(mpv_bin)) - - self.mpv_bin = mpv_bin - - def _init_mpv(self, mpv_args=None): if self._player: try: - self._player.quit() - except: - self.logger.debug('Failed to quit mpv before _exec: {}'. - format(str)) + self.quit() + except Exception as e: + self.logger.debug('Failed to quit mpv before play: {}'. + format(str(e))) - mpv_args = mpv_args or [] - args = [self.mpv_bin] + self._mpv_bin_default_args - for arg in self.args + mpv_args: - if arg not in args: - args.append(arg) + mpv_args = self.args.copy() + if args: + mpv_args.update(args) - popen_args = { - 'stdin': subprocess.PIPE, - 'stdout': subprocess.PIPE, - } + self._player = mpv.MPV(**mpv_args) + self._player.register_event_callback(self._event_callback()) - if self._env: - popen_args['env'] = self._env + def _event_callback(self): + def callback(event): + from mpv import MpvEventID as Event + self.logger.debug('Received mpv event: {}'.format(event)) - self._player = subprocess.Popen(args, **popen_args) - threading.Thread(target=self._process_monitor()).start() + evt = event.get('event_id') + if not evt: + return - def _exec(self, cmd, *args, mpv_args=None, prefix=None, - wait_for_response=False): - cmd_name = cmd - response = None + bus = get_bus() + if evt == Event.FILE_LOADED: + self._mpv_stopped_event.clear() + bus.post(NewPlayingMediaEvent(resource=self._get_current_resource())) + bus.post(MediaPlayEvent(resource=self._get_current_resource())) + elif evt == Event.PAUSE: + bus.post(MediaPauseEvent(resource=self._get_current_resource())) + elif evt == Event.UNPAUSE: + bus.post(MediaPlayEvent(resource=self._get_current_resource())) + elif evt == Event.END_FILE or evt == Event.SHUTDOWN: + if evt == Event.SHUTDOWN: + self._player = None + self._mpv_stopped_event.set() + bus.post(MediaStopEvent()) + return callback - if cmd_name == 'loadfile' or cmd_name == 'loadlist': - self._init_mpv(mpv_args) - else: - if not self._player: - self.logger.warning('mpv is not running') + def _get_youtube_link(self, resource): + base_url = 'https://youtu.be/' + regexes = ['^https://(www\.)?youtube.com/watch\?v=([^?&#]+)', + '^https://(www\.)?youtu.be.com/([^?&#]+)', + '^(youtube:video):([^?&#]+)'] - cmd = '{}{}{}{}\n'.format( - prefix + ' ' if prefix else '', - cmd_name, ' ' if args else '', - ' '.join(repr(a) for a in args)).encode() + for regex in regexes: + m = re.search(regex, resource) + if m: return base_url + m.group(2) + return None - self._player.stdin.write(cmd) - self._player.stdin.flush() - bus = get_bus() - - if cmd_name == 'loadfile' or cmd_name == 'loadlist': - bus.post(NewPlayingMediaEvent(resource=args[0])) - elif cmd_name == 'pause': - bus.post(MediaPauseEvent()) - elif cmd_name == 'quit' or cmd_name == 'stop': - if cmd_name == 'quit': - self._player.terminate() - self._player.wait() - try: self._player.kill() - except: pass - self._player = None - - if not wait_for_response: - return - - poll = select.poll() - poll.register(self._player.stdout, select.POLLIN) - last_read_time = time.time() - - while time.time() - last_read_time < self._mpv_timeout: - result = poll.poll(0) - if result: - line = self._player.stdout.readline().decode() - last_read_time = time.time() - - if line.startswith('ANS_'): - k, v = tuple(line[4:].split('=')) - v = v.strip() - if v == 'yes': v = True - elif v == 'no': v = False - - try: v = eval(v) - except: pass - response = { k: v } - - return response @action - def execute(self, cmd, args=None): + def execute(self, cmd, **args): """ Execute a raw mpv command. """ - - args = args or [] - return self._exec(cmd, *args) - - @action - def list_actions(self): - return [ { 'action': action, 'args': self._actions[action] } - for action in sorted(self._actions.keys()) ] - - def _process_monitor(self): - def _thread(): - if not self._player: - return - - self._mpv_stopped_event.clear() - self._player.wait() - try: self.quit() - except: pass - - get_bus().post(MediaStopEvent()) - self._mpv_stopped_event.set() - self._player = None - - return _thread - - def _get_subtitles_file(self, subtitles): - if not subtitles: - return - - if subtitles.startswith('file://'): - subtitles = subtitles[len('file://'):] - if os.path.isfile(subtitles): - return os.path.abspath(subtitles) - else: - import requests - content = requests.get(subtitles).content - f = tempfile.NamedTemporaryFile(prefix='media_subs_', - suffix='.srt', delete=False) - - with f: - f.write(content) - return f.name + if not self._player: + return (None, 'No mpv instance is running') + return self._player.command(cmd, *args) @action - def play(self, resource, subtitles=None, mpv_args=None): + def play(self, resource, subtitles=None, **args): """ Play a resource. @@ -234,15 +121,16 @@ class MediaMpvPlugin(MediaPlugin): :param subtitles: Path to optional subtitle file :type subtitles: str - :param mpv_args: Extra runtime arguments that will be passed to the - mpv executable - :type mpv_args: list[str] + :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] """ get_bus().post(MediaPlayRequestEvent(resource=resource)) + self._init_mpv(args) + if subtitles: - mpv_args = mpv_args or [] - mpv_args += ['-sub', self._get_subtitles_file(subtitles)] + args['sub_file'] = self.get_subtitles_file(subtitles) resource = self._get_resource(resource) if resource.startswith('file://'): @@ -250,18 +138,24 @@ class MediaMpvPlugin(MediaPlugin): elif resource.startswith('magnet:?'): self._is_playing_torrent = True return get_plugin('media.webtorrent').play(resource) + else: + yt_resource = self._get_youtube_link(resource) + if yt_resource: resource = yt_resource self._is_playing_torrent = False - ret = self._exec('loadfile', resource, mpv_args=mpv_args) - get_bus().post(MediaPlayEvent(resource=resource)) - return ret + ret = self._player.play(resource) + return self.status() + @action def pause(self): """ Toggle the paused state """ - ret = self._exec('pause') - get_bus().post(MediaPauseEvent()) - return ret + if not self._player: + return (None, 'No mpv instance is running') + + self._player.pause = not self._player.pause + return self.status() + def _stop_torrent(self): if self._is_playing_torrent: @@ -272,96 +166,36 @@ class MediaMpvPlugin(MediaPlugin): format(str(e))) @action - def stop(self): - """ Stop the playback """ - # return self._exec('stop') - return self.quit() + def quit(self): + """ Quit the player (same as `stop`) """ + self._stop_torrent() + if not self._player: + return (None, 'No mpv instance is running') + + self._player.quit() + self._player = None + # self._player.terminate() + return { 'state': PlayerState.STOP.value } + @action - def quit(self): - """ Quit the player """ - self._stop_torrent() - self._exec('quit') - get_bus().post(MediaStopEvent()) + def stop(self): + """ Stop the application (same as `quit`) """ + return self.quit() @action def voldown(self, step=10.0): """ Volume down by (default: 10)% """ - return self._exec('volume', -step*10) + if not self._player: + return (None, 'No mpv instance is running') + return self.set_volume(self._player.volume-step) @action def volup(self, step=10.0): """ Volume up by (default: 10)% """ - return self._exec('volume', step*10) - - @action - def back(self, offset=60.0): - """ Back by (default: 60) seconds """ - return self.step_property('time_pos', -offset) - - @action - def forward(self, offset=60.0): - """ Forward by (default: 60) seconds """ - return self.step_property('time_pos', offset) - - @action - def toggle_subtitles(self): - """ Toggle the subtitles visibility """ - subs = self.get_property('sub_visibility').output.get('sub_visibility') - return self._exec('sub_visibility', int(not subs)) - - @action - def set_subtitles(self, filename): - """ Sets media subtitles from filename """ - self._exec('sub_visibility', 1) - return self._exec('sub_load', filename) - - @action - def remove_subtitles(self, index=None): - """ Removes the subtitle specified by the index (default: all) """ - if index is None: - return self._exec('sub_remove') - else: - return self._exec('sub_remove', index) - - @action - def is_playing(self): - """ - :returns: True if it's playing, False otherwise - """ - return self.get_property('pause').output.get('pause') == False - - @action - def load(self, resource, mpv_args={}): - """ - Load a resource/video in the player. - """ - return self.play(resource, mpv_args=mpv_args) - - @action - def mute(self): - """ Toggle mute state """ - return self._exec('mute') - - @action - def seek(self, position): - """ - Seek backward/forward by the specified number of seconds - - :param relative_position: Number of seconds relative to the current cursor - :type relative_position: int - """ - return self.step_property('time_pos', position) - - @action - def set_position(self, position): - """ - Seek backward/forward to the specified absolute position - - :param position: Number of seconds from the start - :type position: int - """ - return self.set_property('time_pos', position) + if not self._player: + return (None, 'No mpv instance is running') + return self.set_volume(self._player.volume+step) @action def set_volume(self, volume): @@ -371,7 +205,164 @@ class MediaMpvPlugin(MediaPlugin): :param volume: Volume value between 0 and 100 :type volume: float """ - return self._exec('volume', volume) + if not self._player: + return (None, 'No mpv instance is running') + + volume = max(0, min(self._player.volume_max, volume)) + self._player.volume = volume + return { 'volume': volume } + + @action + def seek(self, position): + """ + Seek backward/forward by the specified number of seconds + + :param relative_position: Number of seconds relative to the current cursor + :type relative_position: int + """ + if not self._player: + return (None, 'No mpv instance is running') + if not self._player.seekable: + return (None, 'The resource is not seekable') + pos = min(self._player.time_pos+self._player.time_remaining, + max(0, position)) + self._player.time_pos = pos + return { 'position': pos } + + @action + def back(self, offset=60.0): + """ Back by (default: 60) seconds """ + if not self._player: + return (None, 'No mpv instance is running') + if not self._player.seekable: + return (None, 'The resource is not seekable') + pos = max(0, self._player.time_pos-offset) + return self.seek(pos) + + @action + def forward(self, offset=60.0): + """ Forward by (default: 60) seconds """ + if not self._player: + return (None, 'No mpv instance is running') + if not self._player.seekable: + return (None, 'The resource is not seekable') + pos = min(self._player.time_pos+self._player.time_remaining, + self._player.time_pos+offset) + return self.seek(pos) + + @action + def next(self): + """ Play the next item in the queue """ + if not self._player: + return (None, 'No mpv instance is running') + self._player.playlist_next() + + @action + def prev(self): + """ Play the previous item in the queue """ + if not self._player: + return (None, 'No mpv instance is running') + self._player.playlist_prev() + + @action + def toggle_subtitles(self, visibile=None): + """ Toggle the subtitles visibility """ + return self.toggle_property('sub_visibility') + + @action + def toggle_fullscreen(self, fullscreen=None): + """ Toggle the fullscreen mode """ + return self.toggle_property('fullscreen') + + @action + def toggle_property(self, property): + """ + 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 + """ + if not self._player: + return (None, 'No mpv instance is running') + + 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) + return { property: value } + + @action + def get_property(self, property): + """ + Get a player property (e.g. pause, fullscreen etc.). See + ``man mpv`` for a full list of the available properties + """ + if not self._player: + return (None, 'No mpv instance is running') + return getattr(self._player, property) + + @action + def set_property(self, **props): + """ + Set the value of an mpv property (e.g. fullscreen, sub_visibility + etc.). See ``man mpv`` for a full list of properties + + :param props: Key-value args for the properties to set + :type props: dict + """ + if not self._player: + return (None, 'No mpv instance is running') + + for k,v in props: + setattr(self._player, k, v) + return props + + @action + def set_subtitles(self, filename): + """ Sets media subtitles from filename """ + return self.set_property(subfile=filename, sub_visibility=True) + + @action + def remove_subtitles(self): + """ Removes (hides) the subtitles """ + if not self._player: + return (None, 'No mpv instance is running') + self._player.sub_visibility = False + + @action + def is_playing(self): + """ + :returns: True if it's playing, False otherwise + """ + if not self._player: + return False + return not self._player.pause + + @action + def load(self, resource, **args): + """ + Load/queue a resource/video to the player + """ + if not self._player: + return self.play(resource, **args) + return self.loadfile(resource, mode='append-play', **args) + + @action + def mute(self): + """ Toggle mute state """ + if not self._player: + return (None, 'No mpv instance is running') + mute = not self._player.mute + self._player.mute = mute + return { 'muted': 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): @@ -383,88 +374,26 @@ class MediaMpvPlugin(MediaPlugin): Example:: output = { + "filename": "filename or stream URL", "state": "play" # or "stop" or "pause" } """ + if not self._player or not hasattr(self._player, 'pause'): + return { 'state': PlayerState.STOP.value } - state = { 'state': PlayerState.STOP.value } + return { + 'filename': self._get_current_resource(), + 'state': (PlayerState.PAUSE.value if self._player.pause else + PlayerState.PLAY.value), + } - try: - paused = self.get_property('pause').output.get('pause') - if paused is True: - state['state'] = PlayerState.PAUSE.value - elif paused is False: - state['state'] = PlayerState.PLAY.value - except: - pass - finally: - return state + def _get_current_resource(self): + if not self._player or not self._player.stream_path: + return - @action - def get_property(self, property, args=None): - """ - Get a player property (e.g. pause, fullscreen etc.). See - http://www.mpvhq.hu/DOCS/tech/slave.txt for a full list of the - available properties - """ + return ('file://' if os.path.isfile(self._player.stream_path) + else '') + self._player.stream_path - 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: - response.errors.append('{}{}: {}'.format(property, args, v)) - else: - response.output[k] = v - - return response - - @action - def set_property(self, property, value, args=None): - """ - Set a player property (e.g. pause, fullscreen etc.). - """ - - 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: - response.errors.append('{} {}{}: {}'.format(property, value, - args, v)) - else: - response.output[k] = v - - return response - - @action - def step_property(self, property, value, args=None): - """ - Step a player property (e.g. volume, time_pos etc.). - """ - - 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: - response.errors.append('{} {}{}: {}'.format(property, value, - args, v)) - else: - response.output[k] = v - - return response # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/media/webtorrent.py b/platypush/plugins/media/webtorrent.py index 4a8baf49..31c3e69f 100644 --- a/platypush/plugins/media/webtorrent.py +++ b/platypush/plugins/media/webtorrent.py @@ -39,7 +39,8 @@ class MediaWebtorrentPlugin(MediaPlugin): or media.omxplayer) """ - _supported_media_plugins = { 'media.mplayer', 'media.omxplayer' } + _supported_media_plugins = {'media.mplayer', 'media.omxplayer', + 'media.webtorrent'} # Download at least 10 MBs before starting streaming _download_size_before_streaming = 10 * 2**20 @@ -258,6 +259,8 @@ class MediaWebtorrentPlugin(MediaPlugin): if media_cls == 'MediaMplayerPlugin': stop_evt = player._mplayer_stopped_event + elif media_cls == 'MediaMpvPlugin': + stop_evt = player._mpv_stopped_event elif media_cls == 'MediaOmxplayerPlugin': stop_evt = threading.Event() def stop_callback(): diff --git a/requirements.txt b/requirements.txt index 70e6d6cc..3cb23837 100644 --- a/requirements.txt +++ b/requirements.txt @@ -131,3 +131,6 @@ inputs # Mopidy backend websocket-client +# mpv player plugin +python-mpv + diff --git a/setup.py b/setup.py index a408be96..a3cd1ae2 100755 --- a/setup.py +++ b/setup.py @@ -97,6 +97,7 @@ setup( 'Support for sound devices': ['sounddevice', 'soundfile', 'numpy'], 'Support for web media subtitles': ['webvtt-py'], 'Support for mopidy backend': ['websocket-client'], + 'Support for mpv player plugin': ['python-mpv'], # 'Support for Leap Motion backend': ['git+ssh://git@github.com:BlackLight/leap-sdk-python3.git'], # 'Support for Flic buttons': ['git+https://@github.com/50ButtonsEach/fliclib-linux-hci.git'] # 'Support for media subtitles': ['git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles']