Add user tracking to playlog
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 29 Nov 2025 03:10:59 +0000 (04:10 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 29 Nov 2025 03:10:59 +0000 (04:10 +0100)
music_assistant/controllers/music.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/webserver/auth.py
music_assistant/controllers/webserver/controller.py

index 41b3d0e02c2ef4b193d7de3882c1d136f519fa51..b407b9a12216e0fe6b32cbf1b68796ce2a636678 100644 (file)
@@ -93,7 +93,7 @@ CONF_RESET_DB = "reset_db"
 DEFAULT_SYNC_INTERVAL = 12 * 60  # default sync interval in minutes
 CONF_SYNC_INTERVAL = "sync_interval"
 CONF_DELETED_PROVIDERS = "deleted_providers"
-DB_SCHEMA_VERSION: Final[int] = 21
+DB_SCHEMA_VERSION: Final[int] = 22
 
 CACHE_CATEGORY_LAST_SYNC: Final[int] = 9
 
@@ -466,7 +466,7 @@ class MusicController(CoreController):
 
     @api_command("music/recently_played_items")
     async def recently_played(
-        self, limit: int = 10, media_types: list[MediaType] | None = None
+        self, limit: int = 10, media_types: list[MediaType] | None = None, all_users: bool = True
     ) -> list[ItemMapping]:
         """Return a list of the last played items."""
         if media_types is None:
@@ -478,8 +478,10 @@ class MusicController(CoreController):
             f"SELECT * FROM {DB_TABLE_PLAYLOG} "
             f"WHERE media_type in {media_types_str} AND fully_played = 1 "
             f"AND provider in {available_providers_str} "
-            "ORDER BY timestamp DESC"
         )
+        if not all_users and (user := get_current_user()):
+            query += f"AND userid = '{user.user_id}' "
+        query += "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("available_providers", []))
@@ -513,7 +515,9 @@ class MusicController(CoreController):
         return await self.tracks.library_items(limit=limit, order_by="timestamp_added_desc")
 
     @api_command("music/in_progress_items")
-    async def in_progress_items(self, limit: int = 10) -> list[ItemMapping]:
+    async def in_progress_items(
+        self, limit: int = 10, all_users: bool = False
+    ) -> list[ItemMapping]:
         """Return a list of the Audiobooks and PodcastEpisodes that are in progress."""
         available_providers = ("library", *get_global_cache_value("unique_providers", []))
         available_providers_str = "(" + ",".join(f'"{x}"' for x in available_providers) + ")"
@@ -522,8 +526,11 @@ class MusicController(CoreController):
             f"WHERE media_type in ('audiobook', 'podcast_episode') AND fully_played = 0 "
             f"AND provider in {available_providers_str} "
             "AND seconds_played > 0 "
-            "ORDER BY timestamp DESC"
         )
+        if not all_users and (user := get_current_user()):
+            query += f"AND userid = '{user.user_id}' "
+
+        query += "ORDER BY timestamp DESC"
         db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit)
         result: list[ItemMapping] = []
 
@@ -981,8 +988,19 @@ class MusicController(CoreController):
         fully_played: bool = True,
         seconds_played: int | None = None,
         is_playing: bool = False,
+        userid: str | None = None,
+        all_users: bool = False,
     ) -> None:
-        """Mark item as played in playlog."""
+        """
+        Mark item as played in playlog.
+
+        :param media_item: The media item to mark as played.
+        :param fully_played: If True, mark the item as fully played.
+        :param seconds_played: The number of seconds played.
+        :param is_playing: If True, the item is currently playing.
+        :param userid: The user ID to mark the item as played for (instead of the current user).
+        :param all_users: If True, mark the item as played for all users.
+        """
         timestamp = utc_timestamp()
         if (
             media_item.provider.startswith("builtin")
@@ -992,22 +1010,28 @@ class MusicController(CoreController):
             # one-off items like TTS or some sound effect etc.
             return
 
+        params = {
+            "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,
+        }
+        if not all_users:
+            if not userid:
+                current_user = get_current_user()
+                userid = current_user.user_id if current_user else None
+            if userid:
+                params["userid"] = userid
+
         # update generic playlog table (when not playing)
         if not is_playing:
             await self.database.insert(
                 DB_TABLE_PLAYLOG,
-                {
-                    "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,
-                },
+                params,
                 allow_replace=True,
             )
 
@@ -1043,17 +1067,24 @@ class MusicController(CoreController):
     async def mark_item_unplayed(
         self,
         media_item: MediaItemType,
+        all_users: bool = False,
     ) -> None:
-        """Mark item as unplayed in playlog."""
-        # update generic playlog table
-        await self.database.delete(
-            DB_TABLE_PLAYLOG,
-            {
-                "item_id": media_item.item_id,
-                "provider": media_item.provider,
-                "media_type": media_item.media_type.value,
-            },
-        )
+        """
+        Mark item as unplayed in playlog.
+
+        :param media_item: The media item to mark as unplayed.
+        :param all_users: If True, mark the item as unplayed for all users.
+        """
+        current_user = get_current_user()
+        user_id = current_user.user_id if current_user else "system"
+        params = {
+            "item_id": media_item.item_id,
+            "provider": media_item.provider,
+            "media_type": media_item.media_type.value,
+        }
+        if not all_users:
+            params["userid"] = user_id
+        await self.database.delete(DB_TABLE_PLAYLOG, params)
         # 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):
@@ -1155,7 +1186,9 @@ class MusicController(CoreController):
         # no match found
         return None
 
-    async def get_resume_position(self, media_item: Audiobook | PodcastEpisode) -> tuple[bool, int]:
+    async def get_resume_position(
+        self, media_item: Audiobook | PodcastEpisode, userid: str | None = None
+    ) -> tuple[bool, int]:
         """
         Get progress (resume point) details for the given audiobook or episode.
 
@@ -1188,14 +1221,14 @@ class MusicController(CoreController):
         # Get MA's internal position from playlog
         ma_fully_played = False
         ma_position_ms = 0
-        if db_entry := await self.database.get_row(
-            DB_TABLE_PLAYLOG,
-            {
-                "media_type": media_item.media_type.value,
-                "item_id": media_item.item_id,
-                "provider": media_item.provider,
-            },
-        ):
+        params = {
+            "media_type": media_item.media_type.value,
+            "item_id": media_item.item_id,
+            "provider": media_item.provider,
+        }
+        if userid:
+            params["userid"] = userid
+        if db_entry := await self.database.get_row(DB_TABLE_PLAYLOG, params):
             ma_position_ms = db_entry["seconds_played"] * 1000 if db_entry["seconds_played"] else 0
             ma_fully_played = db_entry["fully_played"]
 
@@ -1811,6 +1844,28 @@ class MusicController(CoreController):
             await self._database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_SMART_FADES_ANALYSIS}")
             await self.__create_database_tables()
 
+        if prev_version <= 22:
+            # add userid column to playlog table
+            try:
+                await self._database.execute(
+                    f"ALTER TABLE {DB_TABLE_PLAYLOG} ADD COLUMN userid TEXT"
+                )
+            except Exception as err:
+                if "duplicate column" not in str(err):
+                    raise
+            # Note: SQLite doesn't support modifying constraints directly
+            # The UNIQUE constraint will be updated when the table is recreated
+            # For now, we'll keep the old constraint and add a new one via unique index
+            try:
+                await self._database.execute(f"DROP INDEX IF EXISTS {DB_TABLE_PLAYLOG}_unique_idx")
+                await self._database.execute(
+                    f"CREATE UNIQUE INDEX {DB_TABLE_PLAYLOG}_unique_idx "
+                    f"ON {DB_TABLE_PLAYLOG}(item_id,provider,media_type,userid)"
+                )
+            except Exception as err:
+                # If we can't create the index due to duplicate entries, log and continue
+                self.logger.warning("Could not create unique index on playlog: %s", err)
+
         # save changes
         await self._database.commit()
 
@@ -1846,7 +1901,8 @@ class MusicController(CoreController):
                 [timestamp] INTEGER DEFAULT 0,
                 [fully_played] BOOLEAN,
                 [seconds_played] INTEGER,
-                UNIQUE(item_id, provider, media_type));"""
+                [userid] TEXT NOT NULL,
+                UNIQUE(item_id, provider, media_type, userid));"""
         )
         await self.database.execute(
             f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_ALBUMS}(
@@ -2166,6 +2222,11 @@ class MusicController(CoreController):
             f"CREATE INDEX IF NOT EXISTS {DB_TABLE_SMART_FADES_ANALYSIS}_idx "
             f"on {DB_TABLE_SMART_FADES_ANALYSIS}(item_id,provider,fragment);"
         )
+        # unique index on playlog table
+        await self.database.execute(
+            f"CREATE UNIQUE INDEX IF NOT EXISTS {DB_TABLE_PLAYLOG}_unique_idx "
+            f"on {DB_TABLE_PLAYLOG}(item_id,provider,media_type,userid);"
+        )
         await self.database.commit()
 
     async def __create_database_triggers(self) -> None:
index 1c4e4764061ae9a8e3ca9f6f7f4bfc3a0fa07ac6..9780d2fa51b81104a3ad92020410183584261254 100644 (file)
@@ -68,6 +68,7 @@ from music_assistant.constants import (
     MASS_LOGO_ONLINE,
     VERBOSE_LOG_LEVEL,
 )
+from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
 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
@@ -78,6 +79,7 @@ from music_assistant.models.player import Player, PlayerMedia
 if TYPE_CHECKING:
     from collections.abc import Iterator
 
+    from music_assistant_models.auth import User
     from music_assistant_models.media_items.metadata import MediaItemImage
 
     from music_assistant import MusicAssistant
@@ -368,6 +370,7 @@ class PlayerQueuesController(CoreController):
         option: QueueOption | None = None,
         radio_mode: bool = False,
         start_item: PlayableMediaItemType | str | None = None,
+        username: str | None = None,
     ) -> None:
         """Play media item(s) on the given queue.
 
