Chore: Improve syncing of resume/progress info
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 11 Feb 2025 14:57:16 +0000 (15:57 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 11 Feb 2025 14:57:16 +0000 (15:57 +0100)
music_assistant/controllers/media/audiobooks.py
music_assistant/controllers/media/podcasts.py
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

index f59edc155b053766ab7ff47faeec0ce6161489e4..b7fee93a06e3c9dc0389cf620574ed70bd05de5c 100644 (file)
@@ -8,13 +8,14 @@ from music_assistant_models.enums import MediaType, ProviderFeature
 from music_assistant_models.errors import InvalidDataError
 from music_assistant_models.media_items import Artist, Audiobook, UniqueList
 
-from music_assistant.constants import DB_TABLE_AUDIOBOOKS
+from music_assistant.constants import DB_TABLE_AUDIOBOOKS, DB_TABLE_PLAYLOG
 from music_assistant.controllers.media.base import MediaControllerBase
 from music_assistant.helpers.compare import (
     compare_audiobook,
     compare_media_item,
     loose_compare_strings,
 )
+from music_assistant.helpers.datetime import utc_timestamp
 from music_assistant.helpers.json import serialize_to_json
 
 if TYPE_CHECKING:
@@ -45,8 +46,13 @@ class AudiobooksController(MediaControllerBase[Audiobook, Audiobook]):
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
                         'details', provider_mappings.details
-                )) FROM provider_mappings WHERE provider_mappings.item_id = audiobooks.item_id AND media_type = 'audiobook') AS provider_mappings
-            FROM audiobooks"""  # noqa: E501
+                )) FROM provider_mappings WHERE provider_mappings.item_id = audiobooks.item_id AND media_type = 'audiobook') AS provider_mappings,
+            playlog.fully_played AS fully_played,
+            playlog.seconds_played AS seconds_played,
+            playlog.seconds_played * 1000 as resume_position_ms
+            FROM audiobooks
+            LEFT JOIN playlog ON playlog.item_id = audiobooks.item_id AND playlog.media_type = 'audiobook'
+            """  # noqa: E501
         # register (extra) api handlers
         api_base = self.api_base
         self.mass.register_api_command(f"music/{api_base}/audiobook_versions", self.versions)
@@ -139,6 +145,7 @@ class AudiobooksController(MediaControllerBase[Audiobook, Audiobook]):
         # update/set provider_mappings table
         await self._set_provider_mappings(db_id, item.provider_mappings)
         self.logger.debug("added %s to database (id: %s)", item.name, db_id)
