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
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
@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", []))
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.
: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 (
"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
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",
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()
[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(
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
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)
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
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)
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)
# 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]:
seconds_played=seconds_played,
is_playing=is_playing,
userid=queue.userid,
+ queue_id=queue.queue_id,
+ user_initiated=False,
)
)