Support for casting torrents to Chromecast

This commit is contained in:
Fabio Manganiello 2019-02-05 02:30:20 +01:00
parent 3798414f22
commit dc2a686d23
5 changed files with 134 additions and 71 deletions

View file

@ -22,7 +22,7 @@ class MediaPlugin(Plugin):
Requires: Requires:
* A media player installed (supported so far: mplayer, omxplayer) * A media player installed (supported so far: mplayer, omxplayer, chromecast)
* **python-libtorrent** (``pip install python-libtorrent``), optional for Torrent support * **python-libtorrent** (``pip install python-libtorrent``), optional for Torrent support
* **youtube-dl** installed on your system (see your distro instructions), optional for YouTube support * **youtube-dl** installed on your system (see your distro instructions), optional for YouTube support
""" """
@ -53,7 +53,8 @@ class MediaPlugin(Plugin):
'f4b', 'f4b',
} }
_supported_media_plugins = { 'media.mplayer', 'media.omxplayer' } _supported_media_plugins = {'media.mplayer', 'media.omxplayer',
'media.chromecast'}
def __init__(self, media_dirs=[], download_dir=None, env=None, def __init__(self, media_dirs=[], download_dir=None, env=None,
*args, **kwargs): *args, **kwargs):
@ -75,10 +76,17 @@ class MediaPlugin(Plugin):
player = None player = None
player_config = {} player_config = {}
if self.__class__.__name__ == 'MediaPlugin':
# Abstract class, initialize with the default configured player
for plugin in Config.get_plugins().keys(): for plugin in Config.get_plugins().keys():
if plugin in self._supported_media_plugins: if plugin in self._supported_media_plugins:
player = plugin player = plugin
if get_plugin(player).is_local():
# Local players have priority as default if configured
break break
else:
player = self # Derived concrete class
if not player: if not player:
raise AttributeError('No media plugin configured') raise AttributeError('No media plugin configured')
@ -126,6 +134,10 @@ class MediaPlugin(Plugin):
if resource.startswith('youtube:') \ if resource.startswith('youtube:') \
or resource.startswith('https://www.youtube.com/watch?v='): or resource.startswith('https://www.youtube.com/watch?v='):
if self.__class.__.__name__ != 'MediaChromecastPlugin':
# The Chromecast has already its way to handle YouTube
return resource
resource = self._get_youtube_content(resource) resource = self._get_youtube_content(resource)
elif resource.startswith('magnet:?'): elif resource.startswith('magnet:?'):
try: try:

View file

