forked from platypush/platypush
[media.chromecast
] Full plugin rewrite.
This commit is contained in:
parent
20aeb0b72e
commit
1f321c32dc
9 changed files with 517 additions and 373 deletions
|
@ -1,6 +1,7 @@
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from typing import Generator, Optional
|
from typing import Generator, Optional
|
||||||
|
|
||||||
from platypush.message import JSONAble
|
from platypush.message import JSONAble
|
||||||
|
@ -56,6 +57,9 @@ class MediaHandler(JSONAble, ABC):
|
||||||
logging.exception(e)
|
logging.exception(e)
|
||||||
errors[hndl_class.__name__] = str(e)
|
errors[hndl_class.__name__] = str(e)
|
||||||
|
|
||||||
|
if os.path.exists(source):
|
||||||
|
source = f'file://{source}'
|
||||||
|
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
f'The source {source} has no handlers associated. Errors: {errors}'
|
f'The source {source} has no handlers associated. Errors: {errors}'
|
||||||
)
|
)
|
||||||
|
|
|
@ -18,13 +18,15 @@ export default {
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
async getPlayers() {
|
async getPlayers() {
|
||||||
const devices = await this.request(`${this.pluginName}.get_chromecasts`)
|
const devices = Object.values(
|
||||||
|
await this.request(`${this.pluginName}.status`)
|
||||||
|
)
|
||||||
|
|
||||||
return Promise.all(devices.map(async (device) => {
|
return Promise.all(devices.map(async (device) => {
|
||||||
return {
|
return {
|
||||||
...device,
|
...device,
|
||||||
iconClass: device.type === 'audio' ? 'fa fa-volume-up' : 'fab fa-chromecast',
|
iconClass: device.type === 'audio' ? 'fa fa-volume-up' : 'fab fa-chromecast',
|
||||||
pluginName: this.pluginName,
|
pluginName: this.pluginName,
|
||||||
status: this.request(`${this.pluginName}.status`, {chromecast: device.name}),
|
|
||||||
component: this,
|
component: this,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
@ -41,7 +43,9 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
async status(player) {
|
async status(player) {
|
||||||
return await this.request(`${this.pluginName}.status`, {chromecast: this.getPlayerName(player)})
|
return (
|
||||||
|
await this.request(`${this.pluginName}.status`, {chromecast: this.getPlayerName(player)})
|
||||||
|
)?.status
|
||||||
},
|
},
|
||||||
|
|
||||||
async play(resource, player) {
|
async play(resource, player) {
|
||||||
|
@ -76,9 +80,8 @@ export default {
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
||||||
supports(resource) {
|
supports() {
|
||||||
return resource?.type === 'youtube' ||
|
return true
|
||||||
(resource.url || resource).startsWith('http://') || (resource.url || resource).startsWith('https://')
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ from platypush.message.event import Event
|
||||||
class MediaEvent(Event):
|
class MediaEvent(Event):
|
||||||
"""Base class for media events"""
|
"""Base class for media events"""
|
||||||
|
|
||||||
def __init__(self, player=None, plugin=None, *args, **kwargs):
|
def __init__(self, player=None, plugin=None, status=None, *args, **kwargs):
|
||||||
super().__init__(player=player, plugin=plugin, *args, **kwargs)
|
super().__init__(player=player, plugin=plugin, status=status, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayRequestEvent(MediaEvent):
|
class MediaPlayRequestEvent(MediaEvent):
|
||||||
|
@ -13,13 +13,22 @@ class MediaPlayRequestEvent(MediaEvent):
|
||||||
Event triggered when a new media playback request is received
|
Event triggered when a new media playback request is received
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, player=None, plugin=None, resource=None, title=None, *args, **kwargs):
|
def __init__(
|
||||||
|
self, player=None, plugin=None, resource=None, title=None, *args, **kwargs
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
:param resource: File name or URI of the played video
|
:param resource: File name or URI of the played video
|
||||||
:type resource: str
|
:type resource: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(*args, player=player, plugin=plugin, resource=resource, title=title, **kwargs)
|
super().__init__(
|
||||||
|
*args,
|
||||||
|
player=player,
|
||||||
|
plugin=plugin,
|
||||||
|
resource=resource,
|
||||||
|
title=title,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayEvent(MediaEvent):
|
class MediaPlayEvent(MediaEvent):
|
||||||
|
@ -27,13 +36,22 @@ class MediaPlayEvent(MediaEvent):
|
||||||
Event triggered when a new media content is played
|
Event triggered when a new media content is played
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, player=None, plugin=None, resource=None, title=None, *args, **kwargs):
|
def __init__(
|
||||||
|
self, player=None, plugin=None, resource=None, title=None, *args, **kwargs
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
:param resource: File name or URI of the played video
|
:param resource: File name or URI of the played video
|
||||||
:type resource: str
|
:type resource: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(*args, player=player, plugin=plugin, resource=resource, title=title, **kwargs)
|
super().__init__(
|
||||||
|
*args,
|
||||||
|
player=player,
|
||||||
|
plugin=plugin,
|
||||||
|
resource=resource,
|
||||||
|
title=title,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MediaStopEvent(MediaEvent):
|
class MediaStopEvent(MediaEvent):
|
||||||
|
@ -69,7 +87,9 @@ class MediaSeekEvent(MediaEvent):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, position, player=None, plugin=None, *args, **kwargs):
|
def __init__(self, position, player=None, plugin=None, *args, **kwargs):
|
||||||
super().__init__(*args, player=player, plugin=plugin, position=position, **kwargs)
|
super().__init__(
|
||||||
|
*args, player=player, plugin=plugin, position=position, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MediaVolumeChangedEvent(MediaEvent):
|
class MediaVolumeChangedEvent(MediaEvent):
|
||||||
|
@ -101,7 +121,9 @@ class NewPlayingMediaEvent(MediaEvent):
|
||||||
:type resource: str
|
:type resource: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(*args, player=player, plugin=plugin, resource=resource, **kwargs)
|
super().__init__(
|
||||||
|
*args, player=player, plugin=plugin, resource=resource, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -326,11 +326,11 @@ class MediaPlugin(Plugin, ABC):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if resource.startswith('file://'):
|
if resource.startswith('file://'):
|
||||||
resource = resource[len('file://') :]
|
path = resource[len('file://') :]
|
||||||
assert os.path.isfile(resource), f'File {resource} not found'
|
assert os.path.isfile(path), f'File {path} not found'
|
||||||
self._latest_resource = MediaResource(
|
self._latest_resource = MediaResource(
|
||||||
resource=resource,
|
resource=resource,
|
||||||
url=f'file://{resource}',
|
url=resource,
|
||||||
title=os.path.basename(resource),
|
title=os.path.basename(resource),
|
||||||
filename=os.path.basename(resource),
|
filename=os.path.basename(resource),
|
||||||
)
|
)
|
||||||
|
@ -635,7 +635,11 @@ class MediaPlugin(Plugin, ABC):
|
||||||
}
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
return self._start_streaming(media, subtitles=subtitles, download=download)
|
||||||
|
|
||||||
|
def _start_streaming(
|
||||||
|
self, media: str, subtitles: Optional[str] = None, download: bool = False
|
||||||
|
):
|
||||||
http = get_backend('http')
|
http = get_backend('http')
|
||||||
assert http, f'Unable to stream {media}: HTTP backend not configured'
|
assert http, f'Unable to stream {media}: HTTP backend not configured'
|
||||||
|
|
||||||
|
@ -715,6 +719,7 @@ class MediaPlugin(Plugin, ABC):
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def get_youtube_info(self, url):
|
def get_youtube_info(self, url):
|
||||||
|
# Legacy conversion for Mopidy YouTube URIs
|
||||||
m = re.match('youtube:video:(.*)', url)
|
m = re.match('youtube:video:(.*)', url)
|
||||||
if m:
|
if m:
|
||||||
url = f'https://www.youtube.com/watch?v={m.group(1)}'
|
url = f'https://www.youtube.com/watch?v={m.group(1)}'
|
||||||
|
@ -759,7 +764,7 @@ class MediaPlugin(Plugin, ABC):
|
||||||
.pop()
|
.pop()
|
||||||
.strip(),
|
.strip(),
|
||||||
)
|
)
|
||||||
.group(1)
|
.group(1) # type: ignore
|
||||||
.split(':')[::-1]
|
.split(':')[::-1]
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -797,7 +802,7 @@ class MediaPlugin(Plugin, ABC):
|
||||||
return self._is_local
|
return self._is_local
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_subtitles_file(subtitles):
|
def get_subtitles_file(subtitles: Optional[str] = None):
|
||||||
if not subtitles:
|
if not subtitles:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
@ -1,156 +1,49 @@
|
||||||
import datetime
|
import threading
|
||||||
import re
|
from typing import Callable, Optional
|
||||||
import time
|
|
||||||
|
|
||||||
from platypush.context import get_bus
|
import pychromecast # type: ignore
|
||||||
from platypush.plugins import action
|
|
||||||
|
from platypush.backend.http.app.utils import get_remote_base_url
|
||||||
|
from platypush.plugins import RunnablePlugin, action
|
||||||
from platypush.plugins.media import MediaPlugin
|
from platypush.plugins.media import MediaPlugin
|
||||||
from platypush.utils import get_mime_type
|
from platypush.utils import get_mime_type
|
||||||
from platypush.message.event.media import (
|
from platypush.message.event.media import MediaPlayRequestEvent
|
||||||
MediaPlayEvent,
|
|
||||||
MediaPlayRequestEvent,
|
from ._listener import MediaListener
|
||||||
MediaStopEvent,
|
from ._subtitles import SubtitlesAsyncHandler
|
||||||
MediaPauseEvent,
|
from ._utils import convert_status, post_event
|
||||||
NewPlayingMediaEvent,
|
|
||||||
MediaVolumeChangedEvent,
|
|
||||||
MediaSeekEvent,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_status(status):
|
class MediaChromecastPlugin(MediaPlugin, RunnablePlugin):
|
||||||
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
|
Plugin to control Chromecast devices.
|
||||||
|
|
||||||
Supported formats:
|
|
||||||
|
|
||||||
* HTTP media URLs
|
|
||||||
* YouTube URLs
|
|
||||||
* Plex (through ``media.plex`` plugin, experimental)
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
STREAM_TYPE_UNKNOWN = "UNKNOWN"
|
STREAM_TYPE_UNKNOWN = "UNKNOWN"
|
||||||
STREAM_TYPE_BUFFERED = "BUFFERED"
|
STREAM_TYPE_BUFFERED = "BUFFERED"
|
||||||
STREAM_TYPE_LIVE = "LIVE"
|
STREAM_TYPE_LIVE = "LIVE"
|
||||||
|
|
||||||
class MediaListener:
|
def __init__(
|
||||||
def __init__(self, name, cast):
|
self, chromecast: Optional[str] = None, poll_interval: float = 30, **kwargs
|
||||||
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,
|
|
||||||
)
|
|
||||||
# noinspection PyUnresolvedReferences
|
|
||||||
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, *_):
|
|
||||||
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
|
:param chromecast: Default Chromecast to cast to if no name is specified.
|
||||||
:type chromecast: str
|
:param poll_interval: How often the plugin should poll for new/removed
|
||||||
|
Chromecast devices (default: 30 seconds).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(poll_interval=poll_interval, **kwargs)
|
||||||
|
|
||||||
self._is_local = False
|
self._is_local = False
|
||||||
self.chromecast = chromecast
|
self.chromecast = chromecast
|
||||||
self.chromecasts = {}
|
self.chromecasts = {}
|
||||||
self._media_listeners = {}
|
self._media_listeners = {}
|
||||||
|
self._refresh_lock = threading.RLock()
|
||||||
|
|
||||||
@staticmethod
|
def _get_chromecasts(self, *args, **kwargs):
|
||||||
def _get_chromecasts(*args, **kwargs):
|
with self._refresh_lock:
|
||||||
import pychromecast
|
|
||||||
|
|
||||||
chromecasts = pychromecast.get_chromecasts(*args, **kwargs)
|
chromecasts = pychromecast.get_chromecasts(*args, **kwargs)
|
||||||
|
|
||||||
if isinstance(chromecasts, tuple):
|
if isinstance(chromecasts, tuple):
|
||||||
return chromecasts[0]
|
return chromecasts[0]
|
||||||
return chromecasts
|
return chromecasts
|
||||||
|
@ -161,49 +54,11 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
return getattr(cc.device, prop)
|
return getattr(cc.device, prop)
|
||||||
return getattr(cc.cast_info, prop)
|
return getattr(cc.cast_info, prop)
|
||||||
|
|
||||||
@action
|
def _serialize_device(self, cc: pychromecast.Chromecast) -> dict:
|
||||||
def get_chromecasts(
|
|
||||||
self, tries=2, retry_wait=10, timeout=60, blocking=True, callback=None
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Get the list of Chromecast devices
|
Convert a Chromecast object and its status to a dictionary.
|
||||||
|
|
||||||
: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(
|
return {
|
||||||
{
|
|
||||||
self._get_device_property(cast, '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,
|
'type': cc.cast_type,
|
||||||
'name': cc.name,
|
'name': cc.name,
|
||||||
'manufacturer': self._get_device_property(cc, 'manufacturer'),
|
'manufacturer': self._get_device_property(cc, 'manufacturer'),
|
||||||
|
@ -217,31 +72,71 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
'id': cc.app_id,
|
'id': cc.app_id,
|
||||||
'name': cc.app_display_name,
|
'name': cc.app_display_name,
|
||||||
},
|
},
|
||||||
'media': self.status(cc.name).output,
|
|
||||||
'is_active_input': cc.status.is_active_input,
|
'is_active_input': cc.status.is_active_input,
|
||||||
'is_stand_by': cc.status.is_stand_by,
|
'is_stand_by': cc.status.is_stand_by,
|
||||||
'is_idle': cc.is_idle,
|
'is_idle': cc.is_idle,
|
||||||
'namespaces': cc.status.namespaces,
|
'namespaces': cc.status.namespaces,
|
||||||
'volume': round(100 * cc.status.volume_level, 2),
|
'volume': round(100 * cc.status.volume_level, 2),
|
||||||
'muted': cc.status.volume_muted,
|
'muted': cc.status.volume_muted,
|
||||||
|
**convert_status(cc.media_controller.status),
|
||||||
}
|
}
|
||||||
if cc.status
|
if cc.status
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
for cc in self.chromecasts.values()
|
|
||||||
]
|
def _refresh_chromecasts(
|
||||||
|
self,
|
||||||
|
tries: int = 2,
|
||||||
|
retry_wait: float = 10,
|
||||||
|
timeout: float = 60,
|
||||||
|
blocking: bool = True,
|
||||||
|
callback: Optional[Callable] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the list of Chromecast devices
|
||||||
|
|
||||||
|
:param tries: Number of retries (default: 2)
|
||||||
|
:param retry_wait: Number of seconds between retries (default: 10 seconds)
|
||||||
|
:param timeout: Timeout before failing the call (default: 60 seconds)
|
||||||
|
: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
|
||||||
|
:param callback: If blocking is false, then you can provide a callback
|
||||||
|
function that will be invoked when a new device is discovered
|
||||||
|
"""
|
||||||
|
self.chromecasts = {
|
||||||
|
self._get_device_property(cast, '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)
|
||||||
|
|
||||||
|
for cast in self.chromecasts.values():
|
||||||
|
cast.wait()
|
||||||
|
|
||||||
|
def _event_callback(self, _, cast: pychromecast.Chromecast):
|
||||||
|
with self._refresh_lock:
|
||||||
|
self.chromecasts[self._get_device_property(cast, 'friendly_name')] = cast
|
||||||
|
|
||||||
def _update_listeners(self, name, cast):
|
def _update_listeners(self, name, cast):
|
||||||
if name not in self._media_listeners:
|
if name not in self._media_listeners:
|
||||||
cast.start()
|
cast.start()
|
||||||
self._media_listeners[name] = self.MediaListener(name=name, cast=cast)
|
self._media_listeners[name] = MediaListener(
|
||||||
|
name=name, cast=cast, callback=self._event_callback
|
||||||
|
)
|
||||||
cast.media_controller.register_status_listener(self._media_listeners[name])
|
cast.media_controller.register_status_listener(self._media_listeners[name])
|
||||||
|
|
||||||
def get_chromecast(self, chromecast=None, n_tries=2):
|
def get_chromecast(self, chromecast=None, n_tries=2):
|
||||||
import pychromecast
|
|
||||||
|
|
||||||
if isinstance(chromecast, pychromecast.Chromecast):
|
if isinstance(chromecast, pychromecast.Chromecast):
|
||||||
|
assert chromecast, 'Invalid Chromecast object'
|
||||||
return chromecast
|
return chromecast
|
||||||
|
|
||||||
if not chromecast:
|
if not chromecast:
|
||||||
|
@ -267,7 +162,7 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
break
|
break
|
||||||
|
|
||||||
if chromecast not in self.chromecasts:
|
if chromecast not in self.chromecasts:
|
||||||
raise RuntimeError('Device {} not found'.format(chromecast))
|
raise RuntimeError(f'Device {chromecast} not found')
|
||||||
|
|
||||||
cast = self.chromecasts[chromecast]
|
cast = self.chromecasts[chromecast]
|
||||||
cast.wait()
|
cast.wait()
|
||||||
|
@ -276,99 +171,72 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
@action
|
@action
|
||||||
def play(
|
def play(
|
||||||
self,
|
self,
|
||||||
resource,
|
resource: str,
|
||||||
content_type=None,
|
*_,
|
||||||
chromecast=None,
|
content_type: Optional[str] = None,
|
||||||
title=None,
|
chromecast: Optional[str] = None,
|
||||||
image_url=None,
|
title: Optional[str] = None,
|
||||||
autoplay=True,
|
image_url: Optional[str] = None,
|
||||||
current_time=0,
|
autoplay: bool = True,
|
||||||
stream_type=STREAM_TYPE_BUFFERED,
|
current_time: int = 0,
|
||||||
subtitles=None,
|
stream_type: str = STREAM_TYPE_BUFFERED,
|
||||||
subtitles_lang='en-US',
|
subtitles: Optional[str] = None,
|
||||||
subtitles_mime='text/vtt',
|
subtitles_lang: str = 'en-US',
|
||||||
subtitle_id=1,
|
subtitles_mime: str = 'text/vtt',
|
||||||
|
subtitle_id: int = 1,
|
||||||
|
**__,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Cast media to a visible Chromecast
|
Cast media to an available Chromecast device.
|
||||||
|
|
||||||
:param resource: Media to cast
|
:param resource: Media to cast
|
||||||
:type resource: str
|
|
||||||
|
|
||||||
:param content_type: Content type as a MIME type string
|
: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.
|
||||||
: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
|
:param title: Optional title
|
||||||
:type title: str
|
|
||||||
|
|
||||||
:param image_url: URL of the image to use for the thumbnail
|
: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)
|
||||||
: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)
|
: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
|
||||||
: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
|
:param subtitles: URL of the subtitles to be shown
|
||||||
:type subtitles: str
|
|
||||||
|
|
||||||
:param subtitles_lang: Subtitles language (default: en-US)
|
:param subtitles_lang: Subtitles language (default: en-US)
|
||||||
:type subtitles_lang: str
|
|
||||||
|
|
||||||
:param subtitles_mime: Subtitles MIME type (default: text/vtt)
|
: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)
|
: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:
|
if not chromecast:
|
||||||
chromecast = self.chromecast
|
chromecast = self.chromecast
|
||||||
|
|
||||||
post_event(MediaPlayRequestEvent, resource=resource, device=chromecast)
|
post_event(MediaPlayRequestEvent, resource=resource, device=chromecast)
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
|
|
||||||
mc = cast.media_controller
|
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)
|
resource = self._get_resource(resource)
|
||||||
|
|
||||||
if not content_type:
|
if not content_type:
|
||||||
content_type = get_mime_type(resource)
|
content_type = get_mime_type(resource)
|
||||||
|
|
||||||
if not content_type:
|
if not content_type:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f'content_type required to process media {resource}')
|
||||||
'content_type required to process media {}'.format(resource)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not resource.startswith('http://') and not resource.startswith('https://'):
|
if not resource.startswith('http://') and not resource.startswith('https://'):
|
||||||
resource = self.start_streaming(resource).output['url']
|
resource = self._start_streaming(resource)['url']
|
||||||
self.logger.info('HTTP media stream started on {}'.format(resource))
|
resource = get_remote_base_url() + resource
|
||||||
|
self.logger.info('HTTP media stream started on %s', resource)
|
||||||
|
|
||||||
self.logger.info('Playing {} on {}'.format(resource, chromecast))
|
if self._latest_resource:
|
||||||
|
if not title:
|
||||||
|
title = self._latest_resource.title
|
||||||
|
if not image_url:
|
||||||
|
image_url = self._latest_resource.image
|
||||||
|
|
||||||
|
self.logger.info('Playing %s on %s', resource, chromecast)
|
||||||
|
|
||||||
mc.play_media(
|
mc.play_media(
|
||||||
resource,
|
resource,
|
||||||
content_type,
|
content_type,
|
||||||
title=title,
|
title=self._latest_resource.title if self._latest_resource else title,
|
||||||
thumb=image_url,
|
thumb=image_url,
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
autoplay=autoplay,
|
autoplay=autoplay,
|
||||||
|
@ -380,33 +248,26 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
)
|
)
|
||||||
|
|
||||||
if subtitles:
|
if subtitles:
|
||||||
mc.register_status_listener(self.SubtitlesAsyncHandler(mc, subtitle_id))
|
mc.register_status_listener(SubtitlesAsyncHandler(mc, subtitle_id))
|
||||||
|
|
||||||
mc.block_until_active()
|
mc.block_until_active()
|
||||||
|
|
||||||
if self.volume:
|
if self.volume:
|
||||||
self.set_volume(volume=self.volume, chromecast=chromecast)
|
self.set_volume(volume=self.volume, chromecast=chromecast)
|
||||||
|
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _get_youtube_url(cls, url):
|
|
||||||
m = re.match(r'https?://(www\.)?youtube\.com/watch\?v=([^&]+).*', url)
|
|
||||||
if m:
|
|
||||||
return m.group(2)
|
|
||||||
|
|
||||||
m = re.match('youtube:video:(.*)', url)
|
|
||||||
if m:
|
|
||||||
return m.group(1)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def load(self, *args, **kwargs):
|
def load(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Alias for :meth:`.play`.
|
||||||
|
"""
|
||||||
return self.play(*args, **kwargs)
|
return self.play(*args, **kwargs)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def pause(self, chromecast=None):
|
def pause(self, *_, chromecast: Optional[str] = None, **__):
|
||||||
|
"""
|
||||||
|
Pause the current media on the Chromecast.
|
||||||
|
"""
|
||||||
chromecast = chromecast or self.chromecast
|
chromecast = chromecast or self.chromecast
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
|
|
||||||
|
@ -419,7 +280,7 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def stop(self, chromecast=None):
|
def stop(self, *_, chromecast: Optional[str] = None, **__):
|
||||||
chromecast = chromecast or self.chromecast
|
chromecast = chromecast or self.chromecast
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
cast.media_controller.stop()
|
cast.media_controller.stop()
|
||||||
|
@ -428,7 +289,7 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def rewind(self, chromecast=None):
|
def rewind(self, chromecast: Optional[str] = None):
|
||||||
chromecast = chromecast or self.chromecast
|
chromecast = chromecast or self.chromecast
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
cast.media_controller.rewind()
|
cast.media_controller.rewind()
|
||||||
|
@ -436,18 +297,18 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def set_position(self, position, chromecast=None):
|
def set_position(self, position: float, chromecast: Optional[str] = None, **_):
|
||||||
cast = self.get_chromecast(chromecast or self.chromecast)
|
cast = self.get_chromecast(chromecast or self.chromecast)
|
||||||
cast.media_controller.seek(position)
|
cast.media_controller.seek(position)
|
||||||
cast.wait()
|
cast.wait()
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def seek(self, position, chromecast=None):
|
def seek(self, position: float, chromecast: Optional[str] = None, **_):
|
||||||
return self.forward(chromecast=chromecast, offset=position)
|
return self.forward(chromecast=chromecast, offset=position)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def back(self, chromecast=None, offset=30):
|
def back(self, chromecast: Optional[str] = None, offset: int = 30, **_):
|
||||||
cast = self.get_chromecast(chromecast or self.chromecast)
|
cast = self.get_chromecast(chromecast or self.chromecast)
|
||||||
mc = cast.media_controller
|
mc = cast.media_controller
|
||||||
if mc.status.current_time:
|
if mc.status.current_time:
|
||||||
|
@ -457,7 +318,7 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def forward(self, chromecast=None, offset=30):
|
def forward(self, chromecast: Optional[str] = None, offset: int = 30, **_):
|
||||||
cast = self.get_chromecast(chromecast or self.chromecast)
|
cast = self.get_chromecast(chromecast or self.chromecast)
|
||||||
mc = cast.media_controller
|
mc = cast.media_controller
|
||||||
if mc.status.current_time:
|
if mc.status.current_time:
|
||||||
|
@ -467,158 +328,248 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def is_playing(self, chromecast=None):
|
def is_playing(self, chromecast: Optional[str] = None, **_):
|
||||||
return self.get_chromecast(
|
return self.get_chromecast(
|
||||||
chromecast or self.chromecast
|
chromecast or self.chromecast
|
||||||
).media_controller.is_playing
|
).media_controller.is_playing
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def is_paused(self, chromecast=None):
|
def is_paused(self, chromecast: Optional[str] = None, **_):
|
||||||
return self.get_chromecast(
|
return self.get_chromecast(
|
||||||
chromecast or self.chromecast
|
chromecast or self.chromecast
|
||||||
).media_controller.is_paused
|
).media_controller.is_paused
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def is_idle(self, chromecast=None):
|
def is_idle(self, chromecast: Optional[str] = None):
|
||||||
return self.get_chromecast(
|
return self.get_chromecast(
|
||||||
chromecast or self.chromecast
|
chromecast or self.chromecast
|
||||||
).media_controller.is_idle
|
).media_controller.is_idle
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def list_subtitles(self, chromecast=None):
|
def list_subtitles(self, chromecast: Optional[str] = None):
|
||||||
return self.get_chromecast(
|
return self.get_chromecast(
|
||||||
chromecast or self.chromecast
|
chromecast or self.chromecast
|
||||||
).media_controller.subtitle_tracks
|
).media_controller.subtitle_tracks
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def enable_subtitles(self, chromecast=None, track_id=None):
|
def enable_subtitles(
|
||||||
|
self, chromecast: Optional[str] = None, track_id: Optional[str] = None, **_
|
||||||
|
):
|
||||||
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
||||||
if track_id is not None:
|
if track_id is not None:
|
||||||
return mc.enable_subtitle(track_id)
|
return mc.enable_subtitle(track_id)
|
||||||
elif mc.subtitle_tracks:
|
if mc.subtitle_tracks:
|
||||||
return mc.enable_subtitle(mc.subtitle_tracks[0].get('trackId'))
|
return mc.enable_subtitle(mc.subtitle_tracks[0].get('trackId'))
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def disable_subtitles(self, chromecast=None, track_id=None):
|
def disable_subtitles(
|
||||||
|
self, chromecast: Optional[str] = None, track_id: Optional[str] = None, **_
|
||||||
|
):
|
||||||
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
||||||
if track_id:
|
if track_id:
|
||||||
return mc.disable_subtitle(track_id)
|
return mc.disable_subtitle(track_id)
|
||||||
elif mc.current_subtitle_tracks:
|
if mc.current_subtitle_tracks:
|
||||||
return mc.disable_subtitle(mc.current_subtitle_tracks[0])
|
return mc.disable_subtitle(mc.current_subtitle_tracks[0])
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def toggle_subtitles(self, chromecast=None):
|
def toggle_subtitles(self, chromecast: Optional[str] = None, **_):
|
||||||
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
mc = self.get_chromecast(chromecast or self.chromecast).media_controller
|
||||||
all_subs = mc.status.subtitle_tracks
|
all_subs = mc.status.subtitle_tracks
|
||||||
cur_subs = mc.status.status.current_subtitle_tracks
|
cur_subs = mc.status.status.current_subtitle_tracks
|
||||||
|
|
||||||
if cur_subs:
|
if cur_subs:
|
||||||
return self.disable_subtitles(chromecast, cur_subs[0])
|
return self.disable_subtitles(chromecast, cur_subs[0])
|
||||||
else:
|
|
||||||
return self.enable_subtitles(chromecast, all_subs[0].get('trackId'))
|
return self.enable_subtitles(chromecast, all_subs[0].get('trackId'))
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def status(self, chromecast=None):
|
def status(self, chromecast: Optional[str] = None):
|
||||||
cast = self.get_chromecast(chromecast or self.chromecast)
|
"""
|
||||||
status = cast.media_controller.status
|
:return: The status of a Chromecast (if ``chromecast`` is specified) or
|
||||||
return convert_status(status)
|
all the discovered/available Chromecasts. Format:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
{
|
||||||
|
"type": "cast", # Can be "cast" or "audio"
|
||||||
|
"name": "Living Room TV",
|
||||||
|
"manufacturer": "Google Inc.",
|
||||||
|
"model_name": "Chromecast",
|
||||||
|
"uuid": "f812afac-80ff-11ee-84dc-001500e8f607",
|
||||||
|
"address": "192.168.1.2",
|
||||||
|
"port": 8009,
|
||||||
|
"status": {
|
||||||
|
"app": {
|
||||||
|
"id": "CC1AD845",
|
||||||
|
"name": "Default Media Receiver"
|
||||||
|
},
|
||||||
|
"is_active_input": false,
|
||||||
|
"is_stand_by": true,
|
||||||
|
"is_idle": true,
|
||||||
|
"namespaces": [
|
||||||
|
"urn:x-cast:com.google.cast.cac",
|
||||||
|
"urn:x-cast:com.google.cast.debugoverlay",
|
||||||
|
"urn:x-cast:com.google.cast.media"
|
||||||
|
],
|
||||||
|
"volume": 100,
|
||||||
|
"muted": false,
|
||||||
|
"adjusted_current_time": 14.22972,
|
||||||
|
"album_artist": null,
|
||||||
|
"album_name": null,
|
||||||
|
"artist": null,
|
||||||
|
"url": "https://some/video.mp4",
|
||||||
|
"content_type": "video/mp4",
|
||||||
|
"current_subtitle_tracks": [],
|
||||||
|
"position": 1.411891,
|
||||||
|
"duration": 253.376145,
|
||||||
|
"episode": null,
|
||||||
|
"idle_reason": null,
|
||||||
|
"images": [
|
||||||
|
[
|
||||||
|
"https://some/image.jpg",
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"last_updated": "2023-11-12T02:03:33.888843",
|
||||||
|
"media_custom_data": {},
|
||||||
|
"media_is_generic": true,
|
||||||
|
"media_is_movie": false,
|
||||||
|
"media_is_musictrack": false,
|
||||||
|
"media_is_photo": false,
|
||||||
|
"media_is_tvshow": false,
|
||||||
|
"media_metadata": {
|
||||||
|
"title": "Some media",
|
||||||
|
"thumb": "https://some/image.jpg",
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://some/image.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadataType": 0
|
||||||
|
},
|
||||||
|
"media_session_id": 1,
|
||||||
|
"metadata_type": 0,
|
||||||
|
"playback_rate": 1,
|
||||||
|
"player_is_idle": false,
|
||||||
|
"player_is_paused": false,
|
||||||
|
"player_is_playing": true,
|
||||||
|
"state": "play",
|
||||||
|
"season": null,
|
||||||
|
"series_title": null,
|
||||||
|
"stream_type": "BUFFERED",
|
||||||
|
"stream_type_is_buffered": true,
|
||||||
|
"stream_type_is_live": false,
|
||||||
|
"subtitle_tracks": [],
|
||||||
|
"supported_media_commands": 12303,
|
||||||
|
"supports_pause": true,
|
||||||
|
"supports_queue_next": false,
|
||||||
|
"supports_queue_prev": false,
|
||||||
|
"seekable": true,
|
||||||
|
"supports_skip_backward": false,
|
||||||
|
"supports_skip_forward": false,
|
||||||
|
"supports_stream_mute": true,
|
||||||
|
"supports_stream_volume": true,
|
||||||
|
"title": "Some media",
|
||||||
|
"track": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
return self._status(chromecast=chromecast)
|
||||||
|
|
||||||
|
def _status(self, chromecast: Optional[str] = None) -> dict:
|
||||||
|
if chromecast:
|
||||||
|
assert (
|
||||||
|
chromecast in self.chromecasts
|
||||||
|
), f'No such Chromecast device: {chromecast}'
|
||||||
|
return self._serialize_device(self.chromecasts[chromecast])
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: self._serialize_device(cast)
|
||||||
|
for name, cast in self.chromecasts.items()
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def disconnect(self, chromecast=None, timeout=None, blocking=True):
|
def disconnect(
|
||||||
|
self,
|
||||||
|
chromecast: Optional[str] = None,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
blocking: bool = True,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Disconnect a Chromecast and wait for it to terminate
|
Disconnect a Chromecast and wait for it to terminate
|
||||||
|
|
||||||
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
:param chromecast: Chromecast to cast to. If none is specified, then
|
||||||
will be used.
|
the default configured Chromecast will be used.
|
||||||
:type chromecast: str
|
:param timeout: Number of seconds to wait for disconnection (default:
|
||||||
|
None: block until termination).
|
||||||
:param timeout: Number of seconds to wait for disconnection (default: None: block until termination)
|
:param blocking: If set (default), then the code will wait until
|
||||||
:type timeout: float
|
disconnection, otherwise it will return immediately.
|
||||||
|
|
||||||
: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 = self.get_chromecast(chromecast)
|
||||||
cast.disconnect(timeout=timeout, blocking=blocking)
|
cast.disconnect(timeout=timeout, blocking=blocking)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def join(self, chromecast=None, timeout=None):
|
def join(self, chromecast: Optional[str] = None, timeout: Optional[float] = None):
|
||||||
"""
|
"""
|
||||||
Blocks the thread until the Chromecast connection is terminated.
|
Blocks the thread until the Chromecast connection is terminated.
|
||||||
|
|
||||||
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
:param chromecast: Chromecast to cast to. If none is specified, then
|
||||||
will be used.
|
the default configured Chromecast will be used.
|
||||||
:type chromecast: str
|
:param timeout: Number of seconds to wait for disconnection (default:
|
||||||
|
None: block until termination).
|
||||||
:param timeout: Number of seconds to wait for disconnection (default: None: block until termination)
|
|
||||||
:type timeout: float
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
cast.join(timeout=timeout)
|
cast.join(timeout=timeout)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def quit(self, chromecast=None):
|
def quit(self, chromecast: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Exits the current app on the Chromecast
|
Exits the current app on the Chromecast
|
||||||
|
|
||||||
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
:param chromecast: Chromecast to cast to. If none is specified, then
|
||||||
will be used.
|
the default configured Chromecast will be used.
|
||||||
:type chromecast: str
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
cast.quit_app()
|
cast.quit_app()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def reboot(self, chromecast=None):
|
def reboot(self, chromecast: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Reboots the Chromecast
|
Reboots the Chromecast
|
||||||
|
|
||||||
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
:param chromecast: Chromecast to cast to. If none is specified, then
|
||||||
will be used.
|
the default configured Chromecast will be used.
|
||||||
:type chromecast: str
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
cast.reboot()
|
cast.reboot()
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def set_volume(self, volume, chromecast=None):
|
def set_volume(self, volume: float, chromecast: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Set the Chromecast volume
|
Set the Chromecast volume
|
||||||
|
|
||||||
:param volume: Volume to be set, between 0 and 100
|
: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.
|
||||||
: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
|
chromecast = chromecast or self.chromecast
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
cast.set_volume(volume / 100)
|
cast.set_volume(volume / 100)
|
||||||
cast.wait()
|
cast.wait()
|
||||||
status = self.status(chromecast=chromecast)
|
return {
|
||||||
status.output['volume'] = volume
|
**self._status(chromecast=chromecast),
|
||||||
return status
|
'volume': volume,
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def volup(self, chromecast=None, step=10):
|
def volup(self, chromecast: Optional[str] = None, step: float = 10, **_):
|
||||||
"""
|
"""
|
||||||
Turn up the Chromecast volume by 10% or step.
|
Turn up the Chromecast volume by 10% or step.
|
||||||
|
|
||||||
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
:param chromecast: Chromecast to cast to. If none is specified, then
|
||||||
will be used.
|
the default configured Chromecast will be used.
|
||||||
:type chromecast: str
|
:param step: Volume increment between 0 and 100 (default: 10%).
|
||||||
|
|
||||||
:param step: Volume increment between 0 and 100 (default: 100%)
|
|
||||||
:type step: float
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
chromecast = chromecast or self.chromecast
|
chromecast = chromecast or self.chromecast
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
step /= 100
|
step /= 100
|
||||||
|
@ -627,18 +578,14 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def voldown(self, chromecast=None, step=10):
|
def voldown(self, chromecast: Optional[str] = None, step: float = 10, **_):
|
||||||
"""
|
"""
|
||||||
Turn down the Chromecast volume by 10% or step.
|
Turn down the Chromecast volume by 10% or step.
|
||||||
|
|
||||||
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
:param chromecast: Chromecast to cast to. If none is specified, then
|
||||||
will be used.
|
the default configured Chromecast will be used.
|
||||||
:type chromecast: str
|
:param step: Volume decrement between 0 and 100 (default: 10%).
|
||||||
|
|
||||||
:param step: Volume decrement between 0 and 100 (default: 100%)
|
|
||||||
:type step: float
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
chromecast = chromecast or self.chromecast
|
chromecast = chromecast or self.chromecast
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
step /= 100
|
step /= 100
|
||||||
|
@ -647,26 +594,31 @@ class MediaChromecastPlugin(MediaPlugin):
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def mute(self, chromecast=None):
|
def mute(self, chromecast: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Toggle the mute status on the Chromecast
|
Toggle the mute status on the Chromecast
|
||||||
|
|
||||||
:param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast
|
:param chromecast: Chromecast to cast to. If none is specified, then
|
||||||
will be used.
|
the default configured Chromecast will be used.
|
||||||
:type chromecast: str
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
chromecast = chromecast or self.chromecast
|
chromecast = chromecast or self.chromecast
|
||||||
cast = self.get_chromecast(chromecast)
|
cast = self.get_chromecast(chromecast)
|
||||||
cast.set_volume_muted(not cast.status.volume_muted)
|
cast.set_volume_muted(not cast.status.volume_muted)
|
||||||
cast.wait()
|
cast.wait()
|
||||||
return self.status(chromecast=chromecast)
|
return self.status(chromecast=chromecast)
|
||||||
|
|
||||||
def set_subtitles(self, filename, *args, **kwargs):
|
def set_subtitles(self, *_, **__):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def remove_subtitles(self, *args, **kwargs):
|
def remove_subtitles(self, *_, **__):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def main(self):
|
||||||
|
while not self.should_stop():
|
||||||
|
try:
|
||||||
|
self._refresh_chromecasts()
|
||||||
|
finally:
|
||||||
|
self.wait_stop(self.poll_interval)
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
77
platypush/plugins/media/chromecast/_listener.py
Normal file
77
platypush/plugins/media/chromecast/_listener.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from platypush.message.event.media import (
|
||||||
|
MediaPlayEvent,
|
||||||
|
MediaStopEvent,
|
||||||
|
MediaPauseEvent,
|
||||||
|
NewPlayingMediaEvent,
|
||||||
|
MediaVolumeChangedEvent,
|
||||||
|
MediaSeekEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ._utils import MediaCallback, convert_status, post_event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaListener:
|
||||||
|
"""
|
||||||
|
Listens for media status changes and posts events accordingly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, cast, callback: Optional[MediaCallback] = None):
|
||||||
|
self.name = name
|
||||||
|
self.cast = cast
|
||||||
|
self.status = convert_status(cast.media_controller.status)
|
||||||
|
self.last_status_timestamp = time.time()
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
def new_media_status(self, status):
|
||||||
|
status = convert_status(status)
|
||||||
|
if status.get('url') and status.get('url') != self.status.get('url'):
|
||||||
|
self._post_event(
|
||||||
|
NewPlayingMediaEvent,
|
||||||
|
title=status.get('title'),
|
||||||
|
)
|
||||||
|
|
||||||
|
state = status.get('state')
|
||||||
|
if state != self.status.get('state'):
|
||||||
|
if state == 'play':
|
||||||
|
self._post_event(MediaPlayEvent)
|
||||||
|
elif state == 'pause':
|
||||||
|
self._post_event(MediaPauseEvent)
|
||||||
|
elif state in ('stop', 'idle'):
|
||||||
|
self._post_event(MediaStopEvent)
|
||||||
|
|
||||||
|
if status.get('volume') != self.status.get('volume'):
|
||||||
|
self._post_event(MediaVolumeChangedEvent, volume=status.get('volume'))
|
||||||
|
|
||||||
|
if (
|
||||||
|
status.get('position')
|
||||||
|
and self.status.get('position')
|
||||||
|
and abs(status['position'] - self.status['position'])
|
||||||
|
> time.time() - self.last_status_timestamp + 5
|
||||||
|
):
|
||||||
|
self._post_event(MediaSeekEvent, position=status.get('position'))
|
||||||
|
|
||||||
|
self.status = status
|
||||||
|
self.last_status_timestamp = time.time()
|
||||||
|
|
||||||
|
def load_media_failed(self, item, error_code):
|
||||||
|
logger.warning('Failed to load media %s: %d', item, error_code)
|
||||||
|
|
||||||
|
def _post_event(self, evt_type, **evt):
|
||||||
|
status = evt.get('status', {})
|
||||||
|
resource = status.get('url')
|
||||||
|
args = {
|
||||||
|
'device': self.name,
|
||||||
|
'plugin': 'media.chromecast',
|
||||||
|
**evt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource:
|
||||||
|
args['resource'] = resource
|
||||||
|
|
||||||
|
post_event(evt_type, callback=self.callback, chromecast=self.cast, **args)
|
16
platypush/plugins/media/chromecast/_subtitles.py
Normal file
16
platypush/plugins/media/chromecast/_subtitles.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
class SubtitlesAsyncHandler:
|
||||||
|
"""
|
||||||
|
This class is used to enable subtitles when the media is loaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, mc, subtitle_id):
|
||||||
|
self.mc = mc
|
||||||
|
self.subtitle_id = subtitle_id
|
||||||
|
self.initialized = False
|
||||||
|
|
||||||
|
def new_media_status(self, *_):
|
||||||
|
if self.subtitle_id and not self.initialized:
|
||||||
|
self.mc.update_status()
|
||||||
|
self.mc.enable_subtitle(self.subtitle_id)
|
||||||
|
self.initialized = True
|
64
platypush/plugins/media/chromecast/_utils.py
Normal file
64
platypush/plugins/media/chromecast/_utils.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import datetime
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
import pychromecast # type: ignore
|
||||||
|
|
||||||
|
from platypush.context import get_bus
|
||||||
|
from platypush.message.event.media import MediaEvent
|
||||||
|
|
||||||
|
MediaCallback = Callable[[MediaEvent, pychromecast.Chromecast], Optional[Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def convert_status(status) -> dict:
|
||||||
|
"""
|
||||||
|
Convert a Chromecast status object to a dictionary.
|
||||||
|
"""
|
||||||
|
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': 'muted',
|
||||||
|
'content_id': 'url',
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = {}
|
||||||
|
for attr in attrs:
|
||||||
|
value = getattr(status, attr)
|
||||||
|
if attr == 'volume_level':
|
||||||
|
value = round(100 * value, 2)
|
||||||
|
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,
|
||||||
|
callback: Optional[MediaCallback] = None,
|
||||||
|
chromecast: Optional[pychromecast.Chromecast] = None,
|
||||||
|
**evt
|
||||||
|
):
|
||||||
|
evt['plugin'] = 'media.chromecast'
|
||||||
|
event = evt_type(player=evt.get('device'), **evt)
|
||||||
|
|
||||||
|
get_bus().post(event)
|
||||||
|
if callback:
|
||||||
|
callback(event, chromecast)
|
|
@ -82,6 +82,7 @@ mock_imports = [
|
||||||
"pvporcupine ",
|
"pvporcupine ",
|
||||||
"pyHS100",
|
"pyHS100",
|
||||||
"pyaudio",
|
"pyaudio",
|
||||||
|
"pychromecast",
|
||||||
"pyclip",
|
"pyclip",
|
||||||
"pydbus",
|
"pydbus",
|
||||||
"pyfirmata2",
|
"pyfirmata2",
|
||||||
|
|
Loading…
Reference in a new issue