Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import base64
2import io
3import logging
4import mimetypes
5import multiprocessing
6import os
7import socket
8from datetime import timedelta
9from typing import Any, Callable, cast, Dict, Optional, Set, Tuple, Type, Union
10from urllib.parse import urlparse
11from uuid import UUID
13from gi.repository import GLib
15from .base import Player, PlayerDeviceEvent, PlayerEvent
16from ..adapters import AdapterManager
17from ..adapters.api_objects import Song
19try:
20 import pychromecast
22 chromecast_imported = True
23except Exception:
24 chromecast_imported = False
26try:
27 import bottle
29 bottle_imported = True
30except Exception:
31 bottle_imported = False
33SERVE_FILES_KEY = "Serve Local Files to Chromecasts on the LAN"
34LAN_PORT_KEY = "LAN Server Port Number"
37class ChromecastPlayer(Player):
38 name = "Chromecast"
39 can_start_playing_with_no_latency = False
41 @property
42 def enabled(self) -> bool:
43 return chromecast_imported
45 @staticmethod
46 def get_configuration_options() -> Dict[str, Union[Type, Tuple[str, ...]]]:
47 if not bottle_imported:
48 return {}
49 return {SERVE_FILES_KEY: bool, LAN_PORT_KEY: int}
51 @property
52 def supported_schemes(self) -> Set[str]:
53 schemes = {"http", "https"}
54 if bottle_imported and self.config.get(SERVE_FILES_KEY):
55 schemes.add("file")
56 return schemes
58 _timepos = 0.0
60 def __init__(
61 self,
62 on_timepos_change: Callable[[Optional[float]], None],
63 on_track_end: Callable[[], None],
64 on_player_event: Callable[[PlayerEvent], None],
65 player_device_change_callback: Callable[[PlayerDeviceEvent], None],
66 config: Dict[str, Union[str, int, bool]],
67 ):
68 self.server_process: Optional[multiprocessing.Process] = None
69 self.on_timepos_change = on_timepos_change
70 self.on_track_end = on_track_end
71 self.on_player_event = on_player_event
72 self.player_device_change_callback = player_device_change_callback
74 self.change_settings(config)
76 if chromecast_imported:
77 self._chromecasts: Dict[UUID, pychromecast.Chromecast] = {}
78 self._current_chromecast: Optional[pychromecast.Chromecast] = None
80 self.stop_get_chromecasts = None
81 self.refresh_players()
83 def chromecast_discovered_callback(self, chromecast: Any):
84 chromecast = cast(pychromecast.Chromecast, chromecast)
85 self._chromecasts[chromecast.device.uuid] = chromecast
86 self.player_device_change_callback(
87 PlayerDeviceEvent(
88 PlayerDeviceEvent.Delta.ADD,
89 type(self),
90 str(chromecast.device.uuid),
91 chromecast.device.friendly_name,
92 )
93 )
95 def change_settings(self, config: Dict[str, Union[str, int, bool]]):
96 if not chromecast_imported:
97 return
99 self.config = config
100 if bottle_imported and self.config.get(SERVE_FILES_KEY):
101 # Try and terminate the existing process if it exists.
102 if self.server_process is not None:
103 try:
104 self.server_process.terminate()
105 except Exception:
106 pass
108 self.server_process = multiprocessing.Process(
109 target=self._run_server_process,
110 args=("0.0.0.0", self.config.get(LAN_PORT_KEY)),
111 )
112 self.server_process.start()
114 def refresh_players(self):
115 if not chromecast_imported:
116 return
118 if self.stop_get_chromecasts is not None:
119 self.stop_get_chromecasts.cancel()
121 for id_, chromecast in self._chromecasts.items():
122 self.player_device_change_callback(
123 PlayerDeviceEvent(
124 PlayerDeviceEvent.Delta.REMOVE,
125 type(self),
126 str(id_),
127 chromecast.device.friendly_name,
128 )
129 )
131 self._chromecasts = {}
133 self.stop_get_chromecasts = pychromecast.get_chromecasts(
134 blocking=False, callback=self.chromecast_discovered_callback
135 )
137 def set_current_device_id(self, device_id: str):
138 self._current_chromecast = self._chromecasts[UUID(device_id)]
139 self._current_chromecast.media_controller.register_status_listener(self)
140 self._current_chromecast.register_status_listener(self)
141 self._current_chromecast.wait()
143 def new_cast_status(self, status: Any):
144 assert self._current_chromecast
145 self.on_player_event(
146 PlayerEvent(
147 PlayerEvent.EventType.VOLUME_CHANGE,
148 str(self._current_chromecast.device.uuid),
149 volume=(status.volume_level * 100 if not status.volume_muted else 0),
150 )
151 )
153 # This happens when the Chromecast is first activated or after "Stop Casting" is
154 # pressed in the Google Home app. Reset `song_loaded` so that it calls
155 # `play_media` the next time around rather than trying to toggle the play state.
156 if status.session_id is None:
157 self.song_loaded = False
159 time_increment_order_token = 0
161 def new_media_status(self, status: Any):
162 # Detect the end of a track and go to the next one.
163 if (
164 status.idle_reason == "FINISHED"
165 and status.player_state == "IDLE"
166 and self._timepos > 0
167 ):
168 logging.debug("Chromecast track ended")
169 self.on_track_end()
170 return
172 self.song_loaded = True
174 self._timepos = status.current_time
176 assert self._current_chromecast
177 self.on_player_event(
178 PlayerEvent(
179 PlayerEvent.EventType.PLAY_STATE_CHANGE,
180 str(self._current_chromecast.device.uuid),
181 playing=(status.player_state in ("PLAYING", "BUFFERING")),
182 )
183 )
185 def increment_time(order_token: int):
186 if self.time_increment_order_token != order_token or not self.playing:
187 return
189 self._timepos += 0.5
190 self.on_timepos_change(self._timepos)
191 GLib.timeout_add(500, increment_time, order_token)
193 self.time_increment_order_token += 1
194 GLib.timeout_add(500, increment_time, self.time_increment_order_token)
196 def shutdown(self):
197 if self.server_process:
198 self.server_process.terminate()
200 try:
201 self._current_chromecast.quit_app()
202 except Exception:
203 pass
205 _serving_song_id = multiprocessing.Array("c", 1024) # huge buffer, just in case
206 _serving_token = multiprocessing.Array("c", 16)
208 def _run_server_process(self, host: str, port: int):
209 app = bottle.Bottle()
211 @app.route("/")
212 def index() -> str:
213 return """
214 <h1>Sublime Music Local Music Server</h1>
215 <p>
216 Sublime Music uses this port as a server for serving music to
217 Chromecasts on the same LAN.
218 </p>
219 """
221 @app.route("/s/<token>")
222 def stream_song(token: str) -> bytes:
223 if token != self._serving_token.value.decode():
224 raise bottle.HTTPError(status=401, body="Invalid token.")
226 song = AdapterManager.get_song_details(
227 self._serving_song_id.value.decode()
228 ).result()
229 filename = AdapterManager.get_song_file_uri(song)
230 with open(filename[7:], "rb") as fin:
231 song_buffer = io.BytesIO(fin.read())
233 content_type = mimetypes.guess_type(filename)[0]
234 bottle.response.set_header("Content-Type", content_type)
235 bottle.response.set_header("Accept-Ranges", "bytes")
236 return song_buffer.read()
238 bottle.run(app, host=host, port=port)
240 @property
241 def playing(self) -> bool:
242 if (
243 not self._current_chromecast
244 or not self._current_chromecast.media_controller
245 ):
246 return False
247 return self._current_chromecast.media_controller.status.player_is_playing
249 def get_volume(self) -> float:
250 if self._current_chromecast:
251 # The volume is in the range [0, 1]. Multiply by 100 to get to [0, 100].
252 return self._current_chromecast.status.volume_level * 100
253 else:
254 return 100
256 def set_volume(self, volume: float):
257 if self._current_chromecast:
258 # volume value is in [0, 100]. Convert to [0, 1] for Chromecast.
259 self._current_chromecast.set_volume(volume / 100)
261 def get_is_muted(self) -> bool:
262 if not self._current_chromecast:
263 return False
264 return self._current_chromecast.volume_muted
266 def set_muted(self, muted: bool):
267 if not self._current_chromecast:
268 return
269 self._current_chromecast.set_volume_muted(muted)
271 def play_media(self, uri: str, progress: timedelta, song: Song):
272 assert self._current_chromecast
273 scheme = urlparse(uri).scheme
274 if scheme == "file":
275 token = base64.b16encode(os.urandom(8))
276 self._serving_token.value = token
277 self._serving_song_id.value = song.id.encode()
279 # If this fails, then we are basically screwed, so don't care if it blows
280 # up.
281 # TODO (#129): this does not work properly when on VPNs when the DNS is
282 # piped over the VPN tunnel.
283 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
284 s.connect(("8.8.8.8", 80))
285 host_ip = s.getsockname()[0]
286 s.close()
288 uri = f"http://{host_ip}:{self.config.get(LAN_PORT_KEY)}/s/{token.decode()}"
289 logging.info("Serving {song.name} at {uri}")
291 assert AdapterManager._instance
292 networked_scheme_priority = ("https", "http")
293 scheme = sorted(
294 AdapterManager._instance.ground_truth_adapter.supported_schemes,
295 key=lambda s: networked_scheme_priority.index(s),
296 )[0]
297 cover_art_url = AdapterManager.get_cover_art_uri(
298 song.cover_art, scheme, size=1000
299 ).result()
300 self._current_chromecast.media_controller.play_media(
301 uri,
302 # Just pretend that whatever we send it is mp3, even if it isn't.
303 "audio/mp3",
304 current_time=progress.total_seconds(),
305 title=song.title,
306 thumb=cover_art_url,
307 metadata={
308 "metadataType": 3,
309 "albumName": song.album.name if song.album else None,
310 "artist": song.artist.name if song.artist else None,
311 "trackNumber": song.track,
312 },
313 )
315 # Make sure to clear out the cache duration state.
316 self.on_player_event(
317 PlayerEvent(
318 PlayerEvent.EventType.STREAM_CACHE_PROGRESS_CHANGE,
319 str(self._current_chromecast.device.uuid),
320 stream_cache_duration=0,
321 )
322 )
323 self._timepos = progress.total_seconds()
325 def pause(self):
326 if self._current_chromecast and self._current_chromecast.media_controller:
327 self._current_chromecast.media_controller.pause()
329 def play(self):
330 if self._current_chromecast and self._current_chromecast.media_controller:
331 self._current_chromecast.media_controller.play()
333 def seek(self, position: timedelta):
334 if not self._current_chromecast:
335 return
337 do_pause = not self.playing
338 self._current_chromecast.media_controller.seek(position.total_seconds())
339 if do_pause:
340 self.pause()
342 def _wait_for_playing(self):
343 pass