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