Chore: Fix audiobook resume
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 8 Feb 2025 15:00:51 +0000 (16:00 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 8 Feb 2025 15:00:51 +0000 (16:00 +0100)
Fix: resume of audiobook
Fix: Clear queue after last track has been (fully) played

music_assistant/controllers/music.py
music_assistant/controllers/player_queues.py
music_assistant/models/music_provider.py
music_assistant/providers/_template_music_provider/__init__.py
music_assistant/providers/audible/__init__.py
music_assistant/providers/opensubsonic/sonic_provider.py

index db796e9078683167d8093b76a529f82d6e4c80ef..fb3c3576a3fe61910f50ca5d727114d96d582d90 100644 (file)
@@ -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
index 67f9f8c100f2963c30cac0f86ebb31326dc1e11d..aa2676900f619a1001ddeda9e609c3c67f1af82e 100644 (file)
@@ -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,
+            ),
         )
index 6abeba3d187b11680b4364c578b38ecfe4bd002c..a56f3126d29a12a89903c93a49d4ba06676cdc47 100644 (file)
@@ -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.
index c305d7b8853551c6c0d56ee2ae91f5358050cf18..ccdf7dd0ccce8261d9ebc0c81ddc2272cc9e9993 100644 (file)
@@ -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.
index b9f05e326c90fd0338acc20ae0f0db7f8052b1b7..79da00ace905b056a34a60b5865ebaca677f38eb 100644 (file)
@@ -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.
index aba988493637c82e3899baf329aee044ba95eed1..289acffaeb56230e1882772257e94e251c80f0b8 100644 (file)
@@ -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.