595 lines
20 KiB
Python
595 lines
20 KiB
Python
import datetime
|
|
import re
|
|
import time
|
|
|
|
from platypush.context import get_plugin, get_bus
|
|
from platypush.plugins import action
|
|
from platypush.plugins.media import MediaPlugin
|
|
from platypush.utils import get_mime_type
|
|
from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \
|
|
MediaStopEvent, MediaPauseEvent, NewPlayingMediaEvent, MediaVolumeChangedEvent, MediaSeekEvent
|
|
|
|
|
|
def convert_status(status):
|
|
attrs = [a for a in dir(status) if not a.startswith('_')
|
|
and not callable(getattr(status, a))]
|
|
|
|
renamed_attrs = {
|
|
'current_time': 'position',
|
|
'player_state': 'state',
|
|
'supports_seek': 'seekable',
|
|
'volume_level': 'volume',
|
|
'volume_muted': 'mute',
|
|
'content_id': 'url',
|
|
}
|
|
|
|
ret = {}
|
|
for attr in attrs:
|
|
value = getattr(status, attr)
|
|
if attr == 'volume_level':
|
|
value *= 100
|
|
if attr == 'player_state':
|
|
value = value.lower()
|
|
if value == 'paused':
|
|
value = 'pause'
|
|
if value == 'playing':
|
|
value = 'play'
|
|
if isinstance(value, datetime.datetime):
|
|
value = value.isoformat()
|
|
|
|
if attr in renamed_attrs:
|
|
ret[renamed_attrs[attr]] = value
|
|
else:
|
|
ret[attr] = value
|
|
|
|
return ret
|
|
|
|
|
|
def post_event(evt_type, **evt):
|
|
bus = get_bus()
|
|
bus.post(evt_type(player=evt.get('device'), plugin='media.chromecast', **evt))
|
|
|
|
|
|
class MediaChromecastPlugin(MediaPlugin):
|
|
"""
|
|
Plugin to interact with Chromecast devices
|
|
|
|
Supported formats:
|
|
|
|
* HTTP media URLs
|
|
* YouTube URLs
|
|
* Plex (through ``media.plex`` plugin, experimental)
|
|
|
|
Requires:
|
|
|
|
* **pychromecast** (``pip install pychromecast``)
|
|
"""
|
|
|
|
STREAM_TYPE_UNKNOWN = "UNKNOWN"
|
|
STREAM_TYPE_BUFFERED = "BUFFERED"
|
|
STREAM_TYPE_LIVE = "LIVE"
|
|
|
|
class MediaListener:
|
|
def __init__(self, name, cast):
|
|
self.name = name
|
|
self.cast = cast
|
|
self.status = convert_status(cast.media_controller.status)
|
|
self.last_status_timestamp = time.time()
|
|
|
|
def new_media_status(self, status):
|
|
status = convert_status(status)
|
|
|
|
if status.get('url') and status.get('url') != self.status.get('url'):
|
|
post_event(NewPlayingMediaEvent, resource=status['url'],
|
|
title=status.get('title'), device=self.name)
|
|
if status.get('state') != self.status.get('state'):
|
|
if status.get('state') == 'play':
|
|
post_event(MediaPlayEvent, resource=status['url'], device=self.name)
|
|
elif status.get('state') == 'pause':
|
|
post_event(MediaPauseEvent, resource=status['url'], device=self.name)
|
|
elif status.get('state') in ['stop', 'idle']:
|
|
post_event(MediaStopEvent, device=self.name)
|
|
if status.get('volume') != self.status.get('volume'):
|
|
post_event(MediaVolumeChangedEvent, volume=status.get('volume'), device=self.name)
|
|
if abs(status.get('position') - self.status.get('position')) > time.time() - self.last_status_timestamp + 5:
|
|
post_event(MediaSeekEvent, position=status.get('position'), device=self.name)
|
|
|
|
self.last_status_timestamp = time.time()
|
|
self.status = status
|
|
|
|
class SubtitlesAsyncHandler:
|
|
def __init__(self, mc, subtitle_id):
|
|
self.mc = mc
|
|
self.subtitle_id = subtitle_id
|
|
self.initialized = False
|
|
|
|
# pylint: disable=unused-argument
|
|
def new_media_status(self, status):
|
|
if self.subtitle_id and not self.initialized:
|
|
self.mc.update_status()
|
|
self.mc.enable_subtitle(self.subtitle_id)
|
|
self.initialized = True
|
|
|
|
def __init__(self, chromecast=None, *args, **kwargs):
|
|
"""
|
|
:param chromecast: Default Chromecast to cast to if no name is specified
|
|
:type chromecast: str
|
|
"""
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self._is_local = False
|
|
self.chromecast = chromecast
|
|
self.chromecasts = {}
|
|
self._media_listeners = {}
|
|
|
|
@staticmethod
|
|
def _get_chromecasts(*args, **kwargs):
|
|
import pychromecast
|
|
chromecasts = pychromecast.get_chromecasts(*args, **kwargs)
|
|
if isinstance(chromecasts, tuple):
|
|
return chromecasts[0]
|
|
return chromecasts
|
|
|
|
@action
|
|
def get_chromecasts(self, tries=2, retry_wait=10, timeout=60,
|
|
blocking=True, callback=None):
|
|
"""
|
|
Get the list of Chromecast devices
|
|
|
|
:param tries: Number of retries (default: 2)
|
|
:type tries: int
|
|
|
|
:param retry_wait: Number of seconds between retries (default: 10 seconds)
|
|
:type retry_wait: int
|
|
|
|
:param timeout: Timeout before failing the call (default: 60 seconds)
|
|
:type timeout: int
|
|
|
|
:param blocking: If true, then the function will block until all the Chromecast
|
|
devices have been scanned. If false, then the provided callback function will be
|
|
invoked when a new device is discovered
|
|
:type blocking: bool
|
|
|
|
:param callback: If blocking is false, then you can provide a callback function that
|
|
will be invoked when a new device is discovered
|
|
:type callback: func
|
|
"""
|
|
|
|
self.chromecasts.update({
|
|
cast.device.friendly_name: cast
|
|
for cast in self._get_chromecasts(tries=tries, retry_wait=retry_wait,
|
|
timeout=timeout, blocking=blocking,
|
|
callback=callback)
|
|
})
|
|
|
|
for name, cast in self.chromecasts.items():
|
|
self._update_listeners(name, cast)
|
|
|
|
return [{
|
|
'type': cc.cast_type,
|
|
'name': cc.name,
|
|
'manufacturer': cc.device.manufacturer,
|
|
'model_name': cc.model_name,
|
|
'uuid': str(cc.device.uuid),
|
|
'address': cc.host if hasattr(cc, 'host') else cc.uri.split(':')[0],
|
|
'port': cc.port if hasattr(cc, 'port') else int(cc.uri.split(':')[1]),
|
|
|
|
'status': ({
|
|
'app': {
|
|
'id': cc.app_id,
|
|
'name': cc.app_display_name,
|
|
},
|
|
|
|
'media': self.status(cc.name).output,
|
|
'is_active_input': cc.status.is_active_input,
|
|
'is_stand_by': cc.status.is_stand_by,
|
|
'is_idle': cc.is_idle,
|
|
'namespaces': cc.status.namespaces,
|
|
'volume': round(100*cc.status.volume_level, 2),
|
|
'muted': cc.status.volume_muted,
|
|
} if cc.status else {}),
|
|
} for cc in self.chromecasts.values()]
|
|
|
|
def _update_listeners(self, name, cast):
|
|
if name not in self._media_listeners:
|
|
cast.start()
|
|
self._media_listeners[name] = self.MediaListener(name=name, cast=cast)
|
|
cast.media_controller.register_status_listener(self._media_listeners[name])
|
|
|
|
def get_chromecast(self, chromecast=None, n_tries=2):
|
|
import pychromecast
|
|
if isinstance(chromecast, pychromecast.Chromecast):
|
|
return chromecast
|
|
|
|
if not chromecast:
|
|
if not self.chromecast:
|
|
raise RuntimeError('No Chromecast specified nor default Chromecast configured')
|
|
chromecast = self.chromecast
|
|
|
|
if chromecast not in self.chromecasts:
|
|
casts = {}
|
|
while n_tries > 0:
|
|
n_tries -= 1
|
|
casts.update({
|
|
cast.device.friendly_name: cast
|
|
for cast in self._get_chromecasts()
|
|
})
|
|
|
|
if chromecast in casts:
|
|
self.chromecasts.update(casts)
|
|
break
|
|
|
|
if chromecast not in self.chromecasts:
|
|
raise RuntimeError('Device {} not found'.format(chromecast))
|
|
|
|
cast = self.chromecasts[chromecast]
|
|
cast.wait()
|
|
return cast
|
|
|
|
@action
|
|
def play(self, resource, content_type=None, chromecast=None, title=None,
|
|
image_url=None, autoplay=True, current_time=0,
|
|
stream_type=STREAM_TYPE_BUFFERED, subtitles=None,
|
|
subtitles_lang='en-US', subtitles_mime='text/vtt',
|
|
subtitle_id=1):
|
|
"""
|
|
Cast media to a visible Chromecast
|
|
|
|
:param resource: Media to cast
|
|
:type resource: str
|
|
|
|
:param content_type: Content type as a MIME type string
|
|
:type content_type: str
|
|
|
|
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
|
will be used.
|
|
:type chromecast: str
|
|
|
|
:param title: Optional title
|
|
:type title: str
|
|
|
|
:param image_url: URL of the image to use for the thumbnail
|
|
:type image_url: str
|
|
|
|
:param autoplay: Set it to false if you don't want the content to start playing immediately (default: true)
|
|
:type autoplay: bool
|
|
|
|
:param current_time: Time to start the playback in seconds (default: 0)
|
|
:type current_time: int
|
|
|
|
:param stream_type: Type of stream to cast. Can be BUFFERED (default), LIVE or UNKNOWN
|
|
:type stream_type: str
|
|
|
|
:param subtitles: URL of the subtitles to be shown
|
|
:type subtitles: str
|
|
|
|
:param subtitles_lang: Subtitles language (default: en-US)
|
|
:type subtitles_lang: str
|
|
|
|
:param subtitles_mime: Subtitles MIME type (default: text/vtt)
|
|
:type subtitles_mime: str
|
|
|
|
:param subtitle_id: ID of the subtitles to be loaded (default: 1)
|
|
:type subtitle_id: int
|
|
"""
|
|
|
|
from pychromecast.controllers.youtube import YouTubeController
|
|
if not chromecast:
|
|
chromecast = self.chromecast
|
|
|
|
post_event(MediaPlayRequestEvent, resource=resource, device=chromecast)
|
|
cast = self.get_chromecast(chromecast)
|
|
|
|
mc = cast.media_controller
|
|
yt = self._get_youtube_url(resource)
|
|
|
|
if yt:
|
|
self.logger.info('Playing YouTube video {} on {}'.format(
|
|
yt, chromecast))
|
|
|
|
hndl = YouTubeController()
|
|
cast.register_handler(hndl)
|
|
hndl.update_screen_id()
|
|
return hndl.play_video(yt)
|
|
|
|
resource = self._get_resource(resource)
|
|
|
|
if not content_type:
|
|
content_type = get_mime_type(resource)
|
|
|
|
if not content_type:
|
|
raise RuntimeError('content_type required to process media {}'.
|
|
format(resource))
|
|
|
|
if not resource.startswith('http://') and \
|
|
not resource.startswith('https://'):
|
|
resource = self.start_streaming(resource).output['url']
|
|
self.logger.info('HTTP media stream started on {}'.format(resource))
|
|
|
|
self.logger.info('Playing {} on {}'.format(resource, chromecast))
|
|
|
|
mc.play_media(resource, content_type, title=title, thumb=image_url,
|
|
current_time=current_time, autoplay=autoplay,
|
|
stream_type=stream_type, subtitles=subtitles,
|
|
subtitles_lang=subtitles_lang,
|
|
subtitles_mime=subtitles_mime, subtitle_id=subtitle_id)
|
|
|
|
if subtitles:
|
|
mc.register_status_listener(self.SubtitlesAsyncHandler(mc, subtitle_id))
|
|
|
|
mc.block_until_active()
|
|
|
|
if self.volume:
|
|
self.set_volume(volume=self.volume, chromecast=chromecast)
|
|
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@classmethod
|
|
def _get_youtube_url(cls, url):
|
|
m = re.match('https?://www.youtube.com/watch\?v=([^&]+).*', url)
|
|
if m:
|
|
return m.group(1)
|
|
|
|
m = re.match('youtube:video:(.*)', url)
|
|
if m:
|
|
return m.group(1)
|
|
|
|
return None
|
|
|
|
@action
|
|
def load(self, *args, **kwargs):
|
|
return self.play(*args, **kwargs)
|
|
|
|
@action
|
|
def pause(self, chromecast=None):
|
|
chromecast = chromecast or self.chromecast
|
|
cast = self.get_chromecast(chromecast)
|
|
|
|
if cast.media_controller.is_paused:
|
|
cast.media_controller.play()
|
|
elif cast.media_controller.is_playing:
|
|
cast.media_controller.pause()
|
|
|
|
cast.wait()
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@action
|
|
def stop(self, chromecast=None):
|
|
chromecast = chromecast or self.chromecast
|
|
cast = self.get_chromecast(chromecast)
|
|
cast.media_controller.stop()
|
|
cast.wait()
|
|
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@action
|
|
def rewind(self, chromecast=None):
|
|
chromecast = chromecast or self.chromecast
|
|
cast = self.get_chromecast(chromecast)
|
|
cast.media_controller.rewind()
|
|
cast.wait()
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@action
|
|
def set_position(self, position, chromecast=None):
|
|
cast = self.get_chromecast(chromecast or self.chromecast)
|
|
cast.media_controller.seek(position)
|
|
cast.wait()
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@action
|
|
def seek(self, position, chromecast=None):
|
|
return self.forward(chromecast=chromecast, offset=position)
|
|
|
|
@action
|
|
def back(self, chromecast=None, offset=30):
|
|
cast = self.get_chromecast(chromecast or self.chromecast)
|
|
mc = cast.media_controller
|
|
if mc.status.current_time:
|
|
mc.seek(mc.status.current_time-offset)
|
|
cast.wait()
|
|
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@action
|
|
def forward(self, chromecast=None, offset=30):
|
|
cast = self.get_chromecast(chromecast or self.chromecast)
|
|
mc = cast.media_controller
|
|
if mc.status.current_time:
|
|
mc.seek(mc.status.current_time+offset)
|
|
cast.wait()
|
|
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@action
|
|
def is_playing(self, chromecast=None):
|
|
return self.get_chromecast(chromecast or self.chromecast).media_controller.is_playing
|
|
|
|
@action
|
|
def is_paused(self, chromecast=None):
|
|
return self.get_chromecast(chromecast or self.chromecast).media_controller.is_paused
|
|
|
|
@action
|
|
def is_idle(self, chromecast=None):
|
|
return self.get_chromecast(chromecast or self.chromecast).media_controller.is_idle
|
|
|
|
@action
|
|
def list_subtitles(self, chromecast=None):
|
|
return self.get_chromecast(chromecast or self.chromecast) \
|
|
.media_controller.subtitle_tracks
|
|
|
|
@action
|
|
def enable_subtitles(self, chromecast=None, track_id=None):
|
|
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
|
if track_id is not None:
|
|
return mc.enable_subtitle(track_id)
|
|
elif mc.subtitle_tracks:
|
|
return mc.enable_subtitle(mc.subtitle_tracks[0].get('trackId'))
|
|
|
|
@action
|
|
def disable_subtitles(self, chromecast=None, track_id=None):
|
|
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
|
if track_id:
|
|
return mc.disable_subtitle(track_id)
|
|
elif mc.current_subtitle_tracks:
|
|
return mc.disable_subtitle(mc.current_subtitle_tracks[0])
|
|
|
|
@action
|
|
def toggle_subtitles(self, chromecast=None):
|
|
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
|
all_subs = mc.status.subtitle_tracks
|
|
cur_subs = mc.status.status.current_subtitle_tracks
|
|
|
|
if cur_subs:
|
|
return self.disable_subtitles(chromecast, cur_subs[0])
|
|
else:
|
|
return self.enable_subtitles(chromecast, all_subs[0].get('trackId'))
|
|
|
|
@action
|
|
def status(self, chromecast=None):
|
|
cast = self.get_chromecast(chromecast or self.chromecast)
|
|
status = cast.media_controller.status
|
|
return convert_status(status)
|
|
|
|
@action
|
|
def disconnect(self, chromecast=None, timeout=None, blocking=True):
|
|
"""
|
|
Disconnect a Chromecast and wait for it to terminate
|
|
|
|
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
|
will be used.
|
|
:type chromecast: str
|
|
|
|
:param timeout: Number of seconds to wait for disconnection (default: None: block until termination)
|
|
:type timeout: float
|
|
|
|
:param blocking: If set (default), then the code will wait until disconnection, otherwise it will return
|
|
immediately.
|
|
:type blocking: bool
|
|
"""
|
|
|
|
cast = self.get_chromecast(chromecast)
|
|
cast.disconnect(timeout=timeout, blocking=blocking)
|
|
|
|
@action
|
|
def join(self, chromecast=None, timeout=None):
|
|
"""
|
|
Blocks the thread until the Chromecast connection is terminated.
|
|
|
|
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
|
will be used.
|
|
:type chromecast: str
|
|
|
|
:param timeout: Number of seconds to wait for disconnection (default: None: block until termination)
|
|
:type timeout: float
|
|
"""
|
|
|
|
cast = self.get_chromecast(chromecast)
|
|
cast.join(timeout=timeout)
|
|
|
|
@action
|
|
def quit(self, chromecast=None):
|
|
"""
|
|
Exits the current app on the Chromecast
|
|
|
|
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
|
will be used.
|
|
:type chromecast: str
|
|
"""
|
|
|
|
cast = self.get_chromecast(chromecast)
|
|
cast.quit_app()
|
|
|
|
@action
|
|
def reboot(self, chromecast=None):
|
|
"""
|
|
Reboots the Chromecast
|
|
|
|
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
|
will be used.
|
|
:type chromecast: str
|
|
"""
|
|
|
|
cast = self.get_chromecast(chromecast)
|
|
cast.reboot()
|
|
|
|
@action
|
|
def set_volume(self, volume, chromecast=None):
|
|
"""
|
|
Set the Chromecast volume
|
|
|
|
:param volume: Volume to be set, between 0 and 100
|
|
:type volume: float
|
|
|
|
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
|
will be used.
|
|
:type chromecast: str
|
|
"""
|
|
|
|
chromecast = chromecast or self.chromecast
|
|
cast = self.get_chromecast(chromecast)
|
|
cast.set_volume(volume/100)
|
|
cast.wait()
|
|
status = self.status(chromecast=chromecast)
|
|
status.output['volume'] = volume
|
|
return status
|
|
|
|
@action
|
|
def volup(self, chromecast=None, step=10):
|
|
"""
|
|
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.
|
|
:type chromecast: str
|
|
|
|
:param step: Volume increment between 0 and 100 (default: 100%)
|
|
:type step: float
|
|
"""
|
|
|
|
chromecast = chromecast or self.chromecast
|
|
cast = self.get_chromecast(chromecast)
|
|
step /= 100
|
|
cast.volume_up(min(step, 1))
|
|
cast.wait()
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@action
|
|
def voldown(self, chromecast=None, step=10):
|
|
"""
|
|
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.
|
|
:type chromecast: str
|
|
|
|
:param step: Volume decrement between 0 and 100 (default: 100%)
|
|
:type step: float
|
|
"""
|
|
|
|
chromecast = chromecast or self.chromecast
|
|
cast = self.get_chromecast(chromecast)
|
|
step /= 100
|
|
cast.volume_down(max(step, 0))
|
|
cast.wait()
|
|
return self.status(chromecast=chromecast)
|
|
|
|
@action
|
|
def mute(self, chromecast=None):
|
|
"""
|
|
Toggle the mute status on the Chromecast
|
|
|
|
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
|
will be used.
|
|
:type chromecast: str
|
|
"""
|
|
|
|
chromecast = chromecast or self.chromecast
|
|
cast = self.get_chromecast(chromecast)
|
|
cast.set_volume_muted(not cast.status.volume_muted)
|
|
cast.wait()
|
|
return self.status(chromecast=chromecast)
|
|
|
|
|
|
# vim:sw=4:ts=4:et:
|