/
/
1"""
2Player-state reconciliation for the Player Queues controller.
3
4Translates a player's reported state into the queue's state: tracks the current index/elapsed time,
5detects track changes and end-of-queue, drives the playback-progress reports (and the user-initiated
6/ album-credit play-counting), and computes the flow-mode stream index. Owns no per-queue state of
7its own; it reads and mutates the controller's `PlayerQueueData` records via its owning controller.
8"""
9
10# ruff: noqa: PLR0915 -- the player-state reconciliation methods are large state machines by nature
11
12from __future__ import annotations
13
14import asyncio
15import time
16from contextlib import suppress
17from typing import TYPE_CHECKING
18
19from music_assistant_models.enums import (
20 EventType,
21 MediaType,
22 PlaybackState,
23)
24from music_assistant_models.errors import (
25 MusicAssistantError,
26)
27from music_assistant_models.media_items import (
28 Album,
29 Artist,
30 ItemMapping,
31 MediaItemType,
32)
33from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport
34
35from music_assistant.constants import (
36 PLAYBACK_REPORT_INTERVAL_SECONDS,
37 VERBOSE_LOG_LEVEL,
38)
39from music_assistant.controllers.player_queues.base import _PlayerQueuesBase
40from music_assistant.controllers.player_queues.helpers import (
41 CompareState,
42 build_queue_item,
43 find_dynamic_source,
44 get_current_playback_speed,
45)
46from music_assistant.controllers.webserver.helpers.auth_middleware import (
47 set_current_user,
48)
49from music_assistant.helpers.audio import resolve_output_player_ids
50from music_assistant.helpers.util import get_changed_keys, percentage
51from music_assistant.models.player import Player
52
53if TYPE_CHECKING:
54 from music_assistant_models.player_queue import PlayerQueue
55 from music_assistant_models.queue_item import QueueItem
56
57 from music_assistant.controllers.player_queues.state import PlayerQueueData
58
59
60# media types that never put a queue in the ended state: a live source has no natural end, so it
61# going idle means the source stopped and not that the queue ran out (marking it ended would strand
62# a later resume), and a sound effect is a one-off that leaves the queue as it found it.
63UNENDABLE_MEDIA_TYPES = (MediaType.RADIO, MediaType.AUDIO_SOURCE, MediaType.SOUND_EFFECT)
64
65
66class PlaybackTrackerMixin(_PlayerQueuesBase):
67 """Reconcile a queue's state against its player and drive playback-progress reporting."""
68
69 def _update_current_index_from_player(self, queue: PlayerQueue, player: Player) -> bool:
70 """
71 Update the current item/index/elapsed time on the queue from the player state.
72
73 Returns True if the update was successful, False if the caller should return early.
74 """
75 queue_id = queue.queue_id
76 if queue.active and queue.state in (
77 PlaybackState.PLAYING,
78 PlaybackState.PAUSED,
79 ):
80 # NOTE: If the queue is not playing (yet) we will not update the current index
81 # to ensure we keep the previously known current index
82 if queue.flow_mode:
83 # flow mode active, the player is playing one long stream
84 # so we need to calculate the current index and elapsed time
85 # (already returned in media-time)
86 current_index, elapsed_time = self._get_flow_queue_stream_index(queue, player)
87 elif item_id := self._parse_player_current_item_id(queue_id, player):
88 # normal mode, the player itself will report the current item
89 elapsed_time = player.state.corrected_elapsed_time or 0
90 current_index = self.index_by_id(queue_id, item_id)
91 else:
92 # this may happen if the player is still transitioning between tracks
93 # we ignore this for now and keep the current index as is
94 return False
95
96 # get current/next item based on current index
97 queue.current_index = current_index
98 queue.current_item = current_item = self.get_item(queue_id, current_index)
99 queue.next_item = (
100 self.get_next_item(queue_id, current_index)
101 if current_item and current_index is not None
102 else None
103 )
104
105 # convert player's stream-time to media-time and add seek offset (non-flow only;
106 # flow mode already returns media-time from _get_flow_queue_stream_index above)
107 speed = get_current_playback_speed(queue)
108 if not queue.flow_mode:
109 elapsed_time *= speed
110 if (
111 current_item
112 and current_item.streamdetails
113 and current_item.streamdetails.seek_position
114 ):
115 elapsed_time += current_item.streamdetails.seek_position
116 queue.elapsed_time = elapsed_time
117 queue.elapsed_time_last_updated = time.time()
118 queue.playback_speed = speed
119
120 elif not queue.current_item and queue.current_index is not None:
121 current_index = queue.current_index
122 queue.current_item = current_item = self.get_item(queue_id, current_index)
123 queue.next_item = (
124 self.get_next_item(queue_id, current_index)
125 if current_item and current_index is not None
126 else None
127 )
128 return True
129
130 def _update_queue_from_player(
131 self,
132 player: Player,
133 ) -> None:
134 """Update the Queue when the player state changed."""
135 queue_id = player.player_id
136 queue_data = self._queue_data[queue_id]
137 queue = queue_data.queue
138
139 # basic properties
140 queue.display_name = player.state.name
141 queue.available = player.state.available
142 queue.smart_fades_active = self.mass.streams.is_smart_fades_active(queue)
143 queue.smart_shuffle_active = self.is_smart_shuffle_active(queue)
144 queue.items = len(self._queue_data[queue_id].items)
145
146 queue.state = (
147 player.state.playback_state or PlaybackState.IDLE
148 if queue.active
149 else PlaybackState.IDLE
150 )
151 # update current item/index from player report
152 if not self._update_current_index_from_player(queue, player):
153 return
154
155 output_player_ids = self._get_output_player_ids(player)
156
157 # basic throttle: do not send state changed events if queue did not actually change
158 prev_state: CompareState = self._queue_data[queue_id].prev_state or CompareState(
159 queue_id=queue_id,
160 state=PlaybackState.IDLE,
161 current_item_id=None,
162 next_item_id=None,
163 current_item=None,
164 elapsed_time=0,
165 last_playing_elapsed_time=0,
166 stream_title=None,
167 codec_type=None,
168 output_player_ids=None,
169 )
170 # update last_playing_elapsed_time only when the player is actively playing
171 # use corrected_elapsed_time which accounts for time since last update
172 # this preserves the last known elapsed time when transitioning to idle/paused
173 prev_playing_elapsed = prev_state["last_playing_elapsed_time"]
174 prev_item_id = prev_state["current_item_id"]
175 current_item_id = queue.current_item.queue_item_id if queue.current_item else None
176 if queue.state == PlaybackState.PLAYING:
177 current_elapsed = int(queue.corrected_elapsed_time)
178 if current_item_id != prev_item_id:
179 # new track started, reset the elapsed time tracker
180 last_playing_elapsed_time = current_elapsed
181 else:
182 # same track, use the max of current and previous to handle timing issues
183 last_playing_elapsed_time = max(current_elapsed, prev_playing_elapsed)
184 else:
185 last_playing_elapsed_time = prev_playing_elapsed
186 new_state = CompareState(
187 queue_id=queue_id,
188 state=queue.state,
189 current_item_id=queue.current_item.queue_item_id if queue.current_item else None,
190 next_item_id=queue.next_item.queue_item_id if queue.next_item else None,
191 current_item=queue.current_item,
192 elapsed_time=int(queue.elapsed_time),
193 last_playing_elapsed_time=last_playing_elapsed_time,
194 stream_title=(
195 queue.current_item.streamdetails.stream_title
196 if queue.current_item and queue.current_item.streamdetails
197 else None
198 ),
199 codec_type=(
200 queue.current_item.streamdetails.audio_format.codec_type
201 if queue.current_item and queue.current_item.streamdetails
202 else None
203 ),
204 output_player_ids=sorted(output_player_ids),
205 )
206 changed_keys = get_changed_keys(dict(prev_state), dict(new_state))
207 with suppress(KeyError):
208 changed_keys.remove("next_item_id")
209 with suppress(KeyError):
210 changed_keys.remove("last_playing_elapsed_time")
211
212 # store the new state
213 if queue.active:
214 self._queue_data[queue_id].prev_state = new_state
215 else:
216 self._queue_data[queue_id].prev_state = None
217
218 # return early if nothing changed
219 if len(changed_keys) == 0:
220 return
221
222 # signal update and store state
223 send_update = True
224 if changed_keys == {"elapsed_time"}:
225 # only elapsed time changed, do not send full queue update
226 send_update = False
227 prev_time = prev_state.get("elapsed_time") or 0
228 cur_time = new_state.get("elapsed_time") or 0
229 if abs(cur_time - prev_time) > 2:
230 # send dedicated event for time updates when seeking
231 self.mass.signal_event(
232 EventType.QUEUE_TIME_UPDATED,
233 object_id=queue_id,
234 data=queue.elapsed_time,
235 )
236 # also signal update to the player itself so it can update its current_media
237 self.mass.players.trigger_player_update(queue_id)
238
239 processing_update_sent = False
240 if "output_player_ids" in changed_keys:
241 processing_update_sent = self.mass.streams.audio_processing.retain_outputs(
242 queue_id,
243 output_player_ids,
244 )
245 if send_update and not processing_update_sent:
246 self.signal_update(queue_id)
247
248 # handle updating stream_metadata if needed
249 if (
250 queue.current_item
251 and (streamdetails := queue.current_item.streamdetails)
252 and streamdetails.stream_metadata_update_callback
253 and (
254 streamdetails.stream_metadata_last_updated is None
255 or (
256 time.time() - streamdetails.stream_metadata_last_updated
257 >= streamdetails.stream_metadata_update_interval
258 )
259 )
260 ):
261 streamdetails.stream_metadata_last_updated = time.time()
262 self.mass.create_task(
263 streamdetails.stream_metadata_update_callback(
264 streamdetails, int(queue.corrected_elapsed_time)
265 )
266 )
267
268 # handle sending a playback progress report
269 # we do this every 30 seconds or when the state changes
270 if (
271 changed_keys.intersection({"state", "current_item_id"})
272 or int(queue.elapsed_time) % PLAYBACK_REPORT_INTERVAL_SECONDS == 0
273 ):
274 self._handle_playback_progress_report(queue, prev_state, new_state)
275
276 # check if we need to clear the queue if we reached the end
277 if "state" in changed_keys and queue.state == PlaybackState.IDLE:
278 self._handle_end_of_queue(queue, prev_state, new_state)
279
280 # refill the queue (dynamic mode or autoplay) when running low on tracks
281 if "current_item_id" in changed_keys:
282 running_low = (
283 queue.current_index is not None and (queue.items - queue.current_index) < 5
284 )
285 if queue.is_dynamic and running_low:
286 # a dynamic queue tops up its bounded managed pool from its (dynamic + finite) sources
287 task_id = f"fill_dynamic_tracks_{queue_id}"
288 self.mass.call_later(5, self._fill_dynamic_tracks, queue_id, task_id=task_id)
289 elif queue.autoplay_enabled and running_low:
290 # autoplay appends whatever continues the queue's last item (more music, the
291 # next podcast episode/audiobook, or nothing at all)
292 task_id = f"fill_autoplay_tracks_{queue_id}"
293 self.mass.call_later(5, self._fill_autoplay_tracks, queue_id, task_id=task_id)
294
295 def _get_output_player_ids(self, player: Player) -> set[str]:
296 """Return destination player IDs represented in the processing chain."""
297 return resolve_output_player_ids(
298 self.mass,
299 [player.player_id, *player.state.group_members],
300 )
301
302 def _get_flow_queue_stream_index(
303 self, queue: PlayerQueue, player: Player
304 ) -> tuple[int | None, float]:
305 """
306 Calculate current queue index and current track elapsed time when flow mode is active.
307
308 The player reports cumulative stream-time (post-atempo). The returned
309 track elapsed time is in media-time, scaled by the current item's
310 playback_speed when we hit the active entry.
311 """
312 queue_data = self._queue_data[queue.queue_id]
313 elapsed_time_queue_total = player.state.corrected_elapsed_time or 0
314 if queue.current_index is None and not queue_data.flow_mode_stream_log:
315 return queue.current_index, queue.elapsed_time
316
317 # For each track that has been streamed/buffered to the player,
318 # a playlog entry will be created with the queue item id
319 # and the amount of seconds streamed. We traverse the playlog to figure
320 # out where we are in the queue, accounting for actual streamed
321 # seconds (and not duration) and skipped seconds. If a track has been repeated,
322 # it will simply be in the playlog multiple times.
323 played_time = 0.0
324 queue_index: int | None = queue.current_index or 0
325 track_time = 0.0
326 flow_log = queue_data.flow_mode_stream_log
327 for log_index, play_log_entry in enumerate(flow_log):
328 # seconds_streamed is bytes-derived stream-time, so the boundary check
329 # doesn't need a speed factor. Normally only the still-streaming tail entry
330 # has seconds_streamed=None (we'll break inside it before the sentinel
331 # matters); an abandoned probe entry is the exception, handled below.
332 if play_log_entry.seconds_streamed is not None:
333 # NOTE: 'seconds_streamed' can be 0 if there was a stream error
334 entry_stream_duration = play_log_entry.seconds_streamed
335 elif log_index < len(flow_log) - 1:
336 # Some players open the same flow URL several times while probing the
337 # stream. A probe can leave an unfinished entry behind before the
338 # connection that actually plays the audio appends the next entry.
339 # Recover the completed stream duration from the shared QueueItem;
340 # treating this non-tail entry as the active sentinel would pin the
341 # queue to the previous track and let elapsed time overflow its duration.
342 stale_queue_item = self.get_item(queue.queue_id, play_log_entry.queue_item_id)
343 if (
344 stale_queue_item
345 and stale_queue_item.streamdetails
346 and stale_queue_item.streamdetails.seconds_streamed is not None
347 ):
348 entry_stream_duration = stale_queue_item.streamdetails.seconds_streamed
349 else:
350 entry_stream_duration = 0
351 else:
352 entry_stream_duration = 3600 * 24 * 7
353 if elapsed_time_queue_total > (entry_stream_duration + played_time):
354 # total elapsed time is more than (streamed) track duration
355 # this track has been fully played, move on.
356 played_time += entry_stream_duration
357 else:
358 # no more seconds left to divide, this is our track
359 # account for any seeking by adding the skipped/seeked seconds
360 queue_index = self.index_by_id(queue.queue_id, play_log_entry.queue_item_id)
361 queue_item = self.get_item(queue.queue_id, queue_index)
362 if queue_item and queue_item.streamdetails:
363 track_sec_skipped = queue_item.streamdetails.seek_position
364 else:
365 track_sec_skipped = 0
366 # stream-time within this entry, scaled to media-time using the
367 # speed of the entry we broke on (queue.current_item may still be
368 # the previous entry during a transition)
369 entry_speed = (
370 float(queue_item.extra_attributes.get("playback_speed") or 1.0)
371 if queue_item
372 else 1.0
373 )
374 stream_pos_in_item = elapsed_time_queue_total - played_time
375 track_time = track_sec_skipped + stream_pos_in_item * entry_speed
376 break
377 if player.state.playback_state != PlaybackState.PLAYING:
378 # if the player is not playing, we can't be sure that the elapsed time is correct
379 # so we just return the queue index and the elapsed time
380 return queue.current_index, queue.elapsed_time
381 return queue_index, track_time
382
383 def _parse_player_current_item_id(self, queue_id: str, player: Player) -> str | None:
384 """Parse QueueItem ID from Player's current url."""
385 protocol_player = player
386 if player.active_output_protocol and player.active_output_protocol != "native":
387 protocol_player = self.mass.players.get_player(player.active_output_protocol) or player
388 if not protocol_player.current_media:
389 # YES, we use player.current_media on purpose here because we need the raw metadata
390 return None
391 # prefer queue_id and queue_item_id within the current media
392 if (
393 protocol_player.current_media.source_id == queue_id
394 and protocol_player.current_media.queue_item_id
395 ):
396 return protocol_player.current_media.queue_item_id
397 # special case for sonos players
398 if protocol_player.current_media.uri and protocol_player.current_media.uri.startswith(
399 f"mass:{queue_id}"
400 ):
401 if protocol_player.current_media.queue_item_id:
402 return protocol_player.current_media.queue_item_id
403 current_item_id = protocol_player.current_media.uri.split(":")[-1]
404 if self.get_item(queue_id, current_item_id):
405 return current_item_id
406 return None
407 # try to extract the item id from a mass stream url
408 # URL format: {base_url}/{mode}/{session_id}/{queue_id}/{queue_item_id}/{player_id}.{fmt}
409 base_url = self.mass.streams.base_url
410 if (
411 protocol_player.current_media.uri
412 and base_url
413 and protocol_player.current_media.uri.startswith(base_url)
414 ):
415 path_parts = protocol_player.current_media.uri[len(base_url) :].strip("/").split("/")
416 # path_parts: [mode, session_id, queue_id, queue_item_id, player_id.fmt]
417 if len(path_parts) >= 5:
418 current_item_id = path_parts[3]
419 if self.get_item(queue_id, current_item_id):
420 return current_item_id
421
422 return None
423
424 def _handle_end_of_queue(
425 self, queue: PlayerQueue, prev_state: CompareState, new_state: CompareState
426 ) -> None:
427 """Check if the queue should be cleared after the current item."""
428 queue_data = self._queue_data[queue.queue_id]
429 # check if queue state changed to stopped (from playing/paused to idle)
430 if not (
431 prev_state["state"] in (PlaybackState.PLAYING, PlaybackState.PAUSED)
432 and new_state["state"] == PlaybackState.IDLE
433 ):
434 return
435 # check if no more items in the queue (next_item should be None at end of queue)
436 if queue.next_item is not None:
437 return
438 # check if we had a previous item playing
439 if prev_state["current_item_id"] is None:
440 return
441
442 # retrieve prev_item here so it's available in the _settle_or_resume_delayed closure
443 # regardless of which code path (flow mode or non-flow mode) creates the task
444 prev_item = prev_state["current_item"]
445
446 if prev_item is not None and prev_item.media_type in UNENDABLE_MEDIA_TYPES:
447 return
448
449 async def _settle_or_resume_delayed() -> None:
450 for _ in range(5):
451 await asyncio.sleep(1)
452 if queue.state != PlaybackState.IDLE:
453 return
454 if queue.next_item is not None:
455 return
456 # check the actual queue items list for newly added items
457 # queue.next_item may be stale as it's only updated during PLAYING/PAUSED
458 if queue.current_index is not None and (
459 next_item := self.get_next_item(queue.queue_id, queue.current_index)
460 ):
461 next_index = self.index_by_id(queue.queue_id, next_item.queue_item_id)
462 if next_index is not None:
463 self.logger.info(
464 "Items added to queue while idle, resuming playback for %s",
465 queue.display_name,
466 )
467 await self.play_index(queue.queue_id, next_index)
468 return
469 # If the queue was started from a dynamic source, fetch fresh tracks and continue.
470 qdata = self._queue_data.get(queue.queue_id)
471 dynamic_source = find_dynamic_source(qdata) if qdata else None
472 if dynamic_source is not None:
473 try:
474 # Restore the queue owner's user context so provider filters and
475 # per-user logic (e.g. smart playlist dedup) are respected during
476 # this background refill, mirroring _fill_dynamic_tracks.
477 playback_user = (
478 await self.mass.webserver.auth.get_user(queue_data.userid)
479 if queue_data.userid
480 else None
481 )
482 set_current_user(playback_user)
483 dynamic_tracks = await self._media_resolver.get_dynamic_source_tracks(
484 dynamic_source
485 )
486 if dynamic_tracks:
487 queue_items = [
488 build_queue_item(queue.queue_id, x)
489 for x in dynamic_tracks
490 if x.available
491 ]
492 if queue_items:
493 cur_index = queue.current_index or 0
494 await self.load(
495 queue.queue_id,
496 queue_items,
497 insert_at_index=cur_index + 1,
498 keep_remaining=False,
499 keep_played=True,
500 shuffle=False,
501 )
502 if queue.current_index is not None and (
503 next_item := self.get_next_item(queue.queue_id, queue.current_index)
504 ):
505 next_index = self.index_by_id(
506 queue.queue_id, next_item.queue_item_id
507 )
508 if next_index is not None:
509 await self.play_index(queue.queue_id, next_index)
510 return
511 except MusicAssistantError as err:
512 self.logger.warning(
513 "Failed to refresh dynamic source %s for queue %s: %s",
514 getattr(dynamic_source, "name", repr(dynamic_source)),
515 queue.display_name,
516 err,
517 )
518 self._finish_queue(queue, prev_item)
519
520 # all checks passed, we stopped playback at the last (or single) track of the queue
521 # now determine if the item was fully played before settling/resuming
522
523 # For flow mode, check if the last track was fully streamed using the stream log
524 # This is more reliable than elapsed_time which can be reset/incorrect
525 if queue.flow_mode and queue_data.flow_mode_stream_log:
526 last_log_entry = queue_data.flow_mode_stream_log[-1]
527 if last_log_entry.seconds_streamed is not None:
528 # Guard: if a next item (e.g. a radio that caused the flow stream to break
529 # out early) is already queued, the queue_buffer_completed path
530 # (_resume_on_idle) is responsible for starting it. Creating
531 # _settle_or_resume_delayed here would race with that restart and could
532 # incorrectly settle the queue or trigger a double play_index call.
533 if queue.current_index is not None and self.get_next_item(
534 queue.queue_id, queue.current_index
535 ):
536 return
537 self.mass.create_task(_settle_or_resume_delayed())
538 return
539
540 # For non-flow mode, use prev_state values since queue state may have been updated/reset
541 if prev_item and (streamdetails := prev_item.streamdetails):
542 duration = streamdetails.duration or prev_item.duration or 24 * 3600
543 elif prev_item:
544 duration = prev_item.duration or 24 * 3600
545 else:
546 # No current item means player has already cleared it, safe to clear queue
547 self.mass.create_task(_settle_or_resume_delayed())
548 return
549
550 # use last_playing_elapsed_time which preserves the elapsed time from when the player
551 # was still playing (before transitioning to idle where elapsed_time may be reset to 0)
552 seconds_played = int(prev_state["last_playing_elapsed_time"])
553 # debounce this a bit to make sure we're not clearing the queue by accident
554 # only clear if the last track was played to near completion (within 5 seconds of end)
555 if seconds_played >= (duration or 3600) - 5:
556 self.mass.create_task(_settle_or_resume_delayed())
557
558 def _finish_queue(self, queue: PlayerQueue, prev_item: QueueItem | None) -> None:
559 """
560 Settle a queue that has nothing left to play, based on the item it ended on.
561
562 :param queue: The queue that ran out of items.
563 :param prev_item: The item the queue was playing when it went idle, if it is still known.
564 """
565 queue_data = self._queue_data.get(queue.queue_id)
566 # prev_item is gone when the player dropped its current item before we got here; the
567 # queue's last item is the one that finished, so fall back to that
568 ending_item = prev_item or (
569 queue_data.items[-1] if queue_data and queue_data.items else None
570 )
571 if ending_item is not None and ending_item.media_type in UNENDABLE_MEDIA_TYPES:
572 # normally caught before the debounce; reachable only when prev_item was lost
573 return
574 self.logger.info("End of queue reached for %s, marking it as ended", queue.display_name)
575 self.mark_ended(queue.queue_id)
576
577 def _handle_playback_progress_report(
578 self, queue: PlayerQueue, prev_state: CompareState, new_state: CompareState
579 ) -> None:
580 """Handle playback progress report."""
581 queue_data = self._queue_data[queue.queue_id]
582 # detect change in current index to report that a item has been played
583 prev_item_id = prev_state["current_item_id"]
584 cur_item_id = new_state["current_item_id"]
585 if prev_item_id is None and cur_item_id is None:
586 return
587
588 if prev_item_id is not None and prev_item_id != cur_item_id:
589 # we have a new item, so we need report the previous one
590 is_current_item = False
591 item_to_report = prev_state["current_item"]
592 seconds_played = int(prev_state["last_playing_elapsed_time"])
593 else:
594 # report on current item
595 is_current_item = True
596 item_to_report = self.get_item(queue.queue_id, cur_item_id) or new_state["current_item"]
597 seconds_played = int(new_state["elapsed_time"])
598
599 if not item_to_report:
600 return # guard against invalid items
601
602 if not (media_item := item_to_report.media_item):
603 # only report on media items
604 return
605 assert media_item.uri is not None # uri is set in __post_init__
606
607 if item_to_report.streamdetails and item_to_report.streamdetails.stream_error:
608 # Ignore items that had a stream error
609 return
610
611 # a preloaded item is only probed once it actually streams
612 self._apply_probed_duration(item_to_report)
613
614 if item_to_report.streamdetails and item_to_report.streamdetails.duration:
615 duration = int(item_to_report.streamdetails.duration)
616 else:
617 duration = int(item_to_report.duration or 3 * 3600)
618
619 if seconds_played < 5:
620 # ignore items that have been played less than 5 seconds
621 # this also filters out a bounce effect where the previous item
622 # gets reported with 0 elapsed seconds after a new item starts playing
623 return
624
625 if (
626 prev_state.get("state") != PlaybackState.PLAYING.value
627 and not duration < PLAYBACK_REPORT_INTERVAL_SECONDS
628 ):
629 # Do not report when resuming from idle or paused.
630 # (unless track has less seconds than PLAYBACK_REPORT_INTERVAL_SECONDS).
631 # Handles edge case: Queue still holds an audiobook/ podcast, and is paused/ idle.
632 # Audiobook is continued outside of MA. Then playback of another media item is
633 # started in MA on that queue. This triggers a progress report with the old position
634 # overwriting the newest one.
635 # We still want to report when transitioning to pause or idle.
636 return
637
638 # determine if item is fully played
639 # for podcasts and audiobooks we account for the last 60 seconds
640 percentage_played = percentage(seconds_played, duration)
641 if not is_current_item and item_to_report.media_type in (
642 MediaType.AUDIOBOOK,
643 MediaType.PODCAST_EPISODE,
644 ):
645 fully_played = seconds_played >= duration - 60
646 elif not is_current_item:
647 # 90% of the track must be played to be considered fully played
648 fully_played = percentage_played >= 90
649 else:
650 fully_played = seconds_played >= duration - 10
651
652 is_playing = is_current_item and queue.state == PlaybackState.PLAYING
653
654 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
655 self.logger.debug(
656 "%s %s '%s' (%s) - Fully played: %s - Progress: %s (%s/%ss)",
657 queue.display_name,
658 "is playing" if is_playing else "played",
659 item_to_report.name,
660 item_to_report.uri,
661 fully_played,
662 f"{percentage_played}%",
663 seconds_played,
664 duration,
665 )
666 # add entry to playlog - this also handles resume of podcasts/audiobooks
667 if self._should_mark_played(
668 queue.queue_id, item_to_report.queue_item_id, fully_played, is_playing
669 ):
670 self.mass.create_task(
671 self.mass.music.mark_item_played(
672 media_item,
673 fully_played=fully_played,
674 seconds_played=seconds_played,
675 is_playing=is_playing,
676 userid=queue_data.userid,
677 queue_id=queue.queue_id,
678 user_initiated=self._is_user_initiated_play(queue_data, media_item),
679 playback_speed=float(
680 item_to_report.extra_attributes.get("playback_speed") or 1.0
681 )
682 if item_to_report.media_type in (MediaType.AUDIOBOOK, MediaType.PODCAST_EPISODE)
683 else None,
684 )
685 )
686 if fully_played and not is_playing:
687 if credit_album := self._enqueued_album_for_track(
688 queue_data, item_to_report, media_item
689 ):
690 self.mass.create_task(
691 self._mark_album_played(credit_album, media_item, queue_data)
692 )
693
694 album: Album | ItemMapping | None = getattr(media_item, "album", None)
695 # signal 'media item played' event,
696 # which is useful for plugins that want to do scrobbling
697 artists: list[Artist | ItemMapping] = getattr(media_item, "artists", [])
698 artists_names = [a.name for a in artists]
699 self.mass.signal_event(
700 EventType.MEDIA_ITEM_PLAYED,
701 object_id=media_item.uri,
702 data=MediaItemPlaybackProgressReport(
703 uri=media_item.uri,
704 media_type=media_item.media_type,
705 name=media_item.name,
706 version=getattr(media_item, "version", None),
707 artist=(
708 getattr(media_item, "artist_str", None) or artists_names[0]
709 if artists_names
710 else None
711 ),
712 artists=artists_names,
713 artist_mbids=[a.mbid for a in artists if a.mbid] if artists else None,
714 album=album.name if album else None,
715 album_mbid=album.mbid if album else None,
716 album_artist=(album.artist_str if isinstance(album, Album) else None),
717 album_artist_mbids=(
718 [a.mbid for a in album.artists if a.mbid] if isinstance(album, Album) else None
719 ),
720 image_url=(
721 self.mass.metadata.get_image_url(
722 item_to_report.media_item.image, prefer_proxy=False
723 )
724 if item_to_report.media_item.image
725 else None
726 ),
727 duration=duration,
728 mbid=(getattr(media_item, "mbid", None)),
729 seconds_played=seconds_played,
730 fully_played=fully_played,
731 is_playing=is_playing,
732 userid=queue_data.userid,
733 player_id=queue.queue_id,
734 ),
735 )
736
737 def _enqueued_album_for_track(
738 self, queue_data: PlayerQueueData, item_to_report: QueueItem, media_item: MediaItemType
739 ) -> Album | None:
740 """
741 Return the album to credit for this played track, or None.
742
743 Only an album the user explicitly enqueued is eligible, and only on the first
744 track of a contiguous run of its tracks (the previous queue item must belong to
745 a different album), so a single album play is credited once.
746 """
747 album = getattr(media_item, "album", None)
748 if album is None:
749 return None
750 enqueued = next(
751 (
752 item
753 for item in queue_data.enqueued_media_items
754 if isinstance(item, Album) and item == album
755 ),
756 None,
757 )
758 if enqueued is None:
759 return None
760 queue_id = queue_data.queue.queue_id
761 index = self.index_by_id(queue_id, item_to_report.queue_item_id)
762 if index:
763 prev_item = self.get_item(queue_id, index - 1)
764 prev_album = (
765 getattr(prev_item.media_item, "album", None)
766 if prev_item and prev_item.media_item
767 else None
768 )
769 if prev_album == album:
770 return None
771 return enqueued
772
773 def _is_user_initiated_play(
774 self, queue_data: PlayerQueueData, media_item: MediaItemType
775 ) -> bool:
776 """Return whether a played item was explicitly chosen by the user."""
777 return media_item in queue_data.enqueued_media_items
778
779 async def _mark_album_played(
780 self, album: Album, track: MediaItemType, queue_data: PlayerQueueData
781 ) -> None:
782 """Mark an enqueued album played, skipping artists already credited via its track."""
783 self.logger.debug(
784 "Credited album '%s' as played (triggered by track '%s')", album.name, track.name
785 )
786 skip = await self.mass.music.resolve_library_artist_ids(getattr(track, "artists", []))
787 await self.mass.music.mark_item_played(
788 album,
789 userid=queue_data.userid,
790 queue_id=queue_data.queue.queue_id,
791 user_initiated=True,
792 skip_artist_ids=list(skip),
793 )
794
795 def _should_mark_played(
796 self, queue_id: str, queue_item_id: str, fully_played: bool, is_playing: bool
797 ) -> bool:
798 """
799 Return whether this playback report should be forwarded to ``mark_item_played``.
800
801 :param queue_id: The id of the queue the report belongs to.
802 :param queue_item_id: The id of the queue item being reported.
803 :param fully_played: Whether the item was played to completion.
804 :param is_playing: Whether the item is still playing.
805 """
806 queue_data = self._queue_data[queue_id]
807 if fully_played and not is_playing:
808 # the final queue track is reported twice at end-of-queue; skip the duplicate
809 # so a completed play is only counted once
810 if queue_data.last_counted_play == queue_item_id:
811 return False
812 queue_data.last_counted_play = queue_item_id
813 return True
814 # a not-fully-played report for the same item means it restarted (e.g. on repeat),
815 # so re-arm the guard to count its next completion
816 if not fully_played and queue_data.last_counted_play == queue_item_id:
817 queue_data.last_counted_play = None
818 return True
819