import base64 import io import logging import mimetypes import multiprocessing import os import socket from datetime import timedelta from typing import Any, Callable, cast, Dict, Optional, Set, Tuple, Type, Union from urllib.parse import urlparse from uuid import UUID from gi.repository import GLib from ..adapters import AdapterManager from ..adapters.api_objects import Song from .base import Player, PlayerDeviceEvent, PlayerEvent try: import pychromecast chromecast_imported = True except Exception: chromecast_imported = False try: import bottle bottle_imported = True except Exception: bottle_imported = False SERVE_FILES_KEY = "Serve Local Files to Chromecasts on the LAN" LAN_PORT_KEY = "LAN Server Port Number" class ChromecastPlayer(Player): name = "Chromecast" can_start_playing_with_no_latency = False @property def enabled(self) -> bool: return chromecast_imported @staticmethod def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]: if not bottle_imported: return {} return {SERVE_FILES_KEY: bool, LAN_PORT_KEY: int} @property def supported_schemes(self) -> Set[str]: schemes = {"http", "https"} if bottle_imported and self.config.get(SERVE_FILES_KEY): schemes.add("file") return schemes _timepos = 0.0 def __init__( self, on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], player_device_change_callback: Callable[[PlayerDeviceEvent], None], config: Dict[str, Union[str, int, bool]], ): self.server_process: Optional[multiprocessing.Process] = None self.on_timepos_change = on_timepos_change self.on_track_end = on_track_end self.on_player_event = on_player_event self.player_device_change_callback = player_device_change_callback self.change_settings(config) if chromecast_imported: self._chromecasts: Dict[UUID, pychromecast.Chromecast] = {} self._current_chromecast: Optional[pychromecast.Chromecast] = None self.stop_get_chromecasts = None self.refresh_players() def chromecast_discovered_callback(self, chromecast: Any): chromecast = cast(pychromecast.Chromecast, chromecast) self._chromecasts[chromecast.device.uuid] = chromecast self.player_device_change_callback( PlayerDeviceEvent( PlayerDeviceEvent.Delta.ADD, type(self), str(chromecast.device.uuid), chromecast.device.friendly_name, ) ) def change_settings(self, config: Dict[str, Union[str, int, bool]]): if not chromecast_imported: return self.config = config if bottle_imported and self.config.get(SERVE_FILES_KEY): # Try and terminate the existing process if it exists. if self.server_process is not None: try: self.server_process.terminate() except Exception: pass self.server_process = multiprocessing.Process( target=self._run_server_process, args=("0.0.0.0", self.config.get(LAN_PORT_KEY)), ) self.server_process.start() def refresh_players(self): if not chromecast_imported: return if self.stop_get_chromecasts is not None: self.stop_get_chromecasts() for id_, chromecast in self._chromecasts.items(): self.player_device_change_callback( PlayerDeviceEvent( PlayerDeviceEvent.Delta.REMOVE, type(self), str(id_), chromecast.device.friendly_name, ) ) self._chromecasts = {} self.stop_get_chromecasts = pychromecast.get_chromecasts( blocking=False, callback=self.chromecast_discovered_callback ) def set_current_device_id(self, device_id: str): self._current_chromecast = self._chromecasts[UUID(device_id)] self._current_chromecast.media_controller.register_status_listener(self) self._current_chromecast.register_status_listener(self) self._current_chromecast.wait() def new_cast_status(self, status: Any): assert self._current_chromecast self.on_player_event( PlayerEvent( PlayerEvent.EventType.VOLUME_CHANGE, str(self._current_chromecast.device.uuid), volume=(status.volume_level * 100 if not status.volume_muted else 0), ) ) # This normally happens when "Stop Casting" is pressed in the Google # Home app. if status.session_id is None: self.on_player_event( PlayerEvent( PlayerEvent.EventType.PLAY_STATE_CHANGE, str(self._current_chromecast.device.uuid), playing=False, ) ) self.on_player_event( PlayerEvent( PlayerEvent.EventType.DISCONNECT, str(self._current_chromecast.device.uuid), ) ) self.song_loaded = False time_increment_order_token = 0 def new_media_status(self, status: Any): # Detect the end of a track and go to the next one. if ( status.idle_reason == "FINISHED" and status.player_state == "IDLE" and self._timepos > 0 ): self.on_track_end() return self.song_loaded = True self._timepos = status.current_time assert self._current_chromecast self.on_player_event( PlayerEvent( PlayerEvent.EventType.PLAY_STATE_CHANGE, str(self._current_chromecast.device.uuid), playing=(status.player_state in ("PLAYING", "BUFFERING")), ) ) def increment_time(order_token: int): if self.time_increment_order_token != order_token or not self.playing: return self._timepos += 0.5 self.on_timepos_change(self._timepos) GLib.timeout_add(500, increment_time, order_token) self.time_increment_order_token += 1 GLib.timeout_add(500, increment_time, self.time_increment_order_token) def shutdown(self): if self.server_process: self.server_process.terminate() try: self._current_chromecast.disconnect() except Exception: pass _serving_song_id = multiprocessing.Array("c", 1024) # huge buffer, just in case _serving_token = multiprocessing.Array("c", 16) def _run_server_process(self, host: str, port: int): app = bottle.Bottle() @app.route("/") def index() -> str: return """
Sublime Music uses this port as a server for serving music Chromecasts on the same LAN.
""" @app.route("/s/