/
/
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 queue.state != PlaybackState.IDLE:
454 return
455 if queue.next_item is not None:
456 return
457 # check the actual queue items list for newly added items
458 # queue.next_item may be stale as it's only updated during PLAYING/PAUSED
459 if queue.current_index is not None and (
460 next_item := self.get_next_item(queue.queue_id, queue.current_index)
461 ):
462 next_index = self.index_by_id(queue.queue_id, next_item.queue_item_id)
463 if next_index is not None:
464 self.logger.info(
465 "Items added to queue while idle, resuming playback for %s",
466 queue.display_name,
467 )
468 await self.play_index(queue.queue_id, next_index)
469 return
470 # If the queue was started from a dynamic source, fetch fresh tracks and continue.
471 qdata = self._queue_data.get(queue.queue_id)
472 dynamic_source = find_dynamic_source(qdata) if qdata else None
473 if dynamic_source is not None:
474 try:
475 # Restore the queue owner's user context so provider filters and
476 # per-user logic (e.g. smart playlist dedup) are respected during
477 # this background refill, mirroring _fill_dynamic_tracks.
478 playback_user = (
479 await self.mass.webserver.auth.get_user(queue_data.userid)
480 if queue_data.userid
481 else None
482 )
483 set_current_user(playback_user)
484 dynamic_tracks = await self._media_resolver.get_dynamic_source_tracks(
485 dynamic_source
486 )
487 if dynamic_tracks:
488 queue_items = [
489 build_queue_item(queue.queue_id, x)
490 for x in dynamic_tracks
491 if x.available
492 ]
493 if queue_items:
494 cur_index = queue.current_index or 0
495 await self.load(
496 queue.queue_id,
497 queue_items,
498 insert_at_index=cur_index + 1,
499 keep_remaining=False,
500 keep_played=True,
501 shuffle=False,
502 )
503 if queue.current_index is not None and (
504 next_item := self.get_next_item(queue.queue_id, queue.current_index)
505 ):
506 next_index = self.index_by_id(
507 queue.queue_id, next_item.queue_item_id
508 )
509 if next_index is not None:
510 await self.play_index(queue.queue_id, next_index)
511 return
512 except MusicAssistantError as err:
513 self.logger.warning(
514 "Failed to refresh dynamic source %s for queue %s: %s",
515 getattr(dynamic_source, "name", repr(dynamic_source)),
516 queue.display_name,
517 err,
518 )
519 self._finish_queue(queue, prev_item)
520
521 # all checks passed, we stopped playback at the last (or single) track of the queue
522 # now determine if the item was fully played before settling/resuming
523
524 # For flow mode, check if the last track was fully streamed using the stream log
525 # This is more reliable than elapsed_time which can be reset/incorrect
526 if queue.flow_mode and queue_data.flow_mode_stream_log:
527 last_log_entry = queue_data.flow_mode_stream_log[-1]
528 if last_log_entry.seconds_streamed is not None:
529 # Guard: if a next item (e.g. a radio that caused the flow stream to break
530 # out early) is already queued, the queue_buffer_completed path
531 # (_resume_on_idle) is responsible for starting it. Creating
532 # _settle_or_resume_delayed here would race with that restart and could
533 # incorrectly settle the queue or trigger a double play_index call.
534 if queue.current_index is not None and self.get_next_item(
535 queue.queue_id, queue.current_index
536 ):
537 return
538 self.mass.create_task(_settle_or_resume_delayed())
539 return
540
541 # For non-flow mode, use prev_state values since queue state may have been updated/reset
542 if prev_item and (streamdetails := prev_item.streamdetails):
543 duration = streamdetails.duration or prev_item.duration or 24 * 3600
544 elif prev_item:
545 duration = prev_item.duration or 24 * 3600
546 else:
547 # No current item means player has already cleared it, safe to clear queue
548 self.mass.create_task(_settle_or_resume_delayed())
549 return
550
551 # use last_playing_elapsed_time which preserves the elapsed time from when the player
552 # was still playing (before transitioning to idle where elapsed_time may be reset to 0)
553 seconds_played = int(prev_state["last_playing_elapsed_time"])
554 # debounce this a bit to make sure we're not clearing the queue by accident
555 # only clear if the last track was played to near completion (within 5 seconds of end)
556 if seconds_played >= (duration or 3600) - 5:
557 self.mass.create_task(_settle_or_resume_delayed())
558
559 def _finish_queue(self, queue: PlayerQueue, prev_item: QueueItem | None) -> None:
560 """
561 Settle a queue that has nothing left to play, based on the item it ended on.
562
563 :param queue: The queue that ran out of items.
564 :param prev_item: The item the queue was playing when it went idle, if it is still known.
565 """
566 queue_data = self._queue_data.get(queue.queue_id)
567 # prev_item is gone when the player dropped its current item before we got here; the
568 # queue's last item is the one that finished, so fall back to that
569 ending_item = prev_item or (
570 queue_data.items[-1] if queue_data and queue_data.items else None
571 )
572 if ending_item is not None and ending_item.media_type in UNENDABLE_MEDIA_TYPES:
573 # normally caught before the debounce; reachable only when prev_item was lost
574 return
575 self.logger.info("End of queue reached for %s, marking it as ended", queue.display_name)
576 self.mark_ended(queue.queue_id)
577
578 def _handle_playback_progress_report(
579 self, queue: PlayerQueue, prev_state: CompareState, new_state: CompareState
580 ) -> None:
581 """Handle playback progress report."""
582 queue_data = self._queue_data[queue.queue_id]
583 # detect change in current index to report that a item has been played
584 prev_item_id = prev_state["current_item_id"]
585 cur_item_id = new_state["current_item_id"]
586 if prev_item_id is None and cur_item_id is None:
587 return
588
589 if prev_item_id is not None and prev_item_id != cur_item_id:
590 # we have a new item, so we need report the previous one
591 is_current_item = False
592 item_to_report = prev_state["current_item"]
593 seconds_played = int(prev_state["last_playing_elapsed_time"])
594 else:
595 # report on current item
596 is_current_item = True
597 item_to_report = self.get_item(queue.queue_id, cur_item_id) or new_state["current_item"]
598 seconds_played = int(new_state["elapsed_time"])
599
600 if not item_to_report:
601 return # guard against invalid items
602
603 if not (media_item := item_to_report.media_item):
604 # only report on media items
605 return
606 assert media_item.uri is not None # uri is set in __post_init__
607
608 if item_to_report.streamdetails and item_to_report.streamdetails.stream_error:
609 # Ignore items that had a stream error
610 return
611
612 # a preloaded item is only probed once it actually streams
613 self._apply_probed_duration(item_to_report)
614
615 if item_to_report.streamdetails and item_to_report.streamdetails.duration:
616 duration = int(item_to_report.streamdetails.duration)
617 else:
618 duration = int(item_to_report.duration or 3 * 3600)
619
620 if seconds_played < 5:
621 # ignore items that have been played less than 5 seconds
622 # this also filters out a bounce effect where the previous item
623 # gets reported with 0 elapsed seconds after a new item starts playing
624 return
625
626 if (
627 prev_state.get("state") != PlaybackState.PLAYING.value
628 and not duration < PLAYBACK_REPORT_INTERVAL_SECONDS
629 ):
630 # Do not report when resuming from idle or paused.
631 # (unless track has less seconds than PLAYBACK_REPORT_INTERVAL_SECONDS).
632 # Handles edge case: Queue still holds an audiobook/ podcast, and is paused/ idle.
633 # Audiobook is continued outside of MA. Then playback of another media item is
634 # started in MA on that queue. This triggers a progress report with the old position
635 # overwriting the newest one.
636 # We still want to report when transitioning to pause or idle.
637 return
638
639 # determine if item is fully played
640 # for podcasts and audiobooks we account for the last 60 seconds
641 percentage_played = percentage(seconds_played, duration)
642 if not is_current_item and item_to_report.media_type in (
643 MediaType.AUDIOBOOK,
644 MediaType.PODCAST_EPISODE,
645 ):
646 fully_played = seconds_played >= duration - 60
647 elif not is_current_item:
648 # 90% of the track must be played to be considered fully played
649 fully_played = percentage_played >= 90
650 else:
651 fully_played = seconds_played >= duration - 10
652
653 is_playing = is_current_item and queue.state == PlaybackState.PLAYING
654
655 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
656 self.logger.debug(
657 "%s %s '%s' (%s) - Fully played: %s - Progress: %s (%s/%ss)",
658 queue.display_name,
659 "is playing" if is_playing else "played",
660 item_to_report.name,
661 item_to_report.uri,
662 fully_played,
663 f"{percentage_played}%",
664 seconds_played,
665 duration,
666 )
667 # add entry to playlog - this also handles resume of podcasts/audiobooks
668 if self._should_mark_played(
669 queue.queue_id, item_to_report.queue_item_id, fully_played, is_playing
670 ):
671 self.mass.create_task(
672 self.mass.music.mark_item_played(
673 media_item,
674 fully_played=fully_played,
675 seconds_played=seconds_played,
676 is_playing=is_playing,
677 userid=queue_data.userid,
678 queue_id=queue.queue_id,
679 user_initiated=self._is_user_initiated_play(queue_data, media_item),
680 playback_speed=float(
681 item_to_report.extra_attributes.get("playback_speed") or 1.0
682 )
683 if item_to_report.media_type in (MediaType.AUDIOBOOK, MediaType.PODCAST_EPISODE)
684 else None,
685 )
686 )
687 if fully_played and not is_playing:
688 if credit_album := self._claim_enqueued_album_credit(queue_data, media_item):
689 self.mass.create_task(
690 self._mark_album_played(credit_album, media_item, queue_data)
691 )
692
693 album: Album | ItemMapping | None = getattr(media_item, "album", None)
694 # signal 'media item played' event,
695 # which is useful for plugins that want to do scrobbling
696 artists: list[Artist | ItemMapping] = getattr(media_item, "artists", [])
697 artists_names = [a.name for a in artists]
698 self.mass.signal_event(
699 EventType.MEDIA_ITEM_PLAYED,
700 object_id=media_item.uri,
701 data=MediaItemPlaybackProgressReport(
702 uri=media_item.uri,
703 media_type=media_item.media_type,
704 name=media_item.name,
705 version=getattr(media_item, "version", None),
706 artist=(
707 getattr(media_item, "artist_str", None) or artists_names[0]
708 if artists_names
709 else None
710 ),
711 artists=artists_names,
712 artist_mbids=[a.mbid for a in artists if a.mbid] if artists else None,
713 album=album.name if album else None,
714 album_mbid=album.mbid if album else None,
715 album_artist=(album.artist_str if isinstance(album, Album) else None),
716 album_artist_mbids=(
717 [a.mbid for a in album.artists if a.mbid] if isinstance(album, Album) else None
718 ),
719 image_url=(
720 self.mass.metadata.get_image_url(
721 item_to_report.media_item.image, prefer_proxy=False
722 )
723 if item_to_report.media_item.image
724 else None
725 ),
726 duration=duration,
727 mbid=(getattr(media_item, "mbid", None)),
728 seconds_played=seconds_played,
729 fully_played=fully_played,
730 is_playing=is_playing,
731 userid=queue_data.userid,
732 player_id=queue.queue_id,
733 ),
734 )
735
736 def _claim_enqueued_album_credit(
737 self, queue_data: PlayerQueueData, media_item: MediaItemType
738 ) -> Album | None:
739 """
740 Claim the album play this track credits, or None when there is nothing to credit.
741
742 Only an album the user explicitly enqueued is eligible, and only the first of its
743 tracks to complete since it was enqueued, so a single album play is credited once
744 however its tracks ended up ordered in the queue. Claiming marks the album as
745 credited on this queue, so a second call for the same enqueue returns None.
746 """
747 album = getattr(media_item, "album", None)
748 if album is None:
749 return None
750 # the album the user pressed play on keeps the shape of the listing it was picked
751 # from, while the queue's tracks carry the library album. Matching on the provider
752 # mappings recognises both shapes as the same album. The most recent enqueue wins,
753 # because that is the one whose credit was just armed; an earlier entry for the same
754 # album may still be a differently shaped (and therefore separately keyed) object.
755 enqueued = next(
756 (
757 item
758 for item in reversed(queue_data.enqueued_media_items)
759 if isinstance(item, Album) and compare_item_ids(item, album)
760 ),
761 None,
762 )
763 if enqueued is None or enqueued in queue_data.credited_albums:
764 return None
765 queue_data.credited_albums.add(enqueued)
766 # credit the album the track carries, which is the library one whenever the album is
767 # in the library, so the play lands on the row an explicit library play writes instead
768 # of a second provider-scoped one.
769 return album if isinstance(album, Album) else enqueued
770
771 def _is_user_initiated_play(
772 self, queue_data: PlayerQueueData, media_item: MediaItemType
773 ) -> bool:
774 """Return whether a played item was explicitly chosen by the user."""
775 # a played item is reported in its library shape while the enqueued item may still be
776 # the provider one it was picked from. The media type is compared alongside it because
777 # the library numbers each type from one, so ids collide freely across types.
778 return any(
779 item.media_type == media_item.media_type and compare_item_ids(item, media_item)
780 for item in queue_data.enqueued_media_items
781 )
782
783 async def _mark_album_played(
784 self, album: Album, track: MediaItemType, queue_data: PlayerQueueData
785 ) -> None:
786 """Mark an enqueued album played, skipping artists already credited via its track."""
787 self.logger.debug(
788 "Credited album '%s' as played (triggered by track '%s')", album.name, track.name
789 )
790 skip = await self.mass.music.resolve_library_artist_ids(getattr(track, "artists", []))
791 await self.mass.music.mark_item_played(
792 album,
793 userid=queue_data.userid,
794 queue_id=queue_data.queue.queue_id,
795 user_initiated=True,
796 skip_artist_ids=list(skip),
797 )
798
799 def _should_mark_played(
800 self, queue_id: str, queue_item_id: str, fully_played: bool, is_playing: bool
801 ) -> bool:
802 """
803 Return whether this playback report should be forwarded to ``mark_item_played``.
804
805 :param queue_id: The id of the queue the report belongs to.
806 :param queue_item_id: The id of the queue item being reported.
807 :param fully_played: Whether the item was played to completion.
808 :param is_playing: Whether the item is still playing.
809 """
810 queue_data = self._queue_data[queue_id]
811 if fully_played and not is_playing:
812 # the final queue track is reported twice at end-of-queue; skip the duplicate
813 # so a completed play is only counted once
814 if queue_data.last_counted_play == queue_item_id:
815 return False
816 queue_data.last_counted_play = queue_item_id
817 return True
818 # a not-fully-played report for the same item means it restarted (e.g. on repeat),
819 # so re-arm the guard to count its next completion
820 if not fully_played and queue_data.last_counted_play == queue_item_id:
821 queue_data.last_counted_play = None
822 return True
823