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