Home Assistant Unofficial Reference 2024.12.1
media.py
Go to the documentation of this file.
1 """Support for media metadata handling."""
2 
3 from __future__ import annotations
4 
5 import datetime
6 from typing import Any
7 
8 from soco.core import (
9  MUSIC_SRC_AIRPLAY,
10  MUSIC_SRC_LINE_IN,
11  MUSIC_SRC_RADIO,
12  MUSIC_SRC_SPOTIFY_CONNECT,
13  MUSIC_SRC_TV,
14  SoCo,
15 )
16 from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer
17 from soco.music_library import MusicLibrary
18 
19 from homeassistant.core import HomeAssistant
20 from homeassistant.helpers.config_validation import time_period_str
21 from homeassistant.helpers.dispatcher import dispatcher_send
22 from homeassistant.util import dt as dt_util
23 
24 from .const import (
25  SONOS_MEDIA_UPDATED,
26  SONOS_STATE_PLAYING,
27  SONOS_STATE_TRANSITIONING,
28  SOURCE_AIRPLAY,
29  SOURCE_LINEIN,
30  SOURCE_SPOTIFY_CONNECT,
31  SOURCE_TV,
32 )
33 from .helpers import soco_error
34 
35 LINEIN_SOURCES = (MUSIC_SRC_TV, MUSIC_SRC_LINE_IN)
36 SOURCE_MAPPING = {
37  MUSIC_SRC_AIRPLAY: SOURCE_AIRPLAY,
38  MUSIC_SRC_TV: SOURCE_TV,
39  MUSIC_SRC_LINE_IN: SOURCE_LINEIN,
40  MUSIC_SRC_SPOTIFY_CONNECT: SOURCE_SPOTIFY_CONNECT,
41 }
42 UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None}
43 DURATION_SECONDS = "duration_in_s"
44 POSITION_SECONDS = "position_in_s"
45 
46 
47 def _timespan_secs(timespan: str | None) -> int | None:
48  """Parse a time-span into number of seconds."""
49  if timespan in UNAVAILABLE_VALUES:
50  return None
51  return int(time_period_str(timespan).total_seconds()) # type: ignore[arg-type]
52 
53 
54 class SonosMedia:
55  """Representation of the current Sonos media."""
56 
57  def __init__(self, hass: HomeAssistant, soco: SoCo) -> None:
58  """Initialize a SonosMedia."""
59  self.hasshass = hass
60  self.socosoco = soco
61  self.play_modeplay_mode: str | None = None
62  self.playback_statusplayback_status: str | None = None
63 
64  # This block is reset with clear()
65  self.album_namealbum_name: str | None = None
66  self.artistartist: str | None = None
67  self.channelchannel: str | None = None
68  self.durationduration: float | None = None
69  self.image_urlimage_url: str | None = None
70  self.queue_positionqueue_position: int | None = None
71  self.queue_sizequeue_size: int | None = None
72  self.playlist_nameplaylist_name: str | None = None
73  self.source_namesource_name: str | None = None
74  self.titletitle: str | None = None
75  self.uriuri: str | None = None
76 
77  self.positionposition: int | None = None
78  self.position_updated_atposition_updated_at: datetime.datetime | None = None
79 
80  def clear(self) -> None:
81  """Clear basic media info."""
82  self.album_namealbum_name = None
83  self.artistartist = None
84  self.channelchannel = None
85  self.durationduration = None
86  self.image_urlimage_url = None
87  self.playlist_nameplaylist_name = None
88  self.queue_positionqueue_position = None
89  self.queue_sizequeue_size = None
90  self.source_namesource_name = None
91  self.titletitle = None
92  self.uriuri = None
93 
94  def clear_position(self) -> None:
95  """Clear the position attributes."""
96  self.positionposition = None
97  self.position_updated_atposition_updated_at = None
98 
99  @property
100  def library(self) -> MusicLibrary:
101  """Return the soco MusicLibrary instance."""
102  return self.socosoco.music_library
103 
104  @soco_error()
105  def poll_track_info(self) -> dict[str, Any]:
106  """Poll the speaker for current track info, add converted position values."""
107  track_info: dict[str, Any] = self.socosoco.get_current_track_info()
108  track_info[DURATION_SECONDS] = _timespan_secs(track_info.get("duration"))
109  track_info[POSITION_SECONDS] = _timespan_secs(track_info.get("position"))
110  return track_info
111 
112  def write_media_player_states(self) -> None:
113  """Send a signal to media player(s) to write new states."""
114  dispatcher_send(self.hasshass, SONOS_MEDIA_UPDATED, self.socosoco.uid)
115 
116  def set_basic_track_info(self, update_position: bool = False) -> None:
117  """Query the speaker to update media metadata and position info."""
118  self.clearclear()
119 
120  track_info = self.poll_track_infopoll_track_info()
121  if not track_info["uri"]:
122  return
123  self.uriuri = track_info["uri"]
124 
125  audio_source = self.socosoco.music_source_from_uri(self.uriuri)
126  if source := SOURCE_MAPPING.get(audio_source):
127  self.source_namesource_name = source
128  if audio_source in LINEIN_SOURCES:
129  self.clear_positionclear_position()
130  self.titletitle = source
131  return
132 
133  self.artistartist = track_info.get("artist")
134  self.album_namealbum_name = track_info.get("album")
135  self.titletitle = track_info.get("title")
136  self.image_urlimage_url = track_info.get("album_art")
137 
138  playlist_position = int(track_info.get("playlist_position", -1))
139  if playlist_position > 0:
140  self.queue_positionqueue_position = playlist_position
141 
142  self.update_media_positionupdate_media_position(track_info, force_update=update_position)
143 
144  def update_media_from_event(self, evars: dict[str, Any]) -> None:
145  """Update information about currently playing media using an event payload."""
146  new_status = evars["transport_state"]
147  state_changed = new_status != self.playback_statusplayback_status
148 
149  self.play_modeplay_mode = evars["current_play_mode"]
150  self.playback_statusplayback_status = new_status
151 
152  track_uri = evars["enqueued_transport_uri"] or evars["current_track_uri"]
153  audio_source = self.socosoco.music_source_from_uri(track_uri)
154 
155  self.set_basic_track_infoset_basic_track_info(update_position=state_changed)
156 
157  if ct_md := evars["current_track_meta_data"]:
158  if not self.image_urlimage_url:
159  if album_art_uri := getattr(ct_md, "album_art_uri", None):
160  self.image_urlimage_url = self.librarylibrary.build_album_art_full_uri(
161  album_art_uri
162  )
163 
164  et_uri_md = evars["enqueued_transport_uri_meta_data"]
165  if isinstance(et_uri_md, DidlPlaylistContainer):
166  self.playlist_nameplaylist_name = et_uri_md.title
167 
168  if queue_size := evars.get("number_of_tracks", 0):
169  self.queue_sizequeue_size = int(queue_size)
170 
171  if audio_source == MUSIC_SRC_RADIO:
172  if et_uri_md:
173  self.channelchannel = et_uri_md.title
174 
175  # Extra guards for S1 compatibility
176  if ct_md and hasattr(ct_md, "radio_show") and ct_md.radio_show:
177  radio_show = ct_md.radio_show.split(",")[0]
178  self.channelchannel = " • ".join(filter(None, [self.channelchannel, radio_show]))
179 
180  if isinstance(et_uri_md, DidlAudioBroadcast):
181  self.titletitle = self.titletitle or self.channelchannel
182 
183  self.write_media_player_stateswrite_media_player_states()
184 
185  @soco_error()
186  def poll_media(self) -> None:
187  """Poll information about currently playing media."""
188  transport_info = self.socosoco.get_current_transport_info()
189  new_status = transport_info["current_transport_state"]
190 
191  if new_status == SONOS_STATE_TRANSITIONING:
192  return
193 
194  update_position = new_status != self.playback_statusplayback_status
195  self.playback_statusplayback_status = new_status
196  self.play_modeplay_mode = self.socosoco.play_mode
197 
198  self.set_basic_track_infoset_basic_track_info(update_position=update_position)
199 
200  self.write_media_player_stateswrite_media_player_states()
201 
203  self, position_info: dict[str, int], force_update: bool = False
204  ) -> None:
205  """Update state when playing music tracks."""
206  duration = position_info.get(DURATION_SECONDS)
207  current_position = position_info.get(POSITION_SECONDS)
208 
209  if not (duration or current_position):
210  self.clear_positionclear_position()
211  return
212 
213  should_update = force_update
214  self.durationduration = duration
215 
216  # player started reporting position?
217  if current_position is not None and self.positionposition is None:
218  should_update = True
219 
220  # position jumped?
221  if current_position is not None and self.positionposition is not None:
222  if self.playback_statusplayback_status == SONOS_STATE_PLAYING:
223  assert self.position_updated_atposition_updated_at is not None
224  time_delta = dt_util.utcnow() - self.position_updated_atposition_updated_at
225  time_diff = time_delta.total_seconds()
226  else:
227  time_diff = 0
228 
229  calculated_position = self.positionposition + time_diff
230 
231  if abs(calculated_position - current_position) > 1.5:
232  should_update = True
233 
234  if current_position is None:
235  self.clear_positionclear_position()
236  elif should_update:
237  self.positionposition = current_position
238  self.position_updated_atposition_updated_at = dt_util.utcnow()
None __init__(self, HomeAssistant hass, SoCo soco)
Definition: media.py:57
None update_media_from_event(self, dict[str, Any] evars)
Definition: media.py:144
None set_basic_track_info(self, bool update_position=False)
Definition: media.py:116
None update_media_position(self, dict[str, int] position_info, bool force_update=False)
Definition: media.py:204
int|None _timespan_secs(str|None timespan)
Definition: media.py:47
None dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:137