#!/usr/bin/python3
"""
Play streams on a Chromecast Audio, using PyChromecast.
"""
from pychromecast import get_chromecasts, _get_chromecast_from_host, Chromecast
from pychromecast.error import PyChromecastError
from inspect import signature
import logging
import time
from typing import Optional, Union, List, Tuple, Dict, Any
class Discovery:
def __init__(self) -> None:
self._tries: int = 3
self._retry_wait: int = 5
self._timeout: float = 20.0
self._logger = logging.getLogger(self.__class__.__name__)
def _wait_for_status(self, cc: Chromecast) -> bool:
cc.wait(timeout=self._timeout)
if cc.status is not None:
self._logger.info(f"Connected chromecast '{cc.name}'")
return True
else:
cc.disconnect(blocking=True, timeout=self._timeout)
return False
def find_by_name(self, friendly_name: str,
host: Optional[str] = None) -> Optional[Chromecast]:
self._logger.debug(f"Trying to discover chromecast '{friendly_name}'")
try:
extra_args: Dict[str, Any] = {}
if host is not None and 'known_hosts' in \
signature(get_chromecasts).parameters.keys():
extra_args = {'known_hosts': [host]}
discovered: Union[Tuple, List] = get_chromecasts(
blocking=True,
tries=self._tries,
retry_wait=self._retry_wait,
timeout=self._timeout,
**extra_args,
)
if isinstance(discovered, tuple):
# discovered[1].stop_discovery() # keep running
discovered = discovered[0]
cc: Chromecast
for cc in discovered:
self._logger.debug(f"Discovered chromecast '{cc.name}'")
if cc.name == friendly_name:
return cc if self._wait_for_status(cc) else None
return None
except PyChromecastError as e:
self._logger.warning(str(e))
return None
def find_by_host(self, host: str, port: int) -> Optional[Chromecast]:
self._logger.debug(f"Trying to connect chromecast '{host}:{port}'")
try:
# ip_address, port, uuid, model_name, friendly_name
host_info = (host, port, None, "Unknown Model", host)
cc = _get_chromecast_from_host(host_info,
tries=self._tries,
retry_wait=self._retry_wait,
timeout=self._timeout)
return cc if self._wait_for_status(cc) else None
except PyChromecastError as e:
self._logger.warning(str(e))
return None
def discover(self, name: Optional[str],
host: Optional[str], port: int) -> Optional[Chromecast]:
"""
Tries first to discover the chromecast with the given name via mDNS.
As fallback, a direct connection to the given IP address is tried.
"""
cc: Optional[Chromecast] = None
try:
if name is not None:
cc = self.find_by_name(name, host)
if cc is None and host is not None:
cc = self.find_by_host(host, port)
except KeyboardInterrupt:
logger.info("Shutting down")
return None
return cc
class ChromecastStream:
def __init__(self, cc: Chromecast) -> None:
self._cc: Chromecast = cc
self._timeout: float = 20.0
self._logger = logging.getLogger(self.__class__.__name__)
@property
def chromecast(self) -> Chromecast:
return self._cc
def disconnect(self) -> None:
self._logger.debug("Disconnecting")
self._cc.disconnect(blocking=True, timeout=self._timeout)
def stop(self) -> None:
self._logger.debug("Stopping playback")
self._cc.quit_app()
def set_volume(self, volume: int) -> None:
volume = min(max(volume, 0), 100)
self._logger.debug(f"Setting volume to {volume}")
self._cc.set_volume(volume / 100.0) # unmutes
def play_media(self, url: str, content_type: str,
title: str = "PyChromecast") -> None:
self._logger.debug(f"Playing {url}")
self._cc.play_media(url=url,
content_type=content_type,
title=title,
autoplay=True,
stream_type="LIVE")
def play(self) -> None:
self._logger.debug("Starting playback")
self._cc.media_controller.play()
def status(self) -> str:
return str(self._cc.status) if self._cc.status is not None \
else "Status Unknown"
def __str__(self) -> str:
return str(self._cc)
def _stream(logger: logging.Logger,
cc: ChromecastStream,
url: str, content_type: str, volume: Optional[int]) -> bool:
try:
logger.debug("Starting up")
logger.info(str(cc))
logger.info(cc.status())
cc.stop()
if volume is not None:
cc.set_volume(volume)
try:
cc.play_media(url, content_type)
cc.play() # hack trying to minimize buffering delay
logger.info(f"Started '{url}' - Waiting for interrupt")
logger.debug(cc.status())
while True:
time.sleep(60)
logger.debug(cc.status())
except KeyboardInterrupt:
logger.info("Shutting down")
finally:
cc.stop()
cc.disconnect()
logger.debug(cc.status())
return True
except PyChromecastError as e:
logger.warning(str(e))
return False
def _main(logger: logging.Logger,
name: Optional[str], host: Optional[str], port: int,
url: str, content_type: str, volume: Optional[int]) -> bool:
discovery = Discovery()
cc: Optional[Chromecast] = discovery.discover(name, host, port)
if cc is None:
logger.error("Chromecast not found")
return False
return _stream(logger, ChromecastStream(cc), url, content_type, volume)
def _configure_logging(debug: bool) -> logging.Logger:
import logging.config
import os.path
logging.config.dictConfig({
'version': 1,
'formatters': {
'standard': {
'format': '[%(levelname)s] %(name)s: %(message)s',
},
},
'handlers': {
'default': {
'formatter': 'standard',
'class': 'logging.StreamHandler',
'stream': 'ext://sys.stdout',
},
},
'loggers': {
'': {
'handlers': ['default'],
'level': 'DEBUG' if debug else 'INFO',
'propagate': False,
},
},
})
return logging.getLogger(os.path.splitext(os.path.basename(__file__))[0])
if __name__ == "__main__":
from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter
parser = ArgumentParser(description=__doc__,
formatter_class=ArgumentDefaultsHelpFormatter)
parser.add_argument('--debug', action='store_const',
const=True, default=False,
help='enable debug log output')
parser.add_argument('--name', type=str, default=None,
help='chromecast friendly name for discovery')
parser.add_argument('--host', type=str, default=None,
help='chromecast ip address for direct connection')
parser.add_argument('--port', type=int, default=8009,
help='chromecast port for direct connection')
parser.add_argument('--volume', type=int, default=None,
help='volume to set before streaming, 0-100')
parser.add_argument('--ctype', type=str, default='audio/mpeg',
help='stream content type, if not mp3')
parser.add_argument('URL',
help='stream url to play')
args = parser.parse_args()
if args.name is None and args.host is None:
parser.error('either --name or --host is required (or both)')
logger: logging.Logger = _configure_logging(args.debug)
exit(0 if _main(
logger, args.name, args.host, args.port,
args.URL, args.ctype, args.volume
) else 1)