/
/
/
1"""Tests for the Sonos cloud queue window served to the speakers."""
2
3import json
4import logging
5from typing import cast
6from unittest.mock import AsyncMock, MagicMock
7
8import pytest
9from aiosonos.exceptions import FailedCommand
10from music_assistant_models.enums import MediaType
11from music_assistant_models.errors import InvalidDataError
12from music_assistant_models.player import PlayerMedia
13from music_assistant_models.queue_item import QueueItem
14
15from music_assistant.providers.sonos.player import SonosPlayer, SonosQueueWindow
16from music_assistant.providers.sonos.provider import (
17 SonosPlayerProvider,
18 _refresh_task_id,
19 _requested_max,
20)
21
22QUEUE_ID = "party_queue"
23
24
25def _make_queue_item(item_id: str) -> QueueItem:
26 """Build a minimal playable queue item."""
27 return QueueItem(
28 queue_id=QUEUE_ID,
29 queue_item_id=item_id,
30 name=item_id,
31 duration=180,
32 )
33
34
35class _FakeQueues:
36 """The slice of the player-queues controller the cloud queue reads."""
37
38 def __init__(self, items: list[QueueItem], current_index: int = 0) -> None:
39 self.items = items
40 self.queue = MagicMock()
41 self.queue.current_index = current_index
42 self.queue.index_in_buffer = current_index
43
44 def get(self, queue_id: str) -> MagicMock | None:
45 """Return the queue for the given id."""
46 return self.queue if queue_id == QUEUE_ID else None
47
48 def index_by_id(self, queue_id: str, item_id: str) -> int | None:
49 """Return the index of the item with the given id."""
50 return next((i for i, x in enumerate(self.items) if x.queue_item_id == item_id), None)
51
52 def get_item(self, queue_id: str, index: int) -> QueueItem | None:
53 """Return the item at the given index."""
54 return self.items[index] if 0 <= index < len(self.items) else None
55
56 def get_next_item(self, queue_id: str, index_or_id: int | str) -> QueueItem | None:
57 """Return the item that plays after the given index or item id."""
58 if isinstance(index_or_id, int):
59 index = index_or_id
60 elif (found := self.index_by_id(queue_id, index_or_id)) is None:
61 return None
62 else:
63 index = found
64 # the real controller walks past items it cannot play
65 for candidate in self.items[index + 1 :]:
66 if candidate.available:
67 return candidate
68 return None
69
70 async def player_media_from_queue_item(self, queue_item: QueueItem) -> PlayerMedia:
71 """Return the media for the given queue item."""
72 return PlayerMedia(
73 uri=queue_item.uri,
74 media_type=MediaType.TRACK,
75 title=queue_item.name,
76 queue_item_id=queue_item.queue_item_id,
77 source_id=QUEUE_ID,
78 )
79
80
81def _make_player(items: list[QueueItem], current_index: int = 0) -> tuple[SonosPlayer, _FakeQueues]:
82 """Create a SonosPlayer serving a cloud queue for the given items."""
83 queues = _FakeQueues(items, current_index)
84 mass = MagicMock()
85 mass.player_queues = queues
86 mass.streams.resolve_stream_url = AsyncMock(side_effect=lambda _player_id, media: media.uri)
87 player = SonosPlayer.__new__(SonosPlayer)
88 player.mass = mass
89 player.logger = logging.getLogger("test.sonos.cloud_queue")
90 player._player_id = "sonos_player"
91 player.connected = True
92 player.cloud_queue_id = QUEUE_ID
93 player.cloud_queue_version = 1.0
94 player.cloud_queue_item_generation = 0
95 player._announcement_media = None
96 return player, queues
97
98
99async def test_window_is_the_requested_item_and_the_one_after_it() -> None:
100 """Test only the item asked about and its neighbours are served, however long the queue."""
101 items = [_make_queue_item(f"track{i}") for i in range(10)]
102 player, _ = _make_player(items)
103
104 window = await player.build_cloud_queue_window("track5")
105
106 # a deeper window would let the speaker play several tracks out of a cache we cannot
107 # update; this way it has to ask again for every one
108 assert [x.queue_item_id for x in window.items] == ["track4", "track5", "track6"]
109 assert window.includes_beginning is False
110 assert window.includes_end is False
111
112
113@pytest.mark.parametrize("item_id", [None, ""], ids=["omitted", "empty"])
114async def test_window_without_an_item_id_starts_at_the_queue_head(item_id: str | None) -> None:
115 """Test an omitted or empty item id asks for the start of the queue, as Sonos specifies."""
116 items = [_make_queue_item(f"track{i}") for i in range(5)]
117 player, _ = _make_player(items, current_index=3)
118
119 window = await player.build_cloud_queue_window(item_id)
120
121 assert [x.queue_item_id for x in window.items] == ["track0", "track1"]
122 assert window.includes_beginning is True
123
124
125async def test_window_for_an_unknown_item_falls_back_to_the_playing_one() -> None:
126 """Test an item id the queue no longer holds is answered around the playing item."""
127 items = [_make_queue_item(f"track{i}") for i in range(5)]
128 player, queues = _make_player(items, current_index=1)
129 # with crossfade the buffered index runs an item ahead of what is playing
130 queues.queue.index_in_buffer = 2
131
132 window = await player.build_cloud_queue_window("gone")
133
134 assert [x.queue_item_id for x in window.items] == ["track0", "track1", "track2"]
135
136
137async def test_window_flags_the_end_of_the_queue() -> None:
138 """Test the end-of-queue flag is set once the window reaches the last item."""
139 items = [_make_queue_item(f"track{i}") for i in range(2)]
140 player, _ = _make_player(items)
141
142 window = await player.build_cloud_queue_window("track0")
143
144 assert [x.queue_item_id for x in window.items] == ["track0", "track1"]
145 assert window.includes_beginning is True
146 assert window.includes_end is True
147
148
149async def test_window_serves_an_item_added_after_the_last_enqueue() -> None:
150 """Test a track added mid-playback is served as the next one, with no enqueue in between."""
151 items = [_make_queue_item(f"track{i}") for i in range(4)]
152 player, queues = _make_player(items)
153 # the speaker has already loaded the next track, which is what stops the queue
154 # controller from announcing any further change
155 queues.queue.index_in_buffer = 1
156
157 assert [x.queue_item_id for x in (await player.build_cloud_queue_window("track0")).items] == [
158 "track0",
159 "track1",
160 ]
161
162 # a party guest adds a track behind the one the speaker already buffered
163 queues.items.insert(2, _make_queue_item("guest"))
164
165 # the speaker comes back when that buffered track starts, and is handed the new one
166 window = await player.build_cloud_queue_window("track1")
167
168 assert [x.queue_item_id for x in window.items] == ["track0", "track1", "guest"]
169
170
171async def test_unavailable_items_are_left_out() -> None:
172 """Test an item that cannot be played is not offered to the speaker."""
173 items = [_make_queue_item(f"track{i}") for i in range(3)]
174 items[0].available = False
175 player, _ = _make_player(items, current_index=1)
176
177 window = await player.build_cloud_queue_window("track1")
178
179 assert [x.queue_item_id for x in window.items] == ["track1", "track2"]
180
181
182async def test_announcement_is_served_as_a_single_item_queue() -> None:
183 """Test an announcement is the only item in the window while it plays."""
184 player, _ = _make_player([_make_queue_item("track0")])
185 player._announcement_media = PlayerMedia(
186 uri="http://announcement", media_type=MediaType.ANNOUNCEMENT, queue_item_id="announcement"
187 )
188
189 window = await player.build_cloud_queue_window("track0")
190
191 assert [x.queue_item_id for x in window.items] == ["announcement"]
192 assert window.includes_beginning is True
193 assert window.includes_end is True
194
195
196async def test_window_is_empty_without_a_queue() -> None:
197 """Test a speaker with no MA queue loaded is told the queue is over, not just empty."""
198 player, _ = _make_player([_make_queue_item("track0")])
199 player.cloud_queue_id = None
200
201 window = await player.build_cloud_queue_window(None)
202
203 assert window.items == []
204 # both ends flagged, or the speaker keeps what it cached and polls on
205 assert window.includes_beginning is True
206 assert window.includes_end is True
207
208
209async def test_refresh_bumps_the_version_and_signals_the_speaker() -> None:
210 """Test a refresh tells the speaker to re-read and invalidates its cached version."""
211 player, _ = _make_player([_make_queue_item("track0")])
212 client = MagicMock()
213 client.player.group.active_session_id = "session1"
214 client.api.playback_session.refresh_cloud_queue = AsyncMock()
215 player.client = client
216 version_before = player.cloud_queue_version
217
218 await player.refresh_cloud_queue()
219
220 assert player.cloud_queue_version > version_before
221 client.api.playback_session.refresh_cloud_queue.assert_awaited_once_with("session1")
222
223
224async def test_refresh_without_a_session_only_bumps_the_version() -> None:
225 """Test a speaker with no cloud queue loaded is not sent a refresh."""
226 player, _ = _make_player([_make_queue_item("track0")])
227 client = MagicMock()
228 client.player.group.active_session_id = None
229 client.api.playback_session.refresh_cloud_queue = AsyncMock()
230 player.client = client
231 version_before = player.cloud_queue_version
232
233 await player.refresh_cloud_queue()
234
235 assert player.cloud_queue_version > version_before
236 client.api.playback_session.refresh_cloud_queue.assert_not_awaited()
237
238
239async def test_stop_forgets_the_cloud_queue() -> None:
240 """Test a stopped speaker is no longer signalled about that queue."""
241 player, _ = _make_player([_make_queue_item("track0")])
242 client = MagicMock()
243 client.player.is_passive = False
244 client.player.group.stop = AsyncMock()
245 player.client = client
246 player.mark_stop_called = MagicMock() # type: ignore[misc, method-assign]
247 player.update_state = MagicMock() # type: ignore[misc, method-assign]
248 player._announcement_media = PlayerMedia(
249 uri="http://announcement", media_type=MediaType.ANNOUNCEMENT
250 )
251
252 await player.stop()
253
254 assert player.cloud_queue_id is None
255 assert player._announcement_media is None
256
257
258async def test_refresh_survives_a_session_the_speaker_forgot() -> None:
259 """Test a rejected refresh is not an error: the next read carries the change anyway."""
260 player, _ = _make_player([_make_queue_item("track0")])
261 client = MagicMock()
262 client.player.group.active_session_id = "stale_session"
263 client.api.playback_session.refresh_cloud_queue = AsyncMock(
264 side_effect=FailedCommand("no cloud queue loaded")
265 )
266 player.client = client
267
268 await player.refresh_cloud_queue()
269
270 client.api.playback_session.refresh_cloud_queue.assert_awaited_once()
271
272
273def _make_provider() -> SonosPlayerProvider:
274 """Create a bare provider for the cloud-queue request handlers."""
275 provider = SonosPlayerProvider.__new__(SonosPlayerProvider)
276 provider.mass = MagicMock()
277 provider.logger = logging.getLogger("test.sonos.cloud_queue")
278 provider._pending_refresh_tasks = set()
279 return provider
280
281
282async def test_itemwindow_passes_the_speakers_request_through() -> None:
283 """Test the sizes and centre the speaker asks for reach the window builder."""
284 player = MagicMock(spec=SonosPlayer)
285 player.cloud_queue_version = 12.5
286 player.cloud_queue_item_generation = 4
287 player.bare_item_id = SonosPlayer.bare_item_id
288 player.build_cloud_queue_window = AsyncMock(
289 return_value=SonosQueueWindow(includes_beginning=True, includes_end=False)
290 )
291 provider = _make_provider()
292 request = MagicMock()
293 request.query = {
294 "itemId": "track7@4",
295 "previousWindowSize": "9",
296 "upcomingWindowSize": "10",
297 "contextVersion": "3",
298 }
299
300 response = await provider._handle_sonos_queue_itemwindow(player, request)
301
302 player.build_cloud_queue_window.assert_awaited_once_with(
303 "track7", max_previous=9, max_upcoming=10
304 )
305 body = json.loads(response.text or "{}")
306 assert body["queueVersion"] == "12.5"
307 assert body["contextVersion"] == "3"
308 assert body["includesBeginningOfQueue"] is True
309
310
311async def test_itemwindow_reports_end_of_queue_when_it_cannot_be_described() -> None:
312 """Test a queue that went away answers with an empty window instead of an error."""
313 player = MagicMock(spec=SonosPlayer)
314 player.display_name = "Kantoor"
315 player.cloud_queue_version = 1.0
316 player.cloud_queue_item_generation = 1
317 player.build_cloud_queue_window = AsyncMock(side_effect=InvalidDataError("no session"))
318 provider = _make_provider()
319 request = MagicMock()
320 request.query = {}
321
322 response = await provider._handle_sonos_queue_itemwindow(player, request)
323
324 body = json.loads(response.text or "{}")
325 assert body["items"] == []
326 assert body["includesEndOfQueue"] is True
327
328
329@pytest.mark.parametrize(
330 ("requested", "expected_upcoming"),
331 [("10", ["track1"]), ("1", ["track1"]), ("0", []), ("", ["track1"]), (None, ["track1"])],
332 ids=["ten", "one", "zero", "unreadable", "absent"],
333)
334async def test_upcoming_is_capped_by_what_the_speaker_allows(
335 requested: str | None, expected_upcoming: list[str]
336) -> None:
337 """Test we never serve more than the speaker's maximum, though we usually serve fewer."""
338 items = [_make_queue_item(f"track{i}") for i in range(4)]
339 player, _ = _make_player(items)
340
341 window = await player.build_cloud_queue_window("track0", max_upcoming=_requested_max(requested))
342
343 assert [x.queue_item_id for x in window.items] == ["track0", *expected_upcoming]
344
345
346async def test_a_successful_load_releases_the_replaced_sessions_streams() -> None:
347 """The old session's open responses die once the speaker accepted the new load."""
348 player, _ = _make_player([_make_queue_item("track0")])
349 client = MagicMock()
350 client.player.is_passive = False
351 client.player.group.play_cloud_queue = AsyncMock()
352 player.client = client
353
354 with pytest.MonkeyPatch.context() as patch:
355 patch.setattr(SonosPlayer, "flow_mode", property(lambda _self: False))
356 await player.play_media(
357 PlayerMedia(
358 uri="library://track/1",
359 media_type=MediaType.TRACK,
360 source_id=QUEUE_ID,
361 queue_item_id="track0",
362 queue_session_id="session-2",
363 )
364 )
365
366 close_stale = cast("MagicMock", player.mass.streams.close_superseded_item_streams)
367 close_stale.assert_called_once_with(QUEUE_ID, "session-2")
368
369
370async def test_a_forced_flow_load_also_releases_the_replaced_sessions_streams() -> None:
371 """A flow-mode queue (overlay) streams via play_stream_url; the sweep still runs."""
372 player, _ = _make_player([_make_queue_item("track0")])
373 client = MagicMock()
374 client.player.is_passive = False
375 client.player.group.play_stream_url = AsyncMock()
376 player.client = client
377 player._provider = MagicMock()
378 player._provider.mass = player.mass
379
380 with pytest.MonkeyPatch.context() as patch:
381 patch.setattr(SonosPlayer, "flow_mode", property(lambda _self: True))
382 await player.play_media(
383 PlayerMedia(
384 uri="http://ma/flow/abc.flac",
385 media_type=MediaType.TRACK,
386 source_id=QUEUE_ID,
387 queue_item_id="track0",
388 queue_session_id="session-2",
389 )
390 )
391
392 client.player.group.play_stream_url.assert_awaited_once()
393 close_stale = cast("MagicMock", player.mass.streams.close_superseded_item_streams)
394 close_stale.assert_called_once_with(QUEUE_ID, "session-2")
395
396
397async def test_a_failed_forced_flow_load_keeps_the_old_sessions_streams_alive() -> None:
398 """A flow load the speaker refused must not cut the audio that is still playing."""
399 player, _ = _make_player([_make_queue_item("track0")])
400 client = MagicMock()
401 client.player.is_passive = False
402 client.player.group.play_stream_url = AsyncMock(side_effect=FailedCommand("no can do"))
403 player.client = client
404 player._provider = MagicMock()
405 player._provider.mass = player.mass
406
407 with pytest.MonkeyPatch.context() as patch:
408 patch.setattr(SonosPlayer, "flow_mode", property(lambda _self: True))
409 with pytest.raises(FailedCommand):
410 await player.play_media(
411 PlayerMedia(
412 uri="http://ma/flow/abc.flac",
413 media_type=MediaType.TRACK,
414 source_id=QUEUE_ID,
415 queue_item_id="track0",
416 queue_session_id="session-2",
417 )
418 )
419
420 cast("MagicMock", player.mass.streams.close_superseded_item_streams).assert_not_called()
421
422
423async def test_a_failed_load_keeps_the_old_sessions_streams_alive() -> None:
424 """A load the speaker refused must not cut the audio that is still playing."""
425 player, _ = _make_player([_make_queue_item("track0")])
426 client = MagicMock()
427 client.player.is_passive = False
428 client.player.group.play_cloud_queue = AsyncMock(side_effect=FailedCommand("no can do"))
429 player.client = client
430
431 with pytest.MonkeyPatch.context() as patch:
432 patch.setattr(SonosPlayer, "flow_mode", property(lambda _self: False))
433 with pytest.raises(FailedCommand):
434 await player.play_media(
435 PlayerMedia(
436 uri="library://track/1",
437 media_type=MediaType.TRACK,
438 source_id=QUEUE_ID,
439 queue_item_id="track0",
440 queue_session_id="session-2",
441 )
442 )
443
444 cast("MagicMock", player.mass.streams.close_superseded_item_streams).assert_not_called()
445
446
447async def test_a_reload_of_the_same_item_gets_a_fresh_wire_id() -> None:
448 """
449 A seek reloads the item the speaker is already playing.
450
451 Under its old id the speaker keeps waiting out its stale stream; a fresh id
452 per load makes the reload cut over like a track change.
453 """
454 player, _ = _make_player([_make_queue_item("track0")])
455 client = MagicMock()
456 client.player.is_passive = False
457 loaded_ids: list[str] = []
458
459 async def _play_cloud_queue(*_args: object, item_id: str = "", **_kwargs: object) -> None:
460 loaded_ids.append(item_id)
461
462 client.player.group.play_cloud_queue = _play_cloud_queue
463 player.client = client
464 media = PlayerMedia(
465 uri="library://track/1",
466 media_type=MediaType.TRACK,
467 source_id=QUEUE_ID,
468 queue_item_id="track0",
469 )
470
471 with pytest.MonkeyPatch.context() as patch:
472 patch.setattr(SonosPlayer, "flow_mode", property(lambda _self: False))
473 await player.play_media(media)
474 await player.play_media(media)
475
476 assert len(loaded_ids) == 2
477 assert loaded_ids[0] != loaded_ids[1]
478 assert [SonosPlayer.bare_item_id(x) for x in loaded_ids] == ["track0", "track0"]
479
480
481async def test_window_items_are_served_under_the_loads_wire_id() -> None:
482 """The window describes items under the same ids the load command named."""
483 player, _ = _make_player([_make_queue_item("track0"), _make_queue_item("track1")])
484 player.cloud_queue_item_generation = 7
485 provider = _make_provider()
486 request = MagicMock()
487 request.query = {"itemId": player.wire_item_id("track0")}
488
489 response = await provider._handle_sonos_queue_itemwindow(player, request)
490
491 body = json.loads(response.text or "{}")
492 served = [x["id"] for x in body["items"]]
493 assert served == ["track0@7", "track1@7"]
494
495
496async def test_window_ids_stay_on_the_generation_the_request_started_with() -> None:
497 """A load landing mid-request must not relabel the old window's items."""
498 player, _ = _make_player([_make_queue_item("track0")])
499 player.cloud_queue_item_generation = 7
500 provider = _make_provider()
501 request = MagicMock()
502 request.query = {"itemId": player.wire_item_id("track0")}
503
504 async def _bump_mid_build(_player_id: str, media: PlayerMedia) -> str:
505 player.cloud_queue_item_generation = 8
506 return media.uri
507
508 player.mass.streams.resolve_stream_url = AsyncMock( # type: ignore[method-assign]
509 side_effect=_bump_mid_build
510 )
511
512 response = await provider._handle_sonos_queue_itemwindow(player, request)
513
514 body = json.loads(response.text or "{}")
515 assert [x["id"] for x in body["items"]] == ["track0@7"]
516
517
518async def test_time_played_matches_the_playing_item_through_its_wire_id() -> None:
519 """Only the current load's wire id (or a legacy bare id) may update the position."""
520 player = MagicMock(spec=SonosPlayer)
521 player.cloud_queue_item_generation = 3
522 player.wire_item_id = lambda item_id, generation=None: SonosPlayer.wire_item_id(
523 player, item_id, generation
524 )
525 player.current_media = MagicMock()
526 player.current_media.queue_item_id = "track0"
527 provider = _make_provider()
528 request = MagicMock()
529 request.json = AsyncMock(
530 return_value={"items": [{"type": "update", "id": "track0@3", "positionMillis": 5000}]}
531 )
532
533 await provider._handle_sonos_queue_time_played(player, request)
534
535 player.update_elapsed_time.assert_called_once_with(5.0)
536
537 # a report that raced a same-track reload carries the old generation and its
538 # pre-seek position: it must not win from the position the reload seeked to
539 player.update_elapsed_time.reset_mock()
540 request.json = AsyncMock(
541 return_value={"items": [{"type": "update", "id": "track0@2", "positionMillis": 5000}]}
542 )
543
544 await provider._handle_sonos_queue_time_played(player, request)
545
546 player.update_elapsed_time.assert_not_called()
547
548
549async def test_play_media_keeps_describing_the_queue_until_the_new_one_is_loaded() -> None:
550 """Test the still-playing queue is not blanked while its replacement is being loaded."""
551 player, _ = _make_player([_make_queue_item("track0")])
552 client = MagicMock()
553 client.player.is_passive = False
554 loaded_with_queue_id: list[str | None] = []
555
556 async def _play_cloud_queue(*_args: object, **_kwargs: object) -> None:
557 # by the time the speaker is told to load, the id must already point at the new queue
558 loaded_with_queue_id.append(player.cloud_queue_id)
559
560 client.player.group.play_cloud_queue = _play_cloud_queue
561 player.client = client
562
563 with pytest.MonkeyPatch.context() as patch:
564 patch.setattr(SonosPlayer, "flow_mode", property(lambda _self: False))
565 await player.play_media(
566 PlayerMedia(
567 uri="library://track/1",
568 media_type=MediaType.TRACK,
569 source_id="new_queue",
570 queue_item_id="item1",
571 )
572 )
573
574 assert loaded_with_queue_id == ["new_queue"]
575 assert player.cloud_queue_id == "new_queue"
576
577
578async def test_a_failed_load_leaves_no_cloud_queue_described() -> None:
579 """Test a load the speaker never accepted is not described as a queue afterwards."""
580 player, _ = _make_player([_make_queue_item("track0")])
581 client = MagicMock()
582 client.player.is_passive = False
583 client.player.group.play_cloud_queue = AsyncMock(side_effect=FailedCommand("no can do"))
584 player.client = client
585
586 with pytest.MonkeyPatch.context() as patch:
587 patch.setattr(SonosPlayer, "flow_mode", property(lambda _self: False))
588 with pytest.raises(FailedCommand):
589 await player.play_media(
590 PlayerMedia(
591 uri="library://track/1",
592 media_type=MediaType.TRACK,
593 source_id="new_queue",
594 queue_item_id="item1",
595 )
596 )
597
598 # the session was reset before the load, so claiming either queue would be a lie
599 assert player.cloud_queue_id is None
600
601
602def test_queue_change_only_reaches_the_speakers_playing_it() -> None:
603 """Test a queue edit is signalled to the speakers serving that queue."""
604 provider = SonosPlayerProvider.__new__(SonosPlayerProvider)
605 provider.mass = MagicMock()
606 provider.logger = logging.getLogger("test.sonos.cloud_queue")
607 provider._pending_refresh_tasks = set()
608 playing, other = MagicMock(spec=SonosPlayer), MagicMock(spec=SonosPlayer)
609 playing.player_id = "playing"
610 playing.cloud_queue_id = QUEUE_ID
611 other.player_id = "other"
612 other.cloud_queue_id = "another_queue"
613 with pytest.MonkeyPatch.context() as patch:
614 patch.setattr(type(provider), "players", property(lambda _self: [playing, other]))
615 provider._handle_queue_items_updated(MagicMock(object_id=QUEUE_ID))
616
617 # the version must be invalidated before the (debounced) command goes out, or a window
618 # served in between carries a version the speaker reads as current
619 playing.bump_cloud_queue_version.assert_called_once_with()
620 other.bump_cloud_queue_version.assert_not_called()
621 assert provider.mass.call_later.call_count == 1
622 assert provider.mass.call_later.call_args.args[1] == playing.refresh_cloud_queue
623 # the id must be per speaker, or one speaker's refresh cancels another's
624 assert provider.mass.call_later.call_args.kwargs["task_id"] == _refresh_task_id("playing")
625 assert provider._pending_refresh_tasks == {_refresh_task_id("playing")}
626