def setup_logger(data_path: str, level: str = "DEBUG"):
"""Initialize logger."""
- logs_dir = os.path.join(data_path, "logs")
- if not os.path.isdir(logs_dir):
- os.mkdir(logs_dir)
-
# define log formatter
log_fmt = "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s"
logging.captureWarnings(True)
# setup file handler
- log_filename = os.path.join(logs_dir, "musicassistant.log")
+ log_filename = os.path.join(data_path, "musicassistant.log")
file_handler = RotatingFileHandler(log_filename, maxBytes=MAX_LOG_FILESIZE, backupCount=1)
# rotate log at each start
with suppress(OSError):
"""Handle Music/library related endpoints for Music Assistant."""
from __future__ import annotations
+import urllib.parse
from typing import TYPE_CHECKING
from music_assistant.common.models.enums import MediaType
# Tracks related endpoints/commands
- async def get_tracks(
+ async def get_library_tracks(
self,
- in_library: bool | None = None,
+ favorite: bool | None = None,
search: str | None = None,
limit: int | None = None,
offset: int | None = None,
"""Get Track listing from the server."""
return PagedItems.parse(
await self.client.send_command(
- "music/tracks",
- in_library=in_library,
+ "music/tracks/library_items",
+ favorite=favorite,
search=search,
limit=limit,
offset=offset,
self,
item_id: str,
provider_instance_id_or_domain: str,
- force_refresh: bool | None = None,
- lazy: bool | None = None,
- album: str | None = None,
+ album_uri: str | None = None,
) -> Track:
"""Get single Track from the server."""
return Track.from_dict(
await self.client.send_command(
- "music/track",
+ "music/tracks/get_track",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
- force_refresh=force_refresh,
- lazy=lazy,
- album=album,
+ album_uri=album_uri,
),
)
return [
Track.from_dict(item)
for item in await self.client.send_command(
- "music/track/versions",
+ "music/tracks/track_versions",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
)
return [
Album.from_dict(item)
for item in await self.client.send_command(
- "music/track/albums",
+ "music/tracks/track_albums",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
)
]
- async def get_track_preview_url(
+ def get_track_preview_url(
self,
item_id: str,
provider_instance_id_or_domain: str,
) -> str:
"""Get URL to preview clip of given track."""
- return await self.client.send_command(
- "music/track/preview",
- item_id=item_id,
- provider_instance_id_or_domain=provider_instance_id_or_domain,
- )
+ encoded_url = urllib.parse.quote(urllib.parse.quote(item_id))
+ return f"{self.client.server_info.base_url}/preview?path={encoded_url}&provider={provider_instance_id_or_domain}" # noqa: E501
# Albums related endpoints/commands
- async def get_albums(
+ async def get_library_albums(
self,
- in_library: bool | None = None,
+ favorite: bool | None = None,
search: str | None = None,
limit: int | None = None,
offset: int | None = None,
"""Get Albums listing from the server."""
return PagedItems.parse(
await self.client.send_command(
- "music/albums",
- in_library=in_library,
+ "music/albums/library_items",
+ favorite=favorite,
search=search,
limit=limit,
offset=offset,
self,
item_id: str,
provider_instance_id_or_domain: str,
- force_refresh: bool | None = None,
- lazy: bool | None = None,
) -> Album:
"""Get single Album from the server."""
return Album.from_dict(
await self.client.send_command(
- "music/album",
+ "music/albums/get_album",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
- force_refresh=force_refresh,
- lazy=lazy,
),
)
return [
Album.from_dict(item)
for item in await self.client.send_command(
- "music/album/versions",
+ "music/albums/album_versions",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
)
# Artist related endpoints/commands
- async def get_artists(
+ async def get_library_artists(
self,
- in_library: bool | None = None,
+ favorite: bool | None = None,
search: str | None = None,
limit: int | None = None,
offset: int | None = None,
order_by: str | None = None,
+ album_artists_only: bool = False,
) -> PagedItems:
"""Get Artists listing from the server."""
return PagedItems.parse(
await self.client.send_command(
- "music/artists",
- in_library=in_library,
- search=search,
- limit=limit,
- offset=offset,
- order_by=order_by,
- ),
- Artist,
- )
-
- async def get_album_artists(
- self,
- in_library: bool | None = None,
- search: str | None = None,
- limit: int | None = None,
- offset: int | None = None,
- order_by: str | None = None,
- ) -> PagedItems:
- """Get AlbumArtists listing from the server."""
- return PagedItems.parse(
- await self.client.send_command(
- "music/albumartists",
- in_library=in_library,
+ "music/artists/library_items",
+ favorite=favorite,
search=search,
limit=limit,
offset=offset,
order_by=order_by,
+ album_artists_only=album_artists_only,
),
Artist,
)
self,
item_id: str,
provider_instance_id_or_domain: str,
- force_refresh: bool | None = None,
- lazy: bool | None = None,
) -> Artist:
"""Get single Artist from the server."""
return Artist.from_dict(
await self.client.send_command(
- "music/artist",
+ "music/artists/get_artist",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
- force_refresh=force_refresh,
- lazy=lazy,
),
)
return [
Artist.from_dict(item)
for item in await self.client.send_command(
- "music/artist/tracks",
+ "music/artists/artist_tracks",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
)
return [
Album.from_dict(item)
for item in await self.client.send_command(
- "music/artist/albums",
+ "music/artists/artist_albums",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
)
# Playlist related endpoints/commands
- async def get_playlists(
+ async def get_library_playlists(
self,
- in_library: bool | None = None,
+ favorite: bool | None = None,
search: str | None = None,
limit: int | None = None,
offset: int | None = None,
"""Get Playlists listing from the server."""
return PagedItems.parse(
await self.client.send_command(
- "music/playlists",
- in_library=in_library,
+ "music/playlists/library_items",
+ favorite=favorite,
search=search,
limit=limit,
offset=offset,
self,
item_id: str,
provider_instance_id_or_domain: str,
- force_refresh: bool | None = None,
- lazy: bool | None = None,
) -> Playlist:
"""Get single Playlist from the server."""
return Playlist.from_dict(
await self.client.send_command(
- "music/playlist",
+ "music/playlists/get_playlist",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
- force_refresh=force_refresh,
- lazy=lazy,
),
)
return [
Track.from_dict(item)
for item in await self.client.send_command(
- "music/playlist/tracks",
+ "music/playlists/playlist_tracks",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
)
async def add_playlist_tracks(self, db_playlist_id: str | int, uris: list[str]) -> None:
"""Add multiple tracks to playlist. Creates background tasks to process the action."""
await self.client.send_command(
- "music/playlist/tracks/add",
+ "music/playlists/add_playlist_tracks",
db_playlist_id=db_playlist_id,
uris=uris,
)
) -> None:
"""Remove multiple tracks from playlist."""
await self.client.send_command(
- "music/playlist/tracks/add",
+ "music/playlists/remove_playlist_tracks",
db_playlist_id=db_playlist_id,
positions_to_remove=positions_to_remove,
)
"""Create new playlist."""
return Playlist.from_dict(
await self.client.send_command(
- "music/playlist/create",
+ "music/playlists/create_playlist",
name=name,
provider_instance_or_domain=provider_instance_or_domain,
)
# Radio related endpoints/commands
- async def get_radios(
+ async def get_library_radios(
self,
- in_library: bool | None = None,
+ favorite: bool | None = None,
search: str | None = None,
limit: int | None = None,
offset: int | None = None,
"""Get Radio listing from the server."""
return PagedItems.parse(
await self.client.send_command(
- "music/radios",
- in_library=in_library,
+ "music/radio/library_items",
+ favorite=favorite,
search=search,
limit=limit,
offset=offset,
self,
item_id: str,
provider_instance_id_or_domain: str,
- force_refresh: bool | None = None,
- lazy: bool | None = None,
) -> Radio:
"""Get single Radio from the server."""
return Radio.from_dict(
await self.client.send_command(
- "music/radio",
+ "music/radio/get_item",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
- force_refresh=force_refresh,
- lazy=lazy,
),
)
return [
Radio.from_dict(item)
for item in await self.client.send_command(
- "music/radio/versions",
+ "music/radio/radio_versions",
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
)
async def get_item_by_uri(
self,
uri: str,
- force_refresh: bool | None = None,
- lazy: bool | None = None,
) -> MediaItemType:
"""Get single music item providing a mediaitem uri."""
- return media_from_dict(
- await self.client.send_command(
- "music/item_by_uri", uri=uri, force_refresh=force_refresh, lazy=lazy
- )
- )
+ return media_from_dict(await self.client.send_command("music/item_by_uri", uri=uri))
async def refresh_item(
self,
media_type: MediaType,
item_id: str,
provider_instance_id_or_domain: str,
- force_refresh: bool | None = None,
- lazy: bool | None = None,
) -> MediaItemType:
"""Get single music item by id and media type."""
return media_from_dict(
media_type=media_type,
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
- force_refresh=force_refresh,
- lazy=lazy,
)
)
- async def add_to_library(
- self,
- media_type: MediaType,
- item_id: str,
- provider_instance_id_or_domain: str,
+ async def add_item_to_library(self, item: str | MediaItemType) -> MediaItemType:
+ """Add item (uri or mediaitem) to the library."""
+ await self.client.send_command("music/library/add_item", item=item)
+
+ async def remove_item_from_library(
+ self, media_type: MediaType, library_item_id: str | int
) -> None:
- """Add an item to the library."""
+ """
+ Remove item from the library.
+
+ Destructive! Will remove the item and all dependants.
+ """
await self.client.send_command(
- "music/library/add",
- media_type=media_type,
- item_id=item_id,
- provider_instance_id_or_domain=provider_instance_id_or_domain,
+ "music/library/remove", media_type=media_type, library_item_id=library_item_id
)
- async def remove_from_library(
+ async def add_item_to_favorites(
self,
media_type: MediaType,
item_id: str,
provider_instance_id_or_domain: str,
) -> None:
- """Remove an item from the library."""
+ """Add an item to the favorites."""
await self.client.send_command(
- "music/library/remove",
+ "music/favorites/add_item",
media_type=media_type,
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
)
- async def delete_db_item(
- self, media_type: MediaType, db_item_id: str | int, recursive: bool = False
+ async def remove_item_from_favorites(
+ self,
+ media_type: MediaType,
+ item_id: str | int,
) -> None:
- """Remove item from the database."""
+ """Remove (library) item from the favorites."""
await self.client.send_command(
- "music/delete", media_type=media_type, db_item_id=db_item_id, recursive=recursive
+ "music/favorites/remove_item",
+ media_type=media_type,
+ item_id=item_id,
)
async def browse(
def create_sort_name(input_str: str) -> str:
"""Create sort name/title from string."""
input_str = input_str.lower().strip()
- for item in ["the ", "de ", "les "]:
+ for item in ["the ", "de ", "les ", "dj "]:
if input_str.startswith(item):
input_str = input_str.replace(item, "")
return input_str.strip()
MetadataTypes = int | bool | str | list[str]
-JSON_KEYS = ("artists", "artist", "albums", "metadata", "provider_mappings")
-JOINED_KEYS = ("barcode", "isrc")
+JSON_KEYS = ("artists", "metadata", "provider_mappings")
@dataclass
available: bool = True
# quality/audio details (streamable content only)
audio_format: AudioFormat = field(default_factory=AudioFormat)
- # optional details to store provider specific details
- details: str | None = None
# url = link to provider details page if exists
url: str | None = None
+ # isrc (tracks only) - isrc identifier if known
+ isrc: str | None = None
+ # barcode (albums only) - barcode identifier if known
+ barcode: str | None = None
+ # optional details to store provider specific details
+ details: str | None = None
@property
def quality(self) -> int:
# optional fields below
metadata: MediaItemMetadata = field(default_factory=MediaItemMetadata)
- in_library: bool = False
+ favorite: bool = False
media_type: MediaType = MediaType.UNKNOWN
# sort_name and uri are auto generated, do not override unless really needed
sort_name: str | None = None
def from_db_row(cls, db_row: Mapping):
"""Create MediaItem object from database row."""
db_row = dict(db_row)
- db_row["provider"] = "database"
+ db_row["provider"] = "library"
for key in JSON_KEYS:
if key in db_row and db_row[key] is not None:
db_row[key] = json_loads(db_row[key])
- for key in JOINED_KEYS:
- if key not in db_row:
- continue
- db_row[key] = db_row[key].strip()
- db_row[key] = db_row[key].split(";") if db_row[key] else []
- if "in_library" in db_row:
- db_row["in_library"] = bool(db_row["in_library"])
- if db_row.get("albums"):
- db_row["album"] = db_row["albums"][0]
- db_row["disc_number"] = db_row["albums"][0]["disc_number"]
- db_row["track_number"] = db_row["albums"][0]["track_number"]
+ if "favorite" in db_row:
+ db_row["favorite"] = bool(db_row["favorite"])
db_row["item_id"] = str(db_row["item_id"])
return cls.from_dict(db_row)
"""Transform value for db storage."""
if key in JSON_KEYS:
return json_dumps(value)
- if key in JOINED_KEYS:
- return ";".join(value)
return value
return {
item_id: str
provider: str # provider instance id or provider domain
name: str
+ version: str = ""
sort_name: str | None = None
uri: str | None = None
- version: str = ""
available: bool = True
@classmethod
"""Return custom hash."""
return hash((self.media_type.value, self.provider, self.item_id))
+ def __eq__(self, other: ProviderMapping) -> bool:
+ """Check equality of two items."""
+ return self.__hash__() == other.__hash__()
+
@dataclass
class Artist(MediaItem):
"""Model for an artist."""
media_type: MediaType = MediaType.ARTIST
- musicbrainz_id: str | None = None
+ mbid: str | None = None
@dataclass
year: int | None = None
artists: list[Artist | ItemMapping] = field(default_factory=list)
album_type: AlbumType = AlbumType.UNKNOWN
- barcode: set[str] = field(default_factory=set)
- musicbrainz_id: str | None = None # release group id
-
-
-@dataclass
-class DbAlbum(Album):
- """Model for an album when retrieved from the db."""
-
- artists: list[ItemMapping] = field(default_factory=list)
-
-
-@dataclass
-class TrackAlbumMapping(ItemMapping):
- """Model for a track that is mapped to an album."""
-
- disc_number: int | None = None
- track_number: int | None = None
-
- def __hash__(self):
- """Return custom hash."""
- return hash((self.media_type, self.provider, self.item_id))
-
- @classmethod
- def from_item(
- cls,
- item: MediaItemType | ItemMapping,
- disc_number: int | None = None,
- track_number: int | None = None,
- ) -> TrackAlbumMapping:
- """Create TrackAlbumMapping object from regular item."""
- result = super().from_item(item)
- result.disc_number = disc_number
- result.track_number = track_number
- return result
+ mbid: str | None = None # release group id
@dataclass
media_type: MediaType = MediaType.TRACK
duration: int = 0
version: str = ""
- isrc: set[str] = field(default_factory=set)
- musicbrainz_id: str | None = None # Recording ID
+ mbid: str | None = None # Recording ID
artists: list[Artist | ItemMapping] = field(default_factory=list)
- # album track only
- album: Album | ItemMapping | None = None
- albums: list[TrackAlbumMapping] = field(default_factory=list)
- disc_number: int | None = None
- track_number: int | None = None
- # playlist track only
- position: int | None = None
+ album: Album | ItemMapping | None = None # optional
def __hash__(self):
"""Return custom hash."""
return self.metadata and self.metadata.chapters and len(self.metadata.chapters) > 1
-@dataclass
-class DbTrack(Track):
- """Model for a track when retrieved from the db."""
+@dataclass(kw_only=True)
+class AlbumTrack(Track):
+ """Model for a track on an album."""
+
+ album: Album | ItemMapping # required
+ disc_number: int = 0
+ track_number: int = 0
+
+
+@dataclass(kw_only=True)
+class PlaylistTrack(Track):
+ """Model for a track on a playlist."""
- artists: list[ItemMapping] = field(default_factory=list)
- # album track only
- album: ItemMapping | None = None
- albums: list[TrackAlbumMapping] = field(default_factory=list)
+ position: int # required
@dataclass
import pathlib
from typing import Final
-API_SCHEMA_VERSION: Final[int] = 22
-MIN_SCHEMA_VERSION = 22
+API_SCHEMA_VERSION: Final[int] = 23
+MIN_SCHEMA_VERSION: Final[int] = 23
+DB_SCHEMA_VERSION: Final[int] = 24
ROOT_LOGGER_NAME: Final[str] = "music_assistant"
UNKNOWN_ARTIST: Final[str] = "Unknown Artist"
-VARIOUS_ARTISTS: Final[str] = "Various Artists"
-VARIOUS_ARTISTS_ID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377"
+VARIOUS_ARTISTS_NAME: Final[str] = "Various Artists"
+VARIOUS_ARTISTS_ID_MBID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377"
RESOURCES_DIR: Final[pathlib.Path] = (
DB_TABLE_ARTISTS: Final[str] = "artists"
DB_TABLE_ALBUMS: Final[str] = "albums"
DB_TABLE_TRACKS: Final[str] = "tracks"
+DB_TABLE_ALBUM_TRACKS: Final[str] = "albumtracks"
DB_TABLE_PLAYLISTS: Final[str] = "playlists"
DB_TABLE_RADIOS: Final[str] = "radios"
DB_TABLE_CACHE: Final[str] = "cache"
import time
from collections import OrderedDict
from collections.abc import Iterator, MutableMapping
-from typing import TYPE_CHECKING, Any, Final
+from typing import TYPE_CHECKING, Any
from music_assistant.common.helpers.json import json_dumps, json_loads
from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
from music_assistant.common.models.enums import ConfigEntryType
-from music_assistant.constants import DB_TABLE_CACHE, DB_TABLE_SETTINGS, ROOT_LOGGER_NAME
+from music_assistant.constants import (
+ DB_SCHEMA_VERSION,
+ DB_TABLE_CACHE,
+ DB_TABLE_SETTINGS,
+ ROOT_LOGGER_NAME,
+)
from music_assistant.server.helpers.database import DatabaseConnection
from music_assistant.server.models.core_controller import CoreController
LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.cache")
CONF_CLEAR_CACHE = "clear_cache"
-DB_SCHEMA_VERSION: Final[int] = 22
class CacheController(CoreController):
)
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
AlbumType,
- DbAlbum,
ItemMapping,
MediaType,
Track,
)
-from music_assistant.constants import DB_TABLE_ALBUMS, DB_TABLE_TRACKS
+from music_assistant.constants import DB_TABLE_ALBUM_TRACKS, DB_TABLE_ALBUMS, DB_TABLE_TRACKS
from music_assistant.server.controllers.media.base import MediaControllerBase
-from music_assistant.server.helpers.compare import compare_album, loose_compare_strings
+from music_assistant.server.helpers.compare import (
+ compare_album,
+ compare_artists,
+ loose_compare_strings,
+)
if TYPE_CHECKING:
from music_assistant.server.models.music_provider import MusicProvider
db_table = DB_TABLE_ALBUMS
media_type = MediaType.ALBUM
- item_cls = DbAlbum
- _db_add_lock = asyncio.Lock()
+ item_cls = Album
def __init__(self, *args, **kwargs):
"""Initialize class."""
super().__init__(*args, **kwargs)
+ self._db_add_lock = asyncio.Lock()
# register api handlers
- self.mass.register_api_command("music/albums", self.db_items)
- self.mass.register_api_command("music/album", self.get)
- self.mass.register_api_command("music/album/tracks", self.tracks)
- self.mass.register_api_command("music/album/versions", self.versions)
- self.mass.register_api_command("music/album/update", self._update_db_item)
- self.mass.register_api_command("music/album/delete", self.delete)
+ self.mass.register_api_command("music/albums/library_items", self.library_items)
+ self.mass.register_api_command(
+ "music/albums/update_item_in_library", self.update_item_in_library
+ )
+ self.mass.register_api_command(
+ "music/albums/remove_item_from_library", self.remove_item_from_library
+ )
+ self.mass.register_api_command("music/albums/get_album", self.get)
+ self.mass.register_api_command("music/albums/album_tracks", self.tracks)
+ self.mass.register_api_command("music/albums/album_versions", self.versions)
async def get(
self,
force_refresh: bool = False,
lazy: bool = True,
details: Album | ItemMapping = None,
- add_to_db: bool = True,
+ add_to_library: bool = False,
) -> Album:
"""Return (full) details for a single media item."""
album = await super().get(
force_refresh=force_refresh,
lazy=lazy,
details=details,
- add_to_db=add_to_db,
+ add_to_library=add_to_library,
)
# append full artist details to full album item
album.artists = [
await self.mass.music.artists.get(
item.item_id,
item.provider,
- lazy=True,
+ lazy=lazy,
details=item,
- add_to_db=add_to_db,
+ add_to_library=add_to_library,
)
for item in album.artists
]
return album
- async def add(self, item: Album, skip_metadata_lookup: bool = False) -> Album:
- """Add album to local db and return the database item."""
+ async def add_item_to_library(self, item: Album, skip_metadata_lookup: bool = False) -> Album:
+ """Add album to library and return the database item."""
if not isinstance(item, Album):
raise InvalidDataError("Not a valid Album object (ItemMapping can not be added to db)")
+ if not item.provider_mappings:
+ raise InvalidDataError("Album is missing provider mapping(s)")
# resolve any ItemMapping artists
item.artists = [
await self.mass.music.artists.get_provider_item(
else artist
for artist in item.artists
]
+ if not item.artists:
+ raise InvalidDataError("Album is missing artist(s)")
# grab additional metadata
if not skip_metadata_lookup:
await self.mass.metadata.get_album_metadata(item)
- if item.provider == "database":
- db_item = await self._update_db_item(item.item_id, item)
- else:
- # use the lock to prevent a race condition of the same item being added twice
- async with self._db_add_lock:
- db_item = await self._add_db_item(item)
+ # actually add (or update) the item in the library db
+ # use the lock to prevent a race condition of the same item being added twice
+ async with self._db_add_lock:
+ library_item = await self._add_library_item(item)
# also fetch the same album on all providers
if not skip_metadata_lookup:
- await self._match(db_item)
- # preload album tracks listing (do not load them in the db)
- for prov_mapping in db_item.provider_mappings:
- if not prov_mapping.available:
- continue
- await self._get_provider_album_tracks(
- prov_mapping.item_id, prov_mapping.provider_instance
- )
- # return final db_item after all match/metadata actions
- return await self.get_db_item(db_item.item_id)
+ await self._match(library_item)
+ library_item = await self.get_library_item(library_item.item_id)
+ # also add album tracks
+ if not skip_metadata_lookup and item.provider != "library":
+ async with asyncio.TaskGroup() as tg:
+ for track in await self._get_provider_album_tracks(item.item_id, item.provider):
+ track.album = library_item
+ tg.create_task(
+ self.mass.music.tracks.add_item_to_library(
+ track, skip_metadata_lookup=skip_metadata_lookup
+ )
+ )
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_ADDED,
+ library_item.uri,
+ library_item,
+ )
+ return library_item
- async def update(self, item_id: str | int, update: Album, overwrite: bool = False) -> Album:
+ async def update_item_in_library(
+ self, item_id: str | int, update: Album, overwrite: bool = False
+ ) -> Album:
"""Update existing record in the database."""
db_id = int(item_id) # ensure integer
- return await self._update_db_item(item_id=db_id, item=update, overwrite=overwrite)
+ cur_item = await self.get_library_item(db_id)
+ metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite)
+ provider_mappings = self._get_provider_mappings(cur_item, update, overwrite)
+ album_artists = await self._get_artist_mappings(cur_item, update, overwrite)
+ if getattr(update, "album_type", AlbumType.UNKNOWN) != AlbumType.UNKNOWN:
+ album_type = update.album_type
+ else:
+ album_type = cur_item.album_type
+ sort_artist = album_artists[0].sort_name
+ await self.mass.music.database.update(
+ self.db_table,
+ {"item_id": db_id},
+ {
+ "name": update.name if overwrite else cur_item.name,
+ "sort_name": update.sort_name if overwrite else cur_item.sort_name,
+ "sort_artist": sort_artist,
+ "version": update.version if overwrite else cur_item.version,
+ "year": update.year if overwrite else cur_item.year or update.year,
+ "album_type": album_type.value,
+ "artists": serialize_to_json(album_artists),
+ "metadata": serialize_to_json(metadata),
+ "provider_mappings": serialize_to_json(provider_mappings),
+ "mbid": update.mbid or cur_item.mbid,
+ "timestamp_modified": int(utc_timestamp()),
+ },
+ )
+ # update/set provider_mappings table
+ await self._set_provider_mappings(db_id, provider_mappings)
+ self.logger.debug("updated %s in database: %s", update.name, db_id)
+ # get full created object
+ library_item = await self.get_library_item(db_id)
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_UPDATED,
+ library_item.uri,
+ library_item,
+ )
+ # return the full item we just updated
+ return library_item
- async def delete(self, item_id: str | int, recursive: bool = False) -> None:
+ async def remove_item_from_library(self, item_id: str | int) -> None:
"""Delete record from the database."""
db_id = int(item_id) # ensure integer
- # check album tracks
- db_rows = await self.mass.music.database.get_rows_from_query(
- f"SELECT item_id FROM {DB_TABLE_TRACKS} WHERE albums LIKE '%\"{db_id}\"%'",
- limit=5000,
- )
- assert not (db_rows and not recursive), "Tracks attached to album"
- for db_row in db_rows:
+ # recursively also remove album tracks
+ for db_track in await self._get_db_album_tracks(db_id):
with contextlib.suppress(MediaNotFoundError):
- await self.mass.music.tracks.delete(db_row["item_id"], recursive)
-
+ await self.mass.music.tracks.remove_item_from_library(db_track.item_id)
+ # delete entry(s) from albumtracks table
+ await self.mass.music.database.delete(DB_TABLE_ALBUM_TRACKS, {"album_id": db_id})
# delete the album itself from db
- await super().delete(item_id)
+ await super().remove_item_from_library(item_id)
async def tracks(
self,
provider_instance_id_or_domain: str,
) -> list[Track]:
"""Return album tracks for the given provider album id."""
- if provider_instance_id_or_domain == "database":
- if db_result := await self._get_db_album_tracks(item_id):
- return db_result
- # no results in db (yet), grab provider details
- if db_album := await self.get_db_item(item_id):
- for prov_mapping in db_album.provider_mappings:
- # returns the first provider that is available
- if not prov_mapping.available:
- continue
- return await self._get_provider_album_tracks(
- prov_mapping.item_id, prov_mapping.provider_instance
- )
-
+ if provider_instance_id_or_domain == "library":
+ return await self._get_db_album_tracks(item_id)
# return provider album tracks
return await self._get_provider_album_tracks(item_id, provider_instance_id_or_domain)
item_id: str,
provider_instance_id_or_domain: str,
) -> list[Album]:
- """Return all versions of an album we can find on all providers."""
- album = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False)
- # perform a search on all provider(types) to collect all versions/variants
+ """Return all versions of an album we can find on the provider."""
+ album = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False)
search_query = f"{album.artists[0].name} - {album.name}"
- all_versions = {
- prov_item.item_id: prov_item
- for prov_items in await asyncio.gather(
- *[
- self.search(search_query, provider_instance_id)
- for provider_instance_id in self.mass.music.get_unique_providers()
- ]
- )
- for prov_item in prov_items
- # title must (partially) match
+ return [
+ prov_item
+ for prov_item in await self.search(search_query, provider_instance_id_or_domain)
if loose_compare_strings(album.name, prov_item.name)
- # artist must match
- and album.artists[0].sort_name in {x.sort_name for x in prov_item.artists}
- }
- # make sure that the 'base' version is NOT included
- for prov_version in album.provider_mappings:
- all_versions.pop(prov_version.item_id, None)
-
- # return the aggregated result
- return all_versions.values()
+ and compare_artists(prov_item.artists, album.artists, any_match=True)
+ # make sure that the 'base' version is NOT included
+ and prov_item.item_id != item_id
+ ]
- async def _add_db_item(self, item: Album) -> Album:
+ async def _add_library_item(self, item: Album) -> Album:
"""Add a new record to the database."""
- assert item.provider_mappings, "Item is missing provider mapping(s)"
- assert item.artists, f"Album {item.name} is missing artists"
-
# safety guard: check for existing item first
- if cur_item := await self.get_db_item_by_prov_id(item.item_id, item.provider):
+ if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider):
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
- if item.musicbrainz_id:
- match = {"musicbrainz_id": item.musicbrainz_id}
+ return await self.update_item_in_library(cur_item.item_id, item)
+ if item.mbid:
+ match = {"mbid": item.mbid}
if db_row := await self.mass.music.database.get_row(self.db_table, match):
cur_item = Album.from_db_row(db_row)
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
- # try barcode/upc
- if not cur_item and item.barcode:
- for barcode in item.barcode:
- if search_result := await self.mass.music.database.search(
- self.db_table, barcode, "barcode"
- ):
- cur_item = Album.from_db_row(search_result[0])
- # existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
+ return await self.update_item_in_library(cur_item.item_id, item)
# fallback to search and match
match = {"sort_name": item.sort_name}
for row in await self.mass.music.database.get_rows(self.db_table, match):
if compare_album(row_album, item):
cur_item = row_album
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
+ return await self.update_item_in_library(cur_item.item_id, item)
# insert new item
album_artists = await self._get_artist_mappings(item, cur_item)
- sort_artist = album_artists[0].sort_name if album_artists else ""
+ sort_artist = album_artists[0].sort_name
new_item = await self.mass.music.database.insert(
self.db_table,
{
**item.to_db_row(),
- "artists": serialize_to_json(album_artists) or None,
+ "artists": serialize_to_json(album_artists),
"sort_artist": sort_artist,
"timestamp_added": int(utc_timestamp()),
"timestamp_modified": int(utc_timestamp()),
# update/set provider_mappings table
await self._set_provider_mappings(db_id, item.provider_mappings)
self.logger.debug("added %s to database", item.name)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_ADDED,
- db_item.uri,
- db_item,
- )
# return the full item we just added
- return db_item
-
- async def _update_db_item(
- self, item_id: str | int, item: Album | ItemMapping, overwrite: bool = False
- ) -> Album:
- """Update Album record in the database."""
- db_id = int(item_id) # ensure integer
- cur_item = await self.get_db_item(db_id)
- metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
- provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
- album_artists = await self._get_artist_mappings(cur_item, item, overwrite)
- if getattr(item, "barcode", None):
- cur_item.barcode.update(item.barcode)
- if getattr(item, "album_type", AlbumType.UNKNOWN) != AlbumType.UNKNOWN:
- album_type = item.album_type
- else:
- album_type = cur_item.album_type
- sort_artist = album_artists[0].sort_name if album_artists else ""
- await self.mass.music.database.update(
- self.db_table,
- {"item_id": db_id},
- {
- "name": item.name if overwrite else cur_item.name,
- "sort_name": item.sort_name if overwrite else cur_item.sort_name,
- "sort_artist": sort_artist,
- "version": item.version if overwrite else cur_item.version,
- "year": item.year if overwrite else cur_item.year or item.year,
- "barcode": ";".join(cur_item.barcode),
- "album_type": album_type.value,
- "artists": serialize_to_json(album_artists) or None,
- "metadata": serialize_to_json(metadata),
- "provider_mappings": serialize_to_json(provider_mappings),
- "musicbrainz_id": item.musicbrainz_id or cur_item.musicbrainz_id,
- "timestamp_modified": int(utc_timestamp()),
- },
- )
- # update/set provider_mappings table
- await self._set_provider_mappings(db_id, provider_mappings)
- self.logger.debug("updated %s in database: %s", item.name, db_id)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_UPDATED,
- db_item.uri,
- db_item,
- )
- # return the full item we just updated
- return db_item
+ return await self.get_library_item(db_id)
async def _get_provider_album_tracks(
self, item_id: str, provider_instance_id_or_domain: str
- ) -> list[Track]:
+ ) -> list[AlbumTrack]:
"""Return album tracks for the given provider album id."""
- assert provider_instance_id_or_domain != "database"
+ assert provider_instance_id_or_domain != "library"
prov = self.mass.get_provider(provider_instance_id_or_domain)
if prov is None:
return []
else:
cache_checksum = full_album.metadata.checksum
if cache := await self.mass.cache.get(cache_key, checksum=cache_checksum):
- return [Track.from_dict(x) for x in cache]
+ return [AlbumTrack.from_dict(x) for x in cache]
# no items in cache - get listing from provider
items = []
for track in await prov.get_album_tracks(item_id):
+ assert isinstance(track, AlbumTrack)
+ assert track.track_number
# make sure that the (full) album is stored on the tracks
track.album = full_album
if not isinstance(full_album, ItemMapping) and full_album.metadata.images:
limit: int = 25,
):
"""Generate a dynamic list of tracks based on the album content."""
- assert provider_instance_id_or_domain != "database"
+ assert provider_instance_id_or_domain != "library"
prov = self.mass.get_provider(provider_instance_id_or_domain)
if prov is None:
return []
async def _get_db_album_tracks(
self,
item_id: str | int,
- ) -> list[Track]:
+ ) -> list[AlbumTrack]:
"""Return in-database album tracks for the given database album."""
db_id = int(item_id) # ensure integer
- db_album = await self.get_db_item(db_id)
- # simply grab all tracks in the db that are linked to this album
- # TODO: adjust to json query instead of text search?
- query = f'SELECT * FROM {DB_TABLE_TRACKS} WHERE albums LIKE \'%"item_id":"{db_id}","provider":"database"%\'' # noqa: E501
- result = []
- for track in await self.mass.music.tracks.get_db_items_by_query(query):
- if album_mapping := next(
- (x for x in track.albums if x.item_id == db_album.item_id), None
- ):
- # make sure that the full album is set on the track and prefer the album's images
- track.album = db_album
- if db_album.metadata.images:
- track.metadata.images = db_album.metadata.images
- # apply the disc and track number from the mapping
- track.disc_number = album_mapping.disc_number
- track.track_number = album_mapping.track_number
- result.append(track)
- return sorted(result, key=lambda x: (x.disc_number or 0, x.track_number or 0))
+ db_album = await self.get_library_item(db_id)
+ result: list[AlbumTrack] = []
+ async for album_track_row in self.mass.music.database.iter_items(
+ DB_TABLE_ALBUM_TRACKS, {"album_id": db_id}
+ ):
+ # TODO: make this a nice join query
+ track_id = album_track_row["track_id"]
+ track_row = await self.mass.music.database.get_row(
+ DB_TABLE_TRACKS, {"item_id": track_id}
+ )
+ album_track = AlbumTrack.from_db_row(
+ {**track_row, **album_track_row, "album": db_album.to_dict()}
+ )
+ if db_album.metadata.images:
+ album_track.metadata.images = db_album.metadata.images
+ result.append(album_track)
+ return sorted(result, key=lambda x: (x.disc_number, x.track_number))
async def _match(self, db_album: Album) -> None:
- """Try to find matching album on all providers for the provided (database) album.
+ """Try to find match on all (streaming) providers for the provided (database) album.
This is used to link objects of different providers/qualities together.
"""
- if db_album.provider != "database":
+ if db_album.provider != "library":
return # Matching only supported for database items
+ artist_name = db_album.artists[0].name
async def find_prov_match(provider: MusicProvider):
self.logger.debug(
match_found = False
for search_str in (
db_album.name,
- f"{db_album.artists[0].name} - {db_album.name}",
- f"{db_album.artists[0].name} {db_album.name}",
+ f"{artist_name} - {db_album.name}",
+ f"{artist_name} {db_album.name}",
):
if match_found:
break
fallback=search_result_item,
)
if compare_album(prov_album, db_album):
- # 100% match, we can simply update the db with additional provider ids
- await self._update_db_item(db_album.item_id, prov_album)
+ # 100% match, we update the db with the additional provider mapping(s)
match_found = True
+ for provider_mapping in search_result_item.provider_mappings:
+ await self.add_provider_mapping(db_album.item_id, provider_mapping)
return match_found
# try to find match on all providers
continue
if ProviderFeature.SEARCH not in provider.supported_features:
continue
- if provider.is_unique:
- # matching on unique provider sis pointless as they push (all) their content to MA
+ if not provider.library_supported(MediaType.ALBUM):
+ continue
+ if not provider.is_streaming_provider:
+ # matching on unique providers is pointless as they push (all) their content to MA
continue
if await find_prov_match(provider):
cur_provider_domains.add(provider.domain)
import asyncio
import contextlib
-import itertools
from random import choice, random
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING
from music_assistant.common.helpers.datetime import utc_timestamp
from music_assistant.common.helpers.json import serialize_to_json
PagedItems,
Track,
)
-from music_assistant.constants import VARIOUS_ARTISTS, VARIOUS_ARTISTS_ID
+from music_assistant.constants import VARIOUS_ARTISTS_ID_MBID, VARIOUS_ARTISTS_NAME
from music_assistant.server.controllers.media.base import MediaControllerBase
from music_assistant.server.controllers.music import (
DB_TABLE_ALBUMS,
db_table = DB_TABLE_ARTISTS
media_type = MediaType.ARTIST
item_cls = Artist
- _db_add_lock = asyncio.Lock()
def __init__(self, *args, **kwargs):
"""Initialize class."""
super().__init__(*args, **kwargs)
+ self._db_add_lock = asyncio.Lock()
# register api handlers
- self.mass.register_api_command("music/artists", self.db_items)
- self.mass.register_api_command("music/albumartists", self.album_artists)
- self.mass.register_api_command("music/artist", self.get)
- self.mass.register_api_command("music/artist/albums", self.albums)
- self.mass.register_api_command("music/artist/tracks", self.tracks)
- self.mass.register_api_command("music/artist/update", self._update_db_item)
- self.mass.register_api_command("music/artist/delete", self.delete)
+ self.mass.register_api_command("music/artists/library_items", self.library_items)
+ self.mass.register_api_command(
+ "music/artists/update_item_in_library", self.update_item_in_library
+ )
+ self.mass.register_api_command(
+ "music/artists/remove_item_from_library", self.remove_item_from_library
+ )
+ self.mass.register_api_command("music/artists/get_artist", self.get)
+ self.mass.register_api_command("music/artists/artist_albums", self.albums)
+ self.mass.register_api_command("music/artists/artist_tracks", self.tracks)
- async def add(self, item: Artist | ItemMapping, skip_metadata_lookup: bool = False) -> Artist:
- """Add artist to local db and return the database item."""
+ async def add_item_to_library(
+ self, item: Artist | ItemMapping, skip_metadata_lookup: bool = False
+ ) -> Artist:
+ """Add artist to library and return the database item."""
if isinstance(item, ItemMapping):
skip_metadata_lookup = True
# grab musicbrainz id and additional metadata
if not skip_metadata_lookup:
await self.mass.metadata.get_artist_metadata(item)
- if item.provider == "database":
- db_item = await self._update_db_item(item.item_id, item)
- else:
- # use the lock to prevent a race condition of the same item being added twice
- async with self._db_add_lock:
- db_item = await self._add_db_item(item)
+ # actually add (or update) the item in the library db
+ # use the lock to prevent a race condition of the same item being added twice
+ async with self._db_add_lock:
+ library_item = await self._add_library_item(item)
# also fetch same artist on all providers
if not skip_metadata_lookup:
- await self.match_artist(db_item)
- # return final db_item after all match/metadata actions
- return await self.get_db_item(db_item.item_id)
+ await self.match_artist(library_item)
+ library_item = await self.get_library_item(library_item.item_id)
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_ADDED,
+ library_item.uri,
+ library_item,
+ )
+ # return final library_item after all match/metadata actions
+ return library_item
- async def update(self, item_id: str | int, update: Artist, overwrite: bool = False) -> Artist:
+ async def update_item_in_library(
+ self, item_id: str | int, update: Artist, overwrite: bool = False
+ ) -> Artist:
"""Update existing record in the database."""
- return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite)
+ db_id = int(item_id) # ensure integer
+ cur_item = await self.get_library_item(db_id)
+ metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite)
+ provider_mappings = self._get_provider_mappings(cur_item, update, overwrite)
- async def album_artists(
+ # enforce various artists name + id
+ mbid = cur_item.mbid
+ if (not mbid or overwrite) and getattr(update, "mbid", None):
+ if compare_strings(update.name, VARIOUS_ARTISTS_NAME):
+ update.mbid = VARIOUS_ARTISTS_ID_MBID
+ if update.mbid == VARIOUS_ARTISTS_ID_MBID:
+ update.name = VARIOUS_ARTISTS_NAME
+ await self.mass.music.database.update(
+ self.db_table,
+ {"item_id": db_id},
+ {
+ "name": update.name if overwrite else cur_item.name,
+ "sort_name": update.sort_name if overwrite else cur_item.sort_name,
+ "mbid": mbid,
+ "metadata": serialize_to_json(metadata),
+ "provider_mappings": serialize_to_json(provider_mappings),
+ "timestamp_modified": int(utc_timestamp()),
+ },
+ )
+ # update/set provider_mappings table
+ await self._set_provider_mappings(db_id, provider_mappings)
+ self.logger.debug("updated %s in database: %s", update.name, db_id)
+ # get full created object
+ library_item = await self.get_library_item(db_id)
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_UPDATED,
+ library_item.uri,
+ library_item,
+ )
+ # return the full item we just updated
+ return library_item
+
+ async def library_items(
self,
- in_library: bool | None = None,
+ favorite: bool | None = None,
search: str | None = None,
limit: int = 500,
offset: int = 0,
order_by: str = "sort_name",
+ album_artists_only: bool = False,
) -> PagedItems:
"""Get in-database album artists."""
- return await self.db_items(
- in_library=in_library,
+ return await super().library_items(
+ favorite=favorite,
search=search,
limit=limit,
offset=offset,
order_by=order_by,
- query_parts=["artists.sort_name in (select albums.sort_artist from albums)"],
+ query_parts=["artists.sort_name in (select albums.sort_artist from albums)"]
+ if album_artists_only
+ else None,
)
async def tracks(
self,
- item_id: str | None = None,
- provider_instance_id_or_domain: str | None = None,
- artist: Artist | None = None,
+ item_id: str,
+ provider_instance_id_or_domain: str,
) -> list[Track]:
- """Return top tracks for an artist."""
- if not artist:
- artist = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False)
- # get results from all providers
- coros = [
- self.get_provider_artist_toptracks(
- prov_mapping.item_id,
- prov_mapping.provider_instance,
- cache_checksum=artist.metadata.checksum,
+ """Return all/top tracks for an artist."""
+ if provider_instance_id_or_domain == "library":
+ return await self.get_library_artist_tracks(
+ item_id,
)
- for prov_mapping in artist.provider_mappings
- ]
- tracks = itertools.chain.from_iterable(await asyncio.gather(*coros))
- # merge duplicates using a dict
- final_items: dict[str, Track] = {}
- for track in tracks:
- key = f".{track.name}.{track.version}"
- if key in final_items:
- final_items[key].provider_mappings.update(track.provider_mappings)
- else:
- final_items[key] = track
- return list(final_items.values())
+ return await self.get_provider_artist_toptracks(
+ item_id,
+ provider_instance_id_or_domain,
+ )
async def albums(
self,
- item_id: str | None = None,
- provider_instance_id_or_domain: str | None = None,
- artist: Artist | None = None,
+ item_id: str,
+ provider_instance_id_or_domain: str,
) -> list[Album]:
"""Return (all/most popular) albums for an artist."""
- if not artist:
- artist = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False)
- # get results from all providers
- coros = [
- self.get_provider_artist_albums(
- item.item_id,
- item.provider_domain,
- cache_checksum=artist.metadata.checksum,
+ if provider_instance_id_or_domain == "library":
+ return await self.get_library_artist_albums(
+ item_id,
)
- for item in artist.provider_mappings
- ]
- albums: list[Album] = itertools.chain.from_iterable(await asyncio.gather(*coros))
- # merge duplicates using a dict
- final_items: dict[str, Album] = {}
- for album in albums:
- key = f".{album.name}.{album.version}.{album.metadata.explicit}"
- if key in final_items:
- final_items[key].provider_mappings.update(album.provider_mappings)
- else:
- final_items[key] = album
- if album.in_library:
- final_items[key].in_library = True
- return list(final_items.values())
+ return await self.get_provider_artist_albums(
+ item_id,
+ provider_instance_id_or_domain,
+ )
- async def delete(self, item_id: str | int, recursive: bool = False) -> None:
+ async def remove_item_from_library(self, item_id: str | int) -> None:
"""Delete record from the database."""
db_id = int(item_id) # ensure integer
- # check artist albums
- db_rows = await self.mass.music.database.get_rows_from_query(
+ # recursively also remove artist albums
+ for db_row in await self.mass.music.database.get_rows_from_query(
f"SELECT item_id FROM {DB_TABLE_ALBUMS} WHERE artists LIKE '%\"{db_id}\"%'",
limit=5000,
- )
- assert not (db_rows and not recursive), "Albums attached to artist"
- for db_row in db_rows:
+ ):
with contextlib.suppress(MediaNotFoundError):
- await self.mass.music.albums.delete(db_row["item_id"], recursive)
+ await self.mass.music.albums.remove_item_from_library(db_row["item_id"])
- # check artist tracks
- db_rows = await self.mass.music.database.get_rows_from_query(
+ # recursively also remove artist tracks
+ for db_row in await self.mass.music.database.get_rows_from_query(
f"SELECT item_id FROM {DB_TABLE_TRACKS} WHERE artists LIKE '%\"{db_id}\"%'",
limit=5000,
- )
- assert not (db_rows and not recursive), "Tracks attached to artist"
- for db_row in db_rows:
+ ):
with contextlib.suppress(MediaNotFoundError):
- await self.mass.music.albums.delete(db_row["item_id"], recursive)
+ await self.mass.music.tracks.remove_item_from_library(db_row["item_id"])
# delete the artist itself from db
- await super().delete(db_id)
+ await super().remove_item_from_library(db_id)
async def match_artist(self, db_artist: Artist):
"""Try to find matching artists on all providers for the provided (database) item_id.
This is used to link objects of different providers together.
"""
- assert db_artist.provider == "database", "Matching only supported for database items!"
+ assert db_artist.provider == "library", "Matching only supported for database items!"
cur_provider_domains = {x.provider_domain for x in db_artist.provider_mappings}
for provider in self.mass.music.providers:
if provider.domain in cur_provider_domains:
continue
if ProviderFeature.SEARCH not in provider.supported_features:
continue
- if provider.is_unique:
- # matching on unique provider sis pointless as they push (all) their content to MA
+ if not provider.library_supported(MediaType.ARTIST):
+ continue
+ if not provider.is_streaming_provider:
+ # matching on unique providers is pointless as they push (all) their content to MA
continue
if await self._match(db_artist, provider):
cur_provider_domains.add(provider.domain)
async def get_provider_artist_toptracks(
self,
item_id: str,
- provider_instance_id_or_domain: str | None = None,
- cache_checksum: Any = None,
+ provider_instance_id_or_domain: str,
) -> list[Track]:
"""Return top tracks for an artist on given provider."""
- assert provider_instance_id_or_domain != "database"
+ assert provider_instance_id_or_domain != "library"
+ artist = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False)
+ cache_checksum = artist.metadata.checksum
prov = self.mass.get_provider(provider_instance_id_or_domain)
if prov is None:
return []
else:
# fallback implementation using the db
items = []
- if db_artist := await self.mass.music.artists.get_db_item_by_prov_id(
+ if db_artist := await self.mass.music.artists.get_library_item_by_prov_id(
item_id,
provider_instance_id_or_domain,
):
# TODO: adjust to json query instead of text search?
query = f"SELECT * FROM tracks WHERE artists LIKE '%\"{db_artist.item_id}\"%'"
query += f" AND provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'"
- items = await self.mass.music.tracks.get_db_items_by_query(query)
+ items = await self.mass.music.tracks.get_library_items_by_query(query)
# store (serializable items) in cache
self.mass.create_task(
self.mass.cache.set(cache_key, [x.to_dict() for x in items], checksum=cache_checksum)
)
return items
+ async def get_library_artist_tracks(
+ self,
+ item_id: str | int,
+ ) -> list[Track]:
+ """Return all tracks for an artist in the library."""
+ # TODO: adjust to json query instead of text search?
+ query = f"SELECT * FROM tracks WHERE artists LIKE '%\"{item_id}\"%'"
+ return await self.mass.music.tracks.get_library_items_by_query(query)
+
async def get_provider_artist_albums(
self,
item_id: str,
- provider_instance_id_or_domain: str | None = None,
- cache_checksum: Any = None,
+ provider_instance_id_or_domain: str,
) -> list[Album]:
"""Return albums for an artist on given provider."""
- assert provider_instance_id_or_domain != "database"
+ assert provider_instance_id_or_domain != "library"
+ artist = await self.get_provider_item(item_id, provider_instance_id_or_domain)
+ cache_checksum = artist.metadata.checksum
prov = self.mass.get_provider(provider_instance_id_or_domain)
if prov is None:
return []
items = await prov.get_artist_albums(item_id)
else:
# fallback implementation using the db
- if db_artist := await self.mass.music.artists.get_db_item_by_prov_id( # noqa: PLR5501
+ # ruff: noqa: PLR5501
+ if db_artist := await self.mass.music.artists.get_library_item_by_prov_id(
item_id,
provider_instance_id_or_domain,
):
# TODO: adjust to json query instead of text search?
query = f"SELECT * FROM albums WHERE artists LIKE '%\"{db_artist.item_id}\"%'"
query += f" AND provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'"
- items = await self.mass.music.albums.get_db_items_by_query(query)
+ items = await self.mass.music.albums.get_library_items_by_query(query)
else:
# edge case
items = []
)
return items
- async def _add_db_item(self, item: Artist | ItemMapping) -> Artist:
+ async def get_library_artist_albums(
+ self,
+ item_id: str | int,
+ ) -> list[Album]:
+ """Return all in-library albums for an artist."""
+ # TODO: adjust to json query instead of text search?
+ query = f"SELECT * FROM albums WHERE artists LIKE '%\"{item_id}\"%'"
+ return await self.mass.music.albums.get_library_items_by_query(query)
+
+ async def _add_library_item(self, item: Artist | ItemMapping) -> Artist:
"""Add a new item record to the database."""
# enforce various artists name + id
if not isinstance(item, ItemMapping):
- if compare_strings(item.name, VARIOUS_ARTISTS):
- item.musicbrainz_id = VARIOUS_ARTISTS_ID
- if item.musicbrainz_id == VARIOUS_ARTISTS_ID:
- item.name = VARIOUS_ARTISTS
+ if compare_strings(item.name, VARIOUS_ARTISTS_NAME):
+ item.mbid = VARIOUS_ARTISTS_ID_MBID
+ if item.mbid == VARIOUS_ARTISTS_ID_MBID:
+ item.name = VARIOUS_ARTISTS_NAME
# safety guard: check for existing item first
if isinstance(item, ItemMapping) and (
- cur_item := await self.get_db_item_by_prov_id(item.item_id, item.provider)
+ cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider)
):
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
- if cur_item := await self.get_db_item_by_prov_mappings(item.provider_mappings):
- return await self._update_db_item(cur_item.item_id, item)
- if musicbrainz_id := getattr(item, "musicbrainz_id", None):
- match = {"musicbrainz_id": musicbrainz_id}
+ return await self.update_item_in_library(cur_item.item_id, item)
+ if not isinstance(item, ItemMapping) and (
+ cur_item := await self.get_library_item_by_prov_mappings(item.provider_mappings)
+ ):
+ return await self.update_item_in_library(cur_item.item_id, item)
+ if mbid := getattr(item, "mbid", None):
+ match = {"mbid": mbid}
if db_row := await self.mass.music.database.get_row(self.db_table, match):
# existing item found: update it
cur_item = Artist.from_db_row(db_row)
- return await self._update_db_item(cur_item.item_id, item)
+ return await self.update_item_in_library(cur_item.item_id, item)
# fallback to exact name match
# NOTE: we match an artist by name which could theoretically lead to collisions
# but the chance is so small it is not worth the additional overhead of grabbing
if row_artist.sort_name == item.sort_name:
cur_item = row_artist
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
+ return await self.update_item_in_library(cur_item.item_id, item)
# no existing item matched: insert item
item.timestamp_added = int(utc_timestamp())
# update/set provider_mappings table
await self._set_provider_mappings(db_id, item.provider_mappings)
self.logger.debug("added %s to database", item.name)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_ADDED,
- db_item.uri,
- db_item,
- )
# return the full item we just added
- return db_item
-
- async def _update_db_item(
- self, item_id: str | int, item: Artist | ItemMapping, overwrite: bool = False
- ) -> Artist:
- """Update Artist record in the database."""
- db_id = int(item_id) # ensure integer
- cur_item = await self.get_db_item(db_id)
- metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
- provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
-
- # enforce various artists name + id
- musicbrainz_id = cur_item.musicbrainz_id
- if (not musicbrainz_id or overwrite) and getattr(item, "musicbrainz_id", None):
- if compare_strings(item.name, VARIOUS_ARTISTS):
- item.musicbrainz_id = VARIOUS_ARTISTS_ID
- if item.musicbrainz_id == VARIOUS_ARTISTS_ID:
- item.name = VARIOUS_ARTISTS
- await self.mass.music.database.update(
- self.db_table,
- {"item_id": db_id},
- {
- "name": item.name if overwrite else cur_item.name,
- "sort_name": item.sort_name if overwrite else cur_item.sort_name,
- "musicbrainz_id": musicbrainz_id,
- "metadata": serialize_to_json(metadata),
- "provider_mappings": serialize_to_json(provider_mappings),
- "timestamp_modified": int(utc_timestamp()),
- },
- )
- # update/set provider_mappings table
- await self._set_provider_mappings(db_id, provider_mappings)
- self.logger.debug("updated %s in database: %s", item.name, db_id)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_UPDATED,
- db_item.uri,
- db_item,
- )
- # return the full item we just updated
- return db_item
+ return await self.get_library_item(db_id)
async def _get_provider_dynamic_tracks(
self,
limit: int = 25,
):
"""Generate a dynamic list of tracks based on the artist's top tracks."""
- assert provider_instance_id_or_domain != "database"
+ assert provider_instance_id_or_domain != "library"
prov = self.mass.get_provider(provider_instance_id_or_domain)
if prov is None:
return []
"""Try to find matching artists on given provider for the provided (database) artist."""
self.logger.debug("Trying to match artist %s on provider %s", db_artist.name, provider.name)
# try to get a match with some reference tracks of this artist
- for ref_track in await self.tracks(db_artist.item_id, db_artist.provider, artist=db_artist):
+ ref_tracks = await self.mass.music.artists.tracks(db_artist.item_id, db_artist.provider)
+ if len(ref_tracks) < 10:
+ # fetch reference tracks from provider(s) attached to the artist
+ for provider_mapping in db_artist.provider_mappings:
+ ref_tracks += await self.mass.music.artists.tracks(
+ provider_mapping.item_id, provider_mapping.provider_instance
+ )
+ for ref_track in ref_tracks:
# make sure we have a full track
if isinstance(ref_track.album, ItemMapping):
- ref_track = await self.mass.music.tracks.get( # noqa: PLW2901
- ref_track.item_id, ref_track.provider, add_to_db=False
- )
+ try:
+ ref_track = await self.mass.music.tracks.get_provider_item( # noqa: PLW2901
+ ref_track.item_id, ref_track.provider
+ )
+ except MediaNotFoundError:
+ continue
for search_str in (
f"{db_artist.name} - {ref_track.name}",
f"{db_artist.name} {ref_track.name}",
+ f"{db_artist.sort_name} {ref_track.sort_name}",
ref_track.name,
):
search_results = await self.mass.music.tracks.search(search_str, provider.domain)
search_item_artist.provider,
fallback=search_result_item,
)
- await self._update_db_item(db_artist.item_id, prov_artist)
+ # 100% match, we update the db with the additional provider mapping(s)
+ for provider_mapping in search_result_item.provider_mappings:
+ await self.add_provider_mapping(db_artist.item_id, provider_mapping)
return True
# try to get a match with some reference albums of this artist
- artist_albums = await self.albums(db_artist.item_id, db_artist.provider, artist=db_artist)
- for ref_album in artist_albums:
+ ref_albums = await self.mass.music.artists.albums(db_artist.item_id, db_artist.provider)
+ if len(ref_albums) < 10:
+ # fetch reference albums from provider(s) attached to the artist
+ for provider_mapping in db_artist.provider_mappings:
+ ref_albums += await self.mass.music.artists.albums(
+ provider_mapping.item_id, provider_mapping.provider_instance
+ )
+ for ref_album in ref_albums:
if ref_album.album_type == AlbumType.COMPILATION:
continue
if not ref_album.artists:
ref_album.name,
f"{db_artist.name} - {ref_album.name}",
f"{db_artist.name} {ref_album.name}",
+ f"{db_artist.sort_name} {ref_album.sort_name}",
):
search_result = await self.mass.music.albums.search(search_str, provider.domain)
for search_result_item in search_result:
search_result_item.artists[0].provider,
fallback=search_result_item,
)
- await self._update_db_item(db_artist.item_id, prov_artist)
+ await self.update_item_in_library(db_artist.item_id, prov_artist)
return True
return False
import logging
from abc import ABCMeta, abstractmethod
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Iterable
from contextlib import suppress
from time import time
from typing import TYPE_CHECKING, Generic, TypeVar
ProviderMapping,
media_from_dict,
)
-from music_assistant.constants import DB_TABLE_PROVIDER_MAPPINGS, ROOT_LOGGER_NAME, VARIOUS_ARTISTS
+from music_assistant.constants import DB_TABLE_PROVIDER_MAPPINGS, ROOT_LOGGER_NAME
if TYPE_CHECKING:
from music_assistant.common.models.media_items import Album, Artist, Track
self.logger = logging.getLogger(f"{ROOT_LOGGER_NAME}.music.{self.media_type.value}")
@abstractmethod
- async def add(self, item: ItemCls, skip_metadata_lookup: bool = False) -> ItemCls:
- """Add item to local db and return the database item."""
+ async def add_item_to_library(
+ self, item: ItemCls, skip_metadata_lookup: bool = False
+ ) -> ItemCls:
+ """Add item to library and return the database item."""
raise NotImplementedError
@abstractmethod
- async def update(self, item_id: str | int, update: ItemCls, overwrite: bool = False) -> ItemCls:
- """Update existing record in the database."""
+ async def update_item_in_library(
+ self, item_id: str | int, update: ItemCls, overwrite: bool = False
+ ) -> ItemCls:
+ """Update existing library record in the database."""
- async def delete(self, item_id: str | int, recursive: bool = False) -> None: # noqa: ARG002
- """Delete record from the database."""
+ async def remove_item_from_library(self, item_id: str | int) -> None:
+ """Delete library record from the database."""
db_id = int(item_id) # ensure integer
- db_item = await self.get_db_item(db_id)
- assert db_item, f"Item does not exist: {db_id}"
+ library_item = await self.get_library_item(db_id)
+ assert library_item, f"Item does not exist: {db_id}"
# delete item
await self.mass.music.database.delete(
self.db_table,
)
# NOTE: this does not delete any references to this item in other records,
# this is handled/overridden in the mediatype specific controllers
- self.mass.signal_event(EventType.MEDIA_ITEM_DELETED, db_item.uri, db_item)
+ self.mass.signal_event(EventType.MEDIA_ITEM_DELETED, library_item.uri, library_item)
self.logger.debug("deleted item with id %s from database", db_id)
- async def db_items(
+ async def library_items(
self,
- in_library: bool | None = None,
+ favorite: bool | None = None,
search: str | None = None,
limit: int = 500,
offset: int = 0,
query_parts.append("(name LIKE :search or artists LIKE :search)")
else:
query_parts.append("name LIKE :search")
- if in_library is not None:
- query_parts.append("in_library = :in_library")
- params["in_library"] = in_library
+ if favorite is not None:
+ query_parts.append("favorite = :favorite")
+ params["favorite"] = favorite
if query_parts:
sql_query += " WHERE " + " AND ".join(query_parts)
sql_query += f" ORDER BY {order_by}"
- items = await self.get_db_items_by_query(sql_query, params, limit=limit, offset=offset)
+ items = await self.get_library_items_by_query(sql_query, params, limit=limit, offset=offset)
count = len(items)
if 0 < count < limit:
total = offset + count
total = await self.mass.music.database.get_count_from_query(sql_query, params)
return PagedItems(items, count, limit, offset, total)
- async def iter_db_items(
+ async def iter_library_items(
self,
- in_library: bool | None = None,
+ favorite: bool | None = None,
search: str | None = None,
order_by: str = "sort_name",
) -> AsyncGenerator[ItemCls, None]:
limit: int = 500
offset: int = 0
while True:
- next_items = await self.db_items(
- in_library=in_library,
+ next_items = await self.library_items(
+ favorite=favorite,
search=search,
limit=limit,
offset=offset,
force_refresh: bool = False,
lazy: bool = True,
details: ItemCls = None,
- add_to_db: bool = True,
+ add_to_library: bool = False,
) -> ItemCls:
"""Return (full) details for a single media item."""
- if not add_to_db and provider_instance_id_or_domain == "database":
- return await self.get_db_item(item_id)
- if details and not add_to_db and details.provider == "database":
- return details
- db_item = await self.get_db_item_by_prov_id(
+ if provider_instance_id_or_domain == "database":
+ # backwards compatibility - to remove when 2.0 stable is released
+ provider_instance_id_or_domain = "library"
+ # always prefer the full library item if we have it
+ library_item = await self.get_library_item_by_prov_id(
item_id,
provider_instance_id_or_domain,
)
- if db_item and (time() - (db_item.metadata.last_refresh or 0)) > REFRESH_INTERVAL:
+ if library_item and (time() - (library_item.metadata.last_refresh or 0)) > REFRESH_INTERVAL:
# it's been too long since the full metadata was last retrieved (or never at all)
force_refresh = True
- if db_item and force_refresh and add_to_db:
- # get (first) provider item id belonging to this db item
- provider_instance_id_or_domain, item_id = await self.get_provider_mapping(db_item)
- elif db_item:
- # we have a db item and no refreshing is needed, return the results!
- return db_item
+ add_to_library = True
+ if library_item and force_refresh:
+ # get (first) provider item id belonging to this library item
+ provider_instance_id_or_domain, item_id = await self.get_provider_mapping(library_item)
+ elif library_item:
+ # we have a library item and no refreshing is needed, return the results!
+ return library_item
if (
provider_instance_id_or_domain
and item_id
and (
not details
or isinstance(details, ItemMapping)
- or (add_to_db and details.provider == "database")
+ or (add_to_library and details.provider == "library")
)
):
# grab full details from the provider
if not details:
# we couldn't get a match from any of the providers, raise error
raise MediaNotFoundError(f"Item not found: {provider_instance_id_or_domain}/{item_id}")
- if not add_to_db:
+ if not add_to_library:
+ # return the provider item as-is
return details
- # create task to add the item to the db, including matching metadata etc. takes some time
+ # create task to add the item to the library,
+ # including matching metadata etc. takes some time
# in 99% of the cases we just return lazy because we want the details as fast as possible
- # only if we really need to wait for the result (e.g. to prevent race conditions), we
- # can set lazy to false and we await the job to complete.
+ # only if we really need to wait for the result (e.g. to prevent race conditions),
+ # we can set lazy to false and we await the job to complete.
task_id = f"add_{self.media_type.value}.{details.provider}.{details.item_id}"
- add_task = self.mass.create_task(self.add, details, task_id=task_id)
+ add_task = self.mass.create_task(self.add_item_to_library, item=details, task_id=task_id)
if not lazy:
await add_task
return add_task.result()
- return details
+ return library_item or details
async def search(
self,
"""Search database or provider with given query."""
# create safe search string
search_query = search_query.replace("/", " ").replace("'", "")
- if provider_instance_id_or_domain == "database":
+ if provider_instance_id_or_domain == "library":
return [
self.item_cls.from_db_row(db_row)
for db_row in await self.mass.music.database.search(self.db_table, search_query)
)
return items
- async def add_to_library(
- self,
- item_id: str,
- provider_instance_id_or_domain: str,
- ) -> None:
- """Add an item to the library."""
- prov_item = await self.get_db_item_by_prov_id(
- item_id,
- provider_instance_id_or_domain,
- )
- if prov_item is None:
- prov_item = await self.get_provider_item(item_id, provider_instance_id_or_domain)
- if prov_item.in_library is True:
- return
- # mark as favorite/library item on provider(s)
- for prov_mapping in prov_item.provider_mappings:
- if prov := self.mass.get_provider(prov_mapping.provider_instance):
- if not prov.library_edit_supported(self.media_type):
- continue
- await prov.library_add(prov_mapping.item_id, self.media_type)
- # mark as library item in internal db if db item
- if prov_item.provider == "database" and not prov_item.in_library:
- prov_item.in_library = True
- await self.set_db_library(prov_item.item_id, True)
-
- async def remove_from_library(self, item_id: str, provider_instance_id_or_domain: str) -> None:
- """Remove item from the library."""
- prov_item = await self.get_db_item_by_prov_id(
- item_id,
- provider_instance_id_or_domain,
- )
- if prov_item is None:
- prov_item = await self.get_provider_item(item_id, provider_instance_id_or_domain)
- if prov_item.in_library is False:
- return
- # unmark as favorite/library item on provider(s)
- for prov_mapping in prov_item.provider_mappings:
- if prov := self.mass.get_provider(prov_mapping.provider_instance):
- if not prov.library_edit_supported(self.media_type):
- continue
- await prov.library_remove(prov_mapping.item_id, self.media_type)
- # unmark as library item in internal db if db item
- if prov_item.provider == "database":
- prov_item.in_library = False
- await self.set_db_library(prov_item.item_id, False)
-
async def get_provider_mapping(self, item: ItemCls) -> tuple[str, str]:
"""Return (first) provider and item id."""
if not getattr(item, "provider_mappings", None):
# make sure we have a full object
- item = await self.get_db_item(item.item_id)
+ item = await self.get_library_item(item.item_id)
for prefer_unique in (True, False):
for prov_mapping in item.provider_mappings:
# returns the first provider that is available
if not prov_mapping.available:
continue
if provider := self.mass.get_provider(prov_mapping.provider_instance):
- if prefer_unique and not provider.is_unique:
+ if prefer_unique and provider.is_streaming_provider:
continue
return (prov_mapping.provider_instance, prov_mapping.item_id)
return (None, None)
- async def get_db_items_by_query(
+ async def get_library_items_by_query(
self,
custom_query: str | None = None,
query_params: dict | None = None,
)
]
- async def get_db_item(self, item_id: int | str) -> ItemCls:
+ async def get_library_item(self, item_id: int | str) -> ItemCls:
"""Get record by id."""
db_id = int(item_id) # ensure integer
match = {"item_id": db_id}
if db_row := await self.mass.music.database.get_row(self.db_table, match):
return self.item_cls.from_db_row(db_row)
- raise MediaNotFoundError(f"Album not found in database: {db_id}")
+ raise MediaNotFoundError(f"{self.media_type.value} not found in library: {db_id}")
- async def get_db_item_by_prov_id(
+ async def get_library_item_by_prov_id(
self,
item_id: str,
provider_instance_id_or_domain: str,
) -> ItemCls | None:
- """Get the database item for the given provider_instance."""
+ """Get the library item for the given provider_instance."""
assert item_id
assert provider_instance_id_or_domain
- if provider_instance_id_or_domain == "database":
- return await self.get_db_item(item_id)
- for item in await self.get_db_items_by_prov_id(
+ if provider_instance_id_or_domain == "library":
+ return await self.get_library_item(item_id)
+ for item in await self.get_library_items_by_prov_id(
provider_instance_id_or_domain,
provider_item_ids=(item_id,),
):
return item
return None
- async def get_db_item_by_prov_mappings(
+ async def get_library_item_by_prov_mappings(
self,
provider_mappings: list[ProviderMapping],
) -> ItemCls | None:
- """Get the database item for the given provider_instance."""
+ """Get the library item for the given provider_instance."""
# always prefer provider instance first
for mapping in provider_mappings:
- for item in await self.get_db_items_by_prov_id(
+ for item in await self.get_library_items_by_prov_id(
mapping.provider_instance,
provider_item_ids=(mapping.item_id,),
):
return item
# check by domain too
for mapping in provider_mappings:
- for item in await self.get_db_items_by_prov_id(
+ for item in await self.get_library_items_by_prov_id(
mapping.provider_domain,
provider_item_ids=(mapping.item_id,),
):
return item
return None
- async def get_db_items_by_prov_id(
+ async def get_library_items_by_prov_id(
self,
provider_instance_id_or_domain: str,
provider_item_ids: tuple[str, ...] | None = None,
limit: int = 500,
offset: int = 0,
) -> list[ItemCls]:
- """Fetch all records from database for given provider."""
- if provider_instance_id_or_domain == "database":
- return await self.get_db_items_by_query(limit=limit, offset=offset)
+ """Fetch all records from library for given provider."""
+ if provider_instance_id_or_domain == "library":
+ return await self.get_library_items_by_query(limit=limit, offset=offset)
# we use the separate provider_mappings table to perform quick lookups
# from provider id's to database id's because this is faster
prov_ids = prov_ids.replace(",)", ")")
subquery += f" AND provider_item_id in {prov_ids}"
query = f"SELECT * FROM {self.db_table} WHERE item_id in ({subquery})"
- return await self.get_db_items_by_query(query, limit=limit, offset=offset)
+ return await self.get_library_items_by_query(query, limit=limit, offset=offset)
- async def iter_db_items_by_prov_id(
+ async def iter_library_items_by_prov_id(
self,
provider_instance_id_or_domain: str,
provider_item_ids: tuple[str, ...] | None = None,
limit: int = 500
offset: int = 0
while True:
- next_items = await self.get_db_items_by_prov_id(
+ next_items = await self.get_library_items_by_prov_id(
provider_instance_id_or_domain,
provider_item_ids=provider_item_ids,
limit=limit,
break
offset += limit
- async def set_db_library(self, item_id: str | int, in_library: bool) -> None:
- """Set the in-library bool on a database item."""
+ async def set_favorite(self, item_id: str | int, favorite: bool) -> None:
+ """Set the favorite bool on a database item."""
db_id = int(item_id) # ensure integer
+ library_item = await self.get_library_item(db_id)
+ if library_item.favorite == favorite:
+ return
match = {"item_id": db_id}
- await self.mass.music.database.update(self.db_table, match, {"in_library": in_library})
- db_item = await self.get_db_item(db_id)
- self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
+ await self.mass.music.database.update(self.db_table, match, {"favorite": favorite})
+ library_item = await self.get_library_item(db_id)
+ self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, library_item.uri, library_item)
async def get_provider_item(
self,
cache_key = (
f"provider_item.{self.media_type.value}.{provider_instance_id_or_domain}.{item_id}"
)
- if provider_instance_id_or_domain == "database":
- return await self.get_db_item(item_id)
+ if provider_instance_id_or_domain == "library":
+ return await self.get_library_item(item_id)
if not force_refresh and (cache := await self.mass.cache.get(cache_key)):
return self.item_cls.from_dict(cache)
if provider := self.mass.get_provider(provider_instance_id_or_domain):
# so we return the previous details (if we have any) marked as unavailable, so
# at least we have the possibility to sort out the new id through matching logic.
if not fallback:
- fallback = await self.get_db_item_by_prov_id(item_id, provider_instance_id_or_domain)
+ fallback = await self.get_library_item_by_prov_id(
+ item_id, provider_instance_id_or_domain
+ )
if fallback:
fallback_item = ItemMapping.from_item(fallback)
fallback_item.available = False
f"found on provider {provider_instance_id_or_domain}"
)
- async def remove_prov_mapping(self, item_id: str | int, provider_instance_id: str) -> None:
- """Remove provider id(s) from item."""
+ async def add_provider_mapping(
+ self, item_id: str | int, provider_mapping: ProviderMapping
+ ) -> None:
+ """Add provider mapping to existing library item."""
+ db_id = int(item_id) # ensure integer
+ library_item = await self.get_library_item(db_id)
+ # ignore if the mapping is already present
+ if provider_mapping in library_item.provider_mappings:
+ return
+ # update item's db record
+ library_item.provider_mappings.add(provider_mapping)
+ await self.mass.music.database.update(
+ self.db_table,
+ {"item_id": db_id},
+ {
+ "provider_mappings": serialize_to_json(library_item.provider_mappings),
+ },
+ )
+ # update provider_mappings table
+ await self._set_provider_mappings(
+ item_id=item_id, provider_mappings=library_item.provider_mappings
+ )
+
+ async def remove_provider_mapping(
+ self, item_id: str | int, provider_instance_id: str, provider_item_id: str
+ ) -> None:
+ """Remove provider mapping(s) from item."""
db_id = int(item_id) # ensure integer
try:
- db_item = await self.get_db_item(db_id)
+ library_item = await self.get_library_item(db_id)
except MediaNotFoundError:
# edge case: already deleted / race condition
return
"media_type": self.media_type.value,
"item_id": db_id,
"provider_instance": provider_instance_id,
+ "provider_item_id": provider_item_id,
},
)
# update the item in db (provider_mappings column only)
- db_item.provider_mappings = {
- x for x in db_item.provider_mappings if x.provider_instance != provider_instance_id
+ library_item.provider_mappings = {
+ x
+ for x in library_item.provider_mappings
+ if x.provider_instance != provider_instance_id and x.item_id != provider_item_id
}
match = {"item_id": db_id}
- if db_item.provider_mappings:
+ if library_item.provider_mappings:
await self.mass.music.database.update(
self.db_table,
match,
- {"provider_mappings": serialize_to_json(db_item.provider_mappings)},
+ {"provider_mappings": serialize_to_json(library_item.provider_mappings)},
+ )
+ self.logger.debug(
+ "removed provider_mapping %s/%s from item id %s",
+ provider_instance_id,
+ provider_item_id,
+ db_id,
)
- self.logger.debug("removed provider %s from item id %s", provider_instance_id, db_id)
- self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
+ self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, library_item.uri, library_item)
else:
- # delete item if it has no more providers
+ # remove item if it has no more providers
with suppress(AssertionError):
- await self.delete(db_id)
+ await self.remove_item_from_library(db_id)
+
+ async def remove_provider_mappings(self, item_id: str | int, provider_instance_id: str) -> None:
+ """Remove all provider mappings from an item."""
+ db_id = int(item_id) # ensure integer
+ try:
+ library_item = await self.get_library_item(db_id)
+ except MediaNotFoundError:
+ # edge case: already deleted / race condition
+ return
+
+ # update provider_mappings table
+ await self.mass.music.database.delete(
+ DB_TABLE_PROVIDER_MAPPINGS,
+ {
+ "media_type": self.media_type.value,
+ "item_id": db_id,
+ "provider_instance": provider_instance_id,
+ },
+ )
+
+ # update the item in db (provider_mappings column only)
+ library_item.provider_mappings = {
+ x for x in library_item.provider_mappings if x.provider_instance != provider_instance_id
+ }
+ match = {"item_id": db_id}
+ if library_item.provider_mappings:
+ await self.mass.music.database.update(
+ self.db_table,
+ match,
+ {"provider_mappings": serialize_to_json(library_item.provider_mappings)},
+ )
+ self.logger.debug(
+ "removed all provider mappings for provider %s from item id %s",
+ provider_instance_id,
+ db_id,
+ )
+ self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, library_item.uri, library_item)
+ else:
+ # remove item if it has no more providers
+ with suppress(AssertionError):
+ await self.remove_item_from_library(db_id)
async def dynamic_tracks(
self,
"""Get dynamic list of tracks for given item, fallback/default implementation."""
async def _set_provider_mappings(
- self, item_id: str | int, provider_mappings: list[ProviderMapping]
+ self, item_id: str | int, provider_mappings: Iterable[ProviderMapping]
) -> None:
"""Update the provider_items table for the media item."""
db_id = int(item_id) # ensure integer
# get current mappings (if any)
- cur_mappings = set()
+ cur_mappings: set[ProviderMapping] = set()
match = {"media_type": self.media_type.value, "item_id": db_id}
for db_row in await self.mass.music.database.get_rows(DB_TABLE_PROVIDER_MAPPINGS, match):
cur_mappings.add(
overwrite: bool = False,
) -> list[ItemMapping]:
"""Extract (database) album/track artist(s) as ItemMapping."""
+ artist_mappings: list[ItemMapping] = []
if update_item is None or isinstance(update_item, ItemMapping):
source_artists = org_item.artists
elif overwrite and update_item.artists:
source_artists = update_item.artists
else:
source_artists = org_item.artists + update_item.artists
- item_artists = {await self._get_artist_mapping(artist) for artist in source_artists}
- # use intermediate set to prevent duplicates
- # filter various artists if multiple artists
- if len(item_artists) > 1:
- item_artists = {x for x in item_artists if (x.name != VARIOUS_ARTISTS)}
- return list(item_artists)
+ for artist in source_artists:
+ artist_mapping = await self._get_artist_mapping(artist)
+ if artist_mapping not in artist_mappings:
+ artist_mappings.append(artist_mapping)
+ return artist_mappings
async def _get_artist_mapping(self, artist: Artist | ItemMapping) -> ItemMapping:
"""Extract (database) track artist as ItemMapping."""
- if artist.provider == "database":
+ if artist.provider == "library":
if isinstance(artist, ItemMapping):
return artist
return ItemMapping.from_item(artist)
- if db_artist := await self.mass.music.artists.get_db_item_by_prov_id(
+ if db_artist := await self.mass.music.artists.get_library_item_by_prov_id(
artist.item_id, artist.provider
):
return ItemMapping.from_item(db_artist)
# try to request the full item
with suppress(MediaNotFoundError, AssertionError, InvalidDataError):
- db_artist = await self.mass.music.artists.add(artist, skip_metadata_lookup=True)
+ db_artist = await self.mass.music.artists.add_item_to_library(
+ artist, skip_metadata_lookup=True
+ )
return ItemMapping.from_item(db_artist)
# fallback to just the provider item
- album = await self.mass.music.albums.get_provider_item(
+ artist = await self.mass.music.artists.get_provider_item(
artist.item_id, artist.provider, fallback=artist
)
- if isinstance(album, ItemMapping):
+ if isinstance(artist, ItemMapping):
# this can happen for unavailable items
return artist
- return ItemMapping.from_item(album)
+ return ItemMapping.from_item(artist)
db_table = DB_TABLE_PLAYLISTS
media_type = MediaType.PLAYLIST
item_cls = Playlist
- _db_add_lock = asyncio.Lock()
def __init__(self, *args, **kwargs):
"""Initialize class."""
super().__init__(*args, **kwargs)
+ self._db_add_lock = asyncio.Lock()
# register api handlers
- self.mass.register_api_command("music/playlists", self.db_items)
- self.mass.register_api_command("music/playlist", self.get)
- self.mass.register_api_command("music/playlist/tracks", self.tracks)
- self.mass.register_api_command("music/playlist/tracks/add", self.add_playlist_tracks)
- self.mass.register_api_command("music/playlist/tracks/remove", self.remove_playlist_tracks)
- self.mass.register_api_command("music/playlist/update", self._update_db_item)
- self.mass.register_api_command("music/playlist/delete", self.delete)
- self.mass.register_api_command("music/playlist/create", self.create)
+ self.mass.register_api_command("music/playlists/library_items", self.library_items)
+ self.mass.register_api_command(
+ "music/playlists/update_item_in_library", self.update_item_in_library
+ )
+ self.mass.register_api_command(
+ "music/playlists/remove_item_from_library", self.remove_item_from_library
+ )
+ self.mass.register_api_command("music/playlists/create_playlist", self.create_playlist)
- async def add(self, item: Playlist, skip_metadata_lookup: bool = False) -> Playlist:
- """Add playlist to local db and return the new database item."""
- if item.provider == "database":
- db_item = await self._update_db_item(item.item_id, item)
- else:
- # use the lock to prevent a race condition of the same item being added twice
- async with self._db_add_lock:
- db_item = await self._add_db_item(item)
+ self.mass.register_api_command("music/playlists/get_playlist", self.get)
+ self.mass.register_api_command("music/playlists/playlist_tracks", self.tracks)
+ self.mass.register_api_command(
+ "music/playlists/add_playlist_tracks", self.add_playlist_tracks
+ )
+ self.mass.register_api_command(
+ "music/playlists/remove_playlist_tracks", self.remove_playlist_tracks
+ )
+
+ async def add_item_to_library(
+ self, item: Playlist, skip_metadata_lookup: bool = False
+ ) -> Playlist:
+ """Add playlist to library and return the new database item."""
+ if not isinstance(item, Playlist):
+ raise InvalidDataError(
+ "Not a valid Playlist object (ItemMapping can not be added to db)"
+ )
+ if not item.provider_mappings:
+ raise InvalidDataError("Playlist is missing provider mapping(s)")
+
+ # actually add (or update) the item in the library db
+ # use the lock to prevent a race condition of the same item being added twice
+ async with self._db_add_lock:
+ library_item = await self._add_library_item(item)
# preload playlist tracks listing (do not load them in the db)
async for _ in self.tracks(item.item_id, item.provider):
pass
# metadata lookup we need to do after adding it to the db
if not skip_metadata_lookup:
- await self.mass.metadata.get_playlist_metadata(db_item)
- db_item = await self._update_db_item(db_item.item_id, db_item)
- return db_item
+ await self.mass.metadata.get_playlist_metadata(library_item)
+ library_item = await self.update_item_in_library(library_item.item_id, library_item)
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_ADDED,
+ library_item.uri,
+ library_item,
+ )
+ return library_item
- async def update(self, item_id: int, update: Playlist, overwrite: bool = False) -> Playlist:
+ async def update_item_in_library(
+ self, item_id: int, update: Playlist, overwrite: bool = False
+ ) -> Playlist:
"""Update existing record in the database."""
- return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite)
+ db_id = int(item_id) # ensure integer
+ cur_item = await self.get_library_item(db_id)
+ metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite)
+ provider_mappings = self._get_provider_mappings(cur_item, update, overwrite)
+ await self.mass.music.database.update(
+ self.db_table,
+ {"item_id": db_id},
+ {
+ # always prefer name/owner from updated item here
+ "name": update.name or cur_item.name,
+ "sort_name": update.sort_name or cur_item.sort_name,
+ "owner": update.owner or cur_item.sort_name,
+ "is_editable": update.is_editable,
+ "metadata": serialize_to_json(metadata),
+ "provider_mappings": serialize_to_json(provider_mappings),
+ "timestamp_modified": int(utc_timestamp()),
+ },
+ )
+ # update/set provider_mappings table
+ await self._set_provider_mappings(db_id, provider_mappings)
+ self.logger.debug("updated %s in database: %s", update.name, db_id)
+ # get full created object
+ library_item = await self.get_library_item(db_id)
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_UPDATED,
+ library_item.uri,
+ library_item,
+ )
+ # return the full item we just updated
+ return library_item
async def tracks(
self,
):
yield track
- async def create(self, name: str, provider_instance_or_domain: str | None = None) -> Playlist:
+ async def create_playlist(
+ self, name: str, provider_instance_or_domain: str | None = None
+ ) -> Playlist:
"""Create new playlist."""
# if provider is omitted, just pick first provider
if provider_instance_or_domain:
raise ProviderUnavailableError("No provider available which allows playlists creation.")
# create playlist on the provider
- prov_playlist = await provider.create_playlist(name)
- prov_playlist.in_library = True
- # return db playlist
- return await self.add(prov_playlist, True)
+ return await provider.create_playlist(name)
async def add_playlist_tracks(self, db_playlist_id: str | int, uris: list[str]) -> None:
"""Add multiple tracks to playlist. Creates background tasks to process the action."""
db_id = int(db_playlist_id) # ensure integer
- playlist = await self.get_db_item(db_id)
+ playlist = await self.get_library_item(db_id)
if not playlist:
raise MediaNotFoundError(f"Playlist with id {db_id} not found")
if not playlist.is_editable:
"""Add track to playlist - make sure we dont add duplicates."""
db_id = int(db_playlist_id) # ensure integer
# we can only edit playlists that are in the database (marked as editable)
- playlist = await self.get_db_item(db_id)
+ playlist = await self.get_library_item(db_id)
if not playlist:
raise MediaNotFoundError(f"Playlist with id {db_id} not found")
if not playlist.is_editable:
raise InvalidDataError(f"Playlist {playlist.name} is not editable")
# make sure we have recent full track details
- track = await self.mass.music.get_item_by_uri(track_uri, lazy=False)
+ track = await self.mass.music.get_item_by_uri(track_uri)
assert track.media_type == MediaType.TRACK
# a playlist can only have one provider (for now)
playlist_prov = next(iter(playlist.provider_mappings))
provider = self.mass.get_provider(playlist_prov.provider_instance)
await provider.add_playlist_tracks(playlist_prov.item_id, [track_id_to_add])
# invalidate cache by updating the checksum
- await self.get(db_id, "database", force_refresh=True)
+ await self.get(db_id, "library", force_refresh=True)
async def remove_playlist_tracks(
self, db_playlist_id: str | int, positions_to_remove: tuple[int, ...]
) -> None:
"""Remove multiple tracks from playlist."""
db_id = int(db_playlist_id) # ensure integer
- playlist = await self.get_db_item(db_id)
+ playlist = await self.get_library_item(db_id)
if not playlist:
raise MediaNotFoundError(f"Playlist with id {db_id} not found")
if not playlist.is_editable:
continue
await provider.remove_playlist_tracks(prov_mapping.item_id, positions_to_remove)
# invalidate cache by updating the checksum
- await self.get(db_id, "database", force_refresh=True)
+ await self.get(db_id, "library", force_refresh=True)
- async def _add_db_item(self, item: Playlist) -> Playlist:
+ async def _add_library_item(self, item: Playlist) -> Playlist:
"""Add a new record to the database."""
- assert item.provider_mappings, "Item is missing provider mapping(s)"
# safety guard: check for existing item first
- if cur_item := await self.get_db_item_by_prov_mappings(item.provider_mappings):
+ if cur_item := await self.get_library_item_by_prov_mappings(item.provider_mappings):
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
+ return await self.update_item_in_library(cur_item.item_id, item)
# try name matching
match = {"name": item.name, "owner": item.owner}
if db_row := await self.mass.music.database.get_row(self.db_table, match):
cur_item = Playlist.from_db_row(db_row)
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
+ return await self.update_item_in_library(cur_item.item_id, item)
# insert new item
item.timestamp_added = int(utc_timestamp())
item.timestamp_modified = int(utc_timestamp())
# update/set provider_mappings table
await self._set_provider_mappings(db_id, item.provider_mappings)
self.logger.debug("added %s to database", item.name)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_ADDED,
- db_item.uri,
- db_item,
- )
# return the full item we just added
- return db_item
-
- async def _update_db_item(
- self, item_id: str | int, item: Playlist, overwrite: bool = False
- ) -> Playlist:
- """Update Playlist record in the database."""
- db_id = int(item_id) # ensure integer
- cur_item = await self.get_db_item(db_id)
- metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
- provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
- await self.mass.music.database.update(
- self.db_table,
- {"item_id": db_id},
- {
- # always prefer name/owner from updated item here
- "name": item.name or cur_item.name,
- "sort_name": item.sort_name or cur_item.sort_name,
- "owner": item.owner or cur_item.sort_name,
- "is_editable": item.is_editable,
- "metadata": serialize_to_json(metadata),
- "provider_mappings": serialize_to_json(provider_mappings),
- "timestamp_modified": int(utc_timestamp()),
- },
- )
- # update/set provider_mappings table
- await self._set_provider_mappings(db_id, provider_mappings)
- self.logger.debug("updated %s in database: %s", item.name, db_id)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_UPDATED,
- db_item.uri,
- db_item,
- )
- # return the full item we just updated
- return db_item
+ return await self.get_library_item(db_id)
async def _get_provider_playlist_tracks(
self,
cache_checksum: Any = None,
) -> AsyncGenerator[Track, None]:
"""Return album tracks for the given provider album id."""
- assert provider_instance_id_or_domain != "database"
+ assert provider_instance_id_or_domain != "library"
provider = self.mass.get_provider(provider_instance_id_or_domain)
if not provider:
return
limit: int = 25,
):
"""Generate a dynamic list of tracks based on the playlist content."""
- assert provider_instance_id_or_domain != "database"
+ assert provider_instance_id_or_domain != "library"
provider = self.mass.get_provider(provider_instance_id_or_domain)
if not provider or ProviderFeature.SIMILAR_TRACKS not in provider.supported_features:
return []
from music_assistant.common.helpers.datetime import utc_timestamp
from music_assistant.common.helpers.json import serialize_to_json
from music_assistant.common.models.enums import EventType, MediaType
+from music_assistant.common.models.errors import InvalidDataError
from music_assistant.common.models.media_items import Radio, Track
from music_assistant.constants import DB_TABLE_RADIOS
from music_assistant.server.helpers.compare import loose_compare_strings
db_table = DB_TABLE_RADIOS
media_type = MediaType.RADIO
item_cls = Radio
- _db_add_lock = asyncio.Lock()
def __init__(self, *args, **kwargs):
"""Initialize class."""
super().__init__(*args, **kwargs)
+ self._db_add_lock = asyncio.Lock()
# register api handlers
- self.mass.register_api_command("music/radios", self.db_items)
- self.mass.register_api_command("music/radio", self.get)
- self.mass.register_api_command("music/radio/versions", self.versions)
- self.mass.register_api_command("music/radio/update", self._update_db_item)
- self.mass.register_api_command("music/radio/delete", self.delete)
+ self.mass.register_api_command("music/radio/library_items", self.library_items)
+ self.mass.register_api_command("music/radio/get_radio", self.get)
+ self.mass.register_api_command(
+ "music/radio/update_item_in_library", self.update_item_in_library
+ )
+ self.mass.register_api_command(
+ "music/radio/remove_item_from_library", self.remove_item_from_library
+ )
+ self.mass.register_api_command("music/radio/radio_versions", self.versions)
async def versions(
self,
# return the aggregated result
return all_versions.values()
- async def add(self, item: Radio, skip_metadata_lookup: bool = False) -> Radio:
- """Add radio to local db and return the new database item."""
+ async def add_item_to_library(self, item: Radio, skip_metadata_lookup: bool = False) -> Radio:
+ """Add radio to library and return the new database item."""
+ if not isinstance(item, Radio):
+ raise InvalidDataError("Not a valid Radio object (ItemMapping can not be added to db)")
+ if not item.provider_mappings:
+ raise InvalidDataError("Radio is missing provider mapping(s)")
if not skip_metadata_lookup:
await self.mass.metadata.get_radio_metadata(item)
- if item.provider == "database":
- db_item = await self._update_db_item(item.item_id, item)
- else:
- # use the lock to prevent a race condition of the same item being added twice
- async with self._db_add_lock:
- db_item = await self._add_db_item(item)
- return db_item
+ # actually add (or update) the item in the library db
+ # use the lock to prevent a race condition of the same item being added twice
+ async with self._db_add_lock:
+ library_item = await self._add_library_item(item)
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_ADDED,
+ library_item.uri,
+ library_item,
+ )
+ return library_item
- async def update(self, item_id: str | int, update: Radio, overwrite: bool = False) -> Radio:
+ async def update_item_in_library(
+ self, item_id: str | int, update: Radio, overwrite: bool = False
+ ) -> Radio:
"""Update existing record in the database."""
- return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite)
+ db_id = int(item_id) # ensure integer
+ cur_item = await self.get_library_item(db_id)
+ metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite)
+ provider_mappings = self._get_provider_mappings(cur_item, update, overwrite)
+ match = {"item_id": db_id}
+ await self.mass.music.database.update(
+ self.db_table,
+ match,
+ {
+ # always prefer name from updated item here
+ "name": update.name or cur_item.name,
+ "sort_name": update.sort_name or cur_item.sort_name,
+ "metadata": serialize_to_json(metadata),
+ "provider_mappings": serialize_to_json(provider_mappings),
+ "timestamp_modified": int(utc_timestamp()),
+ },
+ )
+ # update/set provider_mappings table
+ await self._set_provider_mappings(db_id, provider_mappings)
+ self.logger.debug("updated %s in database: %s", update.name, db_id)
+ # get full created object
+ library_item = await self.get_library_item(db_id)
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_UPDATED,
+ library_item.uri,
+ library_item,
+ )
+ # return the full item we just updated
+ return library_item
- async def _add_db_item(self, item: Radio) -> Radio:
+ async def _add_library_item(self, item: Radio) -> Radio:
"""Add a new item record to the database."""
- assert item.provider_mappings, "Item is missing provider mapping(s)"
cur_item = None
# safety guard: check for existing item first
- if cur_item := await self.get_db_item_by_prov_id(item.item_id, item.provider):
+ if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider):
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
+ return await self.update_item_in_library(cur_item.item_id, item)
# try name matching
match = {"name": item.name}
if db_row := await self.mass.music.database.get_row(self.db_table, match):
cur_item = Radio.from_db_row(db_row)
# existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
+ return await self.update_item_in_library(cur_item.item_id, item)
# insert new item
item.timestamp_added = int(utc_timestamp())
item.timestamp_modified = int(utc_timestamp())
# update/set provider_mappings table
await self._set_provider_mappings(db_id, item.provider_mappings)
self.logger.debug("added %s to database", item.name)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_ADDED,
- db_item.uri,
- db_item,
- )
# return the full item we just added
- return db_item
-
- async def _update_db_item(
- self, item_id: str | int, item: Radio, overwrite: bool = False
- ) -> Radio:
- """Update Radio record in the database."""
- db_id = int(item_id) # ensure integer
- cur_item = await self.get_db_item(db_id)
- metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
- provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
- match = {"item_id": db_id}
- await self.mass.music.database.update(
- self.db_table,
- match,
- {
- # always prefer name from updated item here
- "name": item.name or cur_item.name,
- "sort_name": item.sort_name or cur_item.sort_name,
- "metadata": serialize_to_json(metadata),
- "provider_mappings": serialize_to_json(provider_mappings),
- "timestamp_modified": int(utc_timestamp()),
- },
- )
- # update/set provider_mappings table
- await self._set_provider_mappings(db_id, provider_mappings)
- self.logger.debug("updated %s in database: %s", item.name, db_id)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_UPDATED,
- db_item.uri,
- db_item,
- )
- # return the full item we just updated
- return db_item
+ return await self.get_library_item(db_id)
async def _get_provider_dynamic_tracks(
self,
import asyncio
import urllib.parse
-from contextlib import suppress
from music_assistant.common.helpers.datetime import utc_timestamp
from music_assistant.common.helpers.json import serialize_to_json
MediaNotFoundError,
UnsupportedFeaturedException,
)
-from music_assistant.common.models.media_items import (
- Album,
- DbTrack,
- ItemMapping,
- Track,
- TrackAlbumMapping,
-)
-from music_assistant.constants import DB_TABLE_TRACKS
+from music_assistant.common.models.media_items import Album, ItemMapping, Track
+from music_assistant.constants import DB_TABLE_ALBUM_TRACKS, DB_TABLE_TRACKS
from music_assistant.server.helpers.compare import (
compare_artists,
compare_track,
db_table = DB_TABLE_TRACKS
media_type = MediaType.TRACK
- item_cls = DbTrack
- _db_add_lock = asyncio.Lock()
+ item_cls = Track
def __init__(self, *args, **kwargs):
"""Initialize class."""
super().__init__(*args, **kwargs)
+ self._db_add_lock = asyncio.Lock()
# register api handlers
- self.mass.register_api_command("music/tracks", self.db_items)
- self.mass.register_api_command("music/track", self.get)
- self.mass.register_api_command("music/track/versions", self.versions)
- self.mass.register_api_command("music/track/albums", self.albums)
- self.mass.register_api_command("music/track/update", self._update_db_item)
- self.mass.register_api_command("music/track/delete", self.delete)
- self.mass.register_api_command("music/track/preview", self.get_preview_url)
+ self.mass.register_api_command("music/tracks/library_items", self.library_items)
+ self.mass.register_api_command("music/tracks/get_track", self.get)
+ self.mass.register_api_command("music/tracks/track_versions", self.versions)
+ self.mass.register_api_command("music/tracks/track_albums", self.albums)
+ self.mass.register_api_command(
+ "music/tracks/update_item_in_library", self.update_item_in_library
+ )
+ self.mass.register_api_command(
+ "music/tracks/remove_item_from_library", self.remove_item_from_library
+ )
+ self.mass.register_api_command("music/tracks/preview", self.get_preview_url)
async def get(
self,
lazy: bool = True,
details: Track = None,
album_uri: str | None = None,
- add_to_db: bool = True,
+ add_to_library: bool = False,
) -> Track:
"""Return (full) details for a single media item."""
track = await super().get(
force_refresh=force_refresh,
lazy=lazy,
details=details,
- add_to_db=add_to_db,
+ add_to_library=add_to_library,
)
# append full album details to full track item
try:
if album_uri and (album := await self.mass.music.get_item_by_uri(album_uri)):
track.album = album
- track.metadata.images = [album.image] + track.metadata.images
elif track.album:
track.album = await self.mass.music.albums.get(
track.album.item_id,
track.album.provider,
- lazy=True,
+ lazy=lazy,
details=None if isinstance(track.album, ItemMapping) else track.album,
- add_to_db=add_to_db,
+ add_to_library=add_to_library,
)
+ elif provider_instance_id_or_domain == "library":
+ # grab the first album this track is attached to
+ for album_track_row in await self.mass.music.database.get_rows(
+ DB_TABLE_ALBUM_TRACKS, {"track_id": int(item_id)}, limit=1
+ ):
+ track.album = await self.mass.music.albums.get_library_item(
+ album_track_row["album_id"]
+ )
except MediaNotFoundError:
# edge case where playlist track has invalid albumdetails
self.logger.warning("Unable to fetch album details %s", track.album.uri)
+ # prefer album image (otherwise it may look weird)
+ if track.album and track.album.image:
+ track.metadata.images = [track.album.image] + track.metadata.images
# append full artist details to full track item
full_artists = []
for artist in track.artists:
await self.mass.music.artists.get(
artist.item_id,
artist.provider,
- lazy=True,
+ lazy=lazy,
details=None if isinstance(artist, ItemMapping) else artist,
- add_to_db=add_to_db,
+ add_to_library=add_to_library,
)
)
track.artists = full_artists
return track
- async def add(self, item: Track, skip_metadata_lookup: bool = False) -> Track:
- """Add track to local db and return the new database item."""
+ async def add_item_to_library(self, item: Track, skip_metadata_lookup: bool = False) -> Track:
+ """Add track to library and return the new database item."""
if not isinstance(item, Track):
raise InvalidDataError("Not a valid Track object (ItemMapping can not be added to db)")
if not item.artists:
raise InvalidDataError("Track is missing artist(s)")
+ if not item.provider_mappings:
+ raise InvalidDataError("Track is missing provider mapping(s)")
# resolve any ItemMapping artists
item.artists = [
await self.mass.music.artists.get_provider_item(
# grab additional metadata
if not skip_metadata_lookup:
await self.mass.metadata.get_track_metadata(item)
- if item.provider == "database":
- db_item = await self._update_db_item(item.item_id, item)
- else:
- # use the lock to prevent a race condition of the same item being added twice
- async with self._db_add_lock:
- db_item = await self._add_db_item(item)
+ # actually add (or update) the item in the library db
+ # use the lock to prevent a race condition of the same item being added twice
+ async with self._db_add_lock:
+ library_item = await self._add_library_item(item)
# also fetch same track on all providers (will also get other quality versions)
if not skip_metadata_lookup:
- await self._match(db_item)
- # return final db_item after all match/metadata actions
- return await self.get_db_item(db_item.item_id)
+ await self._match(library_item)
+ library_item = await self.get_library_item(library_item.item_id)
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_ADDED,
+ library_item.uri,
+ library_item,
+ )
+ # return final library_item after all match/metadata actions
+ return library_item
- async def update(self, item_id: str | int, update: Track, overwrite: bool = False) -> Track:
- """Update existing record in the database."""
- return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite)
+ async def update_item_in_library(
+ self, item_id: str | int, update: Track, overwrite: bool = False
+ ) -> Track:
+ """Update Track record in the database, merging data."""
+ db_id = int(item_id) # ensure integer
+ cur_item = await self.get_library_item(db_id)
+ metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite)
+ provider_mappings = self._get_provider_mappings(cur_item, update, overwrite)
+ track_artists = await self._get_artist_mappings(cur_item, update, overwrite=overwrite)
+ await self.mass.music.database.update(
+ self.db_table,
+ {"item_id": db_id},
+ {
+ "name": update.name or cur_item.name,
+ "sort_name": update.sort_name or cur_item.sort_name,
+ "version": update.version or cur_item.version,
+ "duration": getattr(update, "duration", None) or cur_item.duration,
+ "artists": serialize_to_json(track_artists),
+ "metadata": serialize_to_json(metadata),
+ "provider_mappings": serialize_to_json(provider_mappings),
+ "timestamp_modified": int(utc_timestamp()),
+ },
+ )
+ # update/set provider_mappings table
+ await self._set_provider_mappings(db_id, provider_mappings)
+ # handle track album
+ if update.album:
+ await self._set_track_album(
+ db_id=db_id,
+ album=update.album,
+ disc_number=getattr(update, "disc_number", None) or 0,
+ track_number=getattr(update, "track_number", None) or 0,
+ )
+ # get full created object
+ library_item = await self.get_library_item(db_id)
+ # only signal event if we're not running a sync (to prevent a floodstorm of events)
+ if not self.mass.music.get_running_sync_tasks():
+ self.mass.signal_event(
+ EventType.MEDIA_ITEM_UPDATED,
+ library_item.uri,
+ library_item,
+ )
+ self.logger.debug("updated %s in database: %s", update.name, db_id)
+ # return the full item we just updated
+ return library_item
async def versions(
self,
item_id: str,
provider_instance_id_or_domain: str,
) -> list[Track]:
- """Return all versions of a track we can find on all providers."""
- track = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False)
- # perform a search on all provider(types) to collect all versions/variants
+ """Return all versions of a track we can find on the provider."""
+ track = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False)
search_query = f"{track.artists[0].name} - {track.name}"
- all_versions = {
- prov_item.item_id: prov_item
- for prov_items in await asyncio.gather(
- *[
- self.search(search_query, provider_domain)
- for provider_domain in self.mass.music.get_unique_providers()
- ]
- )
- for prov_item in prov_items
+ return [
+ prov_item
+ for prov_item in await self.search(search_query, provider_instance_id_or_domain)
if loose_compare_strings(track.name, prov_item.name)
and compare_artists(prov_item.artists, track.artists, any_match=True)
- }
- # make sure that the 'base' version is NOT included
- for prov_version in track.provider_mappings:
- all_versions.pop(prov_version.item_id, None)
-
- # return the aggregated result
- return all_versions.values()
+ # make sure that the 'base' version is NOT included
+ and prov_item.item_id != item_id
+ ]
async def albums(
self,
provider_instance_id_or_domain: str,
) -> list[Album]:
"""Return all albums the track appears on."""
- track = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False)
- return await asyncio.gather(
- *[
- self.mass.music.albums.get(album.item_id, album.provider, add_to_db=False)
- for album in track.albums
+ if provider_instance_id_or_domain == "library":
+ return [
+ await self.mass.music.albums.get_library_item(album_track_row["album_id"])
+ async for album_track_row in self.mass.music.database.iter_items(
+ DB_TABLE_ALBUM_TRACKS, {"track_id": int(item_id)}
+ )
]
- )
+ # use search to get all items on the provider
+ # TODO: we could use musicbrainz info here to get a list of all releases known
+ track = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False)
+ search_query = f"{track.artists[0].name} - {track.name}"
+ return [
+ prov_item.album
+ for prov_item in await self.search(search_query, provider_instance_id_or_domain)
+ if loose_compare_strings(track.name, prov_item.name)
+ and prov_item.album
+ and compare_artists(prov_item.artists, track.artists, any_match=True)
+ ]
+
+ async def remove_item_from_library(self, item_id: str | int) -> None:
+ """Delete record from the database."""
+ db_id = int(item_id) # ensure integer
+ # delete entry(s) from albumtracks table
+ await self.mass.music.database.delete(DB_TABLE_ALBUM_TRACKS, {"track_id": db_id})
+ # delete the track itself from db
+ await super().remove_item_from_library(db_id)
async def get_preview_url(self, provider_instance_id_or_domain: str, item_id: str) -> str:
"""Return url to short preview sample."""
This is used to link objects of different providers/qualities together.
"""
- if db_track.provider != "database":
+ if db_track.provider != "library":
return # Matching only supported for database items
+ track_albums = await self.albums(db_track.item_id, db_track.provider)
for provider in self.mass.music.providers:
if ProviderFeature.SEARCH not in provider.supported_features:
continue
- if provider.is_unique:
- # matching on unique provider sis pointless as they push (all) their content to MA
+ if not provider.is_streaming_provider:
+ # matching on unique providers is pointless as they push (all) their content to MA
+ continue
+ if not provider.library_supported(MediaType.TRACK):
continue
self.logger.debug(
"Trying to match track %s on provider %s", db_track.name, provider.name
if not search_result_item.available:
continue
# do a basic compare first
- if not compare_track(search_result_item, db_track, False):
+ if not compare_track(search_result_item, db_track, strict=False):
continue
# we must fetch the full version, search results are simplified objects
prov_track = await self.get_provider_item(
search_result_item.provider,
fallback=search_result_item,
)
- if compare_track(prov_track, db_track):
- # 100% match, we can simply update the db with additional provider ids
+ if compare_track(prov_track, db_track, strict=True, track_albums=track_albums):
+ # 100% match, we update the db with the additional provider mapping(s)
match_found = True
- await self._update_db_item(db_track.item_id, search_result_item)
+ for provider_mapping in search_result_item.provider_mappings:
+ await self.add_provider_mapping(db_track.item_id, provider_mapping)
if not match_found:
self.logger.debug(
limit: int = 25,
):
"""Generate a dynamic list of tracks based on the track."""
- assert provider_instance_id_or_domain != "database"
+ assert provider_instance_id_or_domain != "library"
prov = self.mass.get_provider(provider_instance_id_or_domain)
if prov is None:
return []
"No Music Provider found that supports requesting similar tracks."
)
- async def _add_db_item(self, item: Track) -> Track:
+ async def _add_library_item(self, item: Track) -> Track:
"""Add a new item record to the database."""
- assert isinstance(item, Track), "Not a full Track object"
- assert item.artists, "Track is missing artist(s)"
- assert item.provider_mappings, "Track is missing provider mapping(s)"
- # safety guard: check for existing item first
- if cur_item := await self.get_db_item_by_prov_mappings(item.provider_mappings):
- # existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
- # try matching on musicbrainz_id
- if item.musicbrainz_id:
- match = {"musicbrainz_id": item.musicbrainz_id}
+ # check for existing item first
+ if item.provider == "library":
+ return await self.update_item_in_library(item.item_id, item)
+ if cur_item := await self.get_library_item_by_prov_mappings(item.provider_mappings):
+ return await self.update_item_in_library(cur_item.item_id, item)
+ if item.mbid:
+ match = {"mbid": item.mbid}
if db_row := await self.mass.music.database.get_row(self.db_table, match):
cur_item = Track.from_db_row(db_row)
- # existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
- # try matching on isrc
- for isrc in item.isrc:
- if search_result := await self.mass.music.database.search(self.db_table, isrc, "isrc"):
- cur_item = Track.from_db_row(search_result[0])
- # existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
- # fallback to compare matching
+ return await self.update_item_in_library(cur_item.item_id, item)
match = {"sort_name": item.sort_name}
for row in await self.mass.music.database.get_rows(self.db_table, match):
row_track = Track.from_db_row(row)
- if compare_track(row_track, item):
+ track_albums = await self.albums(row_track.item_id, row_track.provider)
+ if compare_track(row_track, item, strict=True, track_albums=track_albums):
cur_item = row_track
- # existing item found: update it
- return await self._update_db_item(cur_item.item_id, item)
-
- # no existing match found: insert new item
+ return await self.update_item_in_library(cur_item.item_id, item)
track_artists = await self._get_artist_mappings(item)
- track_albums = await self._get_track_albums(item)
- sort_artist = track_artists[0].sort_name if track_artists else ""
- sort_album = track_albums[0].sort_name if track_albums else ""
+ sort_artist = track_artists[0].sort_name
new_item = await self.mass.music.database.insert(
self.db_table,
{
**item.to_db_row(),
"artists": serialize_to_json(track_artists),
- "albums": serialize_to_json(track_albums),
"sort_artist": sort_artist,
- "sort_album": sort_album,
"timestamp_added": int(utc_timestamp()),
"timestamp_modified": int(utc_timestamp()),
},
db_id = new_item["item_id"]
# update/set provider_mappings table
await self._set_provider_mappings(db_id, item.provider_mappings)
- # return created object
- self.logger.debug("added %s to database: %s", item.name, db_id)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_ADDED,
- db_item.uri,
- db_item,
+ # handle track album
+ if item.album:
+ await self._set_track_album(
+ db_id=db_id,
+ album=item.album,
+ disc_number=getattr(item, "disc_number", None) or 0,
+ track_number=getattr(item, "track_number", None) or 0,
)
+ self.logger.debug("added %s to database: %s", item.name, db_id)
# return the full item we just added
- return db_item
-
- async def _update_db_item(
- self, item_id: str | int, item: Track | ItemMapping, overwrite: bool = False
- ) -> Track:
- """Update Track record in the database, merging data."""
- db_id = int(item_id) # ensure integer
- cur_item = await self.get_db_item(db_id)
- metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite)
- provider_mappings = self._get_provider_mappings(cur_item, item, overwrite)
- if getattr(item, "isrc", None):
- cur_item.isrc.update(item.isrc)
- track_artists = await self._get_artist_mappings(cur_item, item, overwrite=overwrite)
- track_albums = await self._get_track_albums(cur_item, item, overwrite=overwrite)
- await self.mass.music.database.update(
- self.db_table,
- {"item_id": db_id},
- {
- "name": item.name or cur_item.name,
- "sort_name": item.sort_name or cur_item.sort_name,
- "version": item.version or cur_item.version,
- "duration": getattr(item, "duration", None) or cur_item.duration,
- "artists": serialize_to_json(track_artists),
- "albums": serialize_to_json(track_albums),
- "metadata": serialize_to_json(metadata),
- "provider_mappings": serialize_to_json(provider_mappings),
- "isrc": ";".join(cur_item.isrc),
- "timestamp_modified": int(utc_timestamp()),
- },
- )
- # update/set provider_mappings table
- await self._set_provider_mappings(db_id, provider_mappings)
- self.logger.debug("updated %s in database: %s", item.name, db_id)
- # get full created object
- db_item = await self.get_db_item(db_id)
- # only signal event if we're not running a sync (to prevent a floodstorm of events)
- if not self.mass.music.get_running_sync_tasks():
- self.mass.signal_event(
- EventType.MEDIA_ITEM_UPDATED,
- db_item.uri,
- db_item,
- )
- # return the full item we just updated
- return db_item
-
- async def _get_track_albums(
- self,
- org_item: DbTrack,
- update_item: Track | ItemMapping | None = None,
- overwrite: bool = False,
- ) -> list[TrackAlbumMapping]:
- """Extract all (unique) albums of track as TrackAlbumMapping."""
- if (update_item is None or isinstance(update_item, ItemMapping)) and org_item.albums:
- # already TrackAlbumMappings
- return org_item.albums
- track_albums: set[TrackAlbumMapping] = set()
- # add base albums (only if not overwriting)
- if (
- not overwrite
- or update_item is None
- or isinstance(update_item, ItemMapping)
- or not (update_item.album or update_item.albums)
- ):
- track_albums.update(org_item.albums)
- if org_item.album:
- track_albums.add(
- await self._get_album_mapping(
- org_item.album, org_item.disc_number, org_item.track_number
- )
- )
-
- # album(s) from update item
- if update_item and not isinstance(update_item, ItemMapping):
- if update_item.albums:
- track_albums.update(update_item.albums)
- if update_item.album:
- track_albums.add(
- await self._get_album_mapping(
- update_item.album, update_item.disc_number, update_item.track_number
- )
- )
- # use intermediate set to prevent duplicates
- return list(track_albums)
-
- async def _get_album_mapping(
- self,
- album: Album | TrackAlbumMapping | ItemMapping,
- disc_number: int | None = None,
- track_number: int | None = None,
- ) -> TrackAlbumMapping:
- """Extract (database) album as TrackAlbumMapping."""
- if album.provider == "database":
- if isinstance(album, TrackAlbumMapping):
- return album
- return TrackAlbumMapping.from_item(album, disc_number, track_number)
+ return await self.get_library_item(db_id)
- if db_album := await self.mass.music.albums.get_db_item_by_prov_id(
- album.item_id, album.provider
+ async def _set_track_album(self, db_id: int, album: Album, disc_number: int, track_number: int):
+ """Store AlbumTrack info."""
+ if album.provider == "library":
+ db_album = album
+ elif match := await self.mass.music.albums.get_library_item_by_prov_mappings(
+ album.provider_mappings
):
- return TrackAlbumMapping.from_item(db_album, disc_number, track_number)
-
- # try to request the full item
- with suppress(MediaNotFoundError, AssertionError, InvalidDataError):
- db_album = await self.mass.music.albums.add(album, skip_metadata_lookup=True)
- return TrackAlbumMapping.from_item(db_album, disc_number, track_number)
-
- # fallback to just the provider item
- album = await self.mass.music.albums.get_provider_item(
- album.item_id, album.provider, fallback=album
- )
- return TrackAlbumMapping.from_item(album, disc_number, track_number)
+ db_album = match
+ else:
+ db_album = await self.mass.music.albums.add_item_to_library(
+ album, skip_metadata_lookup=True
+ )
+ album_mapping = {"track_id": db_id, "album_id": int(db_album.item_id)}
+ if db_row := await self.mass.music.database.get_row(DB_TABLE_ALBUM_TRACKS, album_mapping):
+ # update existing
+ await self.mass.music.database.update(
+ DB_TABLE_ALBUM_TRACKS,
+ album_mapping,
+ {
+ "disc_number": disc_number or db_row["disc_number"],
+ "track_number": track_number or db_row["track_number"],
+ },
+ )
+ else:
+ # create new albumtrack record
+ await self.mass.music.database.insert_or_replace(
+ DB_TABLE_ALBUM_TRACKS,
+ {
+ **album_mapping,
+ "disc_number": disc_number,
+ "track_number": track_number,
+ },
+ )
LOGGER.debug("Start scan for missing artist metadata")
self.scan_busy = True
- async for artist in self.mass.music.artists.iter_db_items():
+ async for artist in self.mass.music.artists.iter_library_items():
if artist.metadata.last_refresh is not None:
continue
# most important is to see artist thumb in listings
async def get_artist_metadata(self, artist: Artist) -> None:
"""Get/update rich metadata for an artist."""
- # set timestamp, used to determine when this function was last called
- artist.metadata.last_refresh = int(time())
-
- if not artist.musicbrainz_id:
- artist.musicbrainz_id = await self.get_artist_musicbrainz_id(artist)
-
- if not artist.musicbrainz_id:
+ if not artist.mbid:
+ artist.mbid = await self.get_artist_mbid(artist)
+ if not artist.mbid:
return
-
# collect metadata from all providers
for provider in self.providers:
if ProviderFeature.ARTIST_METADATA not in provider.supported_features:
artist.name,
provider.name,
)
+ # set timestamp, used to determine when this function was last called
+ artist.metadata.last_refresh = int(time())
async def get_album_metadata(self, album: Album) -> None:
"""Get/update rich metadata for an album."""
- # set timestamp, used to determine when this function was last called
- album.metadata.last_refresh = int(time())
# ensure the album has a musicbrainz id or artist
- if not (album.musicbrainz_id or album.artists):
+ if not (album.mbid or album.artists):
return
# collect metadata from all providers
for provider in self.providers:
album.name,
provider.name,
)
+ # set timestamp, used to determine when this function was last called
+ album.metadata.last_refresh = int(time())
async def get_track_metadata(self, track: Track) -> None:
"""Get/update rich metadata for a track."""
# NOTE: we do not have any metadata for radio so consider this future proofing ;-)
radio.metadata.last_refresh = int(time())
- async def get_artist_musicbrainz_id(self, artist: Artist) -> str | None:
+ async def get_artist_mbid(self, artist: Artist) -> str | None:
"""Fetch musicbrainz id by performing search using the artist name, albums and tracks."""
- ref_albums = await self.mass.music.artists.albums(artist=artist)
- ref_tracks = await self.mass.music.artists.tracks(artist=artist)
+ ref_albums = await self.mass.music.artists.albums(artist.item_id, artist.provider)
+ if len(ref_albums) < 10:
+ # fetch reference albums from provider(s) attached to the artist
+ for provider_mapping in artist.provider_mappings:
+ ref_albums += await self.mass.music.artists.albums(
+ provider_mapping.item_id, provider_mapping.provider_instance
+ )
+ ref_tracks = await self.mass.music.artists.tracks(artist.item_id, artist.provider)
+ if len(ref_tracks) < 10:
+ # fetch reference tracks from provider(s) attached to the artist
+ for provider_mapping in artist.provider_mappings:
+ ref_tracks += await self.mass.music.artists.tracks(
+ provider_mapping.item_id, provider_mapping.provider_instance
+ )
- # randomize providers so average the load
+ # randomize providers to average the load
providers = self.providers
shuffle(providers)
for provider in providers:
if ProviderFeature.GET_ARTIST_MBID not in provider.supported_features:
continue
- if musicbrainz_id := await provider.get_musicbrainz_artist_id(
+ if mbid := await provider.get_musicbrainz_artist_id(
artist, ref_albums=ref_albums, ref_tracks=ref_tracks
):
LOGGER.debug(
artist.name,
provider.name,
)
- return musicbrainz_id
+ return mbid
# lookup failed
ref_albums_str = "/".join(x.name for x in ref_albums) or "none"
from __future__ import annotations
import asyncio
-import logging
import os
+import shutil
import statistics
+from contextlib import suppress
from itertools import zip_longest
-from typing import TYPE_CHECKING, Final
+from typing import TYPE_CHECKING
from music_assistant.common.helpers.datetime import utc_timestamp
from music_assistant.common.helpers.json import json_dumps, json_loads
from music_assistant.common.models.media_items import BrowseFolder, MediaItemType, SearchResults
from music_assistant.common.models.provider import SyncTask
from music_assistant.constants import (
+ DB_SCHEMA_VERSION,
+ DB_TABLE_ALBUM_TRACKS,
DB_TABLE_ALBUMS,
DB_TABLE_ARTISTS,
DB_TABLE_PLAYLISTS,
DB_TABLE_SETTINGS,
DB_TABLE_TRACK_LOUDNESS,
DB_TABLE_TRACKS,
- ROOT_LOGGER_NAME,
)
from music_assistant.server.helpers.api import api_command
from music_assistant.server.helpers.database import DatabaseConnection
if TYPE_CHECKING:
from music_assistant.common.models.config_entries import CoreConfig
-LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.music")
DEFAULT_SYNC_INTERVAL = 3 * 60 # default sync interval in minutes
CONF_SYNC_INTERVAL = "sync_interval"
-DB_SCHEMA_VERSION: Final[int] = 23
class MusicController(CoreController):
if not path or path == "root":
return BrowseFolder(
item_id="root",
- provider="database",
+ provider="library",
path="root",
label="browse",
name="",
return await prov.browse(path)
@api_command("music/item_by_uri")
- async def get_item_by_uri(
- self, uri: str, force_refresh: bool = False, lazy: bool = True
- ) -> MediaItemType:
+ async def get_item_by_uri(self, uri: str) -> MediaItemType:
"""Fetch MediaItem by uri."""
media_type, provider_instance_id_or_domain, item_id = parse_uri(uri)
return await self.get_item(
media_type=media_type,
item_id=item_id,
provider_instance_id_or_domain=provider_instance_id_or_domain,
- force_refresh=force_refresh,
- lazy=lazy,
)
@api_command("music/item")
provider_instance_id_or_domain: str,
force_refresh: bool = False,
lazy: bool = True,
- add_to_db: bool = False,
+ add_to_library: bool = False,
) -> MediaItemType:
"""Get single music item by id and media type."""
+ if provider_instance_id_or_domain == "database":
+ # backwards compatibility - to remove when 2.0 stable is released
+ provider_instance_id_or_domain = "library"
if provider_instance_id_or_domain == "url":
# handle special case of 'URL' MusicProvider which allows us to play regular url's
return await self.mass.get_provider("url").parse_item(item_id)
provider_instance_id_or_domain=provider_instance_id_or_domain,
force_refresh=force_refresh,
lazy=lazy,
- add_to_db=add_to_db,
+ add_to_library=add_to_library,
)
- @api_command("music/library/add")
- async def add_to_library(
+ @api_command("music/favorites/add_item")
+ async def add_item_to_favorites(
self,
- media_type: MediaType,
- item_id: str,
- provider_instance_id_or_domain: str,
+ item: str | MediaItemType,
) -> None:
- """Add an item to the library."""
- # make sure we have a full db item
+ """Add an item to the favorites."""
+ if isinstance(item, str):
+ item = await self.get_item_by_uri(item)
+ # make sure we have a full library item
+ # a favorite must always be in the library
full_item = await self.get_item(
- media_type,
- item_id,
- provider_instance_id_or_domain,
+ item.media_type,
+ item.item_id,
+ item.provider,
lazy=False,
- add_to_db=True,
+ add_to_library=True,
)
- ctrl = self.get_controller(media_type)
- await ctrl.add_to_library(
+ # set favorite in library db
+ ctrl = self.get_controller(item.media_type)
+ await ctrl.set_favorite(
full_item.item_id,
- full_item.provider,
- )
-
- @api_command("music/library/add_items")
- async def add_items_to_library(self, items: list[str | MediaItemType]) -> None:
- """Add multiple items to the library (provide uri or MediaItem)."""
- tasks = []
- for item in items:
- if isinstance(item, str):
- item = await self.get_item_by_uri(item) # noqa: PLW2901
- tasks.append(
- self.mass.create_task(
- self.add_to_library(
- media_type=item.media_type,
- item_id=item.item_id,
- provider_instance_id_or_domain=item.provider,
- )
- )
- )
- await asyncio.gather(*tasks)
+ True,
+ )
- @api_command("music/library/remove")
- async def remove_from_library(
+ @api_command("music/favorites/remove_item")
+ async def remove_item_from_favorites(
self,
media_type: MediaType,
- item_id: str,
- provider_instance_id_or_domain: str,
+ library_item_id: str | int,
) -> None:
- """Remove item from the library."""
+ """Remove (library) item from the favorites."""
ctrl = self.get_controller(media_type)
- await ctrl.remove_from_library(
- item_id,
- provider_instance_id_or_domain,
- )
-
- @api_command("music/library/remove_items")
- async def remove_items_from_library(self, items: list[str | MediaItemType]) -> None:
- """Remove multiple items from the library (provide uri or MediaItem)."""
- tasks = []
- for item in items:
- if isinstance(item, str):
- item = await self.get_item_by_uri(item) # noqa: PLW2901
- tasks.append(
- self.mass.create_task(
- self.remove_from_library(
- media_type=item.media_type,
- item_id=item.item_id,
- provider_instance_id_or_domain=item.provider,
- )
- )
- )
- await asyncio.gather(*tasks)
+ await ctrl.set_favorite(
+ library_item_id,
+ False,
+ )
- @api_command("music/delete")
- async def delete(
- self, media_type: MediaType, db_item_id: str | int, recursive: bool = False
+ @api_command("music/library/remove_item")
+ async def remove_item_from_library(
+ self, media_type: MediaType, library_item_id: str | int
) -> None:
- """Remove item from the database."""
+ """
+ Remove item from the library.
+
+ Destructive! Will remove the item and all dependants.
+ """
ctrl = self.get_controller(media_type)
- await ctrl.delete(db_item_id, recursive)
+ item = await ctrl.get_library_item(library_item_id)
+ # remove from all providers
+ for provider_mapping in item.provider_mappings:
+ prov_controller = self.mass.get_provider(provider_mapping.provider_instance)
+ with suppress(NotImplementedError):
+ await prov_controller.library_remove(provider_mapping.item_id, item.media_type)
+ await ctrl.remove_item_from_library(library_item_id)
+
+ @api_command("music/library/add_item")
+ async def add_item_to_library(self, item: str | MediaItemType) -> MediaItemType:
+ """Add item (uri or mediaitem) to the library."""
+ if isinstance(item, str):
+ item = await self.get_item_by_uri(item)
+ ctrl = self.get_controller(item.media_type)
+ # add to provider's library first
+ provider = self.mass.get_provider(item.provider)
+ if provider.library_edit_supported(item.media_type):
+ await provider.library_add(item.item_id, item.media_type)
+ return await ctrl.get(
+ item_id=item.item_id,
+ provider_instance_id_or_domain=item.provider,
+ details=item,
+ add_to_library=True,
+ )
async def refresh_items(self, items: list[MediaItemType]) -> None:
"""Refresh MediaItems to force retrieval of full info and matches.
media_item.provider,
force_refresh=True,
lazy=False,
- add_to_db=True,
+ add_to_library=True,
)
except MusicAssistantError:
pass
for item in result:
if item.available:
return await self.get_item(
- item.media_type, item.item_id, item.provider, lazy=False, add_to_db=True
+ item.media_type, item.item_id, item.provider, lazy=False, add_to_library=True
)
return None
allow_replace=True,
)
- async def library_add_items(self, items: list[MediaItemType]) -> None:
- """Add media item(s) to the library.
-
- Creates background tasks to process the action.
- """
- for media_item in items:
- self.mass.create_task(
- self.add_to_library(
- media_item.media_type,
- media_item.item_id,
- media_item.provider,
- )
- )
-
- async def library_remove_items(self, items: list[MediaItemType]) -> None:
- """Remove media item(s) from the library.
-
- Creates background tasks to process the action.
- """
- for media_item in items:
- self.mass.create_task(
- self.remove_from_library(
- media_item.media_type,
- media_item.item_id,
- media_item.provider,
- )
- )
-
def get_controller(
self, media_type: MediaType
) -> (
instances = set()
domains = set()
for provider in self.providers:
- if provider.domain not in domains or provider.is_unique:
+ if provider.domain not in domains or not provider.is_streaming_provider:
instances.add(provider.instance_id)
domains.add(provider.domain)
return instances
continue
for media_type in media_types:
if media_type in sync_task.media_types:
- LOGGER.debug(
+ self.logger.debug(
"Skip sync task for %s because another task is already in progress",
provider_instance,
)
async def run_sync() -> None:
# Wrap the provider sync into a lock to prevent
- # race conditions when multiple propviders are syncing at the same time.
+ # race conditions when multiple providers are syncing at the same time.
async with self._sync_lock:
await provider.sync_library(media_types)
def on_sync_task_done(task: asyncio.Task): # noqa: ARG001
self.in_progress_syncs.remove(sync_spec)
+ if task_err := task.exception():
+ self.logger.warning(
+ "Sync task for %s completed with errors", provider.name, exc_info=task_err
+ )
+ else:
+ self.logger.info("Sync task for %s completed", provider.name)
self.mass.signal_event(EventType.SYNC_TASKS_UPDATED, data=self.in_progress_syncs)
- # trigger metadata scan after provider sync completed
- self.mass.metadata.start_scan()
+ # trigger metadata scan after all provider syncs completed
+ if len(self.in_progress_syncs) == 0:
+ self.mass.metadata.start_scan()
task.add_done_callback(on_sync_task_done)
self.mass.music.albums,
self.mass.music.artists,
):
- prov_items = await ctrl.get_db_items_by_prov_id(provider_instance)
+ prov_items = await ctrl.get_library_items_by_prov_id(provider_instance)
for item in prov_items:
- await ctrl.remove_prov_mapping(item.item_id, provider_instance)
+ await ctrl.remove_provider_mappings(item.item_id, provider_instance)
async def _setup_database(self):
"""Initialize database."""
prev_version = 0
if prev_version not in (0, DB_SCHEMA_VERSION):
- LOGGER.info(
+ self.logger.info(
"Performing database migration from %s to %s",
prev_version,
DB_SCHEMA_VERSION,
)
+ # make a backup of db file
+ db_path_backup = db_path + ".backup"
+ await asyncio.to_thread(shutil.copyfile, db_path, db_path_backup)
+
+ if prev_version < 22 or prev_version > DB_SCHEMA_VERSION:
+ # for now just keep it simple and just recreate the tables
+ # if the schema is too old or too new
+ # we allow migrations only for up to 2 schema versions behind
+ await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_ARTISTS}")
+ await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_ALBUMS}")
+ await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_TRACKS}")
+ await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_PLAYLISTS}")
+ await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_RADIOS}")
+ # recreate missing tables
+ await self.__create_database_tables()
+
+ if prev_version in (22, 23):
+ # reset albums, artists, tracks, impossible to migrate in a clean way
+ for table in (
+ DB_TABLE_ARTISTS,
+ DB_TABLE_ALBUMS,
+ DB_TABLE_TRACKS,
+ ):
+ self.logger.warning(
+ "Resetting %s library/database - a full rescan will be performed!", table
+ )
+ await self.database.execute(f"DROP TABLE IF EXISTS {table}")
+ # recreate missing tables
+ await self.__create_database_tables()
- if prev_version == 22:
- # migrate provider_mapping column (audio_format)
- for table in ("tracks", "albums"):
+ # migrate in_library --> favorite
+ for table in (
+ DB_TABLE_PLAYLISTS,
+ DB_TABLE_RADIOS,
+ ):
+ # rename in_library --> favorite
+ await self.database.execute(
+ f"ALTER TABLE {table} RENAME COLUMN in_library TO favorite;"
+ )
+ # clean out all non favorites from library db
+ item_ids_to_delete = set()
async for item in self.database.iter_items(table):
+ if not (item["favorite"] or '"url' in item["provider_mappings"]):
+ item_ids_to_delete.add(item["item_id"])
+ continue
+ # migrate provider_mapping column (audio_format)
prov_mappings = json_loads(item["provider_mappings"])
needs_update = False
for mapping in prov_mappings:
"provider_mappings": json_dumps(prov_mappings),
},
)
- elif prev_version < 22:
- # for now just keep it simple and just recreate the tables if the schema is too old
- await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_ARTISTS}")
- await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_ALBUMS}")
- await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_TRACKS}")
- await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_PLAYLISTS}")
- await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_RADIOS}")
+ for item_id in item_ids_to_delete:
+ await self.database.delete(table, {"item_id": item_id})
- # recreate missing tables
- await self.__create_database_tables()
- else:
- raise RuntimeError("db schema migration missing")
+ self.logger.info(
+ "Database migration to version %s completed",
+ DB_SCHEMA_VERSION,
+ )
# store current schema version
await self.database.insert_or_replace(
name TEXT NOT NULL,
sort_name TEXT NOT NULL,
sort_artist TEXT,
- album_type TEXT,
+ album_type TEXT NOT NULL,
year INTEGER,
version TEXT,
- in_library BOOLEAN DEFAULT 0,
- barcode TEXT,
- musicbrainz_id TEXT,
- artists json,
- metadata json,
- provider_mappings json,
+ favorite BOOLEAN DEFAULT 0,
+ mbid TEXT,
+ artists json NOT NULL,
+ metadata json NOT NULL,
+ provider_mappings json NOT NULL,
timestamp_added INTEGER NOT NULL,
timestamp_modified INTEGER NOT NULL
);"""
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
sort_name TEXT NOT NULL,
- musicbrainz_id TEXT,
- in_library BOOLEAN DEFAULT 0,
- metadata json,
- provider_mappings json,
+ mbid TEXT,
+ favorite BOOLEAN DEFAULT 0,
+ metadata json NOT NULL,
+ provider_mappings json NOT NULL,
timestamp_added INTEGER NOT NULL,
timestamp_modified INTEGER NOT NULL
);"""
name TEXT NOT NULL,
sort_name TEXT NOT NULL,
sort_artist TEXT,
- sort_album TEXT,
version TEXT,
duration INTEGER,
- in_library BOOLEAN DEFAULT 0,
- isrc TEXT,
- musicbrainz_id TEXT,
- artists json,
- albums json,
- metadata json,
- provider_mappings json,
+ favorite BOOLEAN DEFAULT 0,
+ mbid TEXT,
+ artists json NOT NULL,
+ metadata json NOT NULL,
+ provider_mappings json NOT NULL,
timestamp_added INTEGER NOT NULL,
timestamp_modified INTEGER NOT NULL
);"""
)
+ await self.database.execute(
+ f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_ALBUM_TRACKS}(
+ track_id INTEGER NOT NULL,
+ album_id INTEGER NOT NULL,
+ disc_number INTEGER NOT NULL,
+ track_number INTEGER NOT NULL,
+ UNIQUE(track_id, album_id)
+ );"""
+ )
await self.database.execute(
f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_PLAYLISTS}(
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
sort_name TEXT NOT NULL,
owner TEXT NOT NULL,
is_editable BOOLEAN NOT NULL,
- in_library BOOLEAN DEFAULT 0,
+ favorite BOOLEAN DEFAULT 0,
metadata json,
provider_mappings json,
timestamp_added INTEGER NOT NULL,
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
sort_name TEXT NOT NULL,
- in_library BOOLEAN DEFAULT 0,
+ favorite BOOLEAN DEFAULT 0,
metadata json,
provider_mappings json,
timestamp_added INTEGER NOT NULL,
async def __create_database_indexes(self) -> None:
"""Create database indexes."""
await self.database.execute(
- "CREATE INDEX IF NOT EXISTS artists_in_library_idx on artists(in_library);"
+ "CREATE INDEX IF NOT EXISTS artists_in_library_idx on artists(favorite);"
)
await self.database.execute(
- "CREATE INDEX IF NOT EXISTS albums_in_library_idx on albums(in_library);"
+ "CREATE INDEX IF NOT EXISTS albums_in_library_idx on albums(favorite);"
)
await self.database.execute(
- "CREATE INDEX IF NOT EXISTS tracks_in_library_idx on tracks(in_library);"
+ "CREATE INDEX IF NOT EXISTS tracks_in_library_idx on tracks(favorite);"
)
await self.database.execute(
- "CREATE INDEX IF NOT EXISTS playlists_in_library_idx on playlists(in_library);"
+ "CREATE INDEX IF NOT EXISTS playlists_in_library_idx on playlists(favorite);"
)
await self.database.execute(
- "CREATE INDEX IF NOT EXISTS radios_in_library_idx on radios(in_library);"
+ "CREATE INDEX IF NOT EXISTS radios_in_library_idx on radios(favorite);"
)
await self.database.execute(
"CREATE INDEX IF NOT EXISTS artists_sort_name_idx on artists(sort_name);"
await self.database.execute(
"CREATE INDEX IF NOT EXISTS radios_sort_name_idx on radios(sort_name);"
)
- await self.database.execute(
- "CREATE INDEX IF NOT EXISTS artists_musicbrainz_id_idx on artists(musicbrainz_id);"
- )
- await self.database.execute(
- "CREATE INDEX IF NOT EXISTS albums_musicbrainz_id_idx on albums(musicbrainz_id);"
- )
- await self.database.execute(
- "CREATE INDEX IF NOT EXISTS tracks_musicbrainz_id_idx on tracks(musicbrainz_id);"
- )
- await self.database.execute("CREATE INDEX IF NOT EXISTS tracks_isrc_idx on tracks(isrc);")
- await self.database.execute(
- "CREATE INDEX IF NOT EXISTS albums_barcode_idx on albums(barcode);"
- )
+ await self.database.execute("CREATE INDEX IF NOT EXISTS artists_mbid_idx on artists(mbid);")
+ await self.database.execute("CREATE INDEX IF NOT EXISTS albums_mbid_idx on albums(mbid);")
+ await self.database.execute("CREATE INDEX IF NOT EXISTS tracks_mbid_idx on tracks(mbid);")
from music_assistant.common.helpers.util import create_sort_name
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
Artist,
ItemMapping,
MediaItem,
MediaItemMetadata,
+ ProviderMapping,
Track,
)
+IGNORE_VERSIONS = (
+ "remaster",
+ "explicit",
+ "music from and inspired by the motion picture",
+ "original soundtrack",
+)
+
def create_safe_string(input_str: str) -> str:
"""Return clean lowered string for compare actions."""
return create_sort_name(str1) == create_sort_name(str2)
-def compare_version(left_version: str, right_version: str) -> bool:
+def compare_version(base_version: str, compare_version: str) -> bool:
"""Compare version string."""
- if not left_version and not right_version:
+ if not base_version and not compare_version:
+ return True
+ if not base_version and compare_version.lower() in IGNORE_VERSIONS:
return True
- if not left_version and right_version:
+ if not compare_version and base_version.lower() in IGNORE_VERSIONS:
+ return True
+ if not base_version and compare_version:
return False
- if left_version and not right_version:
+ if base_version and not compare_version:
return False
- if " " not in left_version:
- return compare_strings(left_version, right_version)
+ if " " not in base_version:
+ return compare_strings(base_version, compare_version)
# do this the hard way as sometimes the version string is in the wrong order
- left_versions = left_version.lower().split(" ").sort()
- right_versions = right_version.lower().split(" ").sort()
- return left_versions == right_versions
+ base_versions = base_version.lower().split(" ").sort()
+ compare_versions = compare_version.lower().split(" ").sort()
+ return base_versions == compare_versions
-def compare_explicit(left: MediaItemMetadata, right: MediaItemMetadata) -> bool:
+def compare_explicit(base: MediaItemMetadata, compare: MediaItemMetadata) -> bool:
"""Compare if explicit is same in metadata."""
- if left.explicit is None or right.explicit is None:
+ if base.explicit is None or compare.explicit is None:
# explicitness info is not always present in metadata
# only strict compare them if both have the info set
return True
- return left == right
+ return base == compare
def compare_artist(
- left_artist: Artist | ItemMapping,
- right_artist: Artist | ItemMapping,
+ base_item: Artist | ItemMapping,
+ compare_item: Artist | ItemMapping,
) -> bool:
"""Compare two artist items and return True if they match."""
- if left_artist is None or right_artist is None:
+ if base_item is None or compare_item is None:
return False
# return early on exact item_id match
- if compare_item_ids(left_artist, right_artist):
+ if compare_item_ids(base_item, compare_item):
return True
- # prefer match on musicbrainz_id
- if getattr(left_artist, "musicbrainz_id", None) and getattr(
- right_artist, "musicbrainz_id", None
- ):
- return left_artist.musicbrainz_id == right_artist.musicbrainz_id
+ # prefer match on mbid
+ if getattr(base_item, "mbid", None) and getattr(compare_item, "mbid", None):
+ return base_item.mbid == compare_item.mbid
# fallback to comparing
- return compare_strings(left_artist.name, right_artist.name, False)
+ return compare_strings(base_item.name, compare_item.name, False)
def compare_artists(
- left_artists: list[Artist | ItemMapping],
- right_artists: list[Artist | ItemMapping],
- any_match: bool = False,
+ base_items: list[Artist | ItemMapping],
+ compare_items: list[Artist | ItemMapping],
+ any_match: bool = True,
) -> bool:
"""Compare two lists of artist and return True if both lists match (exactly)."""
matches = 0
- for left_artist in left_artists:
- for right_artist in right_artists:
- if compare_artist(left_artist, right_artist):
+ for base_item in base_items:
+ for compare_item in compare_items:
+ if compare_artist(base_item, compare_item):
if any_match:
return True
matches += 1
- return len(left_artists) == matches
+ return len(base_items) == matches
def compare_item_ids(
- left_item: MediaItem | ItemMapping, right_item: MediaItem | ItemMapping
+ base_item: MediaItem | ItemMapping, compare_item: MediaItem | ItemMapping
) -> bool:
"""Compare item_id(s) of two media items."""
- if not left_item.provider or not right_item.provider:
+ if not base_item.provider or not compare_item.provider:
return False
- if not left_item.item_id or not right_item.item_id:
+ if not base_item.item_id or not compare_item.item_id:
return False
- if left_item.provider == right_item.provider and left_item.item_id == right_item.item_id:
+ if base_item.provider == compare_item.provider and base_item.item_id == compare_item.item_id:
return True
- left_prov_ids = getattr(left_item, "provider_mappings", None)
- right_prov_ids = getattr(right_item, "provider_mappings", None)
+ base_prov_ids = getattr(base_item, "provider_mappings", None)
+ compare_prov_ids = getattr(compare_item, "provider_mappings", None)
- if left_prov_ids is not None:
- for prov_l in left_item.provider_mappings:
+ if base_prov_ids is not None:
+ for prov_l in base_item.provider_mappings:
if (
- prov_l.provider_domain == right_item.provider
- and prov_l.item_id == right_item.item_id
+ prov_l.provider_domain == compare_item.provider
+ and prov_l.item_id == compare_item.item_id
):
return True
- if right_prov_ids is not None:
- for prov_r in right_item.provider_mappings:
- if prov_r.provider_domain == left_item.provider and prov_r.item_id == left_item.item_id:
+ if compare_prov_ids is not None:
+ for prov_r in compare_item.provider_mappings:
+ if prov_r.provider_domain == base_item.provider and prov_r.item_id == base_item.item_id:
return True
- if left_prov_ids is not None and right_prov_ids is not None:
- for prov_l in left_item.provider_mappings:
- for prov_r in right_item.provider_mappings:
+ if base_prov_ids is not None and compare_prov_ids is not None:
+ for prov_l in base_item.provider_mappings:
+ for prov_r in compare_item.provider_mappings:
if prov_l.provider_domain != prov_r.provider_domain:
continue
if prov_l.item_id == prov_r.item_id:
def compare_albums(
- left_albums: list[Album | ItemMapping],
- right_albums: list[Album | ItemMapping],
+ base_items: list[Album | ItemMapping],
+ compare_items: list[Album | ItemMapping],
):
"""Compare two lists of albums and return True if a match was found."""
- for left_album in left_albums:
- for right_album in right_albums:
- if compare_album(left_album, right_album):
+ for base_item in base_items:
+ for compare_item in compare_items:
+ if compare_album(base_item, compare_item):
return True
return False
def compare_barcode(
- left_barcodes: set[str],
- right_barcodes: set[str],
+ base_mappings: set[ProviderMapping],
+ compare_mappings: set[ProviderMapping],
):
- """Compare two sets of barcodes and return True if a match was found."""
- for left_barcode in left_barcodes:
- if not left_barcode.strip():
+ """Compare barcode within provider mappings and return True if a match was found."""
+ for base_mapping in base_mappings:
+ if not base_mapping.barcode:
continue
- for right_barcode in right_barcodes:
- if not right_barcode.strip():
+ for compare_mapping in compare_mappings:
+ if not compare_mapping.barcode:
continue
# convert EAN-13 to UPC-A by stripping off the leading zero
- left_upc = left_barcode[1:] if left_barcode.startswith("0") else left_barcode
- right_upc = right_barcode[1:] if right_barcode.startswith("0") else right_barcode
- if compare_strings(left_upc, right_upc):
+ base_upc = (
+ base_mapping.barcode[1:]
+ if base_mapping.barcode.startswith("0")
+ else base_mapping.barcode
+ )
+ compare_upc = (
+ compare_mapping.barcode[1:]
+ if compare_mapping.barcode.startswith("0")
+ else compare_mapping.barcode
+ )
+ if compare_strings(base_upc, compare_upc):
return True
return False
def compare_isrc(
- left_isrcs: set[str],
- right_isrcs: set[str],
+ base_mappings: set[ProviderMapping],
+ compare_mappings: set[ProviderMapping],
):
- """Compare two sets of isrc codes and return True if a match was found."""
- for left_isrc in left_isrcs:
- if not left_isrc.strip():
+ """Compare isrc within provider mappings and return True if a match was found."""
+ for base_mapping in base_mappings:
+ if not base_mapping.isrc:
continue
- for right_isrc in right_isrcs:
- if not right_isrc.strip():
+ for compare_mapping in compare_mappings:
+ if not compare_mapping.isrc:
continue
- if compare_strings(left_isrc, right_isrc):
+ if compare_strings(base_mapping.isrc, compare_mapping.isrc):
return True
return False
def compare_album(
- left_album: Album | ItemMapping,
- right_album: Album | ItemMapping,
+ base_item: Album | ItemMapping,
+ compare_item: Album | ItemMapping,
):
"""Compare two album items and return True if they match."""
- if left_album is None or right_album is None:
+ if base_item is None or compare_item is None:
return False
# return early on exact item_id match
- if compare_item_ids(left_album, right_album):
+ if compare_item_ids(base_item, compare_item):
return True
+ # prefer match on mbid (not present on ItemMapping)
+ if getattr(base_item, "mbid", None) and getattr(compare_item, "mbid", None):
+ return compare_strings(base_item.mbid, compare_item.mbid)
# prefer match on barcode/upc
# not present on ItemMapping
if (
- getattr(left_album, "barcode", None)
- and getattr(right_album, "barcode", None)
- and compare_barcode(left_album.barcode, right_album.barcode)
+ isinstance(base_item, Album)
+ and isinstance(compare_item, Album)
+ and compare_barcode(base_item.provider_mappings, compare_item.provider_mappings)
):
return True
- # prefer match on musicbrainz_id
- # not present on ItemMapping
- if getattr(left_album, "musicbrainz_id", None) and getattr(right_album, "musicbrainz_id", None):
- return left_album.musicbrainz_id == right_album.musicbrainz_id
-
# fallback to comparing
- if not compare_strings(left_album.name, right_album.name, True):
+ if not compare_strings(base_item.name, compare_item.name, True):
return False
- if not compare_version(left_album.version, right_album.version):
+ if not compare_version(base_item.version, compare_item.version):
return False
if (
- hasattr(left_album, "metadata")
- and hasattr(right_album, "metadata")
- and not compare_explicit(left_album.metadata, right_album.metadata)
+ hasattr(base_item, "metadata")
+ and hasattr(compare_item, "metadata")
+ and not compare_explicit(base_item.metadata, compare_item.metadata)
):
return False
# compare album artist
# Note: Not present on ItemMapping
if (
- isinstance(left_album, Album)
- and isinstance(right_album, Album)
- and not compare_artists(left_album.artists, right_album.artists, True)
+ isinstance(base_item, Album)
+ and isinstance(compare_item, Album)
+ and not compare_artists(base_item.artists, compare_item.artists, True)
):
return False
- return left_album.sort_name == right_album.sort_name
+ return base_item.sort_name == compare_item.sort_name
-def compare_track(left_track: Track, right_track: Track, strict: bool = True):
+def compare_track(
+ base_item: Track | AlbumTrack,
+ compare_item: Track | AlbumTrack,
+ strict: bool = True,
+ track_albums: list[Album | ItemMapping] | None = None,
+):
"""Compare two track items and return True if they match."""
- if left_track is None or right_track is None:
+ if base_item is None or compare_item is None:
return False
- assert isinstance(left_track, Track) and isinstance(right_track, Track)
+ assert isinstance(base_item, Track) and isinstance(compare_item, Track)
# return early on exact item_id match
- if compare_item_ids(left_track, right_track):
+ if compare_item_ids(base_item, compare_item):
return True
- if compare_isrc(left_track.isrc, right_track.isrc):
+ if compare_isrc(base_item.provider_mappings, compare_item.provider_mappings):
return True
- if compare_strings(left_track.musicbrainz_id, right_track.musicbrainz_id):
+ if compare_strings(base_item.mbid, compare_item.mbid):
return True
- # album is required for track linking
- if strict and left_track.album is None or right_track.album is None:
- return False
# track name must match
- if not compare_strings(left_track.name, right_track.name, False):
- return False
- # track version must match
- if not compare_version(left_track.version, right_track.version):
+ if not compare_strings(base_item.name, compare_item.name, False):
return False
# track artist(s) must match
- if not compare_artists(left_track.artists, right_track.artists):
+ if not compare_artists(base_item.artists, compare_item.artists):
+ return False
+ # track version must match
+ if strict and not compare_version(base_item.version, compare_item.version):
return False
# check if both tracks are (not) explicit
- if strict and not compare_explicit(left_track.metadata, right_track.metadata):
+ if base_item.metadata.explicit is None and base_item.album:
+ base_item.metadata.explicit = base_item.album.metadata.explicit
+ if compare_item.metadata.explicit is None and compare_item.album:
+ compare_item.metadata.explicit = compare_item.album.metadata.explicit
+ if strict and not compare_explicit(base_item.metadata, compare_item.metadata):
return False
+ if not strict and not track_albums:
+ # in non-strict mode, the album does not have to match
+ return abs(base_item.duration - compare_item.duration) <= 3
# exact albumtrack match = 100% match
if (
- compare_album(left_track.album, right_track.album)
- and left_track.track_number
- and right_track.track_number
- and ((left_track.disc_number or 1) == (right_track.disc_number or 1))
- and left_track.track_number == right_track.track_number
+ isinstance(base_item, AlbumTrack)
+ and isinstance(compare_item, AlbumTrack)
+ and compare_album(base_item.album, compare_item.album)
+ and base_item.track_number == compare_item.track_number
):
return True
- # check album match
+ # fallback: exact album match and (near-exact) track duration match
if (
- not (album_match_found := compare_album(left_track.album, right_track.album))
- and left_track.albums
- and right_track.albums
+ base_item.album is not None
+ and compare_item.album is not None
+ and compare_album(base_item.album, compare_item.album)
+ and abs(base_item.duration - compare_item.duration) <= 5
):
- for left_album in left_track.albums:
- for right_album in right_track.albums:
- if compare_album(left_album, right_album):
- album_match_found = True
- if (
- (left_album.disc_number or 1) == (right_album.disc_number or 1)
- and left_album.track_number
- and right_album.track_number
- and left_album.track_number == right_album.track_number
- ):
- # exact albumtrack match = 100% match
- return True
- # fallback: exact album match and (near-exact) track duration match
- if album_match_found and abs(left_track.duration - right_track.duration) <= 3:
return True
+ # fallback: additional compare albums provided for base track
+ if (
+ compare_item.album is not None
+ and track_albums
+ and abs(base_item.duration - compare_item.duration) <= 5
+ ):
+ for track_album in track_albums:
+ if compare_album(track_album, compare_item.album):
+ return True
+ # edge case: albumless track
+ if (
+ base_item.album is None
+ and compare_item.album is None
+ and abs(base_item.duration - compare_item.duration) <= 3
+ ):
+ return True
+
+ # all efforts failed, this is NOT a match
return False
return AlbumType.UNKNOWN
@property
- def isrc(self) -> tuple[str, ...]:
- """Return isrc tag(s)."""
- if tag := self.tags.get("isrc"):
- return split_items(tag, True)
- if tag := self.tags.get("tsrc"):
- return split_items(tag, True)
- return tuple()
+ def isrc(self) -> str | None:
+ """Return isrc tag."""
+ for tag in ("isrc", "tsrc"):
+ if tag := self.tags.get("isrc"):
+ # sometyimes the field contains multiple values
+ # we only need one
+ return split_items(tag, True)[0]
+ return None
@property
- def barcode(self) -> tuple[str, ...]:
+ def barcode(self) -> str | None:
"""Return barcode (upc/ean) tag(s)."""
- # prefer multi-artist tag
- if tag := self.tags.get("barcode"):
- return split_items(tag, True)
- if tag := self.tags.get("upc"):
- return split_items(tag, True)
- if tag := self.tags.get("ean"):
- return split_items(tag, True)
- return tuple()
+ for tag in ("barcode", "upc", "ean"):
+ if tag := self.tags.get("isrc"):
+ # sometyimes the field contains multiple values
+ # we only need one
+ return split_items(tag, True)[0]
+ return None
@property
def chapters(self) -> list[MediaItemChapter]:
from collections.abc import AsyncGenerator
from music_assistant.common.models.enums import MediaType, ProviderFeature
+from music_assistant.common.models.errors import MediaNotFoundError
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
Artist,
BrowseFolder,
MediaItemType,
Playlist,
+ PlaylistTrack,
Radio,
SearchResults,
StreamDetails,
"""
@property
- def is_unique(self) -> bool:
+ def is_streaming_provider(self) -> bool:
"""
- Return True if the (non user related) data in this provider instance is unique.
+ Return True if the provider is a streaming provider.
- For example on a global streaming provider (like Spotify),
- the data on all instances is the same.
- For a file provider each instance has other items.
- Setting this to False will only query one instance of the provider for search and lookups.
- Setting this to True will query all instances of this provider for search and lookups.
+ This literally means that the catalog is not the same as the library contents.
+ For local based providers (files, plex), the catalog is the same as the library content.
+ It also means that data is if this provider is NOT a streaming provider,
+ data cross instances is unique, the catalog and library differs per instance.
+
+ Setting this to True will only query one instance of the provider for search and lookups.
+ Setting this to False will query all instances of this provider for search and lookups.
"""
- return False
+ return True
async def search(
self,
raise NotImplementedError
yield # type: ignore
- async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
+ async def get_library_tracks(self) -> AsyncGenerator[Track | AlbumTrack, None]:
"""Retrieve library tracks from the provider."""
if ProviderFeature.LIBRARY_TRACKS in self.supported_features:
raise NotImplementedError
if ProviderFeature.LIBRARY_RADIOS in self.supported_features:
raise NotImplementedError
- async def get_album_tracks(self, prov_album_id: str) -> list[Track]: # type: ignore[return]
+ async def get_album_tracks(
+ self, prov_album_id: str # type: ignore[return]
+ ) -> list[AlbumTrack]:
"""Get album tracks for given album id."""
if ProviderFeature.LIBRARY_ALBUMS in self.supported_features:
raise NotImplementedError
async def get_playlist_tracks( # type: ignore[return]
self, prov_playlist_id: str
- ) -> AsyncGenerator[Track, None]:
+ ) -> AsyncGenerator[PlaylistTrack, None]:
"""Get all playlist tracks for given playlist id."""
if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
raise NotImplementedError
controller = self.mass.music.get_controller(media_type)
cur_db_ids = set()
async for prov_item in self._get_library_gen(media_type):
- db_item = await controller.get_db_item_by_prov_mappings(
+ library_item = await controller.get_library_item_by_prov_mappings(
prov_item.provider_mappings,
)
- if not db_item:
+ if not library_item:
# create full db item
- prov_item.in_library = True
- db_item = await controller.add(prov_item, skip_metadata_lookup=True)
+ # note that we skip the metadata lookup purely to speed up the sync
+ # the additional metadata is then lazy retrieved afterwards
+ prov_item.favorite = True
+ library_item = await controller.add_item_to_library(
+ prov_item, skip_metadata_lookup=True
+ )
elif (
- db_item.metadata.checksum and prov_item.metadata.checksum
- ) and db_item.metadata.checksum != prov_item.metadata.checksum:
+ library_item.metadata.checksum and prov_item.metadata.checksum
+ ) and library_item.metadata.checksum != prov_item.metadata.checksum:
# existing dbitem checksum changed
- db_item = await controller.update(db_item.item_id, prov_item)
- cur_db_ids.add(db_item.item_id)
- if not db_item.in_library:
- await controller.set_db_library(db_item.item_id, True)
+ library_item = await controller.update_item_in_library(
+ library_item.item_id, prov_item
+ )
+ cur_db_ids.add(library_item.item_id)
# process deletions (= no longer in library)
- cache_key = f"db_items.{media_type}.{self.instance_id}"
- prev_db_items: list[int] | None
- if prev_db_items := await self.mass.cache.get(cache_key):
- for db_id in prev_db_items:
+ cache_key = f"library_items.{media_type}.{self.instance_id}"
+ prev_library_items: list[int] | None
+ if prev_library_items := await self.mass.cache.get(cache_key):
+ for db_id in prev_library_items:
if db_id not in cur_db_ids:
- # only mark the item as not in library and leave the metadata in db
- await controller.set_db_library(db_id, False)
+ try:
+ item = await controller.get_library_item(db_id)
+ except MediaNotFoundError:
+ # edge case: the item is already removed
+ continue
+ remaining_providers = {
+ x.provider_domain
+ for x in item.provider_mappings
+ if x.provider_domain != self.domain
+ }
+ if not remaining_providers and media_type != MediaType.ARTIST:
+ # this item is removed from the provider's library
+ # and we have no other providers attached to it
+ # it is safe to remove it from the MA library too
+ # note we skip artists here to prevent a recursive removal
+ # of all albums and tracks underneath this artist
+ await controller.remove_item_from_library(db_id)
+ else:
+ # otherwise: just unmark favorite
+ await controller.set_favorite(db_id, False)
await self.mass.cache.set(cache_key, list(cur_db_ids))
# DO NOT OVERRIDE BELOW
from asyncio import TaskGroup
from collections.abc import AsyncGenerator
from math import ceil
+from typing import Any
import deezer
from aiohttp import ClientTimeout
from music_assistant.common.models.errors import LoginFailed
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
Artist,
AudioFormat,
BrowseFolder,
MediaItemImage,
MediaItemMetadata,
Playlist,
+ PlaylistTrack,
ProviderMapping,
SearchResults,
StreamDetails,
return self.parse_album(album=await self.client.get_album(album_id=int(prov_album_id)))
except deezer.exceptions.DeezerErrorResponse as error:
self.logger.warning("Failed getting album: %s", error)
- return Album(prov_album_id, self.instance_id, "Not Found")
+ return Album(itemid=prov_album_id, provider=self.instance_id, name="Not Found")
async def get_playlist(self, prov_playlist_id: str) -> Playlist:
"""Get full playlist details by id."""
user_country=self.gw_client.user_country,
)
- async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
- """Get all albums in a playlist."""
+ async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]:
+ """Get all tracks in a album."""
album = await self.client.get_album(album_id=int(prov_album_id))
- return [
- self.parse_track(track=track, user_country=self.gw_client.user_country)
- for track in album.tracks
- ]
+ result = []
+ for count, deezer_track in enumerate(album.tracks, start=1):
+ result.append(
+ self.parse_track(
+ track=deezer_track,
+ user_country=self.gw_client.user_country,
+ extra_init_kwargs={"disc_number": 0, "track_number": count},
+ )
+ )
+ return result
- async def get_playlist_tracks(self, prov_playlist_id: str) -> AsyncGenerator[Track, None]:
+ async def get_playlist_tracks(
+ self, prov_playlist_id: str
+ ) -> AsyncGenerator[PlaylistTrack, None]:
"""Get all tracks in a playlist."""
playlist = await self.client.get_playlist(playlist_id=prov_playlist_id)
- for count, track in enumerate(playlist.tracks, start=1):
- track_parsed = self.parse_track(track=track, user_country=self.gw_client.user_country)
- track_parsed.position = count
- track_parsed.id = track.id
- yield track_parsed
+ for count, deezer_track in enumerate(playlist.tracks, start=1):
+ track = self.parse_track(track=deezer_track, user_country=self.gw_client.user_country)
+ track.position = count
+ track.id = track.id
+ yield track
async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
"""Get albums by an artist."""
]
async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool:
- """Add an item to the library."""
+ """Add an item to the provider's library/favorites."""
result = False
if media_type == MediaType.ARTIST:
result = await self.client.add_user_artists(
return result
async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
- """Remove an item to the library."""
+ """Remove an item from the provider's library/favorites."""
result = False
if media_type == MediaType.ARTIST:
result = await self.client.remove_user_artists(
is_editable=playlist.creator.id == self.client.user.id,
)
- def parse_track(self, track: deezer.Track, user_country: str) -> Track:
+ def parse_track(
+ self,
+ track: deezer.Track,
+ user_country: str,
+ extra_init_kwargs: dict[str, Any] | None = None,
+ ) -> Track | PlaylistTrack:
"""Parse the deezer-python track to a MASS track."""
- return Track(
+ if "position" in extra_init_kwargs:
+ track_class = PlaylistTrack
+ elif "disc_number" in extra_init_kwargs and "track_number" in extra_init_kwargs:
+ track_class = AlbumTrack
+ else:
+ track_class = Track
+ return track_class(
item_id=str(track.id),
provider=self.domain,
name=track.title,
)
},
metadata=self.parse_metadata_track(track=track),
+ **extra_init_kwargs or {},
)
### SEARCH AND PARSE FUNCTIONS ###
async def get_artist_metadata(self, artist: Artist) -> MediaItemMetadata | None:
"""Retrieve metadata for artist on fanart.tv."""
- if not artist.musicbrainz_id:
+ if not artist.mbid:
return None
self.logger.debug("Fetching metadata for Artist %s on Fanart.tv", artist.name)
- if data := await self._get_data(f"music/{artist.musicbrainz_id}"):
+ if data := await self._get_data(f"music/{artist.mbid}"):
metadata = MediaItemMetadata()
metadata.images = []
for key, img_type in IMG_MAPPING.items():
async def get_album_metadata(self, album: Album) -> MediaItemMetadata | None:
"""Retrieve metadata for album on fanart.tv."""
- if not album.musicbrainz_id:
+ if not album.mbid:
return None
self.logger.debug("Fetching metadata for Album %s on Fanart.tv", album.name)
- if data := await self._get_data(f"music/albums/{album.musicbrainz_id}"): # noqa: SIM102
+ if data := await self._get_data(f"music/albums/{album.mbid}"): # noqa: SIM102
if data and data.get("albums"):
- data = data["albums"][album.musicbrainz_id]
+ data = data["albums"][album.mbid]
metadata = MediaItemMetadata()
metadata.images = []
for key, img_type in IMG_MAPPING.items():
)
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
Artist,
AudioFormat,
BrowseFolder,
MediaItemImage,
MediaType,
Playlist,
+ PlaylistTrack,
ProviderMapping,
Radio,
SearchResults,
StreamDetails,
Track,
)
-from music_assistant.constants import VARIOUS_ARTISTS, VARIOUS_ARTISTS_ID
+from music_assistant.constants import (
+ DB_TABLE_ALBUM_TRACKS,
+ DB_TABLE_TRACKS,
+ VARIOUS_ARTISTS_ID_MBID,
+ VARIOUS_ARTISTS_NAME,
+)
from music_assistant.server.controllers.cache import use_cache
from music_assistant.server.controllers.music import DB_SCHEMA_VERSION
from music_assistant.server.helpers.compare import compare_strings
# should normally not be needed to override
@property
- def is_unique(self) -> bool:
+ def is_streaming_provider(self) -> bool:
"""
- Return True if the (non user related) data in this provider instance is unique.
+ Return True if the provider is a streaming provider.
+
+ This literally means that the catalog is not the same as the library contents.
+ For local based providers (files, plex), the catalog is the same as the library content.
+ It also means that data is if this provider is NOT a streaming provider,
+ data cross instances is unique, the catalog and library differs per instance.
- For example on a global streaming provider (like Spotify),
- the data on all instances is the same.
- For a file provider each instance has other items.
- Setting this to False will only query one instance of the provider for search and lookups.
- Setting this to True will query all instances of this provider for search and lookups.
+ Setting this to True will only query one instance of the provider for search and lookups.
+ Setting this to False will query all instances of this provider for search and lookups.
"""
- return True
+ return False
async def search(
self, search_query: str, media_types=list[MediaType] | None, limit: int = 5 # noqa: ARG002
# ruff: noqa: E501
if media_types is None or MediaType.TRACK in media_types:
query = "SELECT * FROM tracks WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
- result.tracks = await self.mass.music.tracks.get_db_items_by_query(query, params)
+ result.tracks = await self.mass.music.tracks.get_library_items_by_query(query, params)
if media_types is None or MediaType.ALBUM in media_types:
query = "SELECT * FROM albums WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
- result.albums = await self.mass.music.albums.get_db_items_by_query(query, params)
+ result.albums = await self.mass.music.albums.get_library_items_by_query(query, params)
if media_types is None or MediaType.ARTIST in media_types:
query = "SELECT * FROM artists WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
- result.artists = await self.mass.music.artists.get_db_items_by_query(query, params)
+ result.artists = await self.mass.music.artists.get_library_items_by_query(query, params)
if media_types is None or MediaType.PLAYLIST in media_types:
query = "SELECT * FROM playlists WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
- result.playlists = await self.mass.music.playlists.get_db_items_by_query(query, params)
+ result.playlists = await self.mass.music.playlists.get_library_items_by_query(
+ query, params
+ )
return result
async def browse(self, path: str) -> BrowseFolder:
continue
if item.ext in TRACK_EXTENSIONS:
- if db_item := await self.mass.music.tracks.get_db_item_by_prov_id(
+ if library_item := await self.mass.music.tracks.get_library_item_by_prov_id(
item.path, self.instance_id
):
- subitems.append(db_item)
+ subitems.append(library_item)
elif track := await self.get_track(item.path):
# make sure that the item exists
# https://github.com/music-assistant/hass-music-assistant/issues/707
- db_item = await self.mass.music.tracks.add(track, skip_metadata_lookup=True)
- subitems.append(db_item)
+ library_item = await self.mass.music.tracks.add_item_to_library(
+ track, skip_metadata_lookup=True
+ )
+ subitems.append(library_item)
continue
if item.ext in PLAYLIST_EXTENSIONS:
- if db_item := await self.mass.music.playlists.get_db_item_by_prov_id(
+ if library_item := await self.mass.music.playlists.get_library_item_by_prov_id(
item.path, self.instance_id
):
- subitems.append(db_item)
+ subitems.append(library_item)
elif playlist := await self.get_playlist(item.path):
# make sure that the item exists
# https://github.com/music-assistant/hass-music-assistant/issues/707
- db_item = await self.mass.music.playlists.add(
+ library_item = await self.mass.music.playlists.add(
playlist, skip_metadata_lookup=True
)
- subitems.append(db_item)
+ subitems.append(library_item)
continue
return BrowseFolder(
if item.ext in TRACK_EXTENSIONS:
# add/update track to db
track = await self._parse_track(item)
- await self.mass.music.tracks.add(track, skip_metadata_lookup=True)
+ await self.mass.music.tracks.add_item_to_library(
+ track, skip_metadata_lookup=True
+ )
elif item.ext in PLAYLIST_EXTENSIONS:
playlist = await self.get_playlist(item.path)
# add/update] playlist to db
playlist.metadata.checksum = item.checksum
# playlist is always in-library
- playlist.in_library = True
- await self.mass.music.playlists.add(playlist, skip_metadata_lookup=True)
+ playlist.favorite = True
+ await self.mass.music.playlists.add_item_to_library(
+ playlist, skip_metadata_lookup=True
+ )
except Exception as err: # pylint: disable=broad-except
# we don't want the whole sync to crash on one file so we catch all exceptions here
self.logger.exception("Error processing %s - %s", item.path, str(err))
async def _process_deletions(self, deleted_files: set[str]) -> None:
"""Process all deletions."""
# process deleted tracks/playlists
+ album_ids = set()
+ artist_ids = set()
for file_path in deleted_files:
_, ext = file_path.rsplit(".", 1)
if ext not in SUPPORTED_EXTENSIONS:
else:
controller = self.mass.music.get_controller(MediaType.TRACK)
- if db_item := await controller.get_db_item_by_prov_id(file_path, self.instance_id):
- await controller.delete(db_item.item_id, True)
+ if library_item := await controller.get_library_item_by_prov_id(
+ file_path, self.instance_id
+ ):
+ if library_item.media_type == MediaType.TRACK:
+ album_ids.add(library_item.album.item_id)
+ for artist in library_item.artists + library_item.album.artists:
+ artist_ids.add(artist.item_id)
+ await controller.remove_item_from_library(library_item.item_id)
+ # check if any albums need to be cleaned up
+ for album_id in album_ids:
+ if not self.mass.music.albums.tracks(album_id, "library"):
+ await self.mass.music.albums.remove_item_from_library(album_id)
+ # check if any artists need to be cleaned up
+ for artist_id in artist_ids:
+ if not self.mass.music.artists.albums(
+ artist_id, "library"
+ ) and self.mass.music.artists.tracks(artist_id, "library"):
+ await self.mass.music.artists.remove_item_from_library(album_id)
async def get_artist(self, prov_artist_id: str) -> Artist:
"""Get full artist details by id."""
- db_artist = await self.mass.music.artists.get_db_item_by_prov_id(
+ db_artist = await self.mass.music.artists.get_library_item_by_prov_id(
prov_artist_id, self.instance_id
)
if db_artist is None:
file_item = await self.resolve(prov_playlist_id)
playlist = Playlist(
- file_item.path,
+ item_id=file_item.path,
provider=self.instance_id,
name=file_item.name.replace(f".{file_item.ext}", ""),
)
playlist.metadata.checksum = checksum
return playlist
- async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
+ async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]:
"""Get album tracks for given album id."""
# filesystem items are always stored in db so we can query the database
- db_album = await self.mass.music.albums.get_db_item_by_prov_id(
+ db_album = await self.mass.music.albums.get_library_item_by_prov_id(
prov_album_id, self.instance_id
)
if db_album is None:
raise MediaNotFoundError(f"Album not found: {prov_album_id}")
- # TODO: adjust to json query instead of text search
- query = f"SELECT * FROM tracks WHERE albums LIKE '%\"{db_album.item_id}\"%'"
- query += f" AND provider_mappings LIKE '%\"{self.instance_id}\"%'"
- result = []
- for track in await self.mass.music.tracks.get_db_items_by_query(query):
- track.album = db_album
- if album_mapping := next(
- (x for x in track.albums if x.item_id == db_album.item_id), None
- ):
- track.disc_number = album_mapping.disc_number
- track.track_number = album_mapping.track_number
- result.append(track)
- return sorted(result, key=lambda x: (x.disc_number or 0, x.track_number or 0))
+ result: list[AlbumTrack] = []
+ async for album_track_row in self.mass.music.database.iter_items(
+ DB_TABLE_ALBUM_TRACKS, {"album_id": db_album.item_id}
+ ):
+ track_row = await self.mass.music.database.get_row(
+ DB_TABLE_TRACKS, {"item_id": album_track_row["track_id"]}
+ )
+ if f'"{self.instance_id}"' not in track_row["provider_mappings"]:
+ continue
+ album_track = AlbumTrack.from_db_row(
+ {**track_row, **album_track_row, "album": db_album.to_dict()}
+ )
+ if db_album.metadata.images:
+ album_track.metadata.images = db_album.metadata.images
+ result.append(album_track)
+ return sorted(result, key=lambda x: (x.disc_number, x.track_number))
async def get_playlist_tracks(self, prov_playlist_id: str) -> AsyncGenerator[Track, None]:
"""Get playlist tracks for given playlist id."""
else:
playlist_lines = await parse_pls(playlist_data)
- for line_no, playlist_line in enumerate(playlist_lines):
+ for line_no, playlist_line in enumerate(playlist_lines, 1):
if media_item := await self._parse_playlist_line(
- playlist_line, os.path.dirname(prov_playlist_id)
+ playlist_line, os.path.dirname(prov_playlist_id), line_no
):
- # use the linenumber as position for easier deletions
- media_item.position = line_no + 1
yield media_item
except Exception as err: # pylint: disable=broad-except
self.logger.warning("Error while parsing playlist %s", prov_playlist_id, exc_info=err)
- async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | Radio | None:
+ async def _parse_playlist_line(
+ self, line: str, playlist_path: str, position: int
+ ) -> Track | Radio | None:
"""Try to parse a track from a playlist line."""
try:
if "://" in line:
# handle as generic uri
- return await self.mass.music.get_item_by_uri(line)
+ media_item = await self.mass.music.get_item_by_uri(line)
+ if isinstance(media_item, Track):
+ return PlaylistTrack.from_dict({**media_item.to_dict(), "position": position})
+ return media_item
# if a relative path was given in an upper level from the playlist,
# try to resolve it
for filename in (line, os.path.join(playlist_path, line)):
with contextlib.suppress(FileNotFoundError):
item = await self.resolve(filename)
- return await self._parse_track(item)
+ return await self._parse_track(item, playlist_position=position)
except MusicAssistantError as err:
self.logger.warning("Could not parse uri/file %s to track: %s", line, str(err))
async def get_stream_details(self, item_id: str) -> StreamDetails:
"""Return the content details for the given track when it will be streamed."""
- db_item = await self.mass.music.tracks.get_db_item_by_prov_id(item_id, self.instance_id)
- if db_item is None:
+ library_item = await self.mass.music.tracks.get_library_item_by_prov_id(
+ item_id, self.instance_id
+ )
+ if library_item is None:
raise MediaNotFoundError(f"Item not found: {item_id}")
- prov_mapping = next(x for x in db_item.provider_mappings if x.item_id == item_id)
+ prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
file_item = await self.resolve(item_id)
return StreamDetails(
item_id=item_id,
audio_format=prov_mapping.audio_format,
media_type=MediaType.TRACK,
- duration=db_item.duration,
+ duration=library_item.duration,
size=file_item.file_size,
direct=file_item.local_path,
can_seek=prov_mapping.audio_format.content_type in SEEKABLE_FILES,
file_item = await self.resolve(path)
return file_item.local_path or self.read_file_content(file_item.absolute_path)
- async def _parse_track(self, file_item: FileSystemItem) -> Track:
+ async def _parse_track(
+ self, file_item: FileSystemItem, playlist_position: int | None = None
+ ) -> Track | AlbumTrack | PlaylistTrack:
"""Get full track details by id."""
# ruff: noqa: PLR0915, PLR0912
# parse tags
input_file = file_item.local_path or self.read_file_content(file_item.absolute_path)
tags = await parse_tags(input_file, file_item.file_size)
-
name, version = parse_title_and_version(tags.title, tags.version)
- track = Track(
- item_id=file_item.path,
- provider=self.instance_id,
- name=name,
- version=version,
- )
+ base_details = {
+ "item_id": file_item.path,
+ "provider": self.instance_id,
+ "name": name,
+ "version": version,
+ }
+ if playlist_position is not None:
+ track = PlaylistTrack(
+ **base_details,
+ position=playlist_position,
+ )
+ elif tags.album and tags.disc and tags.track:
+ track = AlbumTrack(
+ **base_details,
+ disc_number=tags.disc,
+ track_number=tags.track,
+ )
+ else:
+ track = Track(
+ **base_details,
+ )
# album
if tags.album:
# work out if we have an artist folder
artist_dir = get_parentdir(album_dir, album_artist_str, 1)
artist = await self._parse_artist(album_artist_str, artist_path=artist_dir)
- if not artist.musicbrainz_id:
+ if not artist.mbid:
with contextlib.suppress(IndexError):
- artist.musicbrainz_id = tags.musicbrainz_albumartistids[index]
+ artist.mbid = tags.musicbrainz_albumartistids[index]
album_artists.append(artist)
else:
# album artist tag is missing, determine fallback
self.logger.warning(
"%s is missing ID3 tag [albumartist], using %s as fallback",
file_item.path,
- VARIOUS_ARTISTS,
+ VARIOUS_ARTISTS_NAME,
)
- album_artists = [await self._parse_artist(name=VARIOUS_ARTISTS)]
+ album_artists = [await self._parse_artist(name=VARIOUS_ARTISTS_NAME)]
elif fallback_action == "track_artist":
self.logger.warning(
"%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
raise InvalidDataError("missing ID3 tag [albumartist]")
track.album = await self._parse_album(
- tags.album,
- album_dir,
- disc_dir,
- artists=album_artists,
+ tags.album, album_dir, disc_dir, artists=album_artists, barcode=tags.barcode
)
- else:
- self.logger.warning("%s is missing ID3 tag [album]", file_item.path)
# track artist(s)
for index, track_artist_str in enumerate(tags.artists):
# re-use album artist details if possible
if track.album and (
- artist := next((x for x in track.album.artists if x.name == track_artist_str), None)
+ album_artist := next(
+ (x for x in track.album.artists if x.name == track_artist_str), None
+ )
):
- track.artists.append(artist)
+ artist = album_artist
else:
artist = await self._parse_artist(track_artist_str)
- if not artist.musicbrainz_id:
+ if not artist.mbid:
with contextlib.suppress(IndexError):
- artist.musicbrainz_id = tags.musicbrainz_artistids[index]
+ artist.mbid = tags.musicbrainz_artistids[index]
track.artists.append(artist)
# cover image - prefer embedded image, fallback to album cover
track.metadata.genres = set(tags.genres)
track.disc_number = tags.disc
track.track_number = tags.track
- track.isrc.update(tags.isrc)
track.metadata.copyright = tags.get("copyright")
track.metadata.lyrics = tags.get("lyrics")
explicit_tag = tags.get("itunesadvisory")
if explicit_tag is not None:
track.metadata.explicit = explicit_tag == "1"
- track.musicbrainz_id = tags.musicbrainz_trackid
+ track.mbid = tags.musicbrainz_trackid
track.metadata.chapters = tags.chapters
if track.album:
- if not track.album.musicbrainz_id:
- track.album.musicbrainz_id = tags.musicbrainz_releasegroupid
+ if not track.album.mbid:
+ track.album.mbid = tags.musicbrainz_releasegroupid
if not track.album.year:
track.album.year = tags.year
- track.album.barcode.update(tags.barcode)
track.album.album_type = tags.album_type
track.album.metadata.explicit = track.metadata.explicit
# set checksum to invalidate any cached listings
bit_depth=tags.bits_per_sample,
bit_rate=tags.bit_rate,
),
+ isrc=tags.isrc,
)
)
return track
name = artist_path.split(os.sep)[-1]
artist = Artist(
- artist_path,
- self.instance_id,
- name,
+ item_id=artist_path,
+ provider=self.instance_id,
+ name=name,
provider_mappings={
ProviderMapping(artist_path, self.instance_id, self.instance_id, url=artist_path)
},
- musicbrainz_id=VARIOUS_ARTISTS_ID if compare_strings(name, VARIOUS_ARTISTS) else None,
+ mbid=VARIOUS_ARTISTS_ID_MBID if compare_strings(name, VARIOUS_ARTISTS_NAME) else None,
)
if not await self.exists(artist_path):
artist.name = info.get("title", info.get("name", name))
if sort_name := info.get("sortname"):
artist.sort_name = sort_name
- if musicbrainz_id := info.get("musicbrainzartistid"):
- artist.musicbrainz_id = musicbrainz_id
+ if mbid := info.get("musicbrainzartistid"):
+ artist.mbid = mbid
if description := info.get("biography"):
artist.metadata.description = description
if genre := info.get("genre"):
return artist
async def _parse_album(
- self, name: str | None, album_path: str | None, disc_path: str | None, artists: list[Artist]
+ self,
+ name: str | None,
+ album_path: str | None,
+ disc_path: str | None,
+ artists: list[Artist],
+ barcode: str | None = None,
) -> Album | None:
"""Lookup metadata in Album folder."""
assert (name or album_path) and artists
name = album_path.split(os.sep)[-1]
album = Album(
- album_path,
- self.instance_id,
- name,
+ item_id=album_path,
+ provider=self.instance_id,
+ name=name,
artists=artists,
provider_mappings={
- ProviderMapping(album_path, self.instance_id, self.instance_id, url=album_path)
+ ProviderMapping(
+ album_path, self.instance_id, self.instance_id, url=album_path, barcode=barcode
+ )
},
)
album.name = info.get("title", info.get("name", name))
if sort_name := info.get("sortname"):
album.sort_name = sort_name
- if musicbrainz_id := info.get("musicbrainzreleasegroupid"):
- album.musicbrainz_id = musicbrainz_id
+ if mbid := info.get("musicbrainzreleasegroupid"):
+ album.mbid = mbid
if mb_artist_id := info.get("musicbrainzalbumartistid"): # noqa: SIM102
- if album.artists and not album.artists[0].musicbrainz_id:
- album.artists[0].musicbrainz_id = mb_artist_id
+ if album.artists and not album.artists[0].mbid:
+ album.artists[0].mbid = mb_artist_id
if description := info.get("review"):
album.metadata.description = description
if year := info.get("year"):
await makedirs(self.base_path)
try:
+ await self.unmount()
await self.mount()
except Exception as err:
raise LoginFailed(f"Connection failed for the given details: {err}") from err
"""Discover MusicBrainzArtistId for an artist given some reference albums/tracks."""
for ref_album in ref_albums:
# try matching on album musicbrainz id
- if ref_album.musicbrainz_id: # noqa: SIM102
- if musicbrainz_id := await self._search_artist_by_album_mbid(
- artistname=artist.name, album_mbid=ref_album.musicbrainz_id
+ if ref_album.mbid: # noqa: SIM102
+ if mbid := await self._search_artist_by_album_mbid(
+ artistname=artist.name, album_mbid=ref_album.mbid
):
- return musicbrainz_id
+ return mbid
# try matching on album barcode
- for barcode in ref_album.barcode:
- if musicbrainz_id := await self._search_artist_by_album(
+ for provider_mapping in ref_album.provider_mappings:
+ if not provider_mapping.barcode:
+ continue
+ if mbid := await self._search_artist_by_album(
artistname=artist.name,
- album_barcode=barcode,
+ album_barcode=provider_mapping.barcode,
):
- return musicbrainz_id
+ return mbid
# try again with matching on track isrc
for ref_track in ref_tracks:
- for isrc in ref_track.isrc:
- if musicbrainz_id := await self._search_artist_by_track(
+ for provider_mapping in ref_track.provider_mappings:
+ if not provider_mapping.isrc:
+ continue
+ if mbid := await self._search_artist_by_track(
artistname=artist.name,
- track_isrc=isrc,
+ track_isrc=provider_mapping.isrc,
):
- return musicbrainz_id
+ return mbid
# last restort: track matching by name
for ref_track in ref_tracks:
- if musicbrainz_id := await self._search_artist_by_track(
+ if mbid := await self._search_artist_by_track(
artistname=artist.name,
trackname=ref_track.name,
):
- return musicbrainz_id
+ return mbid
return None
import logging
from asyncio import TaskGroup
from collections.abc import AsyncGenerator, Callable, Coroutine
+from typing import Any
import plexapi.exceptions
from aiohttp import ClientTimeout
from music_assistant.common.models.errors import InvalidDataError, LoginFailed, MediaNotFoundError
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
Artist,
AudioFormat,
ItemMapping,
MediaItemChapter,
MediaItemImage,
Playlist,
+ PlaylistTrack,
ProviderMapping,
SearchResults,
StreamDetails,
)
@property
- def is_unique(self) -> bool:
+ def is_streaming_provider(self) -> bool:
"""
- Return True if the (non user related) data in this provider instance is unique.
+ Return True if the provider is a streaming provider.
- For example on a global streaming provider (like Spotify),
- the data on all instances is the same.
- For a file provider each instance has other items.
- Setting this to False will only query one instance of the provider for search and lookups.
- Setting this to True will query all instances of this provider for search and lookups.
+ This literally means that the catalog is not the same as the library contents.
+ For local based providers (files, plex), the catalog is the same as the library content.
+ It also means that data is if this provider is NOT a streaming provider,
+ data cross instances is unique, the catalog and library differs per instance.
+
+ Setting this to True will only query one instance of the provider for search and lookups.
+ Setting this to False will query all instances of this provider for search and lookups.
"""
- return True
+ return False
async def resolve_image(self, path: str) -> str | bytes | AsyncGenerator[bytes, None]:
"""Return the full image URL including the auth token."""
"name": f"%{artist_name}%",
"provider_instance": f"%{self.instance_id}%",
}
- db_artists = await self.mass.music.artists.get_db_items_by_query(query, params)
+ db_artists = await self.mass.music.artists.get_library_items_by_query(query, params)
if db_artists:
return ItemMapping.from_item(db_artists[0])
)
return playlist
- async def _parse_track(self, plex_track: PlexTrack) -> Track:
+ async def _parse_track(
+ self, plex_track: PlexTrack, extra_init_kwargs: dict[str, Any] | None = None
+ ) -> Track | AlbumTrack | PlaylistTrack:
"""Parse a Plex Track response to a Track model object."""
- track = Track(item_id=plex_track.key, provider=self.instance_id, name=plex_track.title)
+ if extra_init_kwargs and "position" in extra_init_kwargs:
+ track_class = PlaylistTrack
+ elif (
+ extra_init_kwargs
+ and "disc_number" in extra_init_kwargs
+ and "track_number" in extra_init_kwargs
+ ):
+ track_class = AlbumTrack
+ else:
+ track_class = Track
+ track = track_class(
+ item_id=plex_track.key,
+ provider=self.instance_id,
+ name=plex_track.title,
+ **extra_init_kwargs or {},
+ )
if plex_track.originalTitle and plex_track.originalTitle != plex_track.grandparentTitle:
# The artist of the track if different from the album's artist.
)
if plex_track.duration:
track.duration = int(plex_track.duration / 1000)
- if plex_track.trackNumber:
- track.track_number = plex_track.trackNumber
- if plex_track.parentIndex:
- track.disc_number = plex_track.parentIndex
if plex_track.chapters:
track.metadata.chapters = [
MediaItemChapter(
return await self._parse_album(plex_album)
raise MediaNotFoundError(f"Item {prov_album_id} not found")
- async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
+ async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]:
"""Get album tracks for given album id."""
- plex_album = await self._get_data(prov_album_id, PlexAlbum)
-
+ plex_album: PlexAlbum = await self._get_data(prov_album_id, PlexAlbum)
tracks = []
for plex_track in await self._run_async(plex_album.tracks):
- track = await self._parse_track(plex_track)
+ track = await self._parse_track(
+ plex_track,
+ {"disc_number": plex_track.parentIndex, "track_number": plex_track.trackNumber},
+ )
tracks.append(track)
return tracks
if prov_artist_id.startswith(FAKE_ARTIST_PREFIX):
# This artist does not exist in plex, so we can just load it from DB.
- if db_artist := await self.mass.music.artists.get_db_item_by_prov_id(
+ if db_artist := await self.mass.music.artists.get_library_item_by_prov_id(
prov_artist_id, self.instance_id
):
return db_artist
self, prov_playlist_id: str
) -> AsyncGenerator[Track, None]:
"""Get all playlist tracks for given playlist id."""
- plex_playlist = await self._get_data(prov_playlist_id, PlexPlaylist)
-
+ plex_playlist: PlexPlaylist = await self._get_data(prov_playlist_id, PlexPlaylist)
playlist_items = await self._run_async(plex_playlist.items)
- if not playlist_items:
- yield None
- for index, plex_track in enumerate(playlist_items):
- track = await self._parse_track(plex_track)
- if track:
- track.position = index + 1
+ for index, plex_track in enumerate(playlist_items or []):
+ if track := await self._parse_track(plex_track, {"position": index + 1}):
yield track
async def get_artist_albums(self, prov_artist_id) -> list[Album]:
from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
AlbumType,
Artist,
AudioFormat,
MediaItemImage,
MediaType,
Playlist,
+ PlaylistTrack,
ProviderMapping,
SearchResults,
StreamDetails,
Track,
)
-from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME
+from music_assistant.constants import (
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ VARIOUS_ARTISTS_ID_MBID,
+ VARIOUS_ARTISTS_NAME,
+)
from music_assistant.server.helpers.app_vars import app_var # pylint: disable=no-name-in-module
from music_assistant.server.models.music_provider import MusicProvider
ProviderFeature.ARTIST_TOPTRACKS,
)
+VARIOUS_ARTISTS_ID = "145383"
+
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
return await self._parse_playlist(playlist_obj)
raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
- async def get_album_tracks(self, prov_album_id) -> list[Track]:
+ async def get_album_tracks(self, prov_album_id) -> list[AlbumTrack]:
"""Get all album tracks for given album id."""
params = {"album_id": prov_album_id}
return [
if (item and item["id"])
]
- async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]:
+ async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]:
"""Get all playlist tracks for given playlist id."""
count = 1
- for item in await self._get_all_items(
+ for track_obj in await self._get_all_items(
"playlist/get",
key="tracks",
playlist_id=prov_playlist_id,
extra="tracks",
):
- if not (item and item["id"]):
+ if not (track_obj and track_obj["id"]):
continue
- track = await self._parse_track(item)
- # use count as position
- track.position = count
+ track_obj["position"] = count
+ track = await self._parse_track(track_obj)
yield track
count += 1
async def get_artist_albums(self, prov_artist_id) -> list[Album]:
"""Get a list of albums for the given artist."""
- endpoint = "artist/get"
+ result = await self._get_data(
+ "artist/get",
+ artist_id=prov_artist_id,
+ extra="albums",
+ offset=0,
+ limit=100,
+ )
return [
await self._parse_album(item)
- for item in await self._get_all_items(
- endpoint, key="albums", artist_id=prov_artist_id, extra="albums"
- )
+ for item in result["albums"]["items"]
if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id)
]
artist = Artist(
item_id=str(artist_obj["id"]), provider=self.domain, name=artist_obj["name"]
)
+ if artist.item_id == VARIOUS_ARTISTS_ID:
+ artist.mbid = VARIOUS_ARTISTS_ID_MBID
+ artist.name = VARIOUS_ARTISTS_NAME
artist.add_provider_mapping(
ProviderMapping(
item_id=str(artist_obj["id"]),
provider_domain=self.domain,
provider_instance=self.instance_id,
available=album_obj["streamable"] and album_obj["displayable"],
+ barcode=album_obj["upc"],
audio_format=AudioFormat(
content_type=ContentType.FLAC,
sample_rate=album_obj["maximum_sampling_rate"] * 1000,
album.metadata.genres = {album_obj["genre"]["name"]}
if img := self.__get_image(album_obj):
album.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
- album.barcode.add(album_obj["upc"])
if "label" in album_obj:
album.metadata.label = album_obj["label"]["name"]
if (released_at := album_obj.get("released_at")) and released_at != 0:
album.metadata.explicit = True
return album
- async def _parse_track(self, track_obj: dict):
+ async def _parse_track(self, track_obj: dict) -> Track | AlbumTrack | PlaylistTrack:
"""Parse qobuz track object to generic layout."""
# pylint: disable=too-many-branches
name, version = parse_title_and_version(track_obj["title"], track_obj.get("version"))
- track = Track(
+ if "position" in track_obj:
+ track_class = PlaylistTrack
+ extra_init_kwargs = {"position": track_obj["position"]}
+ elif "media_number" in track_obj and "track_number" in track_obj:
+ track_class = AlbumTrack
+ extra_init_kwargs = {
+ "disc_number": track_obj["media_number"],
+ "track_number": track_obj["track_number"],
+ }
+ else:
+ track_class = Track
+ extra_init_kwargs = {}
+ track = track_class(
item_id=str(track_obj["id"]),
provider=self.domain,
name=name,
version=version,
- disc_number=track_obj["media_number"],
- track_number=track_obj["track_number"],
duration=track_obj["duration"],
- position=track_obj.get("position"),
+ **extra_init_kwargs,
)
if track_obj.get("performer") and "Various " not in track_obj["performer"]:
artist = await self._parse_artist(track_obj["performer"])
role = performer_str.split(", ")[1]
name = performer_str.split(", ")[0]
if "artist" in role.lower():
- artist = Artist(name, self.domain, name)
+ artist = Artist(item_id=name, provider=self.domain, name=name)
track.artists.append(artist)
# TODO: fix grabbing composer from details
album = await self._parse_album(track_obj["album"])
if album:
track.album = album
- if track_obj.get("isrc"):
- track.isrc.add(track_obj["isrc"])
if track_obj.get("performers"):
track.metadata.performers = {x.strip() for x in track_obj["performers"].split("-")}
if track_obj.get("copyright"):
bit_depth=track_obj["maximum_bit_depth"],
),
url=track_obj.get("url", f'https://open.qobuz.com/track/{track_obj["id"]}'),
+ isrc=track_obj.get("isrc"),
)
)
return track
if not result.get(key) or not result[key].get("items"):
break
for item in result[key]["items"]:
- item["position"] = len(all_items) + 1
all_items.append(item)
if len(result[key]["items"]) < limit:
break
preset_entries = tuple()
if not (client and client.device_model in self._virtual_providers):
presets = []
- async for playlist in self.mass.music.playlists.iter_db_items(True):
+ async for playlist in self.mass.music.playlists.iter_library_items(True):
presets.append(ConfigValueOption(playlist.name, playlist.uri))
- async for radio in self.mass.music.radio.iter_db_items(True):
+ async for radio in self.mass.music.radio.iter_library_items(True):
presets.append(ConfigValueOption(radio.name, radio.uri))
# dynamically extend the amount of presets when needed
if self.mass.config.get_raw_player_config_value(player_id, "preset_15"):
await self.mass.music.artists.album_artists(True, limit=limit, offset=offset)
).items
elif mode == "artists":
- items = (await self.mass.music.artists.db_items(True, limit=limit, offset=offset)).items
+ items = (
+ await self.mass.music.artists.library_items(True, limit=limit, offset=offset)
+ ).items
elif mode == "artist" and "uri" in kwargs:
artist = await self.mass.music.get_item_by_uri(kwargs["uri"])
items = await self.mass.music.artists.tracks(artist.item_id, artist.provider)
elif mode == "albums":
- items = (await self.mass.music.albums.db_items(True, limit=limit, offset=offset)).items
+ items = (
+ await self.mass.music.albums.library_items(True, limit=limit, offset=offset)
+ ).items
elif mode == "album" and "uri" in kwargs:
album = await self.mass.music.get_item_by_uri(kwargs["uri"])
items = await self.mass.music.albums.tracks(album.item_id, album.provider)
elif mode == "playlists":
items = (
- await self.mass.music.playlists.db_items(True, limit=limit, offset=offset)
+ await self.mass.music.playlists.library_items(True, limit=limit, offset=offset)
).items
elif mode == "radios":
- items = (await self.mass.music.radio.db_items(True, limit=limit, offset=offset)).items
+ items = (
+ await self.mass.music.radio.library_items(True, limit=limit, offset=offset)
+ ).items
elif mode == "playlist" and "uri" in kwargs:
playlist = await self.mass.music.get_item_by_uri(kwargs["uri"])
items = [
MediaItemImage,
MediaType,
Playlist,
+ PlaylistTrack,
ProviderMapping,
SearchResults,
StreamDetails,
self.logger.debug("Parse playlist failed: %s", playlist_obj, exc_info=error)
return playlist
- async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]:
+ async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]:
"""Get all playlist tracks for given playlist id."""
playlist_obj = await self._soundcloud.get_playlist_details(playlist_id=prov_playlist_id)
if "tracks" not in playlist_obj:
for index, item in enumerate(playlist_obj["tracks"]):
song = await self._soundcloud.get_track_details(item["id"])
try:
- track = await self._parse_track(song[0])
- if track:
- track.position = index + 1
+ if track := await self._parse_track(song[0], index + 1):
yield track
except (KeyError, TypeError, InvalidDataError, IndexError) as error:
self.logger.debug("Parse track failed: %s", song, exc_info=error)
playlist.metadata.style = playlist_obj["tag_list"]
return playlist
- async def _parse_track(self, track_obj: dict) -> Track:
+ async def _parse_track(
+ self, track_obj: dict, playlist_position: int | None = None
+ ) -> Track | PlaylistTrack:
"""Parse a Soundcloud Track response to a Track model object."""
name, version = parse_title_and_version(track_obj["title"])
- track = Track(
+ track_class = PlaylistTrack if playlist_position is not None else Track
+ track = track_class(
item_id=track_obj["id"],
provider=self.domain,
name=name,
version=version,
duration=track_obj["duration"] / 1000,
+ **{"position": playlist_position} if playlist_position else {},
)
user_id = track_obj["user"]["id"]
user = await self._soundcloud.get_user_details(user_id)
from collections.abc import AsyncGenerator
from json.decoder import JSONDecodeError
from tempfile import gettempdir
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
import aiohttp
from asyncio_throttle import Throttler
from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
AlbumType,
Artist,
AudioFormat,
MediaItemImage,
MediaType,
Playlist,
+ PlaylistTrack,
ProviderMapping,
SearchResults,
StreamDetails,
return await self._parse_playlist(playlist_obj)
raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
- async def get_album_tracks(self, prov_album_id) -> list[Track]:
+ async def get_album_tracks(self, prov_album_id) -> list[AlbumTrack]:
"""Get all album tracks for given album id."""
return [
await self._parse_track(item)
if (item and item["id"])
]
- async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]:
+ async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]:
"""Get all playlist tracks for given playlist id."""
count = 1
for item in await self._get_all_items(
):
if not (item and item["track"] and item["track"]["id"]):
continue
- track = await self._parse_track(item["track"])
# use count as position
- track.position = count
+ item["track"]["position"] = count
+ track = await self._parse_track(item["track"])
yield track
count += 1
album.metadata.genre = set(album_obj["genres"])
if album_obj.get("images"):
album.metadata.images = [MediaItemImage(ImageType.THUMB, album_obj["images"][0]["url"])]
- if "external_ids" in album_obj and album_obj["external_ids"].get("upc"):
- album.barcode.add(album_obj["external_ids"]["upc"])
- if "external_ids" in album_obj and album_obj["external_ids"].get("ean"):
- album.barcode.add(album_obj["external_ids"]["ean"])
if "label" in album_obj:
album.metadata.label = album_obj["label"]
if album_obj.get("release_date"):
album.metadata.copyright = album_obj["copyrights"][0]["text"]
if album_obj.get("explicit"):
album.metadata.explicit = album_obj["explicit"]
+ barcode = None
+ if "external_ids" in album_obj and album_obj["external_ids"].get("upc"):
+ barcode = album_obj["external_ids"]["upc"]
+ if "external_ids" in album_obj and album_obj["external_ids"].get("ean"):
+ barcode = album_obj["external_ids"]["ean"]
album.add_provider_mapping(
ProviderMapping(
item_id=album_obj["id"],
provider_instance=self.instance_id,
audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320),
url=album_obj["external_urls"]["spotify"],
+ barcode=barcode,
)
)
return album
- async def _parse_track(self, track_obj, artist=None):
+ async def _parse_track(
+ self,
+ track_obj: dict[str, Any],
+ artist=None,
+ ) -> Track | AlbumTrack | PlaylistTrack:
"""Parse spotify track object to generic layout."""
name, version = parse_title_and_version(track_obj["name"])
- track = Track(
+ if "position" in track_obj:
+ track_class = PlaylistTrack
+ extra_init_kwargs = {"position": track_obj["position"]}
+ elif "disc_number" in track_obj and "track_number" in track_obj:
+ track_class = AlbumTrack
+ extra_init_kwargs = {
+ "disc_number": track_obj["disc_number"],
+ "track_number": track_obj["track_number"],
+ }
+ else:
+ track_class = Track
+ extra_init_kwargs = {}
+
+ track = track_class(
item_id=track_obj["id"],
provider=self.domain,
name=name,
version=version,
duration=track_obj["duration_ms"] / 1000,
- disc_number=track_obj["disc_number"],
- track_number=track_obj["track_number"],
- position=track_obj.get("position"),
+ **extra_init_kwargs,
)
+
if artist:
track.artists.append(artist)
for track_artist in track_obj.get("artists", []):
track.metadata.explicit = track_obj["explicit"]
if "preview_url" in track_obj:
track.metadata.preview = track_obj["preview_url"]
- if "external_ids" in track_obj and "isrc" in track_obj["external_ids"]:
- track.isrc.add(track_obj["external_ids"]["isrc"])
if "album" in track_obj:
track.album = await self._parse_album(track_obj["album"])
if track_obj["album"].get("images"):
content_type=ContentType.OGG,
bit_rate=320,
),
+ isrc=track_obj.get("external_ids", {}).get("isrc"),
url=track_obj["external_urls"]["spotify"],
available=not track_obj["is_local"] and track_obj["is_playable"],
)
offset += limit
if not result or key not in result or not result[key]:
break
- for item in result[key]:
- item["position"] = len(all_items) + 1
- all_items.append(item)
+ all_items += result[key]
if len(result[key]) < limit:
break
return all_items
async def get_artist_metadata(self, artist: Artist) -> MediaItemMetadata | None:
"""Retrieve metadata for artist on theaudiodb."""
- if data := await self._get_data("artist-mb.php", i=artist.musicbrainz_id): # noqa: SIM102
+ if data := await self._get_data("artist-mb.php", i=artist.mbid): # noqa: SIM102
if data.get("artists"):
return self.__parse_artist(data["artists"][0])
return None
async def get_album_metadata(self, album: Album) -> MediaItemMetadata | None:
"""Retrieve metadata for album on theaudiodb."""
adb_album = None
- if album.musicbrainz_id:
- result = await self._get_data("album-mb.php", i=album.musicbrainz_id)
+ if album.mbid:
+ result = await self._get_data("album-mb.php", i=album.mbid)
if result and result.get("album"):
adb_album = result["album"][0]
elif album.artists:
if result and result.get("album"):
for item in result["album"]:
assert isinstance(artist, Artist)
- if artist.musicbrainz_id:
- if artist.musicbrainz_id != item["strMusicBrainzArtistID"]:
+ if artist.mbid:
+ if artist.mbid != item["strMusicBrainzArtistID"]:
continue
elif not compare_strings(artist.name, item["strArtistStripped"]):
continue
if adb_album:
if not album.year:
album.year = int(adb_album.get("intYearReleased", "0"))
- if not album.musicbrainz_id:
- album.musicbrainz_id = adb_album["strMusicBrainzID"]
+ if not album.mbid:
+ album.mbid = adb_album["strMusicBrainzID"]
assert isinstance(album.artists[0], Artist)
- if album.artists and not album.artists[0].musicbrainz_id:
- album.artists[0].musicbrainz_id = adb_album["strMusicBrainzArtistID"]
+ if album.artists and not album.artists[0].mbid:
+ album.artists[0].mbid = adb_album["strMusicBrainzArtistID"]
if album.album_type == AlbumType.UNKNOWN:
album.album_type = ALBUMTYPE_MAPPING.get(
adb_album.get("strReleaseFormat"), AlbumType.UNKNOWN
async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None:
"""Retrieve metadata for track on theaudiodb."""
adb_track = None
- if track.musicbrainz_id:
- result = await self._get_data("track-mb.php", i=track.musicbrainz_id)
+ if track.mbid:
+ result = await self._get_data("track-mb.php", i=track.mbid)
if result and result.get("track"):
return self.__parse_track(result["track"][0])
result = await self._get_data("searchtrack.php?", s=track_artist.name, t=track.name)
if result and result.get("track"):
for item in result["track"]:
- if track_artist.musicbrainz_id:
- if track_artist.musicbrainz_id != item["strMusicBrainzArtistID"]:
+ if track_artist.mbid:
+ if track_artist.mbid != item["strMusicBrainzArtistID"]:
continue
elif not compare_strings(track_artist.name, item["strArtist"]):
continue
adb_track = item
break
if adb_track:
- if not track.musicbrainz_id:
- track.musicbrainz_id = adb_track["strMusicBrainzID"]
+ if not track.mbid:
+ track.mbid = adb_track["strMusicBrainzID"]
assert isinstance(track.album, Album)
- if track.album and not track.album.musicbrainz_id:
- track.album.musicbrainz_id = adb_track["strMusicBrainzAlbumID"]
- if not track_artist.musicbrainz_id:
- track_artist.musicbrainz_id = adb_track["strMusicBrainzArtistID"]
+ if track.album and not track.album.mbid:
+ track.album.mbid = adb_track["strMusicBrainzAlbumID"]
+ if not track_artist.mbid:
+ track_artist.mbid = adb_track["strMusicBrainzArtistID"]
return self.__parse_track(adb_track)
return None
ref_tracks: Iterable[Track], # noqa: ARG002
) -> str | None:
"""Discover MusicBrainzArtistId for an artist given some reference albums/tracks."""
- musicbrainz_id = None
+ mbid = None
if data := await self._get_data("searchalbum.php", s=artist.name):
# NOTE: object is 'null' when no records found instead of empty array
albums = data.get("album") or []
if not compare_strings(item["strAlbumStripped"], ref_album.name):
continue
# found match - update album metadata too while we're here
- if not ref_album.musicbrainz_id:
+ if ref_album.provider == "library" and not ref_album.mbid:
ref_album.metadata = self.__parse_album(item)
- await self.mass.music.albums.add(ref_album, skip_metadata_lookup=True)
- musicbrainz_id = item["strMusicBrainzArtistID"]
+ await self.mass.music.albums.update_item_in_library(
+ ref_album.item_id, ref_album
+ )
+ mbid = item["strMusicBrainzArtistID"]
- return musicbrainz_id
+ return mbid
def __parse_artist(self, artist_obj: dict[str, Any]) -> MediaItemMetadata:
"""Parse audiodb artist object to MediaItemMetadata."""
from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
Artist,
AudioFormat,
ContentType,
ItemMapping,
MediaItemImage,
Playlist,
+ PlaylistTrack,
ProviderMapping,
SearchResults,
StreamDetails,
tidal_session = await self._get_tidal_session()
async with self._throttler:
return [
- await self._parse_track(track_obj=track)
- for track in await get_album_tracks(tidal_session, prov_album_id)
+ await self._parse_track(
+ track_obj=track_obj,
+ extra_init_kwargs={
+ "disc_number": track_obj.volume_num,
+ "track_number": track_obj.track_num,
+ },
+ )
+ for track_obj in await get_album_tracks(tidal_session, prov_album_id)
]
async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
for track in await get_artist_toptracks(tidal_session, prov_artist_id)
]
- async def get_playlist_tracks(self, prov_playlist_id: str) -> AsyncGenerator[Track, None]:
+ async def get_playlist_tracks(
+ self, prov_playlist_id: str
+ ) -> AsyncGenerator[PlaylistTrack, None]:
"""Get all playlist tracks for given playlist id."""
tidal_session = await self._get_tidal_session()
total_playlist_tracks = 0
get_playlist_tracks, tidal_session, prov_playlist_id, limit=DEFAULT_LIMIT
):
total_playlist_tracks += 1
- track = await self._parse_track(track_obj=track_obj)
- track.position = total_playlist_tracks
+ track = await self._parse_track(
+ track_obj=track_obj, extra_init_kwargs={"position": total_playlist_tracks}
+ )
yield track
async def get_similar_tracks(self, prov_track_id: str, limit=25) -> list[Track]:
return album
- async def _parse_track(self, track_obj: TidalTrack, full_details: bool = False) -> Track:
+ async def _parse_track(
+ self,
+ track_obj: TidalTrack,
+ full_details: bool = False,
+ extra_init_kwargs: dict[str, Any] | None = None,
+ ) -> Track | AlbumTrack | PlaylistTrack:
"""Parse tidal track object to generic layout."""
version = track_obj.version if track_obj.version is not None else None
track_id = str(track_obj.id)
- track = Track(
+ if "position" in extra_init_kwargs:
+ track_class = PlaylistTrack
+ elif "disc_number" in extra_init_kwargs and "track_number" in extra_init_kwargs:
+ track_class = AlbumTrack
+ else:
+ track_class = Track
+
+ track = track_class(
item_id=track_id,
provider=self.instance_id,
name=track_obj.name,
version=version,
duration=track_obj.duration,
- disc_number=track_obj.volume_num,
- track_number=track_obj.track_num,
+ **extra_init_kwargs or {},
)
- track.isrc.add(track_obj.isrc)
track.album = self.get_item_mapping(
media_type=MediaType.ALBUM,
key=track_obj.album.id,
sample_rate=44100,
bit_depth=16,
),
+ isrc=track_obj.isrc,
url=f"http://www.tidal.com/tracks/{track_id}",
available=available,
)
Called when provider is registered.
"""
self._full_url = {}
- # self.mass.register_api_command("music/tracks", self.db_items)
+ # self.mass.register_api_command("music/tracks", self.library_items)
async def get_track(self, prov_track_id: str) -> Track:
"""Get full track details by id."""
artist = prov_artist_id
# this is here for compatibility reasons only
return Artist(
- artist,
- self.domain,
- artist,
+ item_id=artist,
+ provider=self.domain,
+ name=artist,
provider_mappings={
ProviderMapping(artist, self.domain, self.instance_id, available=False)
},
)
from music_assistant.common.models.media_items import (
Album,
+ AlbumTrack,
AlbumType,
Artist,
AudioFormat,
MediaItemImage,
MediaType,
Playlist,
+ PlaylistTrack,
ProviderMapping,
SearchResults,
StreamDetails,
return await self._parse_album(album_obj=album_obj, album_id=prov_album_id)
raise MediaNotFoundError(f"Item {prov_album_id} not found")
- async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
+ async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]:
"""Get album tracks for given album id."""
await self._check_oauth_token()
album_obj = await get_album(prov_album_id=prov_album_id)
return []
tracks = []
for idx, track_obj in enumerate(album_obj["tracks"], 1):
+ track_obj["disc_number"] = 0
+ track_obj["track_number"] = idx
try:
track = await self._parse_track(track_obj=track_obj)
except InvalidDataError:
continue
- track.disc_number = 0
- track.track_number = idx
tracks.append(track)
return tracks
return await self._parse_playlist(playlist_obj)
raise MediaNotFoundError(f"Item {prov_playlist_id} not found")
- async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]:
+ async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]:
"""Get all playlist tracks for given playlist id."""
await self._check_oauth_token()
# Grab the playlist id from the full url in case of personal playlists
playlist_obj = await get_playlist(prov_playlist_id=prov_playlist_id, headers=self._headers)
if "tracks" not in playlist_obj:
return
- for index, track in enumerate(playlist_obj["tracks"]):
- if track["isAvailable"]:
+ for index, track_obj in enumerate(playlist_obj["tracks"]):
+ if track_obj["isAvailable"]:
# Playlist tracks sometimes do not have a valid artist id
# In that case, call the API for track details based on track id
try:
- track = await self._parse_track(track)
- if track:
- track.position = index + 1
+ track_obj["position"] = index + 1
+ if track := await self._parse_track(track_obj):
yield track
except InvalidDataError:
- track = await self.get_track(track["videoId"])
- if track:
- track.position = index + 1
- yield track
+ if track := await self.get_track(track_obj["videoId"]):
+ yield PlaylistTrack.from_dict({**track.to_dict(), "position": index + 1})
async def get_artist_albums(self, prov_artist_id) -> list[Album]:
"""Get a list of albums for the given artist."""
playlist.metadata.checksum = playlist_obj.get("checksum")
return playlist
- async def _parse_track(self, track_obj: dict) -> Track:
+ async def _parse_track(self, track_obj: dict) -> Track | AlbumTrack | PlaylistTrack:
"""Parse a YT Track response to a Track model object."""
if not track_obj.get("videoId"):
raise InvalidDataError("Track is missing videoId")
- track = Track(item_id=track_obj["videoId"], provider=self.domain, name=track_obj["title"])
+
+ if "position" in track_obj:
+ track_class = PlaylistTrack
+ extra_init_kwargs = {"position": track_obj["position"]}
+ elif "disc_number" in track_obj and "track_number" in track_obj:
+ track_class = AlbumTrack
+ extra_init_kwargs = {
+ "disc_number": track_obj["disc_number"],
+ "track_number": track_obj["track_number"],
+ }
+ else:
+ track_class = Track
+ extra_init_kwargs = {}
+ track = track_class(
+ item_id=track_obj["videoId"],
+ provider=self.domain,
+ name=track_obj["title"],
+ **extra_init_kwargs,
+ )
+
if "artists" in track_obj and track_obj["artists"]:
track.artists = [
self._get_artist_item_mapping(artist)
@api_command("logging/get")
async def get_application_log(self) -> str:
"""Return the application log from file."""
- logfile = os.path.join(self.storage_path, "logs", "musicassistant.log")
+ logfile = os.path.join(self.storage_path, "musicassistant.log")
async with aiofiles.open(logfile, "r") as _file:
return await _file.read()
if prov := self._providers.get(provider_instance_or_domain):
if return_unavailable or prov.available:
return prov
- if prov.is_unique:
+ if not prov.is_streaming_provider:
# no need to lookup other instances because this provider has unique data
return None
provider_instance_or_domain = prov.domain
"""Signal event to subscribers."""
if self.closing:
return
- if (
- event
- in (
- EventType.MEDIA_ITEM_ADDED,
- EventType.MEDIA_ITEM_DELETED,
- EventType.MEDIA_ITEM_UPDATED,
- )
- and self.music.in_progress_syncs
- ):
- # ignore media item events while sync is running because it clutters too much
- return
if LOGGER.isEnabledFor(logging.DEBUG) and event != EventType.QUEUE_TIME_UPDATED:
# do not log queue time updated events because that is too chatty
ignore_missing_imports = true
module = [
"aiorun",
- "coloredlogs",
]
[tool.pytest.ini_options]
from pathlib import Path
from sys import path
-import coloredlogs
from aiorun import run
path.insert(1, dirname(dirname(abspath(__file__))))
if __name__ == "__main__":
# configure logging
logging.basicConfig(level=args.log_level.upper())
- coloredlogs.install(level=args.log_level.upper())
# make sure storage path exists
if not os.path.isdir(args.config):