Skip to content

Commit 866ad76

Browse files
authored
Improve Filesystem provider (#953)
1 parent 2f4eeca commit 866ad76

File tree

24 files changed

+1002
-744
lines changed

24 files changed

+1002
-744
lines changed

music_assistant/common/models/enums.py

Lines changed: 23 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,11 @@
11
"""All enums used by the Music Assistant models."""
22
from __future__ import annotations
33

4-
from enum import Enum
5-
from typing import Any, TypeVar
6-
7-
# pylint:disable=ungrouped-imports
8-
try:
9-
from enum import StrEnum
10-
except (AttributeError, ImportError):
11-
# Python 3.10 compatibility for strenum
12-
_StrEnumSelfT = TypeVar("_StrEnumSelfT", bound="StrEnum")
13-
14-
class StrEnum(str, Enum):
15-
"""Partial backport of Python 3.11's StrEnum for our basic use cases."""
16-
17-
def __new__(
18-
cls: type[_StrEnumSelfT], value: str, *args: Any, **kwargs: Any
19-
) -> _StrEnumSelfT:
20-
"""Create a new StrEnum instance."""
21-
if not isinstance(value, str):
22-
raise TypeError(f"{value!r} is not a string")
23-
return super().__new__(cls, value, *args, **kwargs)
24-
25-
def __str__(self) -> str:
26-
"""Return self."""
27-
return str(self)
28-
29-
@staticmethod
30-
def _generate_next_value_(
31-
name: str, start: int, count: int, last_values: list[Any] # noqa
32-
) -> Any:
33-
"""Make `auto()` explicitly unsupported.
34-
35-
We may revisit this when it's very clear that Python 3.11's
36-
`StrEnum.auto()` behavior will no longer change.
37-
"""
38-
raise TypeError("auto() is not supported by this implementation")
4+
from enum import StrEnum
395

406

417
class MediaType(StrEnum):
42-
"""StrEnum for MediaType."""
8+
"""Enum for MediaType."""
439

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

6430

31+
class ExternalID(StrEnum):
32+
"""Enum with External ID types."""
33+
34+
# musicbrainz:
35+
# for tracks this is the RecordingID
36+
# for albums this is the ReleaseGroupID (NOT the release ID!)
37+
# for artists this is the ArtistID
38+
MUSICBRAINZ = "musicbrainz"
39+
ISRC = "isrc" # used to identify unique recordings
40+
BARCODE = "barcode" # EAN-13 barcode for identifying albums
41+
ACOUSTID = "acoustid" # unique fingerprint (id) for a recording
42+
ASIN = "asin" # amazon unique number to identify albums
43+
DISCOGS = "discogs" # id for media item on discogs
44+
TADB = "tadb" # the audio db id
45+
46+
6547
class LinkType(StrEnum):
66-
"""StrEnum with link types."""
48+
"""Enum with link types."""
6749

6850
WEBSITE = "website"
6951
FACEBOOK = "facebook"
@@ -79,7 +61,7 @@ class LinkType(StrEnum):
7961

8062

8163
class ImageType(StrEnum):
82-
"""StrEnum with image types."""
64+
"""Enum with image types."""
8365

8466
THUMB = "thumb"
8567
LANDSCAPE = "landscape"
@@ -94,7 +76,7 @@ class ImageType(StrEnum):
9476

9577

9678
class AlbumType(StrEnum):
97-
"""StrEnum for Album type."""
79+
"""Enum for Album type."""
9880

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

183165

184166
class QueueOption(StrEnum):
185-
"""StrEnum representation of the queue (play) options.
167+
"""Enum representation of the queue (play) options.
186168
187169
- PLAY -> Insert new item(s) in queue at the current position and start playing.
188170
- REPLACE -> Replace entire queue contents with the new items and start playing from index 0.
@@ -207,7 +189,7 @@ class RepeatMode(StrEnum):
207189

208190

209191
class PlayerState(StrEnum):
210-
"""StrEnum for the (playback)state of a player."""
192+
"""Enum for the (playback)state of a player."""
211193

212194
IDLE = "idle"
213195
PAUSED = "paused"
@@ -323,7 +305,6 @@ class ProviderFeature(StrEnum):
323305
ARTIST_METADATA = "artist_metadata"
324306
ALBUM_METADATA = "album_metadata"
325307
TRACK_METADATA = "track_metadata"
326-
GET_ARTIST_MBID = "get_artist_mbid"
327308

328309
#
329310
# PLUGIN FEATURES

music_assistant/common/models/media_items.py

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from dataclasses import dataclass, field, fields
55
from time import time
6-
from typing import Any
6+
from typing import Any, Self
77

88
from mashumaro import DataClassDictMixin
99

@@ -12,6 +12,7 @@
1212
from music_assistant.common.models.enums import (
1313
AlbumType,
1414
ContentType,
15+
ExternalID,
1516
ImageType,
1617
LinkType,
1718
MediaType,
@@ -66,10 +67,6 @@ class ProviderMapping(DataClassDictMixin):
6667
audio_format: AudioFormat = field(default_factory=AudioFormat)
6768
# url = link to provider details page if exists
6869
url: str | None = None
69-
# isrc (tracks only) - isrc identifier if known
70-
isrc: str | None = None
71-
# barcode (albums only) - barcode identifier if known
72-
barcode: str | None = None
7370
# optional details to store provider specific details
7471
details: str | None = None
7572

@@ -154,14 +151,12 @@ class MediaItemMetadata(DataClassDictMixin):
154151
mood: str | None = None
155152
style: str | None = None
156153
copyright: str | None = None
157-
lyrics: str | None = None
158-
ean: str | None = None
154+
lyrics: str | None = None # tracks only
159155
label: str | None = None
160156
links: set[MediaItemLink] | None = None
161157
chapters: list[MediaItemChapter] | None = None
162158
performers: set[str] | None = None
163159
preview: str | None = None
164-
replaygain: float | None = None
165160
popularity: int | None = None
166161
# last_refresh: timestamp the (full) metadata was last collected
167162
last_refresh: int | None = None
@@ -204,11 +199,10 @@ class MediaItem(DataClassDictMixin):
204199
item_id: str
205200
provider: str # provider instance id or provider domain
206201
name: str
207-
metadata: MediaItemMetadata
208202
provider_mappings: set[ProviderMapping]
209203

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

235+
@property
236+
def mbid(self) -> str | None:
237+
"""Return MusicBrainz ID."""
238+
return self.get_external_id(ExternalID.MUSICBRAINZ)
239+
240+
@mbid.setter
241+
def mbid(self, value: str) -> None:
242+
"""Set MusicBrainz External ID."""
243+
if not value:
244+
return
245+
if len(value.split("-")) != 5:
246+
raise RuntimeError("Invalid MusicBrainz identifier")
247+
if existing := next((x for x in self.external_ids if x[0] == ExternalID.MUSICBRAINZ), None):
248+
# Musicbrainz ID is unique so remove existing entry
249+
self.external_ids.remove(existing)
250+
self.external_ids.add((ExternalID.MUSICBRAINZ, value))
251+
252+
def get_external_id(self, external_id_type: ExternalID) -> str | None:
253+
"""Get (the first instance) of given External ID or None if not found."""
254+
for ext_id in self.external_ids:
255+
if ext_id[0] != external_id_type:
256+
continue
257+
return ext_id[1]
258+
return None
259+
241260
def __hash__(self) -> int:
242261
"""Return custom hash."""
243262
return hash(self.uri)
244263

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

268+
@classmethod
269+
def from_item_mapping(cls: type, item: ItemMapping) -> Self:
270+
"""Instantiate MediaItem from ItemMapping."""
271+
# NOTE: This will not work for albums and tracks!
272+
return cls.from_dict(
273+
{
274+
**item.to_dict(),
275+
"provider_mappings": {
276+
"item_id": item.item_id,
277+
"provider_domain": item.provider,
278+
"provider_instance": item.provider,
279+
"available": item.available,
280+
},
281+
}
282+
)
283+
249284

250285
@dataclass(kw_only=True)
251286
class ItemMapping(DataClassDictMixin):
@@ -259,13 +294,12 @@ class ItemMapping(DataClassDictMixin):
259294
sort_name: str | None = None
260295
uri: str | None = None
261296
available: bool = True
297+
external_ids: set[tuple[ExternalID, str]] = field(default_factory=set)
262298

263299
@classmethod
264300
def from_item(cls, item: MediaItem):
265301
"""Create ItemMapping object from regular item."""
266-
result = cls.from_dict(item.to_dict())
267-
result.available = item.available
268-
return result
302+
return cls.from_dict(item.to_dict())
269303

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

292326
media_type: MediaType = MediaType.ARTIST
293-
mbid: str | None = None
294327

295328

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

307339

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

music_assistant/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
API_SCHEMA_VERSION: Final[int] = 23
77
MIN_SCHEMA_VERSION: Final[int] = 23
8-
DB_SCHEMA_VERSION: Final[int] = 25
8+
DB_SCHEMA_VERSION: Final[int] = 26
99

1010
ROOT_LOGGER_NAME: Final[str] = "music_assistant"
1111

music_assistant/server/controllers/media/albums.py

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,26 @@ async def add_item_to_library(
102102
# grab additional metadata
103103
if metadata_lookup:
104104
await self.mass.metadata.get_album_metadata(item)
105-
# actually add (or update) the item in the library db
106-
# use the lock to prevent a race condition of the same item being added twice
107-
async with self._db_add_lock:
108-
library_item = await self._add_library_item(item)
105+
# check for existing item first
106+
library_item = None
107+
if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider):
108+
# existing item match by provider id
109+
library_item = await self.update_item_in_library(cur_item.item_id, item) # noqa: SIM114
110+
elif cur_item := await self.get_library_item_by_external_ids(item.external_ids):
111+
# existing item match by external id
112+
library_item = await self.update_item_in_library(cur_item.item_id, item)
113+
else:
114+
# search by name
115+
async for db_item in self.iter_library_items(search=item.name):
116+
if compare_album(db_item, item):
117+
# existing item found: update it
118+
library_item = await self.update_item_in_library(db_item.item_id, item)
119+
break
120+
if not library_item:
121+
# actually add a new item in the library db
122+
# use the lock to prevent a race condition of the same item being added twice
123+
async with self._db_add_lock:
124+
library_item = await self._add_library_item(item)
109125
# also fetch the same album on all providers
110126
if metadata_lookup:
111127
await self._match(library_item)
@@ -139,6 +155,7 @@ async def update_item_in_library(
139155
else:
140156
album_type = cur_item.album_type
141157
sort_artist = album_artists[0].sort_name
158+
cur_item.external_ids.update(update.external_ids)
142159
await self.mass.music.database.update(
143160
self.db_table,
144161
{"item_id": db_id},
@@ -152,7 +169,9 @@ async def update_item_in_library(
152169
"artists": serialize_to_json(album_artists),
153170
"metadata": serialize_to_json(metadata),
154171
"provider_mappings": serialize_to_json(provider_mappings),
155-
"mbid": update.mbid or cur_item.mbid,
172+
"external_ids": serialize_to_json(
173+
update.external_ids if overwrite else cur_item.external_ids
174+
),
156175
"timestamp_modified": int(utc_timestamp()),
157176
},
158177
)
@@ -219,28 +238,8 @@ async def versions(
219238

220239
async def _add_library_item(self, item: Album) -> Album:
221240
"""Add a new record to the database."""
222-
# safety guard: check for existing item first
223-
if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider):
224-
# existing item found: update it
225-
return await self.update_item_in_library(cur_item.item_id, item)
226-
if item.mbid:
227-
match = {"mbid": item.mbid}
228-
if db_row := await self.mass.music.database.get_row(self.db_table, match):
229-
cur_item = Album.from_dict(self._parse_db_row(db_row))
230-
# existing item found: update it
231-
return await self.update_item_in_library(cur_item.item_id, item)
232-
# fallback to search and match
233-
match = {"sort_name": item.sort_name}
234-
for db_row in await self.mass.music.database.get_rows(self.db_table, match):
235-
row_album = Album.from_dict(self._parse_db_row(db_row))
236-
if compare_album(row_album, item):
237-
cur_item = row_album
238-
# existing item found: update it
239-
return await self.update_item_in_library(cur_item.item_id, item)
240-
241-
# insert new item
242-
album_artists = await self._get_artist_mappings(item, cur_item)
243-
sort_artist = album_artists[0].sort_name
241+
album_artists = await self._get_artist_mappings(item)
242+
sort_artist = album_artists[0].sort_name if album_artists else ""
244243
new_item = await self.mass.music.database.insert(
245244
self.db_table,
246245
{
@@ -250,11 +249,11 @@ async def _add_library_item(self, item: Album) -> Album:
250249
"favorite": item.favorite,
251250
"album_type": item.album_type,
252251
"year": item.year,
253-
"mbid": item.mbid,
254252
"metadata": serialize_to_json(item.metadata),
255253
"provider_mappings": serialize_to_json(item.provider_mappings),
256254
"artists": serialize_to_json(album_artists),
257255
"sort_artist": sort_artist,
256+
"external_ids": serialize_to_json(item.external_ids),
258257
"timestamp_added": int(utc_timestamp()),
259258
"timestamp_modified": int(utc_timestamp()),
260259
},

0 commit comments

Comments
 (0)