Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
613 lines (514 sloc) 20.3 KB
"""
Provides a controller for controlling the default media players
on the Chromecast.
"""
from datetime import datetime
import logging
from collections import namedtuple
import threading
from ..config import APP_MEDIA_RECEIVER
from . import BaseController
STREAM_TYPE_UNKNOWN = "UNKNOWN"
STREAM_TYPE_BUFFERED = "BUFFERED"
STREAM_TYPE_LIVE = "LIVE"
MEDIA_PLAYER_STATE_PLAYING = "PLAYING"
MEDIA_PLAYER_STATE_BUFFERING = "BUFFERING"
MEDIA_PLAYER_STATE_PAUSED = "PAUSED"
MEDIA_PLAYER_STATE_IDLE = "IDLE"
MEDIA_PLAYER_STATE_UNKNOWN = "UNKNOWN"
MESSAGE_TYPE = "type"
TYPE_EDIT_TRACKS_INFO = "EDIT_TRACKS_INFO"
TYPE_GET_STATUS = "GET_STATUS"
TYPE_LOAD = "LOAD"
TYPE_MEDIA_STATUS = "MEDIA_STATUS"
TYPE_PAUSE = "PAUSE"
TYPE_PLAY = "PLAY"
TYPE_QUEUE_NEXT = "QUEUE_NEXT"
TYPE_QUEUE_PREV = "QUEUE_PREV"
TYPE_SEEK = "SEEK"
TYPE_STOP = "STOP"
METADATA_TYPE_GENERIC = 0
METADATA_TYPE_TVSHOW = 1
METADATA_TYPE_MOVIE = 2
METADATA_TYPE_MUSICTRACK = 3
METADATA_TYPE_PHOTO = 4
# From www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js
CMD_SUPPORT_PAUSE = 1
CMD_SUPPORT_SEEK = 2
CMD_SUPPORT_STREAM_VOLUME = 4
CMD_SUPPORT_STREAM_MUTE = 8
# ALL_BASIC_MEDIA = PAUSE | SEEK | VOLUME | MUTE | EDIT_TRACKS | PLAYBACK_RATE
CMD_SUPPORT_ALL_BASIC_MEDIA = 12303
CMD_SUPPORT_QUEUE_NEXT = 64
CMD_SUPPORT_QUEUE_PREV = 128
CMD_SUPPORT_QUEUE_SHUFFLE = 256
CMD_SUPPORT_QUEUE_REPEAT_ALL = 1024
CMD_SUPPORT_QUEUE_REPEAT_ONE = 2048
CMD_SUPPORT_QUEUE_REPEAT = 3072
CMD_SUPPORT_SKIP_AD = 512
CMD_SUPPORT_EDIT_TRACKS = 4096
CMD_SUPPORT_PLAYBACK_RATE = 8192
CMD_SUPPORT_LIKE = 16384
CMD_SUPPORT_DISLIKE = 32768
CMD_SUPPORT_FOLLOW = 65536
CMD_SUPPORT_UNFOLLOW = 131072
CMD_SUPPORT_STREAM_TRANSFER = 262144
# Legacy?
CMD_SUPPORT_SKIP_FORWARD = 16
CMD_SUPPORT_SKIP_BACKWARD = 32
MediaImage = namedtuple("MediaImage", "url height width")
_LOGGER = logging.getLogger(__name__)
class MediaStatus:
""" Class to hold the media status. """
# pylint: disable=too-many-instance-attributes,too-many-public-methods
def __init__(self):
self.current_time = 0
self.content_id = None
self.content_type = None
self.duration = None
self.stream_type = STREAM_TYPE_UNKNOWN
self.idle_reason = None
self.media_session_id = None
self.playback_rate = 1
self.player_state = MEDIA_PLAYER_STATE_UNKNOWN
self.supported_media_commands = 0
self.volume_level = 1
self.volume_muted = False
self.media_custom_data = {}
self.media_metadata = {}
self.subtitle_tracks = {}
self.current_subtitle_tracks = []
self.last_updated = None
@property
def adjusted_current_time(self):
""" Returns calculated current seek time of media in seconds """
if self.player_state == MEDIA_PLAYER_STATE_PLAYING:
# Add time since last update
return (
self.current_time
+ (datetime.utcnow() - self.last_updated).total_seconds()
)
# Not playing, return last reported seek time
return self.current_time
@property
def metadata_type(self):
""" Type of meta data. """
return self.media_metadata.get("metadataType")
@property
def player_is_playing(self):
""" Return True if player is PLAYING. """
return (
self.player_state == MEDIA_PLAYER_STATE_PLAYING
or self.player_state == MEDIA_PLAYER_STATE_BUFFERING
)
@property
def player_is_paused(self):
""" Return True if player is PAUSED. """
return self.player_state == MEDIA_PLAYER_STATE_PAUSED
@property
def player_is_idle(self):
""" Return True if player is IDLE. """
return self.player_state == MEDIA_PLAYER_STATE_IDLE
@property
def media_is_generic(self):
""" Return True if media status represents generic media. """
return self.metadata_type == METADATA_TYPE_GENERIC
@property
def media_is_tvshow(self):
""" Return True if media status represents a tv show. """
return self.metadata_type == METADATA_TYPE_TVSHOW
@property
def media_is_movie(self):
""" Return True if media status represents a movie. """
return self.metadata_type == METADATA_TYPE_MOVIE
@property
def media_is_musictrack(self):
""" Return True if media status represents a musictrack. """
return self.metadata_type == METADATA_TYPE_MUSICTRACK
@property
def media_is_photo(self):
""" Return True if media status represents a photo. """
return self.metadata_type == METADATA_TYPE_PHOTO
@property
def stream_type_is_buffered(self):
""" Return True if stream type is BUFFERED. """
return self.stream_type == STREAM_TYPE_BUFFERED
@property
def stream_type_is_live(self):
""" Return True if stream type is LIVE. """
return self.stream_type == STREAM_TYPE_LIVE
@property
def title(self):
""" Return title of media. """
return self.media_metadata.get("title")
@property
def series_title(self):
""" Return series title if available. """
return self.media_metadata.get("seriesTitle")
@property
def season(self):
""" Return season if available. """
return self.media_metadata.get("season")
@property
def episode(self):
""" Return episode if available. """
return self.media_metadata.get("episode")
@property
def artist(self):
""" Return artist if available. """
return self.media_metadata.get("artist")
@property
def album_name(self):
""" Return album name if available. """
return self.media_metadata.get("albumName")
@property
def album_artist(self):
""" Return album artist if available. """
return self.media_metadata.get("albumArtist")
@property
def track(self):
""" Return track number if available. """
return self.media_metadata.get("track")
@property
def images(self):
""" Return a list of MediaImage objects for this media. """
return [
MediaImage(item.get("url"), item.get("height"), item.get("width"))
for item in self.media_metadata.get("images", [])
]
@property
def supports_pause(self):
""" True if PAUSE is supported. """
return bool(self.supported_media_commands & CMD_SUPPORT_PAUSE)
@property
def supports_seek(self):
""" True if SEEK is supported. """
return bool(self.supported_media_commands & CMD_SUPPORT_SEEK)
@property
def supports_stream_volume(self):
""" True if STREAM_VOLUME is supported. """
return bool(self.supported_media_commands & CMD_SUPPORT_STREAM_VOLUME)
@property
def supports_stream_mute(self):
""" True if STREAM_MUTE is supported. """
return bool(self.supported_media_commands & CMD_SUPPORT_STREAM_MUTE)
@property
def supports_skip_forward(self):
""" True if SKIP_FORWARD is supported. """
return bool(self.supported_media_commands & CMD_SUPPORT_SKIP_FORWARD)
@property
def supports_skip_backward(self):
""" True if SKIP_BACKWARD is supported. """
return bool(self.supported_media_commands & CMD_SUPPORT_SKIP_BACKWARD)
@property
def supports_queue_next(self):
""" True if QUEUE_NEXT is supported. """
return bool(self.supported_media_commands & CMD_SUPPORT_QUEUE_NEXT)
@property
def supports_queue_prev(self):
""" True if QUEUE_PREV is supported. """
return bool(self.supported_media_commands & CMD_SUPPORT_QUEUE_PREV)
def update(self, data):
""" New data will only contain the changed attributes. """
if not data.get("status", []):
return
status_data = data["status"][0]
media_data = status_data.get("media") or {}
volume_data = status_data.get("volume", {})
self.current_time = status_data.get("currentTime", self.current_time)
self.content_id = media_data.get("contentId", self.content_id)
self.content_type = media_data.get("contentType", self.content_type)
self.duration = media_data.get("duration", self.duration)
self.stream_type = media_data.get("streamType", self.stream_type)
self.idle_reason = status_data.get("idleReason", self.idle_reason)
self.media_session_id = status_data.get("mediaSessionId", self.media_session_id)
self.playback_rate = status_data.get("playbackRate", self.playback_rate)
self.player_state = status_data.get("playerState", self.player_state)
self.supported_media_commands = status_data.get(
"supportedMediaCommands", self.supported_media_commands
)
self.volume_level = volume_data.get("level", self.volume_level)
self.volume_muted = volume_data.get("muted", self.volume_muted)
self.media_custom_data = media_data.get("customData", self.media_custom_data)
self.media_metadata = media_data.get("metadata", self.media_metadata)
self.subtitle_tracks = media_data.get("tracks", self.subtitle_tracks)
self.current_subtitle_tracks = status_data.get(
"activeTrackIds", self.current_subtitle_tracks
)
self.last_updated = datetime.utcnow()
def __repr__(self):
info = {
"metadata_type": self.metadata_type,
"title": self.title,
"series_title": self.series_title,
"season": self.season,
"episode": self.episode,
"artist": self.artist,
"album_name": self.album_name,
"album_artist": self.album_artist,
"track": self.track,
"subtitle_tracks": self.subtitle_tracks,
"images": self.images,
"supports_pause": self.supports_pause,
"supports_seek": self.supports_seek,
"supports_stream_volume": self.supports_stream_volume,
"supports_stream_mute": self.supports_stream_mute,
"supports_skip_forward": self.supports_skip_forward,
"supports_skip_backward": self.supports_skip_backward,
}
info.update(self.__dict__)
return "<MediaStatus {}>".format(info)
# pylint: disable=too-many-public-methods
class MediaController(BaseController):
""" Controller to interact with Google media namespace. """
def __init__(self):
super(MediaController, self).__init__("urn:x-cast:com.google.cast.media")
self.media_session_id = 0
self.status = MediaStatus()
self.session_active_event = threading.Event()
self.app_id = APP_MEDIA_RECEIVER
self._status_listeners = []
def channel_connected(self):
""" Called when media channel is connected. Will update status. """
self.update_status()
def channel_disconnected(self):
""" Called when a media channel is disconnected. Will erase status. """
self.status = MediaStatus()
self._fire_status_changed()
def receive_message(self, message, data):
""" Called when a media message is received. """
if data[MESSAGE_TYPE] == TYPE_MEDIA_STATUS:
self._process_media_status(data)
return True
return False
def register_status_listener(self, listener):
""" Register a listener for new media statuses. A new status will
call listener.new_media_status(status) """
self._status_listeners.append(listener)
def update_status(self, callback_function_param=False):
""" Send message to update the status. """
self.send_message(
{MESSAGE_TYPE: TYPE_GET_STATUS}, callback_function=callback_function_param
)
def _send_command(self, command):
""" Send a command to the Chromecast on media channel. """
if self.status is None or self.status.media_session_id is None:
self.logger.warning(
"%s command requested but no session is active.", command[MESSAGE_TYPE]
)
return
command["mediaSessionId"] = self.status.media_session_id
self.send_message(command, inc_session_id=True)
@property
def is_playing(self):
""" Deprecated as of June 8, 2015. Use self.status.player_is_playing.
Returns if the Chromecast is playing. """
return self.status is not None and self.status.player_is_playing
@property
def is_paused(self):
""" Deprecated as of June 8, 2015. Use self.status.player_is_paused.
Returns if the Chromecast is paused. """
return self.status is not None and self.status.player_is_paused
@property
def is_idle(self):
""" Deprecated as of June 8, 2015. Use self.status.player_is_idle.
Returns if the Chromecast is idle on a media supported app. """
return self.status is not None and self.status.player_is_idle
@property
def title(self):
""" Deprecated as of June 8, 2015. Use self.status.title.
Return title of the current playing item. """
return None if not self.status else self.status.title
@property
def thumbnail(self):
""" Deprecated as of June 8, 2015. Use self.status.images.
Return thumbnail url of current playing item. """
if not self.status:
return None
images = self.status.images
return images[0].url if images else None
def play(self):
""" Send the PLAY command. """
self._send_command({MESSAGE_TYPE: TYPE_PLAY})
def pause(self):
""" Send the PAUSE command. """
self._send_command({MESSAGE_TYPE: TYPE_PAUSE})
def stop(self):
""" Send the STOP command. """
self._send_command({MESSAGE_TYPE: TYPE_STOP})
def rewind(self):
""" Starts playing the media from the beginning. """
self.seek(0)
def skip(self):
""" Skips rest of the media. Values less then -5 behaved flaky. """
self.seek(int(self.status.duration) - 5)
def seek(self, position):
""" Seek the media to a specific location. """
self._send_command(
{
MESSAGE_TYPE: TYPE_SEEK,
"currentTime": position,
"resumeState": "PLAYBACK_START",
}
)
def queue_next(self):
""" Send the QUEUE_NEXT command. """
self._send_command({MESSAGE_TYPE: TYPE_QUEUE_NEXT})
def queue_prev(self):
""" Send the QUEUE_PREV command. """
self._send_command({MESSAGE_TYPE: TYPE_QUEUE_PREV})
def enable_subtitle(self, track_id):
""" Enable specific text track. """
self._send_command(
{MESSAGE_TYPE: TYPE_EDIT_TRACKS_INFO, "activeTrackIds": [track_id]}
)
def disable_subtitle(self):
""" Disable subtitle. """
self._send_command({MESSAGE_TYPE: TYPE_EDIT_TRACKS_INFO, "activeTrackIds": []})
def block_until_active(self, timeout=None):
"""
Blocks thread until the media controller session is active on the
chromecast. The media controller only accepts playback control
commands when a media session is active.
If a session is already active then the method returns immediately.
:param timeout: a floating point number specifying a timeout for the
operation in seconds (or fractions thereof). Or None
to block forever.
"""
self.session_active_event.wait(timeout=timeout)
def _process_media_status(self, data):
""" Processes a STATUS message. """
self.status.update(data)
self.logger.debug("Media:Received status %s", data)
# Update session active threading event
if self.status.media_session_id is None:
self.session_active_event.clear()
else:
self.session_active_event.set()
self._fire_status_changed()
def _fire_status_changed(self):
""" Tells listeners of a changed status. """
for listener in self._status_listeners:
try:
listener.new_media_status(self.status)
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Exception thrown when calling media status " "callback"
)
# pylint: disable=too-many-arguments
def play_media(
self,
url,
content_type,
title=None,
thumb=None,
current_time=0,
autoplay=True,
stream_type=STREAM_TYPE_BUFFERED,
metadata=None,
subtitles=None,
subtitles_lang="en-US",
subtitles_mime="text/vtt",
subtitle_id=1,
):
"""
Plays media on the Chromecast. Start default media receiver if not
already started.
Parameters:
url: str - url of the media.
content_type: str - mime type. Example: 'video/mp4'.
title: str - title of the media.
thumb: str - thumbnail image url.
current_time: float - seconds from the beginning of the media
to start playback.
autoplay: bool - whether the media will automatically play.
stream_type: str - describes the type of media artifact as one of the
following: "NONE", "BUFFERED", "LIVE".
subtitles: str - url of subtitle file to be shown on chromecast.
subtitles_lang: str - language for subtitles.
subtitles_mime: str - mimetype of subtitles.
subtitle_id: int - id of subtitle to be loaded.
metadata: dict - media metadata object, one of the following:
GenericMediaMetadata, MovieMediaMetadata, TvShowMediaMetadata,
MusicTrackMediaMetadata, PhotoMediaMetadata.
Docs:
https://developers.google.com/cast/docs/reference/messages#MediaData
"""
# pylint: disable=too-many-locals
def app_launched_callback():
"""Plays media after chromecast has switched to requested app."""
self._send_start_play_media(
url,
content_type,
title,
thumb,
current_time,
autoplay,
stream_type,
metadata,
subtitles,
subtitles_lang,
subtitles_mime,
subtitle_id,
)
receiver_ctrl = self._socket_client.receiver_controller
receiver_ctrl.launch_app(self.app_id, callback_function=app_launched_callback)
def _send_start_play_media(
self,
url,
content_type,
title=None,
thumb=None,
current_time=0,
autoplay=True,
stream_type=STREAM_TYPE_BUFFERED,
metadata=None,
subtitles=None,
subtitles_lang="en-US",
subtitles_mime="text/vtt",
subtitle_id=1,
):
# pylint: disable=too-many-locals
msg = {
"media": {
"contentId": url,
"streamType": stream_type,
"contentType": content_type,
"metadata": metadata or {},
},
MESSAGE_TYPE: TYPE_LOAD,
"currentTime": current_time,
"autoplay": autoplay,
"customData": {},
}
if title:
msg["media"]["metadata"]["title"] = title
if thumb:
msg["media"]["metadata"]["thumb"] = thumb
if "images" not in msg["media"]["metadata"]:
msg["media"]["metadata"]["images"] = []
msg["media"]["metadata"]["images"].append({"url": thumb})
if subtitles:
sub_msg = [
{
"trackId": subtitle_id,
"trackContentId": subtitles,
"language": subtitles_lang,
"subtype": "SUBTITLES",
"type": "TEXT",
"trackContentType": subtitles_mime,
"name": "{} - {} Subtitle".format(subtitles_lang, subtitle_id),
}
]
msg["media"]["tracks"] = sub_msg
msg["media"]["textTrackStyle"] = {
"backgroundColor": "#FFFFFF00",
"edgeType": "OUTLINE",
"edgeColor": "#000000FF",
}
msg["activeTrackIds"] = [subtitle_id]
self.send_message(msg, inc_session_id=True)
def tear_down(self):
""" Called when controller is destroyed. """
super(MediaController, self).tear_down()
self._status_listeners[:] = []
You can’t perform that action at this time.