From: Marcel van der Veldt Date: Sat, 29 Nov 2025 03:10:59 +0000 (+0100) Subject: Add user tracking to playlog X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=92ede18a52a9807e0f76c80c03793825db30a9d2;p=music-assistant-server.git Add user tracking to playlog --- diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index 41b3d0e0..b407b9a1 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -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: diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 1c4e4764..9780d2fa 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -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, ), ) diff --git a/music_assistant/controllers/webserver/auth.py b/music_assistant/controllers/webserver/auth.py index d51ba01d..186024b0 100644 --- a/music_assistant/controllers/webserver/auth.py +++ b/music_assistant/controllers/webserver/auth.py @@ -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: diff --git a/music_assistant/controllers/webserver/controller.py b/music_assistant/controllers/webserver/controller.py index e8f09b69..bd1fef66 100644 --- a/music_assistant/controllers/webserver/controller.py +++ b/music_assistant/controllers/webserver/controller.py @@ -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.