496 lines
16 KiB
Python
496 lines
16 KiB
Python
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 """
|
|
<h1>Sublime Music Local Music Server</h1>
|
|
<p>
|
|
Sublime Music uses this port as a server for serving music
|
|
Chromecasts on the same LAN.
|
|
</p>
|
|
"""
|
|
|
|
@self.app.route("/s/<token>")
|
|
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)
|