Refactor playlog and item progress reporting
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 14 Jan 2025 23:22:44 +0000 (00:22 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 14 Jan 2025 23:22:44 +0000 (00:22 +0100)
13 files changed:
music_assistant/controllers/music.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/players.py
music_assistant/helpers/audio.py
music_assistant/models/music_provider.py
music_assistant/models/plugin.py
music_assistant/providers/_template_music_provider/__init__.py
music_assistant/providers/audible/__init__.py
music_assistant/providers/builtin/__init__.py
music_assistant/providers/deezer/__init__.py
music_assistant/providers/opensubsonic/sonic_provider.py
music_assistant/providers/plex/__init__.py
music_assistant/providers/qobuz/__init__.py

index b53bcb887aa52a92ac4bfc80ea4e67f758cd1c9d..b880267ffd2a88dcc92c463495a0913ef41194da 100644 (file)
@@ -25,7 +25,6 @@ from music_assistant_models.errors import (
     InvalidProviderURI,
     MediaNotFoundError,
     MusicAssistantError,
-    ProviderUnavailableError,
 )
 from music_assistant_models.helpers import get_global_cache_value
 from music_assistant_models.media_items import (
@@ -79,7 +78,7 @@ DEFAULT_SYNC_INTERVAL = 12 * 60  # default sync interval in minutes
 CONF_SYNC_INTERVAL = "sync_interval"
 CONF_DELETED_PROVIDERS = "deleted_providers"
 CONF_ADD_LIBRARY_ON_PLAY = "add_library_on_play"
-DB_SCHEMA_VERSION: Final[int] = 14
+DB_SCHEMA_VERSION: Final[int] = 15
 
 
 class MusicController(CoreController):
@@ -458,30 +457,31 @@ class MusicController(CoreController):
     @api_command("music/recently_played_items")
     async def recently_played(
         self, limit: int = 10, media_types: list[MediaType] | None = None
-    ) -> list[MediaItemType]:
+    ) -> list[ItemMapping]:
         """Return a list of the last played items."""
         if media_types is None:
             media_types = MediaType.ALL
         media_types_str = "(" + ",".join(f'"{x}"' for x in media_types) + ")"
-        # temporary fix to avoid too many queries on providers:
-        # we only query for library items for now
         query = (
-            f"SELECT * FROM {DB_TABLE_PLAYLOG} WHERE provider = 'library' "
-            f"AND media_type in {media_types_str} ORDER BY timestamp DESC"
+            f"SELECT * FROM {DB_TABLE_PLAYLOG} "
+            f"WHERE media_type in {media_types_str} ORDER BY timestamp DESC"
         )
         db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit)
-        result: list[MediaItemType] = []
+        result: list[ItemMapping] = []
+        available_providers = ("library", *get_global_cache_value("unique_providers", []))
         for db_row in db_rows:
-            if db_row["provider"] not in get_global_cache_value("unique_providers", []):
-                continue
-            with suppress(MediaNotFoundError, ProviderUnavailableError):
-                media_type = MediaType(db_row["media_type"])
-                ctrl = self.get_controller(media_type)
-                item = await ctrl.get(
-                    db_row["item_id"],
-                    db_row["provider"],
+            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,
+                    }
                 )
-                result.append(item)
+            )
         return result
 
     @api_command("music/item_by_uri")
@@ -764,37 +764,31 @@ class MusicController(CoreController):
     @api_command("music/mark_played")
     async def mark_item_played(
         self,
-        media_type: MediaType,
-        item_id: str,
-        provider_instance_id_or_domain: str,
+        media_item: MediaItemType | ItemMapping,
         fully_played: bool | None = None,
         seconds_played: int | None = None,
     ) -> None:
         """Mark item as played in playlog."""
         timestamp = utc_timestamp()
-
         if (
-            provider_instance_id_or_domain.startswith("builtin")
-            and media_type != MediaType.PLAYLIST
+            media_item.provider.startswith("builtin")
+            and media_item.media_type != MediaType.PLAYLIST
         ):
             # we deliberately skip builtin provider items as those are often
             # one-off items like TTS or some sound effect etc.
             return
 
-        if provider_instance_id_or_domain == "library":
-            prov_key = "library"
-        elif prov := self.mass.get_provider(provider_instance_id_or_domain):
-            prov_key = prov.lookup_key
-        else:
-            prov_key = provider_instance_id_or_domain
-
         # update generic playlog table
         await self.database.insert(
             DB_TABLE_PLAYLOG,
             {
-                "item_id": item_id,
-                "provider": prov_key,
-                "media_type": media_type.value,
+                "item_id": media_item.item_id,
+                "provider": media_item.provider,
+                "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": fully_played,
                 "seconds_played": seconds_played,
                 "timestamp": timestamp,
@@ -802,19 +796,30 @@ class MusicController(CoreController):
             allow_replace=True,
         )
 
+        # forward to provider(s) to sync resume state (e.g. for audiobooks)
+        for prov_mapping in media_item.provider_mappings:
+            if music_prov := self.mass.get_provider(prov_mapping.provider_instance):
+                self.mass.create_task(
+                    music_prov.on_played(
+                        media_type=media_item.media_type,
+                        item_id=prov_mapping.item_id,
+                        fully_played=False,
+                        position=0,
+                    )
+                )
+
         # also update playcount in library table
-        if not (ctrl := self.get_controller(media_type)):
+        if not (ctrl := self.get_controller(media_item.media_type)):
             # skip non media items (e.g. plugin source)
             return
-        db_item = await ctrl.get_library_item_by_prov_id(item_id, provider_instance_id_or_domain)
+        db_item = await ctrl.get_library_item_by_prov_id(media_item.item_id, media_item.provider)
         if (
             not db_item
-            and media_type in (MediaType.TRACK, MediaType.RADIO)
+            and media_item.media_type in (MediaType.TRACK, MediaType.RADIO)
             and self.mass.config.get_raw_core_config_value(self.domain, CONF_ADD_LIBRARY_ON_PLAY)
         ):
             # handle feature to add to the lib on playback
-            full_item = await ctrl.get(item_id, provider_instance_id_or_domain)
-            db_item = await ctrl.add_item_to_library(full_item)
+            db_item = await self.add_item_to_library(media_item)
 
         if db_item:
             await self.database.execute(
@@ -825,27 +830,33 @@ class MusicController(CoreController):
 
     @api_command("music/mark_unplayed")
     async def mark_item_unplayed(
-        self, media_type: MediaType, item_id: str, provider_instance_id_or_domain: str
+        self,
+        media_item: MediaItemType | ItemMapping,
     ) -> None:
         """Mark item as unplayed in playlog."""
-        if provider_instance_id_or_domain == "library":
-            prov_key = "library"
-        elif prov := self.mass.get_provider(provider_instance_id_or_domain):
-            prov_key = prov.lookup_key
-        else:
-            prov_key = provider_instance_id_or_domain
         # update generic playlog table
         await self.database.delete(
             DB_TABLE_PLAYLOG,
             {
-                "item_id": item_id,
-                "provider": prov_key,
-                "media_type": media_type.value,
+                "item_id": media_item.item_id,
+                "provider": media_item.provider,
+                "media_type": media_item.media_type.value,
             },
         )
+        # forward to provider(s) to sync resume state (e.g. for audiobooks)
+        for prov_mapping in media_item.provider_mappings:
+            if music_prov := self.mass.get_provider(prov_mapping.provider_instance):
+                self.mass.create_task(
+                    music_prov.on_played(
+                        media_type=media_item.media_type,
+                        item_id=prov_mapping.item_id,
+                        fully_played=False,
+                        position=0,
+                    )
+                )
         # also update playcount in library table
-        ctrl = self.get_controller(media_type)
-        db_item = await ctrl.get_library_item_by_prov_id(item_id, provider_instance_id_or_domain)
+        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.commit()
@@ -1160,73 +1171,8 @@ class MusicController(CoreController):
             "Migrating database from version %s to %s", prev_version, DB_SCHEMA_VERSION
         )
 
-        if prev_version <= 6:
-            # unhandled schema version
-            # we do not try to handle more complex migrations
-            self.logger.warning(
-                "Database schema too old - Resetting library/database - "
-                "a full rescan will be performed, this can take a while!"
-            )
-            for table in (
-                DB_TABLE_TRACKS,
-                DB_TABLE_ALBUMS,
-                DB_TABLE_ARTISTS,
-                DB_TABLE_PLAYLISTS,
-                DB_TABLE_RADIOS,
-                DB_TABLE_AUDIOBOOKS,
-                DB_TABLE_PODCASTS,
-                DB_TABLE_ALBUM_TRACKS,
-                DB_TABLE_PLAYLOG,
-                DB_TABLE_PROVIDER_MAPPINGS,
-            ):
-                await self.database.execute(f"DROP TABLE IF EXISTS {table}")
-            await self.database.commit()
-            # recreate missing tables
-            await self.__create_database_tables()
-            return
-
-        if prev_version <= 7:
-            # remove redundant artists and provider_mappings columns
-            for table in (
-                DB_TABLE_TRACKS,
-                DB_TABLE_ALBUMS,
-                DB_TABLE_ARTISTS,
-                DB_TABLE_RADIOS,
-                DB_TABLE_PLAYLISTS,
-            ):
-                for column in ("artists", "provider_mappings"):
-                    try:
-                        await self.database.execute(f"ALTER TABLE {table} DROP COLUMN {column}")
-                    except Exception as err:
-                        if "no such column" in str(err):
-                            continue
-                        raise
-            # add cache_checksum column to playlists
-            try:
-                await self.database.execute(
-                    f"ALTER TABLE {DB_TABLE_PLAYLISTS} ADD COLUMN cache_checksum TEXT DEFAULT ''"
-                )
-            except Exception as err:
-                if "duplicate column" not in str(err):
-                    raise
-
-        if prev_version <= 8:
-            # migrate track_loudness --> loudness_measurements
-            async for db_row in self.database.iter_items("track_loudness"):
-                if db_row["integrated"] == inf or db_row["integrated"] == -inf:
-                    continue
-                if db_row["provider"] in ("radiobrowser", "tunein"):
-                    continue
-                await self.database.insert_or_replace(
-                    DB_TABLE_LOUDNESS_MEASUREMENTS,
-                    {
-                        "item_id": db_row["item_id"],
-                        "media_type": "track",
-                        "provider": db_row["provider"],
-                        "loudness": db_row["integrated"],
-                    },
-                )
-            await self.database.execute("DROP TABLE IF EXISTS track_loudness")
+        if prev_version <= 9:
+            raise MusicAssistantError("Database schema version too old to migrate")
 
         if prev_version <= 10:
             # add new columns to playlog table
@@ -1261,6 +1207,11 @@ class MusicController(CoreController):
                     {"metadata": serialize_to_json(metadata)},
                 )
 
