ha-core/homeassistant/components/pandora/media_player.py

340 lines
12 KiB
Python

"""Component for controlling Pandora stations through the pianobar client."""
from __future__ import annotations
from datetime import timedelta
import logging
import os
import re
import shutil
import signal
import pexpect
from homeassistant import util
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PLAY_PAUSE,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_UP,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
CMD_MAP = {
SERVICE_MEDIA_NEXT_TRACK: "n",
SERVICE_MEDIA_PLAY_PAUSE: "p",
SERVICE_MEDIA_PLAY: "p",
SERVICE_VOLUME_UP: ")",
SERVICE_VOLUME_DOWN: "(",
}
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2)
CURRENT_SONG_PATTERN = re.compile(r'"(.*?)"\s+by\s+"(.*?)"\son\s+"(.*?)"', re.MULTILINE)
STATION_PATTERN = re.compile(r'Station\s"(.+?)"', re.MULTILINE)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Pandora media player platform."""
if not _pianobar_exists():
return
pandora = PandoraMediaPlayer("Pandora")
# Make sure we end the pandora subprocess on exit in case user doesn't
# power it down.
def _stop_pianobar(_event):
pandora.turn_off()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_pianobar)
add_entities([pandora])
class PandoraMediaPlayer(MediaPlayerEntity):
"""A media player that uses the Pianobar interface to Pandora."""
_attr_media_content_type = MediaType.MUSIC
# MediaPlayerEntityFeature.VOLUME_SET is close to available but we need volume up/down
# controls in the GUI.
_attr_supported_features = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY
)
def __init__(self, name):
"""Initialize the Pandora device."""
self._attr_name = name
self._attr_state = MediaPlayerState.OFF
self._attr_source = ""
self._attr_media_title = ""
self._attr_media_artist = ""
self._attr_media_album_name = ""
self._attr_source_list = []
self._time_remaining = 0
self._attr_media_duration = 0
self._pianobar = None
def turn_on(self) -> None:
"""Turn the media player on."""
if self.state != MediaPlayerState.OFF:
return
self._pianobar = pexpect.spawn("pianobar")
_LOGGER.info("Started pianobar subprocess")
mode = self._pianobar.expect(
["Receiving new playlist", "Select station:", "Email:"]
)
if mode == 1:
# station list was presented. dismiss it.
self._pianobar.sendcontrol("m")
elif mode == 2:
_LOGGER.warning(
"The pianobar client is not configured to log in. "
"Please create a configuration file for it as described at "
"https://www.home-assistant.io/integrations/pandora/"
)
# pass through the email/password prompts to quit cleanly
self._pianobar.sendcontrol("m")
self._pianobar.sendcontrol("m")
self._pianobar.terminate()
self._pianobar = None
return
self._update_stations()
self.update_playing_status()
self._attr_state = MediaPlayerState.IDLE
self.schedule_update_ha_state()
def turn_off(self) -> None:
"""Turn the media player off."""
if self._pianobar is None:
_LOGGER.info("Pianobar subprocess already stopped")
return
self._pianobar.send("q")
try:
_LOGGER.debug("Stopped Pianobar subprocess")
self._pianobar.terminate()
except pexpect.exceptions.TIMEOUT:
# kill the process group
os.killpg(os.getpgid(self._pianobar.pid), signal.SIGTERM)
_LOGGER.debug("Killed Pianobar subprocess")
self._pianobar = None
self._attr_state = MediaPlayerState.OFF
self.schedule_update_ha_state()
def media_play(self) -> None:
"""Send play command."""
self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
self._attr_state = MediaPlayerState.PLAYING
self.schedule_update_ha_state()
def media_pause(self) -> None:
"""Send pause command."""
self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
self._attr_state = MediaPlayerState.PAUSED
self.schedule_update_ha_state()
def media_next_track(self) -> None:
"""Go to next track."""
self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK)
self.schedule_update_ha_state()
@property
def media_title(self) -> str | None:
"""Title of current playing media."""
self.update_playing_status()
return self._attr_media_title
def select_source(self, source: str) -> None:
"""Choose a different Pandora station and play it."""
if self.source_list is None:
return
try:
station_index = self.source_list.index(source)
except ValueError:
_LOGGER.warning("Station %s is not in list", source)
return
_LOGGER.debug("Setting station %s, %d", source, station_index)
self._send_station_list_command()
self._pianobar.sendline(f"{station_index}")
self._pianobar.expect("\r\n")
self._attr_state = MediaPlayerState.PLAYING
def _send_station_list_command(self):
"""Send a station list command."""
self._pianobar.send("s")
try:
self._pianobar.expect("Select station:", timeout=1)
except pexpect.exceptions.TIMEOUT:
# try again. Buffer was contaminated.
self._clear_buffer()
self._pianobar.send("s")
self._pianobar.expect("Select station:")
def update_playing_status(self):
"""Query pianobar for info about current media_title, station."""
response = self._query_for_playing_status()
if not response:
return
self._update_current_station(response)
self._update_current_song(response)
self._update_song_position()
def _query_for_playing_status(self):
"""Query system for info about current track."""
self._clear_buffer()
self._pianobar.send("i")
try:
match_idx = self._pianobar.expect(
[
rb"(\d\d):(\d\d)/(\d\d):(\d\d)",
"No song playing",
"Select station",
"Receiving new playlist",
]
)
except pexpect.exceptions.EOF:
_LOGGER.info("Pianobar process already exited")
return None
self._log_match()
if match_idx == 1:
# idle.
response = None
elif match_idx == 2:
# stuck on a station selection dialog. Clear it.
_LOGGER.warning("On unexpected station list page")
self._pianobar.sendcontrol("m") # press enter
self._pianobar.sendcontrol("m") # do it again b/c an 'i' got in
# pylint: disable=assignment-from-none
response = self.update_playing_status()
elif match_idx == 3:
_LOGGER.debug("Received new playlist list")
# pylint: disable=assignment-from-none
response = self.update_playing_status()
else:
response = self._pianobar.before.decode("utf-8")
return response
def _update_current_station(self, response):
"""Update current station."""
if station_match := re.search(STATION_PATTERN, response):
self._attr_source = station_match.group(1)
_LOGGER.debug("Got station as: %s", self._attr_source)
else:
_LOGGER.warning("No station match")
def _update_current_song(self, response):
"""Update info about current song."""
if song_match := re.search(CURRENT_SONG_PATTERN, response):
(
self._attr_media_title,
self._attr_media_artist,
self._attr_media_album_name,
) = song_match.groups()
_LOGGER.debug("Got song as: %s", self._attr_media_title)
else:
_LOGGER.warning("No song match")
@util.Throttle(MIN_TIME_BETWEEN_UPDATES)
def _update_song_position(self):
"""Get the song position and duration.
It's hard to predict whether or not the music will start during init
so we have to detect state by checking the ticker.
"""
(
cur_minutes,
cur_seconds,
total_minutes,
total_seconds,
) = self._pianobar.match.groups()
time_remaining = int(cur_minutes) * 60 + int(cur_seconds)
self._attr_media_duration = int(total_minutes) * 60 + int(total_seconds)
if time_remaining not in (self._time_remaining, self._attr_media_duration):
self._attr_state = MediaPlayerState.PLAYING
elif self.state == MediaPlayerState.PLAYING:
self._attr_state = MediaPlayerState.PAUSED
self._time_remaining = time_remaining
def _log_match(self):
"""Log grabbed values from console."""
_LOGGER.debug(
"Before: %s\nMatch: %s\nAfter: %s",
repr(self._pianobar.before),
repr(self._pianobar.match),
repr(self._pianobar.after),
)
def _send_pianobar_command(self, service_cmd):
"""Send a command to Pianobar."""
command = CMD_MAP.get(service_cmd)
_LOGGER.debug("Sending pinaobar command %s for %s", command, service_cmd)
if command is None:
_LOGGER.info("Command %s not supported yet", service_cmd)
self._clear_buffer()
self._pianobar.sendline(command)
def _update_stations(self):
"""List defined Pandora stations."""
self._send_station_list_command()
station_lines = self._pianobar.before.decode("utf-8")
_LOGGER.debug("Getting stations: %s", station_lines)
self._attr_source_list = []
for line in station_lines.split("\r\n"):
if match := re.search(r"\d+\).....(.+)", line):
station = match.group(1).strip()
_LOGGER.debug("Found station %s", station)
self._attr_source_list.append(station)
else:
_LOGGER.debug("No station match on %s", line)
self._pianobar.sendcontrol("m") # press enter with blank line
self._pianobar.sendcontrol("m") # do it twice in case an 'i' got in
def _clear_buffer(self):
"""Clear buffer from pexpect.
This is necessary because there are a bunch of 00:00 in the buffer
"""
try:
while not self._pianobar.expect(".+", timeout=0.1):
pass
except pexpect.exceptions.TIMEOUT:
pass
except pexpect.exceptions.EOF:
pass
def _pianobar_exists():
"""Verify that Pianobar is properly installed."""
pianobar_exe = shutil.which("pianobar")
if pianobar_exe:
return True
_LOGGER.warning(
"The Pandora integration depends on the Pianobar client, which "
"cannot be found. Please install using instructions at "
"https://www.home-assistant.io/integrations/media_player.pandora/"
)
return False