-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmedia_player.py
790 lines (678 loc) · 27.4 KB
/
media_player.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
"""Support to interface with Sonos players."""
from __future__ import annotations
import datetime
from functools import partial
import logging
from typing import Any
from soco import alarms
from soco.core import (
MUSIC_SRC_LINE_IN,
MUSIC_SRC_RADIO,
PLAY_MODE_BY_MEANING,
PLAY_MODES,
)
from soco.data_structures import DidlFavorite
from sonos_websocket.exception import SonosWebsocketError
import voluptuous as vol
from homeassistant.components import media_source, spotify
from homeassistant.components.media_player import (
ATTR_INPUT_SOURCE,
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_ENQUEUE,
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
RepeatMode,
async_process_play_media_url,
)
from homeassistant.components.plex import PLEX_URI_SCHEME
from homeassistant.components.plex.services import process_plex_payload
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TIME
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform, service
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import UnjoinData, media_browser
from .const import (
DATA_SONOS,
DOMAIN as SONOS_DOMAIN,
MEDIA_TYPES_TO_SONOS,
MODELS_LINEIN_AND_TV,
MODELS_LINEIN_ONLY,
MODELS_TV_ONLY,
PLAYABLE_MEDIA_TYPES,
SONOS_CREATE_MEDIA_PLAYER,
SONOS_MEDIA_UPDATED,
SONOS_STATE_PLAYING,
SONOS_STATE_TRANSITIONING,
SOURCE_LINEIN,
SOURCE_TV,
)
from .entity import SonosEntity
from .helpers import soco_error
from .speaker import SonosMedia, SonosSpeaker
_LOGGER = logging.getLogger(__name__)
LONG_SERVICE_TIMEOUT = 30.0
UNJOIN_SERVICE_TIMEOUT = 0.1
VOLUME_INCREMENT = 2
REPEAT_TO_SONOS = {
RepeatMode.OFF: False,
RepeatMode.ALL: True,
RepeatMode.ONE: "ONE",
}
SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()}
UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
SERVICE_SNAPSHOT = "snapshot"
SERVICE_RESTORE = "restore"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_UPDATE_ALARM = "update_alarm"
SERVICE_PLAY_QUEUE = "play_queue"
SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue"
ATTR_SLEEP_TIME = "sleep_time"
ATTR_ALARM_ID = "alarm_id"
ATTR_VOLUME = "volume"
ATTR_ENABLED = "enabled"
ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones"
ATTR_MASTER = "master"
ATTR_WITH_GROUP = "with_group"
ATTR_QUEUE_POSITION = "queue_position"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
platform = entity_platform.async_get_current_platform()
@callback
def async_create_entities(speaker: SonosSpeaker) -> None:
"""Handle device discovery and create entities."""
_LOGGER.debug("Creating media_player on %s", speaker.zone_name)
async_add_entities([SonosMediaPlayerEntity(speaker)])
@service.verify_domain_control(hass, SONOS_DOMAIN)
async def async_service_handle(service_call: ServiceCall) -> None:
"""Handle dispatched services."""
assert platform is not None
entities = await platform.async_extract_from_service(service_call)
if not entities:
return
speakers = []
for entity in entities:
assert isinstance(entity, SonosMediaPlayerEntity)
speakers.append(entity.speaker)
if service_call.service == SERVICE_SNAPSHOT:
await SonosSpeaker.snapshot_multi(
hass, speakers, service_call.data[ATTR_WITH_GROUP]
)
elif service_call.service == SERVICE_RESTORE:
await SonosSpeaker.restore_multi(
hass, speakers, service_call.data[ATTR_WITH_GROUP]
)
config_entry.async_on_unload(
async_dispatcher_connect(hass, SONOS_CREATE_MEDIA_PLAYER, async_create_entities)
)
join_unjoin_schema = cv.make_entity_service_schema(
{vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean}
)
hass.services.async_register(
SONOS_DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema
)
hass.services.async_register(
SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema
)
platform.async_register_entity_service(
SERVICE_SET_TIMER,
{
vol.Required(ATTR_SLEEP_TIME): vol.All(
vol.Coerce(int), vol.Range(min=0, max=86399)
)
},
"set_sleep_timer",
)
platform.async_register_entity_service(SERVICE_CLEAR_TIMER, {}, "clear_sleep_timer")
platform.async_register_entity_service(
SERVICE_UPDATE_ALARM,
{
vol.Required(ATTR_ALARM_ID): cv.positive_int,
vol.Optional(ATTR_TIME): cv.time,
vol.Optional(ATTR_VOLUME): cv.small_float,
vol.Optional(ATTR_ENABLED): cv.boolean,
vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
},
"set_alarm",
)
platform.async_register_entity_service(
SERVICE_PLAY_QUEUE,
{vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
"play_queue",
)
platform.async_register_entity_service(
SERVICE_REMOVE_FROM_QUEUE,
{vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
"remove_from_queue",
)
class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Representation of a Sonos entity."""
_attr_name = None
_attr_supported_features = (
MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET
)
_attr_media_content_type = MediaType.MUSIC
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
def __init__(self, speaker: SonosSpeaker) -> None:
"""Initialize the media player entity."""
super().__init__(speaker)
self._attr_unique_id = self.soco.uid
async def async_added_to_hass(self) -> None:
"""Handle common setup when added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SONOS_MEDIA_UPDATED,
self.async_write_media_state,
)
)
@callback
def async_write_media_state(self, uid: str) -> None:
"""Write media state if the provided UID is coordinator of this speaker."""
if self.coordinator.uid == uid:
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Return if the media_player is available."""
return (
self.speaker.available
and bool(self.speaker.sonos_group_entities)
and self.media.playback_status is not None
)
@property
def coordinator(self) -> SonosSpeaker:
"""Return the current coordinator SonosSpeaker."""
return self.speaker.coordinator or self.speaker
@property
def group_members(self) -> list[str] | None:
"""List of entity_ids which are currently grouped together."""
return self.speaker.sonos_group_entities
def __hash__(self) -> int:
"""Return a hash of self."""
return hash(self.unique_id)
@property
def state(self) -> MediaPlayerState:
"""Return the state of the entity."""
if self.media.playback_status in (
"PAUSED_PLAYBACK",
"STOPPED",
):
# Sonos can consider itself "paused" but without having media loaded
# (happens if playing Spotify and via Spotify app
# you pick another device to play on)
if self.media.title is None:
return MediaPlayerState.IDLE
return MediaPlayerState.PAUSED
if self.media.playback_status in (
SONOS_STATE_PLAYING,
SONOS_STATE_TRANSITIONING,
):
return MediaPlayerState.PLAYING
return MediaPlayerState.IDLE
async def _async_fallback_poll(self) -> None:
"""Retrieve latest state by polling."""
await (
self.hass.data[DATA_SONOS].favorites[self.speaker.household_id].async_poll()
)
await self.hass.async_add_executor_job(self._update)
def _update(self) -> None:
"""Retrieve latest state by polling."""
self.speaker.update_groups()
self.speaker.update_volume()
if self.speaker.is_coordinator:
self.media.poll_media()
@property
def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
return self.speaker.volume and self.speaker.volume / 100
@property
def is_volume_muted(self) -> bool | None:
"""Return true if volume is muted."""
return self.speaker.muted
@property
def shuffle(self) -> bool | None:
"""Shuffling state."""
return PLAY_MODES[self.media.play_mode][0]
@property
def repeat(self) -> RepeatMode | None:
"""Return current repeat mode."""
sonos_repeat = PLAY_MODES[self.media.play_mode][1]
return SONOS_TO_REPEAT[sonos_repeat]
@property
def media(self) -> SonosMedia:
"""Return the SonosMedia object from the coordinator speaker."""
return self.coordinator.media
@property
def media_content_id(self) -> str | None:
"""Content id of current playing media."""
return self.media.uri
@property
def media_duration(self) -> int | None:
"""Duration of current playing media in seconds."""
return int(self.media.duration) if self.media.duration else None
@property
def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
return self.media.position
@property
def media_position_updated_at(self) -> datetime.datetime | None:
"""When was the position of the current playing media valid."""
return self.media.position_updated_at
@property
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
return self.media.image_url or None
@property
def media_channel(self) -> str | None:
"""Channel currently playing."""
return self.media.channel or None
@property
def media_playlist(self) -> str | None:
"""Title of playlist currently playing."""
return self.media.playlist_name
@property
def media_artist(self) -> str | None:
"""Artist of current playing media, music track only."""
return self.media.artist or None
@property
def media_album_name(self) -> str | None:
"""Album name of current playing media, music track only."""
return self.media.album_name or None
@property
def media_title(self) -> str | None:
"""Title of current playing media."""
return self.media.title or None
@property
def source(self) -> str | None:
"""Name of the current input source."""
return self.media.source_name or None
@soco_error()
def volume_up(self) -> None:
"""Volume up media player."""
self.soco.volume += VOLUME_INCREMENT
@soco_error()
def volume_down(self) -> None:
"""Volume down media player."""
self.soco.volume -= VOLUME_INCREMENT
@soco_error()
def set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
self.soco.volume = str(int(volume * 100))
@soco_error(UPNP_ERRORS_TO_IGNORE)
def set_shuffle(self, shuffle: bool) -> None:
"""Enable/Disable shuffle mode."""
sonos_shuffle = shuffle
sonos_repeat = PLAY_MODES[self.media.play_mode][1]
self.coordinator.soco.play_mode = PLAY_MODE_BY_MEANING[
(sonos_shuffle, sonos_repeat)
]
@soco_error(UPNP_ERRORS_TO_IGNORE)
def set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode."""
sonos_shuffle = PLAY_MODES[self.media.play_mode][0]
sonos_repeat = REPEAT_TO_SONOS[repeat]
self.coordinator.soco.play_mode = PLAY_MODE_BY_MEANING[
(sonos_shuffle, sonos_repeat)
]
@soco_error()
def mute_volume(self, mute: bool) -> None:
"""Mute (true) or unmute (false) media player."""
self.soco.mute = mute
@soco_error()
def select_source(self, source: str) -> None:
"""Select input source."""
soco = self.coordinator.soco
if source == SOURCE_LINEIN:
soco.switch_to_line_in()
return
if source == SOURCE_TV:
soco.switch_to_tv()
return
self._play_favorite_by_name(source)
def _play_favorite_by_name(self, name: str) -> None:
"""Play a favorite by name."""
fav = [fav for fav in self.speaker.favorites if fav.title == name]
if len(fav) != 1:
return
src = fav.pop()
self._play_favorite(src)
def _play_favorite(self, favorite: DidlFavorite) -> None:
"""Play a favorite."""
uri = favorite.reference.get_uri()
soco = self.coordinator.soco
if soco.music_source_from_uri(uri) in [
MUSIC_SRC_RADIO,
MUSIC_SRC_LINE_IN,
]:
soco.play_uri(uri, title=favorite.title)
else:
soco.clear_queue()
soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT)
soco.play_from_queue(0)
@property
def source_list(self) -> list[str]:
"""List of available input sources."""
model = self.coordinator.model_name.split()[-1].upper()
if model in MODELS_LINEIN_ONLY:
return [SOURCE_LINEIN]
if model in MODELS_TV_ONLY:
return [SOURCE_TV]
if model in MODELS_LINEIN_AND_TV:
return [SOURCE_LINEIN, SOURCE_TV]
return []
@soco_error(UPNP_ERRORS_TO_IGNORE)
def media_play(self) -> None:
"""Send play command."""
self.coordinator.soco.play()
@soco_error(UPNP_ERRORS_TO_IGNORE)
def media_stop(self) -> None:
"""Send stop command."""
self.coordinator.soco.stop()
@soco_error(UPNP_ERRORS_TO_IGNORE)
def media_pause(self) -> None:
"""Send pause command."""
self.coordinator.soco.pause()
@soco_error(UPNP_ERRORS_TO_IGNORE)
def media_next_track(self) -> None:
"""Send next track command."""
self.coordinator.soco.next()
@soco_error(UPNP_ERRORS_TO_IGNORE)
def media_previous_track(self) -> None:
"""Send next track command."""
self.coordinator.soco.previous()
@soco_error(UPNP_ERRORS_TO_IGNORE)
def media_seek(self, position: float) -> None:
"""Send seek command."""
self.coordinator.soco.seek(str(datetime.timedelta(seconds=int(position))))
@soco_error()
def clear_playlist(self) -> None:
"""Clear players playlist."""
self.coordinator.soco.clear_queue()
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Send the play_media command to the media player.
If media_id is a Plex payload, attempt Plex->Sonos playback.
If media_id is an Apple Music, Deezer, Sonos, or Tidal share link,
attempt playback using the respective service.
If media_type is "playlist", media_id should be a Sonos
Playlist name. Otherwise, media_id should be a URI.
"""
is_radio = False
if media_source.is_media_source_id(media_id):
is_radio = media_id.startswith("media-source://radio_browser/")
media_type = MediaType.MUSIC
media = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
media_id = async_process_play_media_url(self.hass, media.url)
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
volume = kwargs.get("extra", {}).get("volume")
_LOGGER.debug("Playing %s using websocket audioclip", media_id)
try:
assert self.speaker.websocket
response, _ = await self.speaker.websocket.play_clip(
async_process_play_media_url(self.hass, media_id),
volume=volume,
)
except SonosWebsocketError as exc:
raise HomeAssistantError(
f"Error when calling Sonos websocket: {exc}"
) from exc
if response["success"]:
return
if spotify.is_spotify_media_type(media_type):
media_type = spotify.resolve_spotify_media_type(media_type)
media_id = spotify.spotify_uri_from_media_browser_url(media_id)
await self.hass.async_add_executor_job(
partial(self._play_media, media_type, media_id, is_radio, **kwargs)
)
@soco_error()
def _play_media(
self, media_type: MediaType | str, media_id: str, is_radio: bool, **kwargs: Any
) -> None:
"""Wrap sync calls to async_play_media."""
enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE)
if media_type == "favorite_item_id":
favorite = self.speaker.favorites.lookup_by_item_id(media_id)
if favorite is None:
raise ValueError(f"Missing favorite for media_id: {media_id}")
self._play_favorite(favorite)
return
soco = self.coordinator.soco
if media_id and media_id.startswith(PLEX_URI_SCHEME):
plex_plugin = self.speaker.plex_plugin
result = process_plex_payload(
self.hass, media_type, media_id, supports_playqueues=False
)
if result.shuffle:
self.set_shuffle(True)
if enqueue == MediaPlayerEnqueue.ADD:
plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT)
elif enqueue in (
MediaPlayerEnqueue.NEXT,
MediaPlayerEnqueue.PLAY,
):
pos = (self.media.queue_position or 0) + 1
new_pos = plex_plugin.add_to_queue(
result.media, position=pos, timeout=LONG_SERVICE_TIMEOUT
)
if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1)
elif enqueue == MediaPlayerEnqueue.REPLACE:
soco.clear_queue()
plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT)
soco.play_from_queue(0)
return
share_link = self.coordinator.share_link
if share_link.is_share_link(media_id):
if enqueue == MediaPlayerEnqueue.ADD:
share_link.add_share_link_to_queue(
media_id, timeout=LONG_SERVICE_TIMEOUT
)
elif enqueue in (
MediaPlayerEnqueue.NEXT,
MediaPlayerEnqueue.PLAY,
):
pos = (self.media.queue_position or 0) + 1
new_pos = share_link.add_share_link_to_queue(
media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT
)
if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1)
elif enqueue == MediaPlayerEnqueue.REPLACE:
soco.clear_queue()
share_link.add_share_link_to_queue(
media_id, timeout=LONG_SERVICE_TIMEOUT
)
soco.play_from_queue(0)
elif media_type in {MediaType.MUSIC, MediaType.TRACK}:
# If media ID is a relative URL, we serve it from HA.
media_id = async_process_play_media_url(self.hass, media_id)
if enqueue == MediaPlayerEnqueue.ADD:
soco.add_uri_to_queue(media_id, timeout=LONG_SERVICE_TIMEOUT)
elif enqueue in (
MediaPlayerEnqueue.NEXT,
MediaPlayerEnqueue.PLAY,
):
pos = (self.media.queue_position or 0) + 1
new_pos = soco.add_uri_to_queue(
media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT
)
if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1)
elif enqueue == MediaPlayerEnqueue.REPLACE:
soco.play_uri(media_id, force_radio=is_radio)
elif media_type == MediaType.PLAYLIST:
if media_id.startswith("S:"):
item = media_browser.get_media(self.media.library, media_id, media_type)
soco.play_uri(item.get_uri())
return
try:
playlists = soco.get_sonos_playlists(complete_result=True)
playlist = next(p for p in playlists if p.title == media_id)
except StopIteration:
_LOGGER.error('Could not find a Sonos playlist named "%s"', media_id)
else:
soco.clear_queue()
soco.add_to_queue(playlist, timeout=LONG_SERVICE_TIMEOUT)
soco.play_from_queue(0)
elif media_type in PLAYABLE_MEDIA_TYPES:
item = media_browser.get_media(self.media.library, media_id, media_type)
if not item:
_LOGGER.error('Could not find "%s" in the library', media_id)
return
soco.play_uri(item.get_uri())
else:
_LOGGER.error('Sonos does not support a media type of "%s"', media_type)
@soco_error()
def set_sleep_timer(self, sleep_time: int) -> None:
"""Set the timer on the player."""
self.coordinator.soco.set_sleep_timer(sleep_time)
@soco_error()
def clear_sleep_timer(self) -> None:
"""Clear the timer on the player."""
self.coordinator.soco.set_sleep_timer(None)
@soco_error()
def set_alarm(
self,
alarm_id: int,
time: datetime.datetime | None = None,
volume: float | None = None,
enabled: bool | None = None,
include_linked_zones: bool | None = None,
) -> None:
"""Set the alarm clock on the player."""
alarm: alarms.Alarm | None = None
for one_alarm in alarms.get_alarms(self.coordinator.soco):
if one_alarm.alarm_id == str(alarm_id):
alarm = one_alarm
if alarm is None:
_LOGGER.warning("Did not find alarm with id %s", alarm_id)
return
if time is not None:
alarm.start_time = time
if volume is not None:
alarm.volume = int(volume * 100)
if enabled is not None:
alarm.enabled = enabled
if include_linked_zones is not None:
alarm.include_linked_zones = include_linked_zones
alarm.save()
@soco_error()
def play_queue(self, queue_position: int = 0) -> None:
"""Start playing the queue."""
self.soco.play_from_queue(queue_position)
@soco_error()
def remove_from_queue(self, queue_position: int = 0) -> None:
"""Remove item from the queue."""
self.coordinator.soco.remove_from_queue(queue_position)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return entity specific state attributes."""
attributes: dict[str, Any] = {}
if self.media.queue_position is not None:
attributes[ATTR_QUEUE_POSITION] = self.media.queue_position
if self.media.queue_size:
attributes["queue_size"] = self.media.queue_size
if self.source:
attributes[ATTR_INPUT_SOURCE] = self.source
return attributes
async def async_get_browse_image(
self,
media_content_type: MediaType | str,
media_content_id: str,
media_image_id: str | None = None,
) -> tuple[bytes | None, str | None]:
"""Fetch media browser image to serve via proxy."""
if (
media_content_type in {MediaType.ALBUM, MediaType.ARTIST}
and media_content_id
):
item = await self.hass.async_add_executor_job(
media_browser.get_media,
self.media.library,
media_content_id,
MEDIA_TYPES_TO_SONOS[media_content_type],
)
if image_url := getattr(item, "album_art_uri", None):
return await self._async_fetch_image(image_url)
return (None, None)
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
return await media_browser.async_browse_media(
self.hass,
self.speaker,
self.media,
self.get_browse_image_url,
media_content_id,
media_content_type,
)
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""
speakers = []
for entity_id in group_members:
if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get(entity_id):
speakers.append(speaker)
else:
raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}")
await SonosSpeaker.join_multi(self.hass, self.speaker, speakers)
async def async_unjoin_player(self) -> None:
"""Remove this player from any group.
Coalesces all calls within UNJOIN_SERVICE_TIMEOUT to allow use of SonosSpeaker.unjoin_multi()
which optimizes the order in which speakers are removed from their groups.
Removing coordinators last better preserves playqueues on the speakers.
"""
sonos_data = self.hass.data[DATA_SONOS]
household_id = self.speaker.household_id
async def async_process_unjoin(now: datetime.datetime) -> None:
"""Process the unjoin with all remove requests within the coalescing period."""
unjoin_data = sonos_data.unjoin_data.pop(household_id)
_LOGGER.debug(
"Processing unjoins for %s", [x.zone_name for x in unjoin_data.speakers]
)
await SonosSpeaker.unjoin_multi(self.hass, unjoin_data.speakers)
unjoin_data.event.set()
if unjoin_data := sonos_data.unjoin_data.get(household_id):
unjoin_data.speakers.append(self.speaker)
else:
unjoin_data = sonos_data.unjoin_data[household_id] = UnjoinData(
speakers=[self.speaker]
)
async_call_later(self.hass, UNJOIN_SERVICE_TIMEOUT, async_process_unjoin)
_LOGGER.debug("Requesting unjoin for %s", self.speaker.zone_name)
await unjoin_data.event.wait()