Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Component for controlling Pandora stations through the pianobar client."""
2 
3 from __future__ import annotations
4 
5 from datetime import timedelta
6 import logging
7 import os
8 import re
9 import shutil
10 import signal
11 
12 import pexpect
13 
14 from homeassistant import util
16  MediaPlayerEntity,
17  MediaPlayerEntityFeature,
18  MediaPlayerState,
19  MediaType,
20 )
21 from homeassistant.const import (
22  EVENT_HOMEASSISTANT_STOP,
23  SERVICE_MEDIA_NEXT_TRACK,
24  SERVICE_MEDIA_PLAY,
25  SERVICE_MEDIA_PLAY_PAUSE,
26  SERVICE_VOLUME_DOWN,
27  SERVICE_VOLUME_UP,
28 )
29 from homeassistant.core import HomeAssistant
30 from homeassistant.helpers.entity_platform import AddEntitiesCallback
31 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
32 
33 _LOGGER = logging.getLogger(__name__)
34 
35 
36 CMD_MAP = {
37  SERVICE_MEDIA_NEXT_TRACK: "n",
38  SERVICE_MEDIA_PLAY_PAUSE: "p",
39  SERVICE_MEDIA_PLAY: "p",
40  SERVICE_VOLUME_UP: ")",
41  SERVICE_VOLUME_DOWN: "(",
42 }
43 MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2)
44 CURRENT_SONG_PATTERN = re.compile(r'"(.*?)"\s+by\s+"(.*?)"\son\s+"(.*?)"', re.MULTILINE)
45 STATION_PATTERN = re.compile(r'Station\s"(.+?)"', re.MULTILINE)
46 
47 
49  hass: HomeAssistant,
50  config: ConfigType,
51  add_entities: AddEntitiesCallback,
52  discovery_info: DiscoveryInfoType | None = None,
53 ) -> None:
54  """Set up the Pandora media player platform."""
55  if not _pianobar_exists():
56  return
57  pandora = PandoraMediaPlayer("Pandora")
58 
59  # Make sure we end the pandora subprocess on exit in case user doesn't
60  # power it down.
61  def _stop_pianobar(_event):
62  pandora.turn_off()
63 
64  hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_pianobar)
65  add_entities([pandora])
66 
67 
69  """A media player that uses the Pianobar interface to Pandora."""
70 
71  _attr_media_content_type = MediaType.MUSIC
72  # MediaPlayerEntityFeature.VOLUME_SET is close to available
73  # but we need volume up/down controls in the GUI.
74  _attr_supported_features = (
75  MediaPlayerEntityFeature.PAUSE
76  | MediaPlayerEntityFeature.TURN_ON
77  | MediaPlayerEntityFeature.TURN_OFF
78  | MediaPlayerEntityFeature.NEXT_TRACK
79  | MediaPlayerEntityFeature.SELECT_SOURCE
80  | MediaPlayerEntityFeature.PLAY
81  )
82 
83  def __init__(self, name):
84  """Initialize the Pandora device."""
85  self._attr_name_attr_name = name
86  self._attr_state_attr_state = MediaPlayerState.OFF
87  self._attr_source_attr_source = ""
88  self._attr_media_title_attr_media_title = ""
89  self._attr_media_artist_attr_media_artist = ""
90  self._attr_media_album_name_attr_media_album_name = ""
91  self._attr_source_list_attr_source_list = []
92  self._time_remaining_time_remaining = 0
93  self._attr_media_duration_attr_media_duration = 0
94  self._pianobar_pianobar = None
95 
96  def turn_on(self) -> None:
97  """Turn the media player on."""
98  if self.statestatestatestatestate != MediaPlayerState.OFF:
99  return
100  self._pianobar_pianobar = pexpect.spawn("pianobar")
101  _LOGGER.debug("Started pianobar subprocess")
102  mode = self._pianobar_pianobar.expect(
103  ["Receiving new playlist", "Select station:", "Email:"]
104  )
105  if mode == 1:
106  # station list was presented. dismiss it.
107  self._pianobar_pianobar.sendcontrol("m")
108  elif mode == 2:
109  _LOGGER.warning(
110  "The pianobar client is not configured to log in. "
111  "Please create a configuration file for it as described at "
112  "https://www.home-assistant.io/integrations/pandora/"
113  )
114  # pass through the email/password prompts to quit cleanly
115  self._pianobar_pianobar.sendcontrol("m")
116  self._pianobar_pianobar.sendcontrol("m")
117  self._pianobar_pianobar.terminate()
118  self._pianobar_pianobar = None
119  return
120  self._update_stations_update_stations()
121  self.update_playing_statusupdate_playing_status()
122 
123  self._attr_state_attr_state = MediaPlayerState.IDLE
124  self.schedule_update_ha_stateschedule_update_ha_state()
125 
126  def turn_off(self) -> None:
127  """Turn the media player off."""
128  if self._pianobar_pianobar is None:
129  _LOGGER.warning("Pianobar subprocess already stopped")
130  return
131  self._pianobar_pianobar.send("q")
132  try:
133  _LOGGER.debug("Stopped Pianobar subprocess")
134  self._pianobar_pianobar.terminate()
135  except pexpect.exceptions.TIMEOUT:
136  # kill the process group
137  os.killpg(os.getpgid(self._pianobar_pianobar.pid), signal.SIGTERM)
138  _LOGGER.debug("Killed Pianobar subprocess")
139  self._pianobar_pianobar = None
140  self._attr_state_attr_state = MediaPlayerState.OFF
141  self.schedule_update_ha_stateschedule_update_ha_state()
142 
143  def media_play(self) -> None:
144  """Send play command."""
145  self._send_pianobar_command_send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
146  self._attr_state_attr_state = MediaPlayerState.PLAYING
147  self.schedule_update_ha_stateschedule_update_ha_state()
148 
149  def media_pause(self) -> None:
150  """Send pause command."""
151  self._send_pianobar_command_send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
152  self._attr_state_attr_state = MediaPlayerState.PAUSED
153  self.schedule_update_ha_stateschedule_update_ha_state()
154 
155  def media_next_track(self) -> None:
156  """Go to next track."""
157  self._send_pianobar_command_send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK)
158  self.schedule_update_ha_stateschedule_update_ha_state()
159 
160  @property
161  def media_title(self) -> str | None:
162  """Title of current playing media."""
163  self.update_playing_statusupdate_playing_status()
164  return self._attr_media_title_attr_media_title
165 
166  def select_source(self, source: str) -> None:
167  """Choose a different Pandora station and play it."""
168  if self.source_listsource_list is None:
169  return
170  try:
171  station_index = self.source_listsource_list.index(source)
172  except ValueError:
173  _LOGGER.warning("Station %s is not in list", source)
174  return
175  _LOGGER.debug("Setting station %s, %d", source, station_index)
176  self._send_station_list_command_send_station_list_command()
177  self._pianobar_pianobar.sendline(f"{station_index}")
178  self._pianobar_pianobar.expect("\r\n")
179  self._attr_state_attr_state = MediaPlayerState.PLAYING
180 
182  """Send a station list command."""
183  self._pianobar_pianobar.send("s")
184  try:
185  self._pianobar_pianobar.expect("Select station:", timeout=1)
186  except pexpect.exceptions.TIMEOUT:
187  # try again. Buffer was contaminated.
188  self._clear_buffer_clear_buffer()
189  self._pianobar_pianobar.send("s")
190  self._pianobar_pianobar.expect("Select station:")
191 
193  """Query pianobar for info about current media_title, station."""
194  response = self._query_for_playing_status_query_for_playing_status()
195  if not response:
196  return
197  self._update_current_station_update_current_station(response)
198  self._update_current_song_update_current_song(response)
199  self._update_song_position_update_song_position()
200 
202  """Query system for info about current track."""
203  self._clear_buffer_clear_buffer()
204  self._pianobar_pianobar.send("i")
205  try:
206  match_idx = self._pianobar_pianobar.expect(
207  [
208  rb"(\d\d):(\d\d)/(\d\d):(\d\d)",
209  "No song playing",
210  "Select station",
211  "Receiving new playlist",
212  ]
213  )
214  except pexpect.exceptions.EOF:
215  _LOGGER.warning("Pianobar process already exited")
216  return None
217 
218  self._log_match_log_match()
219  if match_idx == 1:
220  # idle.
221  response = None
222  elif match_idx == 2:
223  # stuck on a station selection dialog. Clear it.
224  _LOGGER.warning("On unexpected station list page")
225  self._pianobar_pianobar.sendcontrol("m") # press enter
226  self._pianobar_pianobar.sendcontrol("m") # do it again b/c an 'i' got in
227  response = self.update_playing_statusupdate_playing_status()
228  elif match_idx == 3:
229  _LOGGER.debug("Received new playlist list")
230  response = self.update_playing_statusupdate_playing_status()
231  else:
232  response = self._pianobar_pianobar.before.decode("utf-8")
233  return response
234 
235  def _update_current_station(self, response):
236  """Update current station."""
237  if station_match := re.search(STATION_PATTERN, response):
238  self._attr_source_attr_source = station_match.group(1)
239  _LOGGER.debug("Got station as: %s", self._attr_source_attr_source)
240  else:
241  _LOGGER.warning("No station match")
242 
243  def _update_current_song(self, response):
244  """Update info about current song."""
245  if song_match := re.search(CURRENT_SONG_PATTERN, response):
246  (
247  self._attr_media_title_attr_media_title,
248  self._attr_media_artist_attr_media_artist,
249  self._attr_media_album_name_attr_media_album_name,
250  ) = song_match.groups()
251  _LOGGER.debug("Got song as: %s", self._attr_media_title_attr_media_title)
252  else:
253  _LOGGER.warning("No song match")
254 
255  @util.Throttle(MIN_TIME_BETWEEN_UPDATES)
257  """Get the song position and duration.
258 
259  It's hard to predict whether or not the music will start during init
260  so we have to detect state by checking the ticker.
261 
262  """
263  (
264  cur_minutes,
265  cur_seconds,
266  total_minutes,
267  total_seconds,
268  ) = self._pianobar_pianobar.match.groups()
269  time_remaining = int(cur_minutes) * 60 + int(cur_seconds)
270  self._attr_media_duration_attr_media_duration = int(total_minutes) * 60 + int(total_seconds)
271 
272  if time_remaining not in (self._time_remaining_time_remaining, self._attr_media_duration_attr_media_duration):
273  self._attr_state_attr_state = MediaPlayerState.PLAYING
274  elif self.statestatestatestatestate == MediaPlayerState.PLAYING:
275  self._attr_state_attr_state = MediaPlayerState.PAUSED
276  self._time_remaining_time_remaining = time_remaining
277 
278  def _log_match(self):
279  """Log grabbed values from console."""
280  _LOGGER.debug(
281  "Before: %s\nMatch: %s\nAfter: %s",
282  repr(self._pianobar_pianobar.before),
283  repr(self._pianobar_pianobar.match),
284  repr(self._pianobar_pianobar.after),
285  )
286 
287  def _send_pianobar_command(self, service_cmd):
288  """Send a command to Pianobar."""
289  command = CMD_MAP.get(service_cmd)
290  _LOGGER.debug("Sending pinaobar command %s for %s", command, service_cmd)
291  if command is None:
292  _LOGGER.warning("Command %s not supported yet", service_cmd)
293  self._clear_buffer_clear_buffer()
294  self._pianobar_pianobar.sendline(command)
295 
296  def _update_stations(self):
297  """List defined Pandora stations."""
298  self._send_station_list_command_send_station_list_command()
299  station_lines = self._pianobar_pianobar.before.decode("utf-8")
300  _LOGGER.debug("Getting stations: %s", station_lines)
301  self._attr_source_list_attr_source_list = []
302  for line in station_lines.split("\r\n"):
303  if match := re.search(r"\d+\‍).....(.+)", line):
304  station = match.group(1).strip()
305  _LOGGER.debug("Found station %s", station)
306  self._attr_source_list_attr_source_list.append(station)
307  else:
308  _LOGGER.debug("No station match on %s", line)
309  self._pianobar_pianobar.sendcontrol("m") # press enter with blank line
310  self._pianobar_pianobar.sendcontrol("m") # do it twice in case an 'i' got in
311 
312  def _clear_buffer(self):
313  """Clear buffer from pexpect.
314 
315  This is necessary because there are a bunch of 00:00 in the buffer
316 
317  """
318  try:
319  while not self._pianobar_pianobar.expect(".+", timeout=0.1):
320  pass
321  except pexpect.exceptions.TIMEOUT:
322  pass
323  except pexpect.exceptions.EOF:
324  pass
325 
326 
328  """Verify that Pianobar is properly installed."""
329  pianobar_exe = shutil.which("pianobar")
330  if pianobar_exe:
331  return True
332 
333  _LOGGER.warning(
334  "The Pandora integration depends on the Pianobar client, which "
335  "cannot be found. Please install using instructions at "
336  "https://www.home-assistant.io/integrations/media_player.pandora/"
337  )
338  return False
None schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1244
None add_entities(AsusWrtRouter router, AddEntitiesCallback async_add_entities, set[str] tracked)
None setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: media_player.py:53