+        if prev_version <= 14:
+            # Recreate playlog table due to complete new layout
+            await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_PLAYLOG}")
+            await self.__create_database_tables()
+
         # save changes
         await self.database.commit()
 
@@ -1288,7 +1239,9 @@ class MusicController(CoreController):
                 [id] INTEGER PRIMARY KEY AUTOINCREMENT,
                 [item_id] TEXT NOT NULL,
                 [provider] TEXT NOT NULL,
-                [media_type] TEXT NOT NULL DEFAULT 'track',
+                [media_type] TEXT NOT NULL,
+                [name] TEXT NOT NULL,
+                [image] json,
                 [timestamp] INTEGER DEFAULT 0,
                 [fully_played] BOOLEAN,
                 [seconds_played] INTEGER,
index b0eefbe7d416629c80019961da64dc83a413a75d..82cbc9a5c862961ea3cdf3fb77ea7ddbc977b4b7 100644 (file)
@@ -1013,25 +1013,20 @@ class PlayerQueuesController(CoreController):
             and (prev_item := self.get_item(queue_id, prev_item_id))
             and (stream_details := prev_item.streamdetails)
         ):
-            seconds_played = int(prev_state["elapsed_time"])
-            fully_played = seconds_played >= (stream_details.duration or 3600) - 5
+            position = int(prev_state["elapsed_time"])
+            seconds_played = position - stream_details.seek_position
+            fully_played = position >= (stream_details.duration or 3600) - 5
             self.logger.debug(
                 "PlayerQueue %s played item %s for %s seconds",
                 queue.display_name,
                 prev_item.uri,
                 seconds_played,
             )
-            if music_prov := self.mass.get_provider(stream_details.provider):
-                self.mass.create_task(
-                    music_prov.on_streamed(stream_details, seconds_played, fully_played)
-                )
             if prev_item.media_item and (fully_played or seconds_played > 10):
                 # add entry to playlog - this also handles resume of podcasts/audiobooks
                 self.mass.create_task(
                     self.mass.music.mark_item_played(
-                        stream_details.media_type,
-                        stream_details.item_id,
-                        stream_details.provider,
+                        prev_item.media_item,
                         fully_played=fully_played,
                         seconds_played=seconds_played,
                     )
@@ -1557,36 +1552,20 @@ class PlayerQueuesController(CoreController):
     ) -> list[MediaItemType]:
         """Resolve/unwrap media items to enqueue."""
         if media_item.media_type == MediaType.PLAYLIST:
