Add a more smarter way to resume a player with empty queue (#2827)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 16 Dec 2025 20:57:27 +0000 (21:57 +0100)
committerGitHub <noreply@github.com>
Tue, 16 Dec 2025 20:57:27 +0000 (21:57 +0100)
music_assistant/controllers/music.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/players/player_controller.py

index 2a2354a397b7aaf966893a61086a3d81883046fb..2c670632dd7a6ff94495e702071207d86434afdf 100644 (file)
@@ -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(
index 34bb5136554be8994a0ce81ca0461b807b513c07..f766c807c6271b523d30e641882c2da22577a270 100644 (file)
@@ -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,
             )
         )
 
index c4ca6255ffbebe1ded4a3bccb3ddfcdb2c5b1e4e..158f23e5123fbbbd7a290d2b7a84b5ede36343a2 100644 (file)
@@ -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: