Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Filesystem provider #953

Merged
merged 19 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 23 additions & 42 deletions music_assistant/common/models/enums.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,11 @@
"""All enums used by the Music Assistant models."""
from __future__ import annotations

from enum import Enum
from typing import Any, TypeVar

# pylint:disable=ungrouped-imports
try:
from enum import StrEnum
except (AttributeError, ImportError):
# Python 3.10 compatibility for strenum
_StrEnumSelfT = TypeVar("_StrEnumSelfT", bound="StrEnum")

class StrEnum(str, Enum):
"""Partial backport of Python 3.11's StrEnum for our basic use cases."""

def __new__(
cls: type[_StrEnumSelfT], value: str, *args: Any, **kwargs: Any
) -> _StrEnumSelfT:
"""Create a new StrEnum instance."""
if not isinstance(value, str):
raise TypeError(f"{value!r} is not a string")
return super().__new__(cls, value, *args, **kwargs)

def __str__(self) -> str:
"""Return self."""
return str(self)

@staticmethod
def _generate_next_value_(
name: str, start: int, count: int, last_values: list[Any] # noqa
) -> Any:
"""Make `auto()` explicitly unsupported.

We may revisit this when it's very clear that Python 3.11's
`StrEnum.auto()` behavior will no longer change.
"""
raise TypeError("auto() is not supported by this implementation")
from enum import StrEnum


class MediaType(StrEnum):
"""StrEnum for MediaType."""
"""Enum for MediaType."""

ARTIST = "artist"
ALBUM = "album"
Expand All @@ -62,8 +28,24 @@ def ALL(cls) -> tuple[MediaType, ...]: # noqa: N802
)


class ExternalID(StrEnum):
"""Enum with External ID types."""

# musicbrainz:
# for tracks this is the RecordingID
# for albums this is the ReleaseGroupID (NOT the release ID!)
# for artists this is the ArtistID
MUSICBRAINZ = "musicbrainz"
ISRC = "isrc" # used to identify unique recordings
BARCODE = "barcode" # EAN-13 barcode for identifying albums
ACOUSTID = "acoustid" # unique fingerprint (id) for a recording
ASIN = "asin" # amazon unique number to identify albums
DISCOGS = "discogs" # id for media item on discogs
TADB = "tadb" # the audio db id


class LinkType(StrEnum):
"""StrEnum with link types."""
"""Enum with link types."""

WEBSITE = "website"
FACEBOOK = "facebook"
Expand All @@ -79,7 +61,7 @@ class LinkType(StrEnum):


class ImageType(StrEnum):
"""StrEnum with image types."""
"""Enum with image types."""

THUMB = "thumb"
LANDSCAPE = "landscape"
Expand All @@ -94,7 +76,7 @@ class ImageType(StrEnum):


class AlbumType(StrEnum):
"""StrEnum for Album type."""
"""Enum for Album type."""

ALBUM = "album"
SINGLE = "single"
Expand Down Expand Up @@ -182,7 +164,7 @@ def from_bit_depth(cls, bit_depth: int, floating_point: bool = False) -> Content


class QueueOption(StrEnum):
"""StrEnum representation of the queue (play) options.
"""Enum representation of the queue (play) options.

- PLAY -> Insert new item(s) in queue at the current position and start playing.
- REPLACE -> Replace entire queue contents with the new items and start playing from index 0.
Expand All @@ -207,7 +189,7 @@ class RepeatMode(StrEnum):


class PlayerState(StrEnum):
"""StrEnum for the (playback)state of a player."""
"""Enum for the (playback)state of a player."""

IDLE = "idle"
PAUSED = "paused"
Expand Down Expand Up @@ -323,7 +305,6 @@ class ProviderFeature(StrEnum):
ARTIST_METADATA = "artist_metadata"
ALBUM_METADATA = "album_metadata"
TRACK_METADATA = "track_metadata"
GET_ARTIST_MBID = "get_artist_mbid"

#
# PLUGIN FEATURES
Expand Down
65 changes: 48 additions & 17 deletions music_assistant/common/models/media_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from dataclasses import dataclass, field, fields
from time import time
from typing import Any
from typing import Any, Self