-            self.mass.create_task(
-                self.mass.music.mark_item_played(
-                    media_item.media_type, media_item.item_id, media_item.provider
-                )
-            )
+            self.mass.create_task(self.mass.music.mark_item_played(media_item))
             return await self.get_playlist_tracks(media_item, start_item)
         if media_item.media_type == MediaType.ARTIST:
-            self.mass.create_task(
-                self.mass.music.mark_item_played(
-                    media_item.media_type, media_item.item_id, media_item.provider
-                )
-            )
+            self.mass.create_task(self.mass.music.mark_item_played(media_item))
             return await self.get_artist_tracks(media_item)
         if media_item.media_type == MediaType.ALBUM:
-            self.mass.create_task(
-                self.mass.music.mark_item_played(
-                    media_item.media_type, media_item.item_id, media_item.provider
-                )
-            )
+            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
             return [media_item]
         if media_item.media_type == MediaType.PODCAST:
-            self.mass.create_task(
-                self.mass.music.mark_item_played(
-                    media_item.media_type, media_item.item_id, media_item.provider
-                )
-            )
+            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)
         if media_item.media_type == MediaType.PODCAST_EPISODE:
             return await self.get_next_podcast_episodes(None, media_item)
index 4c3a45d9d5527b90332f4a637d714b3c119d49a6..ff82ab57f26269b9f9266f504e995c1d8742d05b 100644 (file)
@@ -197,6 +197,11 @@ class PlayerController(CoreController):
         - player_id: player_id of the player to handle the command.
         """
         player = self._get_player_with_redirect(player_id)
+        if player.state == PlayerState.PLAYING:
+            self.logger.info(
+                "Ignore PLAY request to player %s: player is already playing", player.display_name
+            )
+            return
         # Redirect to queue controller if it is active
         active_source = player.active_source or player.player_id
         if (active_queue := self.mass.player_queues.get(active_source)) and active_queue.items:
index 47e9ec704d25d6f8f95fbb60b6251e6c995c457a..83b8052023198441c2e430f15f15685eb9b9797d 100644 (file)
@@ -436,6 +436,12 @@ async def get_media_stream(
                 task_id = f"analyze_loudness_{streamdetails.uri}"
                 mass.create_task(analyze_loudness, mass, streamdetails, task_id=task_id)
 
+        # report stream to provider
+        if (finished or seconds_streamed >= 30) and (
+            music_prov := mass.get_provider(streamdetails.provider)
+        ):
+            mass.create_task(music_prov.on_streamed(streamdetails))
+
 
 def create_wave_header(samplerate=44100, channels=2, bitspersample=16, duration=None):
     """Generate a wave header from given params."""
index 445a754aada3ed29db44495a5d9540a671d68993..785645ffa28df04a272bb3d0d7707817dd6038f6 100644 (file)
@@ -331,10 +331,38 @@ class MusicProvider(Provider):
     async def on_streamed(
         self,
         streamdetails: StreamDetails,
-        seconds_streamed: int,
-        fully_played: bool = False,
     ) -> None:
-        """Handle callback when an item completed streaming."""
+        """
+        Handle callback when given streamdetails completed streaming.
+
+        To get the number of seconds streamed, see streamdetails.seconds_streamed.
+        To get the number of seconds seeked/skipped, see streamdetails.seek_position.
+        Note that seconds_streamed is the total streamed seconds, so without seeked time.
+
+        NOTE: Due to internal and player buffering,
+        this may be called in advance of the actual completion.
+        """
+
+    async def on_played(
+        self,
+        media_type: MediaType,
+        item_id: str,
+        fully_played: bool,
+        position: int,
+    ) -> None:
+        """
+        Handle callback when a (playable) media item has been played.
+
+        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
+
+        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.
+        When fully_played is set to false and position is 0,
+        the user marked the item as unplayed in the UI.
+        """
 
     async def resolve_image(self, path: str) -> str | bytes:
         """