+        await self._set_playlog(db_id, item)
         return db_id
 
     async def _update_library_item(
@@ -180,6 +187,7 @@ class AudiobooksController(MediaControllerBase[Audiobook, Audiobook]):
         # update/set provider_mappings table
         await self._set_provider_mappings(db_id, provider_mappings, overwrite)
         self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
+        await self._set_playlog(db_id, update)
 
     async def radio_mode_base_tracks(
         self,
@@ -250,3 +258,53 @@ class AudiobooksController(MediaControllerBase[Audiobook, Audiobook]):
                     db_audiobook.name,
                     provider.name,
                 )
+
+    async def _set_playlog(self, db_id: int, media_item: Audiobook) -> None:
+        """Update/set the playlog table for the given audiobook db item_id."""
+        # cleanup provider specific entries for this item
+        # we always prefer the library playlog entry
+        for prov_mapping in media_item.provider_mappings:
+            if not (provider := self.mass.get_provider(prov_mapping.provider_instance)):
+                continue
+            await self.mass.music.database.delete(
+                DB_TABLE_PLAYLOG,
+                {
+                    "media_type": self.media_type.value,
+                    "item_id": prov_mapping.item_id,
+                    "provider": provider.lookup_key,
+                },
+            )
+        if media_item.fully_played is None and media_item.resume_position_ms is None:
+            return
+        cur_entry = await self.mass.music.database.get_row(
+            DB_TABLE_PLAYLOG,
+            {
+                "media_type": self.media_type.value,
+                "item_id": db_id,
+                "provider": "library",
+            },
+        )
+        seconds_played = int(media_item.resume_position_ms or 0 / 1000)
+        # abort if nothing changed
+        if (
+            cur_entry
+            and cur_entry["fully_played"] == media_item.fully_played
+            and abs((cur_entry["seconds_played"] or 0) - seconds_played) > 2
+        ):
+            return
+        await self.mass.music.database.insert(
+            DB_TABLE_PLAYLOG,
+            {
+                "item_id": db_id,
+                "provider": "library",
+                "media_type": media_item.media_type.value,
+                "name": media_item.name,
+                "image": serialize_to_json(media_item.image.to_dict())
+                if media_item.image
+                else None,
+                "fully_played": media_item.fully_played,
+                "seconds_played": seconds_played,
+                "timestamp": utc_timestamp(),
+            },
+            allow_replace=True,
+        )
index 460141ebfcc247b37f27230b7eedfd81f73fa75b..18c8c066b650974945d7f0126b2c6dde4d0a1d74 100644 (file)
@@ -6,7 +6,7 @@ import asyncio
 from typing import TYPE_CHECKING, Any
 
 from music_assistant_models.enums import MediaType, ProviderFeature
-from music_assistant_models.errors import InvalidDataError
+from music_assistant_models.errors import InvalidDataError, MediaNotFoundError
 from music_assistant_models.media_items import Artist, Podcast, PodcastEpisode, UniqueList
 
 from music_assistant.constants import DB_TABLE_PLAYLOG, DB_TABLE_PODCASTS
@@ -102,9 +102,10 @@ class PodcastsController(MediaControllerBase[Podcast, Podcast]):
     ) -> UniqueList[PodcastEpisode]:
         """Return podcast episodes for the given provider podcast id."""
         # always check if we have a library item for this podcast
-        if library_podcast := await self.get_library_item_by_prov_id(
-            item_id, provider_instance_id_or_domain
-        ):
+        if provider_instance_id_or_domain == "library":
+            library_podcast = await self.get_library_item(item_id)
+            if not library_podcast:
+                raise MediaNotFoundError(f"Podcast {item_id} not found in library")
             for provider_mapping in library_podcast.provider_mappings:
                 item_id = provider_mapping.item_id
                 provider_instance_id_or_domain = provider_mapping.provider_instance
@@ -222,8 +223,10 @@ class PodcastsController(MediaControllerBase[Podcast, Podcast]):
 
         async def set_resume_position(episode: PodcastEpisode) -> None:
             if episode.fully_played is not None or episode.resume_position_ms:
+                # provider supports resume info, we can skip
                 return
-            # TODO: inject resume position info here for providers that do not natively provide it
+            # for providers that do not natively support providing resume info,
+            # we fallback to the playlog db table
             resume_info_db_row = await self.mass.music.database.get_row(
                 DB_TABLE_PLAYLOG,
                 {
index fb3c3576a3fe61910f50ca5d727114d96d582d90..a7e82e2beecd697b469585e55cbbf2517cc76cf8 100644 (file)
@@ -70,6 +70,7 @@ from .media.tracks import TracksController
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import CoreConfig
+    from music_assistant_models.media_items import Audiobook, PodcastEpisode
 
     from music_assistant.models.music_provider import MusicProvider
 
@@ -465,7 +466,35 @@ class MusicController(CoreController):
         media_types_str = "(" + ",".join(f'"{x}"' for x in media_types) + ")"
         query = (
             f"SELECT * FROM {DB_TABLE_PLAYLOG} "
-            f"WHERE media_type in {media_types_str} ORDER BY timestamp DESC"
+            f"WHERE media_type in {media_types_str} AND fully_played = 1 "
+            "ORDER BY timestamp DESC"
+        )
+        db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit)
+        result: list[ItemMapping] = []
+        available_providers = ("library", *get_global_cache_value("unique_providers", []))
+        for db_row in db_rows:
+            result.append(
+                ItemMapping.from_dict(
+                    {
+                        "item_id": db_row["item_id"],
+                        "provider": db_row["provider"],
+                        "media_type": db_row["media_type"],
+                        "name": db_row["name"],
+                        "image": json_loads(db_row["image"]) if db_row["image"] else None,
+                        "available": db_row["provider"] in available_providers,
+                    }
+                )
+            )
+        return result
+
+    @api_command("music/in_progress_items")
+    async def in_progress_items(self, limit: int = 10) -> list[ItemMapping]:
+        """Return a list of the Audiobooks and PodcastEpisodes that are in progress."""
+        query = (
+            f"SELECT * FROM {DB_TABLE_PLAYLOG} "
+            f"WHERE media_type in ('audiobook', 'podcast_episode') AND fully_played = 0 "
+            "AND seconds_played > 0 "
+            "ORDER BY timestamp DESC"
         )
         db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit)
         result: list[ItemMapping] = []
