From a6a1fd308907c1bbabd4b2d166d0382a46d10af4 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 16 Dec 2025 21:57:27 +0100 Subject: [PATCH] Add a more smarter way to resume a player with empty queue (#2827) --- music_assistant/controllers/music.py | 83 ++++++++++++++----- music_assistant/controllers/player_queues.py | 76 +++++++++++++++-- .../controllers/players/player_controller.py | 4 +- 3 files changed, 137 insertions(+), 26 deletions(-) diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index 2a2354a3..2c670632 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -13,7 +13,7 @@ from copy import deepcopy from datetime import datetime from itertools import zip_longest from math import inf -from typing import TYPE_CHECKING, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast import numpy as np from music_assistant_models.config_entries import ConfigEntry, ConfigValueType @@ -97,7 +97,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] = 23 +DB_SCHEMA_VERSION: Final[int] = 24 CACHE_CATEGORY_LAST_SYNC: Final[int] = 9 CACHE_CATEGORY_SEARCH_RESULTS: Final[int] = 10 @@ -558,32 +558,51 @@ class MusicController(CoreController): @api_command("music/recently_played_items") async def recently_played( - self, limit: int = 10, media_types: list[MediaType] | None = None, all_users: bool = True + self, + limit: int = 10, + media_types: list[MediaType] | None = None, + userid: str | None = None, + queue_id: str | None = None, + fully_played_only: bool = True, + user_initiated_only: bool = False, ) -> list[ItemMapping]: - """Return a list of the last played items.""" + """Return a list of the last played items. + + :param limit: Maximum number of items to return. + :param media_types: Filter by media types. + :param userid: Filter by specific user ID. + :param queue_id: Filter by specific queue ID. + :param fully_played_only: If True, only return fully played items. + :param user_initiated_only: If True, only return items initiated by the user. + """ if media_types is None: - media_types = [ - MediaType.ALBUM, - MediaType.AUDIOBOOK, - MediaType.ARTIST, - MediaType.PLAYLIST, - MediaType.PODCAST, - MediaType.FOLDER, - MediaType.RADIO, - MediaType.GENRE, - ] + media_types = MediaType.ALL media_types_str = "(" + ",".join(f'"{x}"' for x in media_types) + ")" available_providers = ("library", *self.get_unique_providers()) available_providers_str = "(" + ",".join(f'"{x}"' for x in available_providers) + ")" query = ( f"SELECT * FROM {DB_TABLE_PLAYLOG} " - f"WHERE media_type in {media_types_str} AND fully_played = 1 " + f"WHERE media_type in {media_types_str} " f"AND provider in {available_providers_str} " ) - if not all_users and (user := get_current_user()): - query += f"AND userid = '{user.user_id}' " + params: dict[str, Any] = {} + if fully_played_only: + query += "AND fully_played = 1 " + if user_initiated_only: + query += "AND user_initiated = 1 " + if userid: + query += "AND userid = :userid " + params["userid"] = userid + elif user := get_current_user(): + query += "AND userid = :userid " + params["userid"] = user.user_id + if queue_id: + query += "AND queue_id = :queue_id " + params["queue_id"] = queue_id query += "ORDER BY timestamp DESC" - db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit) + db_rows = await self.mass.music.database.get_rows_from_query( + query, params=params or None, limit=limit + ) result: list[ItemMapping] = [] available_providers = ("library", *get_global_cache_value("available_providers", [])) @@ -1112,6 +1131,8 @@ class MusicController(CoreController): seconds_played: int | None = None, is_playing: bool = False, userid: str | None = None, + queue_id: str | None = None, + user_initiated: bool = True, ) -> None: """ Mark item as played in playlog. @@ -1121,6 +1142,8 @@ class MusicController(CoreController): :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 queue_id: The queue ID where the item was played. + :param user_initiated: If True, the playback was initiated by the user (e.g. enqueued). """ timestamp = utc_timestamp() if ( @@ -1140,6 +1163,8 @@ class MusicController(CoreController): "fully_played": fully_played, "seconds_played": seconds_played, "timestamp": timestamp, + "queue_id": queue_id, + "user_initiated": user_initiated, } # try to figure out the user that triggered the action user: User | None = None @@ -1663,7 +1688,7 @@ class MusicController(CoreController): name="Recently played", translation_key="recently_played", icon="mdi-motion-play", - items=await self.recently_played(limit=10), + items=await self.recently_played(limit=10, user_initiated_only=True), ), RecommendationFolder( item_id="recently_added_tracks", @@ -2124,6 +2149,24 @@ class MusicController(CoreController): if "duplicate column" not in str(err): raise + if prev_version <= 24: + # add queue_id and user_initiated columns to playlog table + try: + await self._database.execute( + f"ALTER TABLE {DB_TABLE_PLAYLOG} ADD COLUMN queue_id TEXT" + ) + except Exception as err: + if "duplicate column" not in str(err): + raise + try: + await self._database.execute( + f"ALTER TABLE {DB_TABLE_PLAYLOG} " + "ADD COLUMN user_initiated BOOLEAN NOT NULL DEFAULT 1" + ) + except Exception as err: + if "duplicate column" not in str(err): + raise + # save changes await self._database.commit() @@ -2160,6 +2203,8 @@ class MusicController(CoreController): [fully_played] BOOLEAN, [seconds_played] INTEGER, [userid] TEXT NOT NULL, + [queue_id] TEXT, + [user_initiated] BOOLEAN NOT NULL DEFAULT 1, UNIQUE(item_id, provider, media_type, userid));""" ) await self.database.execute( diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 34bb5136..f766c807 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -474,7 +474,9 @@ class PlayerQueuesController(CoreController): start_item_uri = start_item elif start_item is not None: start_item_uri = start_item.uri - media_items += await self._resolve_media_items(media_item, start_item_uri) + media_items += await self._resolve_media_items( + media_item, start_item_uri, queue_id=queue_id + ) except MusicAssistantError as err: # invalid MA uri or item not found error @@ -847,6 +849,9 @@ class PlayerQueuesController(CoreController): queue_id, resume_item.queue_item_id, int(resume_pos), fade_in or False ) else: + # Queue is empty, try to resume from playlog + if await self._try_resume_from_playlog(queue): + return msg = f"Resume queue requested but queue {queue.display_name} is empty" raise QueueEmpty(msg) @@ -1775,6 +1780,7 @@ class PlayerQueuesController(CoreController): media_item: MediaItemType | ItemMapping | BrowseFolder, start_item: str | None = None, userid: str | None = None, + queue_id: str | None = None, ) -> list[MediaItemType]: """Resolve/unwrap media items to enqueue.""" # resolve Itemmapping to full media item @@ -1784,15 +1790,25 @@ 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, userid=userid)) + self.mass.create_task( + self.mass.music.mark_item_played( + media_item, userid=userid, queue_id=queue_id, user_initiated=True + ) + ) return list(await self.get_playlist_tracks(media_item, start_item)) if media_item.media_type == MediaType.ARTIST: media_item = cast("Artist", 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, queue_id=queue_id, user_initiated=True) + ) 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, userid=userid)) + self.mass.create_task( + self.mass.music.mark_item_played( + media_item, userid=userid, queue_id=queue_id, user_initiated=True + ) + ) return list(await self.get_album_tracks(media_item, start_item)) if media_item.media_type == MediaType.AUDIOBOOK: media_item = cast("Audiobook", media_item) @@ -1803,7 +1819,11 @@ class PlayerQueuesController(CoreController): 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, userid=userid)) + self.mass.create_task( + self.mass.music.mark_item_played( + media_item, userid=userid, queue_id=queue_id, user_initiated=True + ) + ) 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) @@ -1814,6 +1834,50 @@ class PlayerQueuesController(CoreController): # all other: single track or radio item return [cast("MediaItemType", media_item)] + async def _try_resume_from_playlog(self, queue: PlayerQueue) -> bool: + """Try to resume playback from playlog when queue is empty. + + Attempts to find user-initiated recently played items in the following order: + 1. By userid AND queue_id + 2. By queue_id only + 3. By userid only (if available) + 4. Any recently played item + + :param queue: The queue to resume playback on. + :return: True if playback was started, False otherwise. + """ + # Try different filter combinations in order of specificity + filter_attempts: list[tuple[str | None, str | None, str]] = [] + if queue.userid: + filter_attempts.append((queue.userid, queue.queue_id, "userid + queue_id match")) + filter_attempts.append((None, queue.queue_id, "queue_id match")) + if queue.userid: + filter_attempts.append((queue.userid, None, "userid match")) + filter_attempts.append((None, None, "any recent item")) + + for userid, queue_id, match_type in filter_attempts: + items = await self.mass.music.recently_played( + limit=5, + fully_played_only=False, + user_initiated_only=True, + userid=userid, + queue_id=queue_id, + ) + for item in items: + if not item.uri: + continue + try: + await self.play_media(queue.queue_id, item) + self.logger.info( + "Resumed queue %s from playlog (%s)", queue.display_name, match_type + ) + return True + except MusicAssistantError as err: + self.logger.debug("Failed to resume with item %s: %s", item.name, err) + continue + + return False + async def _get_radio_tracks( self, queue_id: str, is_initial_radio_mode: bool = False ) -> list[Track]: @@ -2355,6 +2419,8 @@ class PlayerQueuesController(CoreController): seconds_played=seconds_played, is_playing=is_playing, userid=queue.userid, + queue_id=queue.queue_id, + user_initiated=False, ) ) diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index c4ca6255..158f23e5 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -1385,8 +1385,8 @@ class PlayerController(CoreController): """Trigger an update for the given player.""" if self.mass.closing: return - player = self.get(player_id, True) - assert player is not None # for type checker + if not (player := self.get(player_id)): + return self.mass.loop.call_soon(player.update_state, force_update) async def unregister(self, player_id: str, permanent: bool = False) -> None: -- 2.34.1