index 0060082e6cb21b19616c84b463ecbfce792f84e6..5978cfa9996ccf8b9f19a677196636ae1d0017b3 100644 (file)
@@ -55,7 +55,14 @@ class PluginProvider(Provider):
     async def on_streamed(
         self,
         streamdetails: StreamDetails,
-        seconds_streamed: int,
-        fully_played: bool = False,
     ) -> None:
-        """Handle callback when an item completed streaming."""
+        """
+        Handle callback when given streamdetails completed streaming.
+
+        To get the number of seconds streamed, see streamdetails.seconds_streamed.
+        To get the number of seconds seeked/skipped, see streamdetails.seek_position.
+        Note that seconds_streamed is the total streamed seconds, so without seeked time.
+
+        NOTE: Due to internal and player buffering,
+        this may be called in advance of the actual completion.
+        """
index 4ca9064737f42cbaaa0f9b7213c18a5191ed8741..2e3226d5dead476e35197c8b76b065dd65450cf0 100644 (file)
@@ -413,11 +413,41 @@ class MyDemoMusicprovider(MusicProvider):
     async def on_streamed(
         self,
         streamdetails: StreamDetails,
-        seconds_streamed: int,
-        fully_played: bool = False,
     ) -> None:
-        """Handle callback when an item completed streaming."""
-        # This is OPTIONAL callback that is called when an item has been streamed.
+        """
+        Handle callback when given streamdetails completed streaming.
+
+        To get the number of seconds streamed, see streamdetails.seconds_streamed.
+        To get the number of seconds seeked/skipped, see streamdetails.seek_position.
+        Note that seconds_streamed is the total streamed seconds, so without seeked time.
+
+        NOTE: Due to internal and player buffering,
+        this may be called in advance of the actual completion.
+        """
+        # This is an OPTIONAL callback that is called when an item has been streamed.
+        # You can use this e.g. for playback reporting or statistics.
+
+    async def on_played(
+        self,
+        media_type: MediaType,
+        item_id: str,
+        fully_played: bool,
+        position: int,
+    ) -> None:
+        """
+        Handle callback when a (playable) media item has been played.
+
+        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
+
+        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.
+        When fully_played is set to false and position is 0,
+        the user marked the item as unplayed in the UI.
+        """
+        # This is an OPTIONAL callback that is called when an item has been streamed.
         # You can use this e.g. for playback reporting or statistics.
 
     async def resolve_image(self, path: str) -> str | bytes:
index 5e3467fc19c0f3357390b2198b5395205fd9720d..4d4d3c351799c67e9ddb94abc89430b3070bc803 100644 (file)
@@ -280,14 +280,27 @@ class Audibleprovider(MusicProvider):
         """Get streamdetails for a audiobook based of asin."""
         return await self.helper.get_stream(asin=item_id)
 