@@ -861,9 +890,47 @@ class MusicController(CoreController):
         ctrl = self.get_controller(media_item.media_type)
         db_item = await ctrl.get_library_item_by_prov_id(media_item.item_id, media_item.provider)
         if db_item:
-            await self.database.execute(f"UPDATE {ctrl.db_table} SET play_count = play_count - 1")
+            await self.database.update(
+                f"UPDATE {ctrl.db_table} SET play_count = play_count - 1, "
+                f"last_played = 0 WHERE item_id = {db_item.item_id}"
+            )
             await self.database.commit()
 
+    async def get_resume_position(self, media_item: Audiobook | PodcastEpisode) -> tuple[bool, int]:
+        """
+        Get progress (resume point) details for the given audiobook or episode.
+
+        This is a separate call to ensure the resume position is always up-to-date
+        and because many providers have this info present on a dedicated endpoint.
+
+        Will be called right before playback starts to ensure the resume position is correct.
+
+        Returns a boolean with the fully_played status
+        and an integer with the resume position in ms.
+        """
+        for prov_mapping in media_item.provider_mappings:
+            if not (music_prov := self.mass.get_provider(prov_mapping.provider_instance)):
+                continue
+            with suppress(NotImplementedError):
+                return await music_prov.get_resume_position(
+                    prov_mapping.item_id, media_item.media_type
+                )
+        # no provider info found, fallback to library playlog
+        if db_entry := await self.mass.music.database.get_row(
+            DB_TABLE_PLAYLOG,
+            {
+                "media_type": media_item.media_type.value,
+                "item_id": media_item.item_id,
+                "provider": media_item.provider,
+            },
+        ):
+            resume_position_ms = (
+                db_entry["seconds_played"] * 1000 if db_entry["seconds_played"] else 0
+            )
+            return (db_entry["fully_played"], resume_position_ms)
+
+        return (False, 0)
+
     def get_controller(
         self, media_type: MediaType
     ) -> (
index c6206eb72c7ea0ec0839e9e2f39ee3cd21005067..6dd05d7299456c4af5222317701082ba9f71c6b9 100644 (file)
@@ -17,7 +17,7 @@ import asyncio
 import random
 import time
 from types import NoneType
-from typing import TYPE_CHECKING, Any, TypedDict
+from typing import TYPE_CHECKING, Any, TypedDict, cast
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
 from music_assistant_models.enums import (
@@ -51,12 +51,7 @@ from music_assistant_models.player import PlayerMedia
 from music_assistant_models.player_queue import PlayerQueue
 from music_assistant_models.queue_item import QueueItem
 
-from music_assistant.constants import (
-    CONF_CROSSFADE,
-    CONF_FLOW_MODE,
-    DB_TABLE_PLAYLOG,
-    MASS_LOGO_ONLINE,
-)
+from music_assistant.constants import CONF_CROSSFADE, CONF_FLOW_MODE, MASS_LOGO_ONLINE
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.audio import get_stream_details, get_stream_dsp_details
 from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER
@@ -1427,31 +1422,8 @@ class PlayerQueuesController(CoreController):
             raise InvalidDataError(
                 f"Unable to resolve chapter to play for Audiobook {audio_book.name}"
             )
-        # prefer the resume point from the provider's item
-        for prov_mapping in audio_book.provider_mappings:
-            if not (provider := self.mass.get_provider(prov_mapping.provider_instance)):
-                continue
-            if provider_item := await provider.get_audiobook(prov_mapping.item_id):
-                if provider_item.fully_played:
-                    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": 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
+        full_played, resume_position_ms = await self.mass.music.get_resume_position(audio_book)
+        return 0 if full_played else resume_position_ms
 
     async def get_next_podcast_episodes(
         self, podcast: Podcast | None, episode: PodcastEpisode | str | None
@@ -1460,7 +1432,17 @@ class PlayerQueuesController(CoreController):
         if podcast is None and isinstance(episode, str | NoneType):
             raise InvalidDataError("Either podcast or episode must be provided")
         if podcast is None:
-            podcast = episode.podcast
+            # single podcast episode requested
+            self.logger.debug(
+                "Fetching resume point to play for Podcast episode %s",
+                episode.name,
+            )
+            episode = cast(PodcastEpisode, episode)
+            fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode)
+            episode.fully_played = fully_played
+            episode.resume_position_ms = 0 if fully_played else resume_position_ms
+            return [episode]
+        # podcast with optional start episode requested
         self.logger.debug(
             "Fetching episode(s) and resume point to play for Podcast %s",
             podcast.name,
@@ -1470,12 +1452,28 @@ class PlayerQueuesController(CoreController):
         # so we need to find the index of the episode in the list
         if isinstance(episode, PodcastEpisode):
             episode = next((x for x in all_episodes if x.uri == episode.uri), None)
+            # ensure we have accurate resume info
+            fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode)
+            episode.resume_position_ms = 0 if fully_played else resume_position_ms
         elif isinstance(episode, str):
             episode = next((x for x in all_episodes if episode in (x.uri, x.item_id)), None)
+            # ensure we have accurate resume info
+            fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode)
+            episode.resume_position_ms = 0 if fully_played else resume_position_ms
         else:
             # get first episode that is not fully played
-            episode = next((x for x in all_episodes if not x.fully_played), None)
-            if episode is None:
+            for episode in all_episodes:
+                if episode.fully_played:
+                    continue
+                # ensure we have accurate resume info
+                fully_played, resume_position_ms = await self.mass.music.get_resume_position(
+                    episode
+                )
+                if fully_played:
+                    continue
+                episode.resume_position_ms = resume_position_ms
+                break
+            else:
                 # no episodes found that are not fully played, so we start at the beginning
                 episode = next((x for x in all_episodes), None)
         if episode is None:
@@ -1571,12 +1569,14 @@ class PlayerQueuesController(CoreController):
             self.mass.create_task(self.mass.music.mark_item_played(media_item))
             return await self.get_album_tracks(media_item, start_item)
         if media_item.media_type == MediaType.AUDIOBOOK:
-            if resume_point := await self.get_audiobook_resume_point(media_item, start_item):
-                media_item.resume_position_ms = resume_point
+            # ensure we grab the correct/latest resume point info
+            media_item.resume_position_ms = await self.get_audiobook_resume_point(
+                media_item, start_item
+            )
             return [media_item]
         if media_item.media_type == MediaType.PODCAST:
             self.mass.create_task(self.mass.music.mark_item_played(media_item))
-            return await self.get_next_podcast_episodes(media_item, start_item or media_item)
+            return await self.get_next_podcast_episodes(media_item, start_item)
         if media_item.media_type == MediaType.PODCAST_EPISODE:
             return await self.get_next_podcast_episodes(None, media_item)
         # all other: single track or radio item
index a56f3126d29a12a89903c93a49d4ba06676cdc47..8fca1d1fcfba373d1b0c68218319d6063de7ce63 100644 (file)
@@ -310,6 +310,21 @@ class MusicProvider(Provider):
         if ProviderFeature.SIMILAR_TRACKS in self.supported_features:
             raise NotImplementedError
 
+    async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:
+        """
+        Get progress (resume point) details for the given Audiobook or Podcast episode.
+
+        This is a separate call from the regular get_item call to ensure the resume position
+        is always up-to-date and because a lot providers have this info present on a dedicated
+        endpoint.
+
+        Will be called right before playback starts to ensure the resume position is correct.
+
+        Returns a boolean with the fully_played status
+        and an integer with the resume position in ms.
+        """
+        raise NotImplementedError
+
     async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
         """Get streamdetails for a track/radio/chapter/episode."""
         raise NotImplementedError
@@ -631,27 +646,20 @@ class MusicProvider(Provider):
                         library_item = await controller.update_item_in_library(
                             library_item.item_id, prov_item
                         )
+                    # check if resume_position_ms or fully_played changed (audiobook only)
+                    resume_pos_prov = getattr(prov_item, "resume_position_ms", None)
+                    fully_played_prov = getattr(prov_item, "fully_played", None)
                     if (
-                        getattr(library_item, "resume_position_ms", None)
-                        != (resume_pos_prov := getattr(prov_item, "resume_position_ms", None))
-                        and resume_pos_prov is not None
-                    ):
-                        # resume_position_ms changed (audiobook only)
-                        library_item.resume_position_ms = resume_pos_prov
-                        library_item = await controller.update_item_in_library(
-                            library_item.item_id, prov_item
-                        )
-                    if (
-                        getattr(library_item, "fully_played", None)
-                        != (fully_played_prov := getattr(prov_item, "fully_played", None))
+                        resume_pos_prov is not None
                         and fully_played_prov is not None
+                        and (
+                            getattr(library_item, "resume_position_ms", None) != resume_pos_prov
+                            or getattr(library_item, "fully_played", None) != fully_played_prov
+                        )
                     ):
-                        # fully_played changed (audiobook only)
-                        library_item.fully_played = fully_played_prov
                         library_item = await controller.update_item_in_library(
                             library_item.item_id, prov_item
                         )
-                    cur_db_ids.add(library_item.item_id)
                     await asyncio.sleep(0)  # yield to eventloop
                 except MusicAssistantError as err:
                     self.logger.warning(
index ccdf7dd0ccce8261d9ebc0c81ddc2272cc9e9993..9f6fdfc3d874b567b22ba9f6bad226147538fca1 100644 (file)
@@ -354,7 +354,7 @@ class MyDemoMusicprovider(MusicProvider):
     ) -> None:
         """Remove track(s) from playlist."""
         # Remove track(s) from a playlist.
-        # This is only called if the provider supports the EDPLAYLIST_TRACKS_EDITIT feature.
+        # This is only called if the provider supports the PLAYLIST_TRACKS_EDIT feature.
 
     async def create_playlist(self, name: str) -> Playlist:  # type: ignore[empty-body]
         """Create a new playlist on provider with given name."""
@@ -368,6 +368,22 @@ class MyDemoMusicprovider(MusicProvider):
         # Get a list of similar tracks based on the provided track.
         # This is only called if the provider supports the SIMILAR_TRACKS feature.
 
+    async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:  # type: ignore[empty-body]
+        """
+        Get progress (resume point) details for the given Audiobook or Podcast episode.
+
+        This is a separate call from the regular get_item call to ensure the resume position
+        is always up-to-date and because a lot providers have this info present on a dedicated
+        endpoint.
+
+        Will be called right before playback starts to ensure the resume position is correct.
+
+        Returns a boolean with the fully_played status
+        and an integer with the resume position in ms.
+        """
+        # optional function to get the resume position of a audiobook or podcast episode
+        # only implement this if your provider supports providing this information
+
     async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
         """Get streamdetails for a track/radio."""
         # Get stream details for a track or radio.