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)