-    async def on_streamed(
+    async def on_played(
         self,
-        streamdetails: StreamDetails,
-        seconds_streamed: int,
-        fully_played: bool = False,
+        media_type: MediaType,
+        item_id: str,
+        fully_played: bool,
+        position: int,
     ) -> None:
-        """Handle callback when an item completed streaming."""
-        await self.helper.set_last_position(streamdetails.item_id, seconds_streamed)
+        """
+        Handle callback when a (playable) media item has been played.
+
+        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
+
+        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.
+        When fully_played is set to false and position is 0,
+        the user marked the item as unplayed in the UI.
+        """
+        await self.helper.set_last_position(item_id, position)
 
     async def unload(self, is_removed: bool = False) -> None:
         """
index 1786e35e2499d7a0956f34202103bf3ba6a69b71..4c47536ace953801912fe2e75bfa076dcb2769a6 100644 (file)
@@ -617,8 +617,23 @@ class BuiltinProvider(MusicProvider):
     async def _get_builtin_playlist_recently_played(self) -> list[Track]:
         result: list[Track] = []
         recent_tracks = await self.mass.music.recently_played(100, [MediaType.TRACK])
-        for idx, track in enumerate(recent_tracks, 1):
-            assert isinstance(track, Track)
+        for idx, item in enumerate(recent_tracks, 1):
+            if not (item_provider := self.mass.get_provider(item.provider)):
+                continue
+            track = Track(
+                item_id=item.item_id,
+                provider=item.provider,
+                name=item.name,
+                provider_mappings={
+                    ProviderMapping(
+                        item_id=item.item_id,
+                        provider_domain=item_provider.domain,
+                        provider_instance=item_provider.instance_id,
+                    )
+                },
+            )
+            if item.image:
+                track.metadata.add_image(item.image)
             track.position = idx
             result.append(track)
         return result
index b50a78125adb730b189b33c2cafced3709bc8c85..057c4fd5f4938f777c55401e3a558b8d780cfca7 100644 (file)
@@ -11,11 +11,7 @@ import deezer
 from aiohttp import ClientSession, ClientTimeout
 from Crypto.Cipher import Blowfish
 from deezer import exceptions as deezer_exceptions
-from music_assistant_models.config_entries import (
-    ConfigEntry,
-    ConfigValueType,
-    ProviderConfig,
-)
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
 from music_assistant_models.enums import (
     AlbumType,
     ConfigEntryType,
@@ -495,8 +491,6 @@ class DeezerProvider(MusicProvider):
     async def on_streamed(
         self,
         streamdetails: StreamDetails,
-        seconds_streamed: int,
-        fully_played: bool = False,
     ) -> None:
         """Handle callback when an item completed streaming."""
         await self.gw_client.log_listen(last_track=streamdetails)
index 9b9207cfc4fe819c18feee5c623a700e23d9eb69..9ba6e1f2d3034153abd43dc503971bfd10fcb0e3 100644 (file)
@@ -786,17 +786,28 @@ class OpenSonicProvider(MusicProvider):
         self.logger.debug("scrobble for now playing called for %s", item_id)
         await self._run_async(self._conn.scrobble, sid=item_id, submission=False)
 
-    async def on_streamed(
+    async def on_played(
         self,
-        streamdetails: StreamDetails,
-        seconds_streamed: int,
-        fully_played: bool = False,
+        media_type: MediaType,
+        item_id: str,
+        fully_played: bool,
+        position: int,
     ) -> None:
-        """Handle callback when an item completed streaming."""
-        self.logger.debug("on_streamed called for %s", streamdetails.item_id)
-        if streamdetails.duration and seconds_streamed >= streamdetails.duration / 2:
-            self.logger.debug("scrobble for listen count called for %s", streamdetails.item_id)
-            await self._run_async(self._conn.scrobble, sid=streamdetails.item_id, submission=True)
+        """
+        Handle callback when a (playable) media item has been played.
+
+        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
+
+        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.
+        When fully_played is set to false and position is 0,
+        the user marked the item as unplayed in the UI.
+        """
+        self.logger.debug("scrobble for listen count called for %s", item_id)
+        await self._run_async(self._conn.scrobble, sid=item_id, submission=True)
 
     async def get_audio_stream(
         self, streamdetails: StreamDetails, seek_position: int = 0
index e43208c0efa99688b99693a9ec4fa54fbf7ab8b4..cb28fb77528c59073d5fed7a1fbd15f013fe8db7 100644 (file)
@@ -940,8 +940,6 @@ class PlexProvider(MusicProvider):
     async def on_streamed(
         self,
         streamdetails: StreamDetails,
-        seconds_streamed: int,
-        fully_played: bool = False,
     ) -> None:
         """Handle callback when an item completed streaming."""
 
index 59571d77ccae2a5f7aff30f062683a2503d5d812..6207f9db1d221d56e4d6ecb1d9021e4c62301b73 100644 (file)
@@ -47,10 +47,7 @@ from music_assistant.constants import (
 )
 from music_assistant.helpers.app_vars import app_var
 from music_assistant.helpers.json import json_loads
-from music_assistant.helpers.throttle_retry import (
-    ThrottlerManager,
-    throttle_with_retries,
-)
+from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries
 from music_assistant.helpers.util import lock, parse_title_and_version, try_parse_int
 from music_assistant.models.music_provider import MusicProvider
 
@@ -479,8 +476,6 @@ class QobuzProvider(MusicProvider):
     async def on_streamed(
         self,
         streamdetails: StreamDetails,
-        seconds_streamed: int,
-        fully_played: bool = False,
     ) -> None:
         """Handle callback when an item completed streaming."""
         user_id = self._user_auth_info["user"]["id"]
@@ -489,7 +484,7 @@ class QobuzProvider(MusicProvider):
                 "/track/reportStreamingEnd",
                 user_id=user_id,
                 track_id=str(streamdetails.item_id),
-                duration=try_parse_int(seconds_streamed),
+                duration=try_parse_int(streamdetails.seconds_streamed),
             )
 
     def _parse_artist(self, artist_obj: dict):