API_SCHEMA_VERSION: Final[int] = 24
MIN_SCHEMA_VERSION: Final[int] = 24
-DB_SCHEMA_VERSION: Final[int] = 1
+DB_SCHEMA_VERSION: Final[int] = 2
MASS_LOGGER_NAME: Final[str] = "music_assistant"
DB_TABLE_ALBUM_TRACKS,
DB_TABLE_ALBUMS,
DB_TABLE_ARTISTS,
+ DB_TABLE_PROVIDER_MAPPINGS,
)
from music_assistant.server.controllers.media.base import MediaControllerBase
from music_assistant.server.helpers.compare import (
"""Initialize class."""
super().__init__(*args, **kwargs)
self.base_query = f"""
- SELECT DISTINCT
- {self.db_table}.*
- FROM {self.db_table}
+ SELECT DISTINCT {self.db_table}.* FROM {self.db_table}
LEFT JOIN {DB_TABLE_ALBUM_ARTISTS} on {DB_TABLE_ALBUM_ARTISTS}.album_id = {self.db_table}.item_id
LEFT JOIN {DB_TABLE_ARTISTS} on {DB_TABLE_ARTISTS}.item_id = {DB_TABLE_ALBUM_ARTISTS}.artist_id
+ LEFT JOIN {DB_TABLE_PROVIDER_MAPPINGS} ON
+ {DB_TABLE_PROVIDER_MAPPINGS}.item_id = {self.db_table}.item_id AND media_type = '{self.media_type}'
""" # noqa: E501
# register (extra) api handlers
api_base = self.api_base
)
from music_assistant.constants import (
DB_TABLE_ALBUM_ARTISTS,
- DB_TABLE_ALBUMS,
DB_TABLE_ARTISTS,
DB_TABLE_TRACK_ARTISTS,
- DB_TABLE_TRACKS,
VARIOUS_ARTISTS_ID_MBID,
VARIOUS_ARTISTS_NAME,
)
self, favorite_only: bool = False, album_artists_only: bool = False
) -> int:
"""Return the total number of items in the library."""
- sql_query = self.base_query
+ sql_query = f"SELECT item_id FROM {self.db_table}"
+ query_parts: list[str] = []
if favorite_only:
- sql_query += f" WHERE {self.db_table}.favorite = 1"
+ query_parts.append("favorite = 1")
if album_artists_only:
- sql_query += " WHERE " if "WHERE" not in sql_query else " AND "
- sql_query += (
- f"artists.item_id in (select {DB_TABLE_ALBUM_ARTISTS}.artist_id "
- f"from {DB_TABLE_ALBUM_ARTISTS})"
+ query_parts.append(
+ f"item_id in (select {DB_TABLE_ALBUM_ARTISTS}.artist_id "
+ f"FROM {DB_TABLE_ALBUM_ARTISTS})"
)
+ if query_parts:
+ sql_query += f" WHERE {' AND '.join(query_parts)}"
return await self.mass.music.database.get_count_from_query(sql_query)
async def library_items(
item_id,
provider_instance_id_or_domain,
):
- subquery = (
- "SELECT item_id FROM provider_mappings WHERE "
- "media_type = 'track' AND (provider_domain = :prov_id "
- "OR provider_instance = :prov_id)"
- )
query = (
- f"WHERE {DB_TABLE_TRACKS}.item_id IN ({subquery}) "
- f"AND {DB_TABLE_TRACK_ARTISTS}.artist_id = :artist_id"
+ f"WHERE {DB_TABLE_TRACK_ARTISTS}.artist_id = :artist_id "
+ "AND (provider_domain = :prov_id "
+ "OR provider_instance = :prov_id)"
)
query_params = {
"artist_id": db_artist.item_id,
item_id,
provider_instance_id_or_domain,
):
- subquery = (
- "SELECT item_id FROM provider_mappings WHERE "
- "media_type = 'album' AND (provider_domain = :prov_id "
- "OR provider_instance = :prov_id)"
- )
query = (
- f"WHERE {DB_TABLE_ALBUMS}.item_id IN ({subquery}) "
- f"AND {DB_TABLE_ALBUM_ARTISTS}.artist_id = :artist_id"
+ f"WHERE {DB_TABLE_ALBUM_ARTISTS}.artist_id = :artist_id "
+ "AND (provider_domain = :prov_id "
+ "OR provider_instance = :prov_id)"
)
query_params = {
"prov_id": provider_instance_id_or_domain,
media_from_dict,
)
from music_assistant.constants import (
- DB_TABLE_ALBUMS,
DB_TABLE_ARTISTS,
DB_TABLE_PLAYLOG,
DB_TABLE_PROVIDER_MAPPINGS,
SORT_KEYS = {
"name": "name COLLATE NOCASE ASC",
"name_desc": "name COLLATE NOCASE DESC",
- "sort_name": "sort_name ASC",
- "sort_name_desc": "sort_name DESC",
+ "sort_name": "sort_name COLLATE NOCASE ASC",
+ "sort_name_desc": "sort_name COLLATE NOCASE DESC",
"timestamp_added": "timestamp_added ASC",
"timestamp_added_desc": "timestamp_added DESC",
"timestamp_modified": "timestamp_modified ASC",
"last_played_desc": "last_played DESC",
"play_count": "play_count ASC",
"play_count_desc": "play_count DESC",
- "artist": "artists.name COLLATE NOCASE ASC",
- "album": "albums.name COLLATE NOCASE ASC",
- "sort_artist": "artists.sort_name ASC",
- "sort_album": "albums.sort_name ASC",
"year": "year ASC",
"year_desc": "year DESC",
"position": "position ASC",
"position_desc": "position DESC",
"random": "RANDOM()",
- "random_play_count": "RANDOM(), play_count",
+ "random_play_count": "random(), play_count ASC",
+ "random_fast": "play_count ASC", # this one is handled with a special query
}
def __init__(self, mass: MusicAssistant) -> None:
"""Initialize class."""
self.mass = mass
- self.base_query = f"SELECT * FROM {self.db_table}"
+ self.base_query = (
+ f"SELECT DISTINCT {self.db_table}.* FROM {self.db_table} "
+ f"LEFT JOIN {DB_TABLE_PROVIDER_MAPPINGS} ON "
+ f"{DB_TABLE_PROVIDER_MAPPINGS}.item_id = {self.db_table}.item_id "
+ f"AND media_type = '{self.media_type}'"
+ )
self.logger = logging.getLogger(f"{MASS_LOGGER_NAME}.music.{self.media_type.value}")
# register (base) api handlers
self.api_base = api_base = f"{self.media_type}s"
async def library_count(self, favorite_only: bool = False) -> int:
"""Return the total number of items in the library."""
- sql_query = self.base_query
if favorite_only:
- sql_query += f" WHERE {self.db_table}.favorite = 1"
- return await self.mass.music.database.get_count_from_query(sql_query)
+ sql_query = f"SELECT item_id FROM {self.db_table} WHERE favorite = 1"
+ return await self.mass.music.database.get_count_from_query(sql_query)
+ return await self.mass.music.database.get_count(self.db_table)
async def library_items(
self,
extra_query_params: dict[str, Any] | None = None,
) -> list[ItemCls]:
"""Get in-database items."""
+ # create special performant random query
+ if order_by == "random_fast" and not extra_query:
+ extra_query = (
+ f"{self.db_table}.rowid > (ABS(RANDOM()) % "
+ f"(SELECT max({self.db_table}.rowid) FROM {self.db_table}))"
+ )
return await self._get_library_items_by_query(
favorite=favorite,
search=search,
assert provider_instance_id_or_domain != "library"
assert provider_domain != "library"
assert provider_instance != "library"
- subquery = f"WHERE provider_mappings.media_type = '{self.media_type.value}' "
if provider_instance:
query_params = {"prov_id": provider_instance}
- subquery += "AND provider_mappings.provider_instance = :prov_id"
+ query = "provider_mappings.provider_instance = :prov_id"
elif provider_domain:
query_params = {"prov_id": provider_domain}
- subquery += "AND provider_mappings.provider_domain = :prov_id"
+ query = "provider_mappings.provider_domain = :prov_id"
else:
query_params = {"prov_id": provider_instance_id_or_domain}
- subquery += (
- "AND (provider_mappings.provider_instance = :prov_id "
- "OR provider_mappings.provider_domain = :prov_id) "
+ query = (
+ "(provider_mappings.provider_instance = :prov_id "
+ "OR provider_mappings.provider_domain = :prov_id)"
)
if provider_item_id:
- subquery += " AND provider_mappings.provider_item_id = :item_id"
+ query += " AND provider_mappings.provider_item_id = :item_id"
query_params["item_id"] = provider_item_id
- query = (
- f"WHERE {self.db_table}.item_id in (SELECT item_id FROM provider_mappings {subquery})"
- )
return await self._get_library_items_by_query(
limit=limit, offset=offset, extra_query=query, extra_query_params=query_params
)
sql_query = self.base_query
query_params = extra_query_params or {}
query_parts: list[str] = []
- # handle extra/custom query
- if extra_query:
- # prevent duplicate where statement
- if extra_query.lower().startswith("where "):
- extra_query = extra_query[5:]
- query_parts.append(extra_query)
# handle basic search on name
if search:
- query_params["search"] = f"%{search}%"
- if self.media_type == MediaType.ALBUM:
+ # handle combined artist + title search
+ if self.media_type in (MediaType.ALBUM, MediaType.TRACK) and " - " in search:
+ artist_str, title_str = search.split(" - ", 1)
query_parts.append(
- f"({self.db_table}.name LIKE :search "
- f"OR {DB_TABLE_ARTISTS}.name LIKE :search)"
- )
- elif self.media_type == MediaType.TRACK:
- query_parts.append(
- f"({self.db_table}.name LIKE :search "
- f"OR {DB_TABLE_ARTISTS}.name LIKE :search "
- f"OR {DB_TABLE_ALBUMS}.name LIKE :search)"
+ f"({self.db_table}.name LIKE :search_title "
+ f"AND {DB_TABLE_ARTISTS}.name LIKE :search_artist)"
)
+ query_params["search_title"] = f"%{title_str}%"
+ query_params["search_artist"] = f"%{artist_str}%"
else:
+ query_params["search"] = f"%{search}%"
query_parts.append(f"{self.db_table}.name LIKE :search")
# handle favorite filter
if favorite is not None:
query_parts.append(f"{self.db_table}.favorite = :favorite")
query_params["favorite"] = favorite
+ # handle extra/custom query
+ if extra_query:
+ # prevent duplicate where statement
+ if extra_query.lower().startswith("where "):
+ extra_query = extra_query[5:]
+ query_parts.append(extra_query)
# concetenate all where queries
if query_parts:
sql_query += " WHERE " + " AND ".join(query_parts)
if order_by:
if sort_key := SORT_KEYS.get(order_by):
sql_query += f" ORDER BY {sort_key}"
- else:
- self.logger.warning("%s is not a valid sort option!", order_by)
-
# return dbresult parsed to media item model
return [
self.item_cls.from_dict(self._parse_db_row(db_row))
DB_TABLE_ALBUM_TRACKS,
DB_TABLE_ALBUMS,
DB_TABLE_ARTISTS,
+ DB_TABLE_PROVIDER_MAPPINGS,
DB_TABLE_TRACK_ARTISTS,
DB_TABLE_TRACKS,
)
LEFT JOIN {DB_TABLE_ALBUMS} on {DB_TABLE_ALBUMS}.item_id = {DB_TABLE_ALBUM_TRACKS}.album_id
LEFT JOIN {DB_TABLE_TRACK_ARTISTS} on {DB_TABLE_TRACK_ARTISTS}.track_id = {self.db_table}.item_id
LEFT JOIN {DB_TABLE_ARTISTS} on {DB_TABLE_ARTISTS}.item_id = {DB_TABLE_TRACK_ARTISTS}.artist_id
+ LEFT JOIN {DB_TABLE_PROVIDER_MAPPINGS} ON
+ {DB_TABLE_PROVIDER_MAPPINGS}.item_id = {self.db_table}.item_id AND media_type = '{self.media_type}'
""" # noqa: E501
# register (extra) api handlers
api_base = self.api_base
self,
search_query: str,
media_types: list[MediaType] = MediaType.ALL,
- limit: int = 50,
+ limit: int = 25,
) -> SearchResults:
"""Perform global search for media items on all providers.
) -> list[MediaItemType]:
"""Return a list of the last played items."""
if media_types is None:
- media_types = [MediaType.TRACK, MediaType.RADIO]
+ media_types = MediaType.ALL
media_types_str = "(" + ",".join(f'"{x}"' for x in media_types) + ")"
query = (
f"SELECT * FROM {DB_TABLE_PLAYLOG} WHERE media_type "
with suppress(MediaNotFoundError, ProviderUnavailableError):
media_type = MediaType(db_row["media_type"])
ctrl = self.get_controller(media_type)
- item = await ctrl.get_provider_item(db_row["item_id"], db_row["provider"])
+ item = await ctrl.get(
+ db_row["item_id"],
+ db_row["provider"],
+ add_to_library=False,
+ lazy=True,
+ force_refresh=False,
+ )
result.append(item)
return result
"""Mark item as played in playlog."""
timestamp = utc_timestamp()
+ if provider_instance_id_or_domain == "builtin":
+ # we deliberately skip builtin provider items as those are often
+ # one-off items like TTS or some sound effect etc.
+ return
+
if provider_instance_id_or_domain == "library":
prov_key = "library"
elif prov := self.mass.get_provider(provider_instance_id_or_domain):
prov_key = prov.lookup_key
else:
prov_key = provider_instance_id_or_domain
- # do not try to store dynamic urls (e.g. with auth token etc.),
- # stick with plain uri/urls only
- if "http" in item_id and "?" in item_id:
- return
# update generic playlog table
await self.database.insert(
async def __migrate_database(self, prev_version: int) -> None:
"""Perform a database migration."""
+ self.logger.info(
+ "Migrating database from version %s to %s", prev_version, DB_SCHEMA_VERSION
+ )
+ if prev_version == 1:
+ # migrate from version 1 to 2
+ await self.database.execute(
+ f"DELETE FROM {DB_TABLE_PLAYLOG} WHERE provider = 'builtin'"
+ )
+ await self.database.commit()
+ return
+
+ # all other versions: reset the database
+ # we only migrate from prtev version to current we do not try to handle
+ # more complex migrations
self.logger.warning(
"Database schema too old - Resetting library/database - "
"a full rescan will be performed, this can take a while!"
await self.database.execute(
f"CREATE INDEX IF NOT EXISTS {db_table}_name_idx on {db_table}(name);"
)
+ # index on name (without case sensitivity)
+ await self.database.execute(
+ f"CREATE INDEX IF NOT EXISTS {db_table}_name_nocase_idx "
+ f"ON {db_table}(name COLLATE NOCASE);"
+ )
# index on sort_name
await self.database.execute(
f"CREATE INDEX IF NOT EXISTS {db_table}_sort_name_idx on {db_table}(sort_name);"
)
+ # index on sort_name (without case sensitivity)
+ await self.database.execute(
+ f"CREATE INDEX IF NOT EXISTS {db_table}_sort_name_nocase_idx "
+ f"ON {db_table}(sort_name COLLATE NOCASE);"
+ )
# index on external_ids
await self.database.execute(
- f"CREATE INDEX IF NOT EXISTS {db_table}_external_ids_idx on {db_table}(external_ids);" # noqa: E501
+ f"CREATE INDEX IF NOT EXISTS {db_table}_external_ids_idx "
+ f"ON {db_table}(external_ids);"
)
# index on timestamp_added
await self.database.execute(
f"CREATE INDEX IF NOT EXISTS {db_table}_timestamp_added_idx "
f"on {db_table}(timestamp_added);"
)
+ # index on play_count
+ await self.database.execute(
+ f"CREATE INDEX IF NOT EXISTS {db_table}_play_count_idx "
+ f"on {db_table}(play_count);"
+ )
+ # index on last_played
+ await self.database.execute(
+ f"CREATE INDEX IF NOT EXISTS {db_table}_last_played_idx "
+ f"on {db_table}(last_played);"
+ )
# indexes on provider_mappings table
await self.database.execute(
f"CREATE UNIQUE INDEX IF NOT EXISTS {DB_TABLE_PROVIDER_MAPPINGS}_provider_instance_idx "
f"on {DB_TABLE_PROVIDER_MAPPINGS}(media_type,provider_instance,provider_item_id);"
)
+ await self.database.execute(
+ "CREATE INDEX IF NOT EXISTS "
+ f"{DB_TABLE_PROVIDER_MAPPINGS}_media_type_provider_instance_idx "
+ f"on {DB_TABLE_PROVIDER_MAPPINGS}(media_type,provider_instance);"
+ )
+ await self.database.execute(
+ "CREATE INDEX IF NOT EXISTS "
+ f"{DB_TABLE_PROVIDER_MAPPINGS}_media_type_provider_domain_idx "
+ f"on {DB_TABLE_PROVIDER_MAPPINGS}(media_type,provider_domain);"
+ )
# indexes on track_artists table
await self.database.execute(
@asynccontextmanager
-async def debug_query(sql_query: str):
+async def debug_query(sql_query: str, query_params: dict | None = None):
"""Time the processing time of an sql query."""
if not ENABLE_DEBUG:
yield
finally:
process_time = time.time() - time_start
if process_time > 0.5:
- LOGGER.warning("SQL Query took %s seconds! (\n%s", process_time, sql_query)
+ # log slow queries
+ for key, value in (query_params or {}).items():
+ sql_query = sql_query.replace(f":{key}", repr(value))
+ LOGGER.warning("SQL Query took %s seconds! (\n%s\n", process_time, sql_query)
def query_params(query: str, params: dict[str, Any] | None) -> tuple[str, dict[str, Any]]:
if limit:
query += f" LIMIT {limit} OFFSET {offset}"
_query, _params = query_params(query, params)
- async with debug_query(_query):
+ async with debug_query(_query, _params):
return await self._db.execute_fetchall(_query, _params)
async def get_count_from_query(
"""Search table by column."""
sql_query = f"SELECT * FROM {table} WHERE {table}.{column} LIKE :search"
params = {"search": f"%{search}%"}
- async with debug_query(sql_query):
+ async with debug_query(sql_query, params):
return await self._db.execute_fetchall(sql_query, params)
async def get_row(self, table: str, match: dict[str, Any]) -> Mapping | None:
"""Get single row for given table where column matches keys/values."""
sql_query = f"SELECT * FROM {table} WHERE "
sql_query += " AND ".join(f"{table}.{x} = :{x}" for x in match)
- async with debug_query(sql_query), self._db.execute(sql_query, match) as cursor:
+ async with debug_query(sql_query, match), self._db.execute(sql_query, match) as cursor:
return await cursor.fetchone()
async def insert(
result: list[Track] = []
if builtin_playlist_id == ALL_FAVORITE_TRACKS:
res = await self.mass.music.tracks.library_items(
- favorite=True, limit=2500, order_by="random_play_count"
+ favorite=True, limit=250000, order_by="random"
)
for idx, item in enumerate(res, 1):
item.position = idx
result.append(item)
return result
if builtin_playlist_id == RANDOM_TRACKS:
- res = await self.mass.music.tracks.library_items(
- limit=500, order_by="random_play_count"
- )
+ res = await self.mass.music.tracks.library_items(limit=500, order_by="random_fast")
for idx, item in enumerate(res, 1):
item.position = idx
result.append(item)
return result
if builtin_playlist_id == RANDOM_ALBUM:
for random_album in await self.mass.music.albums.library_items(
- limit=1, order_by="random"
+ limit=1, order_by="random_fast"
):
# use the function specified in the queue controller as that
# already handles unwrapping an album by user preference
return result
if builtin_playlist_id == RANDOM_ARTIST:
for random_artist in await self.mass.music.artists.library_items(
- limit=1, order_by="random"
+ limit=1, order_by="random_fast"
):
# use the function specified in the queue controller as that
# already handles unwrapping an artist by user preference
----------
- path: path of the directory (relative or absolute) to list contents of.
Empty string for provider's root.
- - recursive: If True will recursively keep unwrapping subdirectories (scandir equivalent).
+ - recursive: If True will recursively keep unwrapping
+ subdirectories (scandir equivalent).
Returns:
-------
"""Perform search on this file based musicprovider."""
result = SearchResults()
# searching the filesystem is slow and unreliable,
- # instead we make some (slow) freaking queries to the db ;-)
+ # so instead we just query the db...
+ query = "provider_mappings.provider_instance = :provider_instance "
params = {
- "name": f"%{search_query}%",
"provider_instance": self.instance_id,
}
- subquery = "WHERE "
- # ruff: noqa: E501
if media_types is None or MediaType.TRACK in media_types:
- subquery = (
- "WHERE provider_mappings.media_type = 'track' "
- "AND provider_mappings.provider_instance = :provider_instance"
- )
- query = (
- "WHERE tracks.name LIKE :name AND tracks.item_id in "
- f"(SELECT item_id FROM provider_mappings {subquery})"
- )
result.tracks = await self.mass.music.tracks._get_library_items_by_query(
- extra_query=query, extra_query_params=params
+ search=search_query, extra_query=query, extra_query_params=params, limit=limit
)
if media_types is None or MediaType.ALBUM in media_types:
- subquery = (
- "WHERE provider_mappings.media_type = 'album' "
- "AND provider_mappings.provider_instance = :provider_instance"
- )
- query = (
- "WHERE albums.name LIKE :name AND albums.item_id in "
- f"(SELECT item_id FROM provider_mappings {subquery})"
- )
result.albums = await self.mass.music.albums._get_library_items_by_query(
- extra_query=query, extra_query_params=params
+ search=search_query, extra_query=query, extra_query_params=params, limit=limit
)
if media_types is None or MediaType.ARTIST in media_types:
- subquery = (
- "WHERE provider_mappings.media_type = 'artist' "
- "AND provider_mappings.provider_instance = :provider_instance"
- )
- query = (
- "WHERE artists.name LIKE :name AND artists.item_id in "
- f"(SELECT item_id FROM provider_mappings {subquery})"
- )
result.artists = await self.mass.music.artists._get_library_items_by_query(
- extra_query=query, extra_query_params=params
+ search=search_query, extra_query=query, extra_query_params=params, limit=limit
)
if media_types is None or MediaType.PLAYLIST in media_types:
- subquery = (
- "WHERE provider_mappings.media_type = 'playlist' "
- "AND provider_mappings.provider_instance = :provider_instance"
- )
- query = (
- "WHERE playlists.name LIKE :name AND playlists.item_id in "
- f"(SELECT item_id FROM provider_mappings {subquery})"
- )
result.playlists = await self.mass.music.playlists._get_library_items_by_query(
- extra_query=query, extra_query_params=params
+ search=search_query, extra_query=query, extra_query_params=params, limit=limit
)
return result
f"WHERE item_id not in "
f"( select artist_id from {DB_TABLE_TRACK_ARTISTS} "
f"UNION SELECT artist_id from {DB_TABLE_ALBUM_ARTISTS} )"
- f"AND item_id in ( SELECT item_id from {DB_TABLE_PROVIDER_MAPPINGS} "
- f"WHERE provider_instance = '{self.instance_id}' and media_type = 'artist' )"
+ f"AND provider_instance = '{self.instance_id}'"
)
for db_row in await self.mass.music.database.get_rows_from_query(
query,