From: Marcel van der Veldt Date: Sat, 8 Feb 2025 15:00:51 +0000 (+0100) Subject: Chore: Fix audiobook resume X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=a52370927c4e3bb6bafeb09fe456937d56d2f14a;p=music-assistant-server.git Chore: Fix audiobook resume Fix: resume of audiobook Fix: Clear queue after last track has been (fully) played --- diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index db796e90..fb3c3576 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -766,7 +766,7 @@ class MusicController(CoreController): async def mark_item_played( self, media_item: MediaItemType | ItemMapping, - fully_played: bool | None = None, + fully_played: bool = True, seconds_played: int | None = None, ) -> None: """Mark item as played in playlog.""" @@ -799,8 +799,6 @@ class MusicController(CoreController): # forward to provider(s) to sync resume state (e.g. for audiobooks) for prov_mapping in media_item.provider_mappings: - if fully_played is None: - fully_played = True if music_prov := self.mass.get_provider(prov_mapping.provider_instance): self.mass.create_task( music_prov.on_played( @@ -811,7 +809,9 @@ class MusicController(CoreController): ) ) - # also update playcount in library table + # also update playcount in library table (if fully played) + if not fully_played: + return if not (ctrl := self.get_controller(media_item.media_type)): # skip non media items (e.g. plugin source) return diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 67f9f8c1..aa267690 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -112,6 +112,21 @@ class CompareState(TypedDict): output_formats: list[str] | None +class MediaItemPlaybackProgressReport(TypedDict): + """Object to submit in a progress the event submitted to report media playback.""" + + uri: str + media_type: MediaType + name: str + artist: str | None + album: str | None + image_url: str | None + duration: int + mbid: str | None + seconds_played: int + fully_played: bool + + class PlayerQueuesController(CoreController): """Controller holding all logic to enqueue music for players.""" @@ -1040,19 +1055,8 @@ class PlayerQueuesController(CoreController): self._handle_playback_progress_report(queue, prev_state, new_state) # check if we need to clear the queue if we reached the end - if ( - # queue stopped (from playing/paused to idle) - prev_state["state"] in (PlayerState.PLAYING, PlayerState.PAUSED) - and new_state["state"] == PlayerState.IDLE - # no more items in the queue - and queue.next_item is None - # we had a previous item - and (prev_item_id := prev_state["current_item_id"]) is not None - and (self.get_item(queue_id, prev_item_id)) is not None - and queue.current_index is not None - and queue.current_item is not None - ): - self.mass.create_task(self._handle_end_of_queue(queue)) + if "state" in changed_keys and queue.state == PlayerState.IDLE: + self._handle_end_of_queue(queue, prev_state, new_state) # watch dynamic radio items refill if needed if "current_item_id" in changed_keys: @@ -1207,10 +1211,10 @@ class PlayerQueuesController(CoreController): return # enqueue next track on the player if we're not in flow mode task_id = f"enqueue_next_item_{queue_id}" - self.mass.call_later(2, self._enqueue_next_item, queue_id, item_id, task_id=task_id) + self.mass.call_later(1, self._enqueue_next_item, queue_id, item_id, task_id=task_id) # repeat this one time because some players # don't accept the next track when still buffering one - self.mass.call_later(30, self._enqueue_next_item, queue_id, item_id, task_id=task_id) + self.mass.call_later(10, self._enqueue_next_item, queue_id, item_id, task_id=task_id) # Main queue manipulation methods @@ -1432,21 +1436,21 @@ class PlayerQueuesController(CoreController): return 0 if provider_item.resume_position_ms is not None: return provider_item.resume_position_ms - # fallback to the resume point from the playlog (if available) - resume_info_db_row = await self.mass.music.database.get_row( - DB_TABLE_PLAYLOG, - { - "item_id": prov_mapping.item_id, - "provider": provider.lookup_key, - "media_type": MediaType.AUDIOBOOK, - }, - ) - if resume_info_db_row is None: - continue + # fallback to the resume point from the playlog (if available) + resume_info_db_row = await self.mass.music.database.get_row( + DB_TABLE_PLAYLOG, + { + "item_id": audio_book.item_id, + "provider": audio_book.provider, + "media_type": MediaType.AUDIOBOOK, + }, + ) + if resume_info_db_row is not None: if resume_info_db_row["fully_played"]: return 0 if resume_info_db_row["seconds_played"]: return int(resume_info_db_row["seconds_played"] * 1000) + return 0 async def get_next_podcast_episodes( @@ -1740,18 +1744,46 @@ class PlayerQueuesController(CoreController): return None - async def _handle_end_of_queue(self, queue: PlayerQueue) -> None: + def _handle_end_of_queue( + self, queue: PlayerQueue, prev_state: CompareState, new_state: CompareState + ) -> None: """Check if the queue should be cleared after the current item.""" - for _ in range(5): - await asyncio.sleep(1) - if queue.state != PlayerState.IDLE: - return - if queue.next_item is not None: - return - if not ((queue.current_index or 0) >= len(self._queue_items[queue.queue_id]) - 1): - return - self.logger.info("End of queue reached, clearing items") - self.clear(queue.queue_id) + # check if queue state changed to stopped (from playing/paused to idle) + if not ( + prev_state["state"] in (PlayerState.PLAYING, PlayerState.PAUSED) + and new_state["state"] == PlayerState.IDLE + ): + return + # check if no more items in the queue + if queue.next_item is not None: + return + # check if we had a previous item + if prev_state["current_item_id"] is None: + return + # check that we have a current item + if queue.current_item is None: + return + + async def _clear_queue_delayed(): + for _ in range(5): + await asyncio.sleep(1) + if queue.state != PlayerState.IDLE: + return + if queue.next_item is not None: + return + self.logger.info("End of queue reached, clearing items") + self.clear(queue.queue_id) + + # all checks passed, we stopped playback at the last (or single) of the queue + # now determine if the item was fully played + if streamdetails := queue.current_item.streamdetails: + duration = streamdetails.duration or queue.current_item.duration or 24 * 3600 + else: + duration = queue.current_item.duration or 24 * 3600 + seconds_played = int(queue.elapsed_time) + # debounce this a bit to make sure we're not clearing the queue by accident + if seconds_played >= (duration or 3600) - 5: + self.mass.create_task(_clear_queue_delayed()) def _handle_playback_progress_report( self, queue: PlayerQueue, prev_state: CompareState, new_state: CompareState @@ -1771,9 +1803,6 @@ class PlayerQueuesController(CoreController): # should not happen, but guard it anyway return seconds_played = int(prev_state["elapsed_time"]) - fully_played = stream_details and ( - seconds_played >= (stream_details.duration or 3600) - 5 - ) else: # report on current item if not (item_to_report := self.get_item(queue.queue_id, cur_item_id)): @@ -1786,13 +1815,13 @@ class PlayerQueuesController(CoreController): if seconds_played < 30: # ignore items that have been played less than 30 seconds return - fully_played = stream_details and ( - seconds_played >= (stream_details.duration or 3600) - 5 - ) if not item_to_report.media_item: # only report on media items return + duration = stream_details.duration or item_to_report.duration or 3600 + fully_played = seconds_played >= (duration or 3600) - 5 + self.logger.debug( "PlayerQueue %s playing/played item %s - fully_played: %s - progress: %s", queue.display_name, @@ -1813,25 +1842,24 @@ class PlayerQueuesController(CoreController): self.mass.signal_event( EventType.MEDIA_ITEM_PLAYED, object_id=item_to_report.media_item.uri, - data={ - # TODO: Maybe we should create a dataclass for this as well?! - "media_item": { - "uri": item_to_report.media_item.uri, - "name": item_to_report.media_item.name, - "media_type": item_to_report.media_item.media_type, - "artist": getattr(item_to_report.media_item, "artist_str", None), - "album": album.name + data=MediaItemPlaybackProgressReport( + uri=item_to_report.media_item.uri, + name=item_to_report.media_item.name, + media_type=item_to_report.media_item.media_type, + artist=getattr(item_to_report.media_item, "artist_str", None), + album=( + album.name if (album := getattr(item_to_report.media_item, "album", None)) - else None, - "image_url": self.mass.metadata.get_image_url( - item_to_report.media_item.image, size=512 - ) + else None + ), + image_url=( + self.mass.metadata.get_image_url(item_to_report.media_item.image, size=512) if item_to_report.media_item.image - else None, - "duration": getattr(item_to_report.media_item, "duration", 0), - "mbid": getattr(item_to_report.media_item, "mbid", None), - }, - "seconds_played": seconds_played, - "fully_played": fully_played, - }, + else None + ), + duration=duration, + mbid=(getattr(item_to_report.media_item, "mbid", None)), + seconds_played=seconds_played, + fully_played=fully_played, + ), ) diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index 6abeba3d..a56f3126 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -353,8 +353,8 @@ class MusicProvider(Provider): This is called by the Queue controller when; - a track has been fully played - - a track has been skipped - - a track has been stopped after being played + - a track has been stopped (or skipped) after being played + - every 30s when a track is playing Fully played is True when the track has been played to the end. Position is the last known position of the track in seconds, to sync resume state. diff --git a/music_assistant/providers/_template_music_provider/__init__.py b/music_assistant/providers/_template_music_provider/__init__.py index c305d7b8..ccdf7dd0 100644 --- a/music_assistant/providers/_template_music_provider/__init__.py +++ b/music_assistant/providers/_template_music_provider/__init__.py @@ -441,8 +441,8 @@ class MyDemoMusicprovider(MusicProvider): This is called by the Queue controller when; - a track has been fully played - - a track has been skipped - - a track has been stopped after being played + - a track has been stopped (or skipped) after being played + - every 30s when a track is playing Fully played is True when the track has been played to the end. Position is the last known position of the track in seconds, to sync resume state. diff --git a/music_assistant/providers/audible/__init__.py b/music_assistant/providers/audible/__init__.py index b9f05e32..79da00ac 100644 --- a/music_assistant/providers/audible/__init__.py +++ b/music_assistant/providers/audible/__init__.py @@ -291,8 +291,8 @@ class Audibleprovider(MusicProvider): This is called by the Queue controller when; - a track has been fully played - - a track has been skipped - - a track has been stopped after being played + - a track has been stopped (or skipped) after being played + - every 30s when a track is playing Fully played is True when the track has been played to the end. Position is the last known position of the track in seconds, to sync resume state. diff --git a/music_assistant/providers/opensubsonic/sonic_provider.py b/music_assistant/providers/opensubsonic/sonic_provider.py index aba98849..289acffa 100644 --- a/music_assistant/providers/opensubsonic/sonic_provider.py +++ b/music_assistant/providers/opensubsonic/sonic_provider.py @@ -796,8 +796,8 @@ class OpenSonicProvider(MusicProvider): This is called by the Queue controller when; - a track has been fully played - - a track has been skipped - - a track has been stopped after being played + - a track has been stopped (or skipped) after being played + - every 30s when a track is playing Fully played is True when the track has been played to the end. Position is the last known position of the track in seconds, to sync resume state.