@@ -376,6 +379,10 @@ class PlayerQueuesController(CoreController):
         :param option: Which enqueue mode to use.
         :param radio_mode: Enable radio mode for the given item(s).
         :param start_item: Optional item to start the playlist or album from.
+        :param username: The username of the user requesting the playback.
+            Setting the username allows for overriding the logged-in user
+            to account for playback history per user when the play_media is
+            called from a shared context (like a web hook or automation).
         """
         # ruff: noqa: PLR0915
         # we use a contextvar to bypass the throttler for this asyncio task/context
@@ -391,6 +398,14 @@ class PlayerQueuesController(CoreController):
             self.logger.warning("Ignore queue command: An announcement is in progress")
             return
 
+        # save the user requesting the playback
+        playback_user: User | None
+        if username and (user := await self.mass.webserver.auth.get_user_by_username(username)):
+            playback_user = user
+        else:
+            playback_user = get_current_user()
+        queue.userid = playback_user.user_id if playback_user else None
+
         # a single item or list of items may be provided
         media_list = media if isinstance(media, list) else [media]
 
@@ -1499,7 +1514,7 @@ class PlayerQueuesController(CoreController):
         return result
 
     async def get_audiobook_resume_point(
-        self, audio_book: Audiobook, chapter: str | int | None = None
+        self, audio_book: Audiobook, chapter: str | int | None = None, userid: str | None = None
     ) -> int:
         """Return resume point (in milliseconds) for given audio book."""
         self.logger.debug(
@@ -1515,11 +1530,16 @@ class PlayerQueuesController(CoreController):
             raise InvalidDataError(
                 f"Unable to resolve chapter to play for Audiobook {audio_book.name}"
             )
-        full_played, resume_position_ms = await self.mass.music.get_resume_position(audio_book)
+        full_played, resume_position_ms = await self.mass.music.get_resume_position(
+            audio_book, userid=userid
+        )
         return 0 if full_played else resume_position_ms
 
     async def get_next_podcast_episodes(
-        self, podcast: Podcast | None, episode: PodcastEpisode | str | None
+        self,
+        podcast: Podcast | None,
+        episode: PodcastEpisode | str | None,
+        userid: str | None = None,
     ) -> UniqueList[PodcastEpisode]:
         """Return (next) episode(s) and resume point for given podcast."""
         if podcast is None and isinstance(episode, str | NoneType):
@@ -1534,7 +1554,7 @@ class PlayerQueuesController(CoreController):
             (
                 fully_played,
                 resume_position_ms,
-            ) = await self.mass.music.get_resume_position(episode)
+            ) = await self.mass.music.get_resume_position(episode, userid=userid)
             episode.fully_played = fully_played
             episode.resume_position_ms = 0 if fully_played else resume_position_ms
             return UniqueList([episode])
@@ -1557,7 +1577,7 @@ class PlayerQueuesController(CoreController):
                 (
                     fully_played,
                     resume_position_ms,
-                ) = await self.mass.music.get_resume_position(resolved_episode)
+                ) = await self.mass.music.get_resume_position(resolved_episode, userid=userid)
                 resolved_episode.resume_position_ms = 0 if fully_played else resume_position_ms
         elif isinstance(episode, str):
             resolved_episode = next(
@@ -1568,7 +1588,7 @@ class PlayerQueuesController(CoreController):
                 (
                     fully_played,
                     resume_position_ms,
-                ) = await self.mass.music.get_resume_position(resolved_episode)
+                ) = await self.mass.music.get_resume_position(resolved_episode, userid=userid)
                 resolved_episode.resume_position_ms = 0 if fully_played else resume_position_ms
         else:
             # get first episode that is not fully played
@@ -1579,7 +1599,7 @@ class PlayerQueuesController(CoreController):
                 (
                     fully_played,
                     resume_position_ms,
-                ) = await self.mass.music.get_resume_position(ep)
+                ) = await self.mass.music.get_resume_position(ep, userid=userid)
                 if fully_played:
                     continue
                 ep.resume_position_ms = resume_position_ms
@@ -1739,7 +1759,10 @@ class PlayerQueuesController(CoreController):
         )
 
     async def _resolve_media_items(
-        self, media_item: MediaItemType | ItemMapping | BrowseFolder, start_item: str | None = None
+        self,
+        media_item: MediaItemType | ItemMapping | BrowseFolder,
+        start_item: str | None = None,
+        userid: str | None = None,
     ) -> list[MediaItemType]:
         """Resolve/unwrap media items to enqueue."""
         # resolve Itemmapping to full media item
@@ -1749,7 +1772,7 @@ class PlayerQueuesController(CoreController):
             media_item = await self.mass.music.get_item_by_uri(media_item.uri)
         if media_item.media_type == MediaType.PLAYLIST:
             media_item = cast("Playlist", media_item)
-            self.mass.create_task(self.mass.music.mark_item_played(media_item))
+            self.mass.create_task(self.mass.music.mark_item_played(media_item, userid=userid))
             return list(await self.get_playlist_tracks(media_item, start_item))
         if media_item.media_type == MediaType.ARTIST:
             media_item = cast("Artist", media_item)
@@ -1757,22 +1780,22 @@ class PlayerQueuesController(CoreController):
             return list(await self.get_artist_tracks(media_item))
         if media_item.media_type == MediaType.ALBUM:
             media_item = cast("Album", media_item)
-            self.mass.create_task(self.mass.music.mark_item_played(media_item))
+            self.mass.create_task(self.mass.music.mark_item_played(media_item, userid=userid))
             return list(await self.get_album_tracks(media_item, start_item))
         if media_item.media_type == MediaType.AUDIOBOOK:
             media_item = cast("Audiobook", media_item)
             # ensure we grab the correct/latest resume point info
             media_item.resume_position_ms = await self.get_audiobook_resume_point(
-                media_item, start_item
+                media_item, start_item, userid=userid
             )
             return [media_item]
         if media_item.media_type == MediaType.PODCAST:
             media_item = cast("Podcast", media_item)
-            self.mass.create_task(self.mass.music.mark_item_played(media_item))
-            return list(await self.get_next_podcast_episodes(media_item, start_item))
+            self.mass.create_task(self.mass.music.mark_item_played(media_item, userid=userid))
+            return list(await self.get_next_podcast_episodes(media_item, start_item, userid=userid))
         if media_item.media_type == MediaType.PODCAST_EPISODE:
             media_item = cast("PodcastEpisode", media_item)
-            return list(await self.get_next_podcast_episodes(None, media_item))
+            return list(await self.get_next_podcast_episodes(None, media_item, userid=userid))
         if media_item.media_type == MediaType.FOLDER:
             media_item = cast("BrowseFolder", media_item)
             return list(await self._get_folder_tracks(media_item))
@@ -2303,6 +2326,7 @@ class PlayerQueuesController(CoreController):
                 fully_played=fully_played,
                 seconds_played=seconds_played,
                 is_playing=is_playing,
+                userid=queue.userid,
             )
         )
 
@@ -2344,5 +2368,6 @@ class PlayerQueuesController(CoreController):
                 seconds_played=seconds_played,
                 fully_played=fully_played,
                 is_playing=is_playing,
+                userid=queue.userid,
             ),
         )
index d51ba01d357c6bf7872a44a8fe97447f72abc7a4..186024b0a8802d5c228d61abea10170994f13e5e 100644 (file)
@@ -437,6 +437,19 @@ class AuthenticationManager:
             provider_filter=json_loads(user_row["provider_filter"]),
         )
 
+    async def get_user_by_username(self, username: str) -> User | None:
+        """
+        Get user by username.
+
+        :param username: The username.
+        :return: User object or None if not found.
+        """
+        user_row = await self.database.get_row("users", {"username": username})
+        if not user_row:
+            return None
+
+        return await self.get_user(user_row["user_id"])
+
     async def get_user_by_provider_link(
         self, provider_type: AuthProviderType, provider_user_id: str
     ) -> User | None:
index e8f09b695450e1a931765f4e1b5023907e95d227..bd1fef666fffe37b7e6e58307bfafc5bbb380040 100644 (file)
@@ -36,6 +36,7 @@ from music_assistant.constants import (
     CONF_BIND_IP,
     CONF_BIND_PORT,
     CONF_ONBOARD_DONE,
+    DB_TABLE_PLAYLOG,
     RESOURCES_DIR,
     VERBOSE_LOG_LEVEL,
 )
@@ -1067,6 +1068,9 @@ class WebserverController(CoreController):
             )
             token = await self.auth.create_token(user, device_name)
 
+            # Migrate existing playlog entries to this first user
+            await self._migrate_playlog_to_first_user(user.user_id)
+
             # Mark onboarding as complete
             self.mass.config.set(CONF_ONBOARD_DONE, True)
             self.mass.config.save(immediate=True)
@@ -1087,6 +1091,27 @@ class WebserverController(CoreController):
                 {"success": False, "error": f"Setup failed: {e!s}"}, status=500
             )
 
+    async def _migrate_playlog_to_first_user(self, user_id: str) -> None:
+        """
+        Migrate all existing playlog entries to the first user.
+
+        This is called during onboarding when the first admin user is created.
+        All existing playlog entries (which have NULL userid) will be updated
+        to belong to this first user.
+
+        :param user_id: The user ID of the first admin user.
+        """
+        try:
+            # Update all playlog entries with NULL userid to this user
+            await self.mass.music.database.execute(
+                f"UPDATE {DB_TABLE_PLAYLOG} SET userid = :userid WHERE userid IS NULL",
+                {"userid": user_id},
+            )
+            await self.mass.music.database.commit()
+            self.logger.info("Migrated existing playlog entries to first user: %s", user_id)
+        except Exception as err:
+            self.logger.warning("Failed to migrate playlog entries: %s", err)
+
     async def _get_ingress_user(self, request: web.Request) -> User | None:
         """
         Get or create user for ingress (Home Assistant) requests.