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