import base64 import io import logging import mimetypes import os import socket import threading from concurrent.futures import Future, ThreadPoolExecutor from time import sleep from typing import Any, Callable, List, Optional from urllib.parse import urlparse from uuid import UUID import bottle import mpv import pychromecast from sublime.cache_manager import CacheManager from sublime.config import AppConfiguration from sublime.server.api_objects import Child class PlayerEvent: name: str value: Any def __init__(self, name: str, value: Any): self.name = name self.value = value class Player: _can_hotswap_source: bool def __init__( self, on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], config: AppConfiguration, ): self.on_timepos_change = on_timepos_change self.on_track_end = on_track_end self.on_player_event = on_player_event self.config = config self._song_loaded = False @property def playing(self) -> bool: return self._is_playing() @property def song_loaded(self) -> bool: return self._song_loaded @property def can_hotswap_source(self) -> bool: return self._can_hotswap_source @property def volume(self) -> float: return self._get_volume() @volume.setter def volume(self, value: float): self._set_volume(value) @property def is_muted(self) -> bool: return self._get_is_muted() @is_muted.setter def is_muted(self, value: bool): self._set_is_muted(value) def reset(self): raise NotImplementedError("reset must be implemented by implementor of Player") def play_media(self, file_or_url: str, progress: float, song: Child): raise NotImplementedError( "play_media must be implemented by implementor of Player" ) def _is_playing(self): raise NotImplementedError( "_is_playing must be implemented by implementor of Player" ) def pause(self): raise NotImplementedError("pause must be implemented by implementor of Player") def toggle_play(self): raise NotImplementedError( "toggle_play must be implemented by implementor of Player" ) def seek(self, value: float): raise NotImplementedError("seek must be implemented by implementor of Player") def _get_timepos(self): raise NotImplementedError( "get_timepos must be implemented by implementor of Player" ) def _get_volume(self): raise NotImplementedError( "_get_volume must be implemented by implementor of Player" ) def _set_volume(self, value: float): raise NotImplementedError( "_set_volume must be implemented by implementor of Player" ) def _get_is_muted(self): raise NotImplementedError( "_get_is_muted must be implemented by implementor of Player" ) def _set_is_muted(self, value: bool): raise NotImplementedError( "_set_is_muted must be implemented by implementor of Player" ) def shutdown(self): raise NotImplementedError( "shutdown must be implemented by implementor of Player" ) class MPVPlayer(Player): def __init__( self, on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], config: AppConfiguration, ): super().__init__(on_timepos_change, on_track_end, on_player_event, config) self.mpv = mpv.MPV() self.mpv.audio_client_name = "sublime-music" self.mpv.replaygain = config.replay_gain.as_string() self.progress_value_lock = threading.Lock() self.progress_value_count = 0 self._muted = False self._volume = 100.0 self._can_hotswap_source = True @self.mpv.property_observer("time-pos") def time_observer(_: Any, value: Optional[float]): self.on_timepos_change(value) if value is None and self.progress_value_count > 1: self.on_track_end() with self.progress_value_lock: self.progress_value_count = 0 if value: with self.progress_value_lock: self.progress_value_count += 1 def _is_playing(self) -> bool: return not self.mpv.pause def reset(self): self._song_loaded = False with self.progress_value_lock: self.progress_value_count = 0 def play_media(self, file_or_url: str, progress: float, song: Child): self.had_progress_value = False with self.progress_value_lock: self.progress_value_count = 0 self.mpv.pause = False self.mpv.command( "loadfile", file_or_url, "replace", f"start={progress}" if progress else "", ) self._song_loaded = True def pause(self): self.mpv.pause = True def toggle_play(self): self.mpv.cycle("pause") def seek(self, value: float): self.mpv.seek(str(value), "absolute") def _get_volume(self) -> float: return self._volume def _set_volume(self, value: float): self._volume = value self.mpv.volume = self._volume def _get_is_muted(self) -> bool: return self._muted def _set_is_muted(self, value: bool): self._muted = value self.mpv.volume = 0 if value else self._volume def shutdown(self): pass class ChromecastPlayer(Player): chromecasts: List[Any] = [] chromecast: pychromecast.Chromecast = None executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=10) class CastStatusListener: on_new_cast_status: Optional[Callable] = None def new_cast_status(self, status: Any): if self.on_new_cast_status: self.on_new_cast_status(status) class MediaStatusListener: on_new_media_status: Optional[Callable] = None def new_media_status(self, status: Any): if self.on_new_media_status: self.on_new_media_status(status) cast_status_listener = CastStatusListener() media_status_listener = MediaStatusListener() class ServerThread(threading.Thread): def __init__(self, host: str, port: int): super().__init__() self.daemon = True self.host = host self.port = port self.token: Optional[str] = None self.song_id: Optional[str] = None self.app = bottle.Bottle() @self.app.route("/") def index() -> str: return """

Sublime Music Local Music Server

Sublime Music uses this port as a server for serving music Chromecasts on the same LAN.

""" @self.app.route("/s/") def stream_song(token: str) -> bytes: if token != self.token: raise bottle.HTTPError(status=401, body="Invalid token.") song = CacheManager.get_song_details(self.song_id).result() filename, _ = CacheManager.get_song_filename_or_stream(song) with open(filename, "rb") as fin: song_buffer = io.BytesIO(fin.read()) bottle.response.set_header( "Content-Type", mimetypes.guess_type(filename)[0], ) bottle.response.set_header("Accept-Ranges", "bytes") return song_buffer.read() def set_song_and_token(self, song_id: str, token: str): self.song_id = song_id self.token = token def run(self): bottle.run(self.app, host=self.host, port=self.port) getting_chromecasts = False @classmethod def get_chromecasts(cls) -> Future: def do_get_chromecasts() -> List[pychromecast.Chromecast]: if not ChromecastPlayer.getting_chromecasts: logging.info("Getting Chromecasts") ChromecastPlayer.getting_chromecasts = True ChromecastPlayer.chromecasts = pychromecast.get_chromecasts() else: logging.info("Already getting Chromecasts... busy wait") while ChromecastPlayer.getting_chromecasts: sleep(0.1) ChromecastPlayer.getting_chromecasts = False return ChromecastPlayer.chromecasts return ChromecastPlayer.executor.submit(do_get_chromecasts) def set_playing_chromecast(self, uuid: str): self.chromecast = next( cc for cc in ChromecastPlayer.chromecasts if cc.device.uuid == UUID(uuid) ) self.chromecast.media_controller.register_status_listener( ChromecastPlayer.media_status_listener ) self.chromecast.register_status_listener(ChromecastPlayer.cast_status_listener) self.chromecast.wait() logging.info(f"Using: {self.chromecast.device.friendly_name}") def __init__( self, on_timepos_change: Callable[[Optional[float]], None], on_track_end: Callable[[], None], on_player_event: Callable[[PlayerEvent], None], config: AppConfiguration, ): super().__init__(on_timepos_change, on_track_end, on_player_event, config) self._timepos = 0.0 self.time_incrementor_running = False self._can_hotswap_source = False ChromecastPlayer.cast_status_listener.on_new_cast_status = ( self.on_new_cast_status ) ChromecastPlayer.media_status_listener.on_new_media_status = ( self.on_new_media_status ) # Set host_ip # TODO (#128): should have a mechanism to update this. Maybe it should # be determined every time we try and play a song. # TODO (#129): does not work properly when on VPNs when the DNS is # piped over the VPN tunnel. try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) self.host_ip = s.getsockname()[0] s.close() except OSError: self.host_ip = None self.port = config.port_number self.serve_over_lan = config.serve_over_lan if self.serve_over_lan: self.server_thread = ChromecastPlayer.ServerThread("0.0.0.0", self.port) self.server_thread.start() def on_new_cast_status( self, status: pychromecast.socket_client.CastStatus, ): self.on_player_event( PlayerEvent( "volume_change", 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("play_state_change", False)) self._song_loaded = False def on_new_media_status( self, status: pychromecast.controllers.media.MediaStatus, ): # 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() self._timepos = status.current_time self.on_player_event( PlayerEvent( "play_state_change", status.player_state in ("PLAYING", "BUFFERING"), ) ) # Start the time incrementor just in case this was a play notification. self.start_time_incrementor() def time_incrementor(self): if self.time_incrementor_running: return self.time_incrementor_running = True while True: if not self.playing: self.time_incrementor_running = False return self._timepos += 0.5 self.on_timepos_change(self._timepos) sleep(0.5) def start_time_incrementor(self): ChromecastPlayer.executor.submit(self.time_incrementor) def wait_for_playing(self, callback: Callable, url: str = None): def do_wait_for_playing(): while True: sleep(0.1) if self.playing: break if url is not None: if url == self.chromecast.media_controller.status.content_id: break callback() ChromecastPlayer.executor.submit(do_wait_for_playing) def _is_playing(self) -> bool: if not self.chromecast or not self.chromecast.media_controller: return False return self.chromecast.media_controller.status.player_is_playing def reset(self): self._song_loaded = False def play_media(self, file_or_url: str, progress: float, song: Child): stream_scheme = urlparse(file_or_url).scheme # If it's a local file, then see if we can serve it over the LAN. if not stream_scheme: if self.serve_over_lan: token = base64.b64encode(os.urandom(64)).decode("ascii") for r in (("+", "."), ("/", "-"), ("=", "_")): token = token.replace(*r) self.server_thread.set_song_and_token(song.id, token) file_or_url = f"http://{self.host_ip}:{self.port}/s/{token}" else: file_or_url, _ = CacheManager.get_song_filename_or_stream( song, force_stream=True, ) cover_art_url = CacheManager.get_cover_art_url(song.coverArt) self.chromecast.media_controller.play_media( file_or_url, # Just pretend that whatever we send it is mp3, even if it isn't. "audio/mp3", current_time=progress, title=song.title, thumb=cover_art_url, metadata={ "metadataType": 3, "albumName": song.album, "artist": song.artist, "trackNumber": song.track, }, ) self._timepos = progress def on_play_begin(): self._song_loaded = True self.start_time_incrementor() self.wait_for_playing(on_play_begin, url=file_or_url) def pause(self): if self.chromecast and self.chromecast.media_controller: self.chromecast.media_controller.pause() def toggle_play(self): if self.playing: self.chromecast.media_controller.pause() else: self.chromecast.media_controller.play() self.wait_for_playing(self.start_time_incrementor) def seek(self, value: float): do_pause = not self.playing self.chromecast.media_controller.seek(value) if do_pause: self.pause() def _get_volume(self) -> float: if self.chromecast: return self.chromecast.status.volume_level * 100 else: return 100 def _set_volume(self, value: float): # Chromecast volume is in the range [0, 1], not [0, 100]. if self.chromecast: self.chromecast.set_volume(value / 100) def _get_is_muted(self) -> bool: return self.chromecast.volume_muted def _set_is_muted(self, value: bool): self.chromecast.set_volume_muted(value) def shutdown(self): if self.chromecast: self.chromecast.disconnect(blocking=True)