from mashumaro import DataClassDictMixin

Expand All @@ -12,6 +12,7 @@
from music_assistant.common.models.enums import (
AlbumType,
ContentType,
ExternalID,
ImageType,
LinkType,
MediaType,
Expand Down Expand Up @@ -66,10 +67,6 @@ class ProviderMapping(DataClassDictMixin):
audio_format: AudioFormat = field(default_factory=AudioFormat)
# 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

Expand Down Expand Up @@ -154,14 +151,12 @@ class MediaItemMetadata(DataClassDictMixin):
mood: str | None = None
style: str | None = None
copyright: str | None = None
lyrics: str | None = None
ean: str | None = None
lyrics: str | None = None # tracks only
label: str | None = None
links: set[MediaItemLink] | None = None
chapters: list[MediaItemChapter] | None = None
performers: set[str] | None = None
preview: str | None = None
replaygain: float | None = None
popularity: int | None = None
# last_refresh: timestamp the (full) metadata was last collected
last_refresh: int | None = None
Expand Down Expand Up @@ -204,11 +199,10 @@ class MediaItem(DataClassDictMixin):
item_id: str
provider: str # provider instance id or provider domain
name: str
metadata: MediaItemMetadata
provider_mappings: set[ProviderMapping]

# optional fields below
# provider_mappings: set[ProviderMapping] = field(default_factory=set)
external_ids: set[tuple[ExternalID, str]] = field(default_factory=set)
metadata: MediaItemMetadata = field(default_factory=MediaItemMetadata)
favorite: bool = False
media_type: MediaType = MediaType.UNKNOWN
Expand Down Expand Up @@ -238,14 +232,55 @@ def image(self) -> MediaItemImage | None:
return None
return next((x for x in self.metadata.images if x.type == ImageType.THUMB), None)

@property
def mbid(self) -> str | None:
"""Return MusicBrainz ID."""
return self.get_external_id(ExternalID.MUSICBRAINZ)

@mbid.setter
def mbid(self, value: str) -> None:
"""Set MusicBrainz External ID."""
if not value:
return
if len(value.split("-")) != 5:
raise RuntimeError("Invalid MusicBrainz identifier")
if existing := next((x for x in self.external_ids if x[0] == ExternalID.MUSICBRAINZ), None):
# Musicbrainz ID is unique so remove existing entry
self.external_ids.remove(existing)
self.external_ids.add((ExternalID.MUSICBRAINZ, value))

def get_external_id(self, external_id_type: ExternalID) -> str | None:
"""Get (the first instance) of given External ID or None if not found."""
for ext_id in self.external_ids:
if ext_id[0] != external_id_type:
continue
return ext_id[1]
return None

def __hash__(self) -> int:
"""Return custom hash."""
return hash(self.uri)

def __eq__(self, other: ItemMapping) -> bool:
def __eq__(self, other: MediaItem | ItemMapping) -> bool:
"""Check equality of two items."""
return self.uri == other.uri

@classmethod
def from_item_mapping(cls: type, item: ItemMapping) -> Self:
"""Instantiate MediaItem from ItemMapping."""
# NOTE: This will not work for albums and tracks!
return cls.from_dict(
{
**item.to_dict(),
"provider_mappings": {
"item_id": item.item_id,
"provider_domain": item.provider,
"provider_instance": item.provider,
"available": item.available,
},
}
)


@dataclass(kw_only=True)
class ItemMapping(DataClassDictMixin):
Expand All @@ -259,13 +294,12 @@ class ItemMapping(DataClassDictMixin):
sort_name: str | None = None
uri: str | None = None
available: bool = True
external_ids: set[tuple[ExternalID, str]] = field(default_factory=set)

@classmethod
def from_item(cls, item: MediaItem):
"""Create ItemMapping object from regular item."""
result = cls.from_dict(item.to_dict())
result.available = item.available
return result
return cls.from_dict(item.to_dict())

def __post_init__(self):
"""Call after init."""
Expand All @@ -290,7 +324,6 @@ class Artist(MediaItem):
"""Model for an artist."""

media_type: MediaType = MediaType.ARTIST
mbid: str | None = None


@dataclass(kw_only=True)
Expand All @@ -302,7 +335,6 @@ class Album(MediaItem):
year: int | None = None
artists: list[Artist | ItemMapping] = field(default_factory=list)
album_type: AlbumType = AlbumType.UNKNOWN
mbid: str | None = None # release group id


@dataclass(kw_only=True)
Expand All @@ -312,7 +344,6 @@ class Track(MediaItem):
media_type: MediaType = MediaType.TRACK
duration: int = 0
version: str = ""
mbid: str | None = None # Recording ID
artists: list[Artist | ItemMapping] = field(default_factory=list)
album: Album | ItemMapping | None = None # optional

Expand Down
2 changes: 1 addition & 1 deletion music_assistant/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

API_SCHEMA_VERSION: Final[int] = 23
MIN_SCHEMA_VERSION: Final[int] = 23
DB_SCHEMA_VERSION: Final[int] = 25
DB_SCHEMA_VERSION: Final[int] = 26

ROOT_LOGGER_NAME: Final[str] = "music_assistant"

Expand Down
55 changes: 27 additions & 28 deletions music_assistant/server/controllers/media/albums.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,26 @@ async def add_item_to_library(
# grab additional metadata
if metadata_lookup:
await self.mass.metadata.get_album_metadata(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)
# check for existing item first
library_item = None
if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider):
# existing item match by provider id
library_item = await self.update_item_in_library(cur_item.item_id, item) # noqa: SIM114
elif cur_item := await self.get_library_item_by_external_ids(item.external_ids):
# existing item match by external id
library_item = await self.update_item_in_library(cur_item.item_id, item)
else:
# search by name
async for db_item in self.iter_library_items(search=item.name):
if compare_album(db_item, item):
# existing item found: update it
library_item = await self.update_item_in_library(db_item.item_id, item)
break
if not library_item:
# actually add a new 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 metadata_lookup:
await self._match(library_item)
Expand Down Expand Up @@ -139,6 +155,7 @@ async def update_item_in_library(
else:
album_type = cur_item.album_type
sort_artist = album_artists[0].sort_name
cur_item.external_ids.update(update.external_ids)
await self.mass.music.database.update(
self.db_table,
{"item_id": db_id},
Expand All @@ -152,7 +169,9 @@ async def update_item_in_library(
"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,
"external_ids": serialize_to_json(
update.external_ids if overwrite else cur_item.external_ids
),
"timestamp_modified": int(utc_timestamp()),
},
)
Expand Down Expand Up @@ -219,28 +238,8 @@ async def versions(

async def _add_library_item(self, item: Album) -> Album:
"""Add a new record to the database."""
# safety guard: check for existing item first
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_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_dict(self._parse_db_row(db_row))
# existing item found: update it
return await self.update_item_in_library(cur_item.item_id, item)
# fallback to search and match
match = {"sort_name": item.sort_name}
for db_row in await self.mass.music.database.get_rows(self.db_table, match):
row_album = Album.from_dict(self._parse_db_row(db_row))
if compare_album(row_album, item):
cur_item = row_album
# existing item found: update it
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
album_artists = await self._get_artist_mappings(item)
sort_artist = album_artists[0].sort_name if album_artists else ""
new_item = await self.mass.music.database.insert(
self.db_table,
{
Expand All @@ -250,11 +249,11 @@ async def _add_library_item(self, item: Album) -> Album:
"favorite": item.favorite,
"album_type": item.album_type,
"year": item.year,
"mbid": item.mbid,
"metadata": serialize_to_json(item.metadata),
"provider_mappings": serialize_to_json(item.provider_mappings),
"artists": serialize_to_json(album_artists),
"sort_artist": sort_artist,
"external_ids": serialize_to_json(item.external_ids),
"timestamp_added": int(utc_timestamp()),
"timestamp_modified": int(utc_timestamp()),
},
Expand Down
Loading