@ -3,15 +3,18 @@ import pychromecast
from pychromecast.controllers.youtube import YouTubeController from pychromecast.controllers.youtube import YouTubeController
from platypush.context import get_plugin
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
from platypush.plugins.media import MediaPlugin
class MediaChromecastPlugin(Plugin): class MediaChromecastPlugin(MediaPlugin):
""" """
Plugin to interact with Chromecast devices Plugin to interact with Chromecast devices
Supported formats: Supported formats:
* HTTP media URLs
* YouTube URLs * YouTube URLs
* Plex (through ``media.plex`` plugin, experimental) * Plex (through ``media.plex`` plugin, experimental)
@ -32,6 +35,7 @@ class MediaChromecastPlugin(Plugin):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._is_local = False
self.chromecast = chromecast self.chromecast = chromecast
self.chromecasts = {} self.chromecasts = {}
@ -67,7 +71,10 @@ class MediaChromecastPlugin(Plugin):
} for cc in pychromecast.get_chromecasts() ] } for cc in pychromecast.get_chromecasts() ]
def get_chromecast(self, chromecast=None): def get_chromecast(self, chromecast=None, n_tries=3):
if isinstance(chromecast, pychromecast.Chromecast):
return chromecast
if not chromecast: if not chromecast:
if not self.chromecast: if not self.chromecast:
raise RuntimeError('No Chromecast specified nor default Chromecast configured') raise RuntimeError('No Chromecast specified nor default Chromecast configured')
@ -75,10 +82,17 @@ class MediaChromecastPlugin(Plugin):
if chromecast not in self.chromecasts: if chromecast not in self.chromecasts:
self.chromecasts = { casts = {}
while n_tries > 0:
n_tries -= 1
casts.update({
cast.device.friendly_name: cast cast.device.friendly_name: cast
for cast in pychromecast.get_chromecasts() for cast in pychromecast.get_chromecasts()
} })
if chromecast in casts:
self.chromecasts.update(casts)
break
if chromecast not in self.chromecasts: if chromecast not in self.chromecasts:
raise RuntimeError('Device {} not found'.format(chromecast)) raise RuntimeError('Device {} not found'.format(chromecast))
@ -87,7 +101,7 @@ class MediaChromecastPlugin(Plugin):
@action @action
def cast_media(self, media, content_type=None, chromecast=None, title=None, def play(self, resource, content_type=None, chromecast=None, title=None,
image_url=None, autoplay=True, current_time=0, image_url=None, autoplay=True, current_time=0,
stream_type=STREAM_TYPE_BUFFERED, subtitles=None, stream_type=STREAM_TYPE_BUFFERED, subtitles=None,
subtitles_lang='en-US', subtitles_mime='text/vtt', subtitles_lang='en-US', subtitles_mime='text/vtt',
@ -95,10 +109,10 @@ class MediaChromecastPlugin(Plugin):
""" """
Cast media to a visible Chromecast Cast media to a visible Chromecast
:param media: Media to cast :param resource: Media to cast
:type media: str :type resource: str
:param content_type: Content type :param content_type: Content type as a MIME type string
:type content_type: str :type content_type: str
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used.
@ -139,7 +153,7 @@ class MediaChromecastPlugin(Plugin):
cast.wait() cast.wait()
mc = cast.media_controller mc = cast.media_controller
yt = self._get_youtube_url(media) yt = self._get_youtube_url(resource)
if yt: if yt:
self.logger.info('Playing YouTube video {} on {}'.format( self.logger.info('Playing YouTube video {} on {}'.format(
@ -150,13 +164,33 @@ class MediaChromecastPlugin(Plugin):
hndl.update_screen_id() hndl.update_screen_id()
return hndl.play_video(yt) return hndl.play_video(yt)
resource = self._get_resource(resource)
if resource.startswith('magnet:?'):
player_args = { 'chromecast': cast }
return get_plugin('media.webtorrent').play(resource,
player='chromecast',
**player_args)
# Best effort from the extension
if not content_type:
for ext in self.video_extensions:
if ('.' + ext).lower() in resource.lower():
content_type = 'video/' + ext
break
if not content_type:
for ext in self.audio_extensions:
if ('.' + ext).lower() in resource.lower():
content_type = 'audio/' + ext
break
if not content_type: if not content_type:
raise RuntimeError('content_type required to process media {}'. raise RuntimeError('content_type required to process media {}'.
format(media)) format(resource))
self.logger.info('Playing {} on {}'.format(media, chromecast)) self.logger.info('Playing {} on {}'.format(resource, chromecast))
mc.play_media(media, content_type, title=title, thumb=image_url, mc.play_media(resource, content_type, title=title, thumb=image_url,
current_time=current_time, autoplay=autoplay, current_time=current_time, autoplay=autoplay,
stream_type=stream_type, subtitles=subtitles, stream_type=stream_type, subtitles=subtitles,
subtitles_lang=subtitles_lang, subtitles_mime=subtitles_mime, subtitles_lang=subtitles_lang, subtitles_mime=subtitles_mime,
@ -177,19 +211,12 @@ class MediaChromecastPlugin(Plugin):
return None return None
@action @action
def play(self, chromecast=None): def load(self, *args, **kwargs):
return self.get_chromecast(chromecast or self.chromecast).media_controller.play() return self.play(*args, **kwargs)
@action @action
def pause(self, chromecast=None): def pause(self, chromecast=None):
return self.get_chromecast(chromecast or self.chromecast).media_controller.pause()
@action
def toggle_pause(self, chromecast=None):
cast = self.get_chromecast(chromecast or self.chromecast) cast = self.get_chromecast(chromecast or self.chromecast)
if cast.media_controller.is_paused: if cast.media_controller.is_paused:
return cast.media_controller.play() return cast.media_controller.play()
@ -208,22 +235,25 @@ class MediaChromecastPlugin(Plugin):
@action @action
def seek(self, location, chromecast=None): def set_position(self, position, chromecast=None):
return self.get_chromecast(chromecast or self.chromecast).media_controller.seek(location) return self.get_chromecast(chromecast or self.chromecast).media_controller.seek(position)
@action
def seek(self, position, chromecast=None):
return self.forward(chromecast=chromecast, offset=position)
@action
def back(self, chromecast=None, offset=60):
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
if mc.status.current_time:
return mc.seek(mc.status.current_time-offset)
@action @action
def back(self, chromecast=None, delta=30): def forward(self, chromecast=None, offset=60):
mc = self.get_chromecast(chromecast or self.chromecast).media_controller mc = self.get_chromecast(chromecast or self.chromecast).media_controller
if mc.status.current_time: if mc.status.current_time:
return mc.seek(mc.status.current_time-delta) return mc.seek(mc.status.current_time+offset)
@action
def forward(self, chromecast=None, delta=30):
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
if mc.status.current_time:
return mc.seek(mc.status.current_time+delta)
@action @action
@ -288,7 +318,7 @@ class MediaChromecastPlugin(Plugin):
cast.join(timeout=timeout, blocking=blocking) cast.join(timeout=timeout, blocking=blocking)
@action @action
def quit_app(self, chromecast=None): def quit(self, chromecast=None):
""" """
Exits the current app on the Chromecast Exits the current app on the Chromecast
@ -327,41 +357,41 @@ class MediaChromecastPlugin(Plugin):
cast.set_volume(volume/100) cast.set_volume(volume/100)
@action @action
def volume_up(self, chromecast=None, delta=10): def volup(self, chromecast=None, step=10):
""" """
Turn up the Chromecast volume by 10% or delta. Turn up the Chromecast volume by 10% or step.
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used.
:type chromecast: str :type chromecast: str
:param delta: Volume increment between 0 and 100 (default: 100%) :param step: Volume increment between 0 and 100 (default: 100%)
:type delta: float :type step: float
""" """
cast = self.get_chromecast(chromecast) cast = self.get_chromecast(chromecast)
delta /= 100 step /= 100
cast.volume_up(min(delta, 1)) cast.volume_up(min(step, 1))
@action @action
def volume_down(self, chromecast=None, delta=10): def voldown(self, chromecast=None, step=10):
""" """
Turn down the Chromecast volume by 10% or delta. Turn down the Chromecast volume by 10% or step.
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used.
:type chromecast: str :type chromecast: str
:param delta: Volume decrement between 0 and 100 (default: 100%) :param step: Volume decrement between 0 and 100 (default: 100%)
:type delta: float :type step: float
""" """
cast = self.get_chromecast(chromecast) cast = self.get_chromecast(chromecast)
delta /= 100 step /= 100
cast.volume_down(max(delta, 0)) cast.volume_down(max(step, 0))
@action @action
def toggle_mute(self, chromecast=None): def mute(self, chromecast=None):
""" """
Toggle the mute status on the Chromecast Toggle the mute status on the Chromecast
@ -374,4 +404,3 @@ class MediaChromecastPlugin(Plugin):
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -330,14 +330,14 @@ class MediaMplayerPlugin(MediaPlugin):
return self._exec('mute') return self._exec('mute')
@action @action
def seek(self, relative_position): def seek(self, position):
""" """
Seek backward/forward by the specified number of seconds Seek backward/forward by the specified number of seconds
:param relative_position: Number of seconds relative to the current cursor :param relative_position: Number of seconds relative to the current cursor
:type relative_position: int :type relative_position: int
""" """
return self.step_property('time_pos', offset) return self.step_property('time_pos', position)
@action @action
def set_position(self, position): def set_position(self, position):

View file

@ -17,7 +17,7 @@ from platypush.message.event.torrent import TorrentDownloadStartEvent, \
from platypush.plugins import action from platypush.plugins import action
from platypush.utils import find_bins_in_path, find_files_by_ext, \ from platypush.utils import find_bins_in_path, find_files_by_ext, \
is_process_alive is_process_alive, get_ip_or_hostname
class TorrentState(enum.Enum): class TorrentState(enum.Enum):
@ -26,6 +26,7 @@ class TorrentState(enum.Enum):
DOWNLOADING = 3 DOWNLOADING = 3
DOWNLOADED = 4 DOWNLOADED = 4
class MediaWebtorrentPlugin(MediaPlugin): class MediaWebtorrentPlugin(MediaPlugin):
""" """
Plugin to download and stream videos using webtorrent Plugin to download and stream videos using webtorrent
@ -118,7 +119,7 @@ class MediaWebtorrentPlugin(MediaPlugin):
return re.sub('\x1b\[((\d+m)|(.{1,2}))', '', line).strip() return re.sub('\x1b\[((\d+m)|(.{1,2}))', '', line).strip()
def _process_monitor(self, resource, download_dir): def _process_monitor(self, resource, download_dir, player_type, player_args):
def _thread(): def _thread():
if not self._webtorrent_process: if not self._webtorrent_process:
return return
@ -162,6 +163,9 @@ class MediaWebtorrentPlugin(MediaPlugin):
# Streaming started # Streaming started
webtorrent_url = re.search('server running at: (.+?)$', webtorrent_url = re.search('server running at: (.+?)$',
line, flags=re.IGNORECASE).group(1) line, flags=re.IGNORECASE).group(1)
webtorrent_url = webtorrent_url.replace(
'http://localhost', 'http://' + get_ip_or_hostname())
self.logger.info('Torrent stream started on {}'.format( self.logger.info('Torrent stream started on {}'.format(
webtorrent_url)) webtorrent_url))
@ -215,17 +219,19 @@ class MediaWebtorrentPlugin(MediaPlugin):
except FileNotFoundError: except FileNotFoundError:
continue continue
player = get_plugin('media.' + player_type) if player_type \
else self._media_plugin
media = media_file if player.is_local() else webtorrent_url
self.logger.info( self.logger.info(
'Starting playback of {} to {} through {}'.format( 'Starting playback of {} to {} through {}'.format(
media_file, self._media_plugin.__class__.__name__, media_file, player.__class__.__name__,
webtorrent_url)) webtorrent_url))
media = media_file if self._media_plugin.is_local() \ player.play(media, **player_args)
else webtorrent_url
self._media_plugin.play(media)
self.logger.info('Waiting for player to terminate') self.logger.info('Waiting for player to terminate')
self._wait_for_player() self._wait_for_player(player)
self.logger.info('Torrent player terminated') self.logger.info('Torrent player terminated')
bus.post(TorrentDownloadCompletedEvent(resource=resource)) bus.post(TorrentDownloadCompletedEvent(resource=resource))
@ -235,17 +241,17 @@ class MediaWebtorrentPlugin(MediaPlugin):
return _thread return _thread
def _wait_for_player(self): def _wait_for_player(self, player):
media_cls = self._media_plugin.__class__.__name__ media_cls = player.__class__.__name__
stop_evt = None stop_evt = None
if media_cls == 'MediaMplayerPlugin': if media_cls == 'MediaMplayerPlugin':
stop_evt = self._media_plugin._mplayer_stopped_event stop_evt = player._mplayer_stopped_event
elif media_cls == 'MediaOmxplayerPlugin': elif media_cls == 'MediaOmxplayerPlugin':
stop_evt = threading.Event() stop_evt = threading.Event()
def stop_callback(): def stop_callback():
stop_evt.set() stop_evt.set()
self._media_plugin.add_handler('stop', stop_callback) player.add_handler('stop', stop_callback)
if stop_evt: if stop_evt:
stop_evt.wait() stop_evt.wait()
@ -264,13 +270,22 @@ class MediaWebtorrentPlugin(MediaPlugin):
@action @action
def play(self, resource): def play(self, resource, player=None, **player_args):
""" """
Download and stream a torrent Download and stream a torrent
:param resource: Play a resource, as a magnet link, torrent URL or :param resource: Play a resource, as a magnet link, torrent URL or
torrent file path torrent file path
:type resource: str :type resource: str
:param player: If set, use this plugin type as a player for the
torrent. Supported types: 'mplayer', 'omxplayer', 'chromecast'.
If not set, then the default configured media plugin will be used.
:type player: str
:param player_args: Any arguments to pass to the player plugin's
play() method
:type player_args: dict
""" """
if self._webtorrent_process: if self._webtorrent_process:
@ -291,7 +306,8 @@ class MediaWebtorrentPlugin(MediaPlugin):
stdout=subprocess.PIPE) stdout=subprocess.PIPE)
threading.Thread(target=self._process_monitor( threading.Thread(target=self._process_monitor(
resource=resource, download_dir=download_dir)).start() resource=resource, download_dir=download_dir,
player_type=player, player_args=player_args)).start()
return { 'resource': resource } return { 'resource': resource }

View file

@ -6,6 +6,7 @@ import inspect
import logging import logging
import os import os
import signal import signal
import socket
import ssl import ssl
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -210,4 +211,9 @@ def is_process_alive(pid):
return False return False
def get_ip_or_hostname():
ip = socket.gethostbyname(socket.gethostname())
return socket.getfqdn() if ip.startswith('127.') else ip
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et: