Improve tag and matching logic (#571)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 24 Mar 2023 22:29:48 +0000 (23:29 +0100)
committerGitHub <noreply@github.com>
Fri, 24 Mar 2023 22:29:48 +0000 (23:29 +0100)
* fix typo in soundcloud

* fix parsing albumtype and version

* fixes for tags, add timestamps

* handle edge cases

* enable metadata scanner

* bump frontend

* supress images not found

* add explicit metadata

27 files changed:
music_assistant/common/models/enums.py
music_assistant/common/models/media_items.py
music_assistant/constants.py
music_assistant/server/controllers/cache.py
music_assistant/server/controllers/media/albums.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/media/radio.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/music.py
music_assistant/server/helpers/audio.py
music_assistant/server/helpers/compare.py
music_assistant/server/helpers/images.py
music_assistant/server/helpers/process.py
music_assistant/server/helpers/tags.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/filesystem_local/helpers.py
music_assistant/server/providers/musicbrainz/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/soundcloud/soundcloudpy/asyncsoundcloudpy.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/ytmusic/__init__.py
pyproject.toml
requirements_all.txt

index fa78b6a43e048f3bd9fd8189705c6aa6d42e1a33..1b2cda524d8598f857fb7353537766de23c51ac4 100644 (file)
@@ -100,6 +100,8 @@ class AlbumType(StrEnum):
     SINGLE = "single"
     COMPILATION = "compilation"
     EP = "ep"
+    PODCAST = "podcast"
+    AUDIOBOOK = "audiobook"
     UNKNOWN = "unknown"
 
 
@@ -116,6 +118,7 @@ class ContentType(StrEnum):
     AIFF = "aiff"
     WMA = "wma"
     M4A = "m4a"
+    M4B = "m4b"
     DSF = "dsf"
     WAVPACK = "wv"
     PCM_S16LE = "s16le"  # PCM signed 16-bit little-endian
index f405257f836b8fc2ebbc1f3be45d93af132159d6..8d385c04db304b38137d0088a9f94b2687e3791f 100755 (executable)
@@ -22,6 +22,7 @@ from music_assistant.common.models.enums import (
 MetadataTypes = int | bool | str | list[str]
 
 JSON_KEYS = ("artists", "artist", "albums", "metadata", "provider_mappings")
+JOINED_KEYS = ("barcode", "isrc")
 
 
 @dataclass(frozen=True)
@@ -77,13 +78,27 @@ class MediaItemImage(DataClassDictMixin):
 
     type: ImageType
     url: str
-    is_file: bool = False  # indicator that image is local filepath instead of url
+    source: str = "http"  # set to instance_id of file provider if path is local
 
     def __hash__(self):
         """Return custom hash."""
         return hash(self.url)
 
 
+@dataclass(frozen=True)
+class MediaItemChapter(DataClassDictMixin):
+    """Model for a chapter."""
+
+    chapter_id: int
+    position_start: float
+    position_end: float | None = None
+    title: str | None = None
+
+    def __hash__(self):
+        """Return custom hash."""
+        return hash(self.number)
+
+
 @dataclass
 class MediaItemMetadata(DataClassDictMixin):
     """Model for a MediaItem's metadata."""
@@ -100,6 +115,7 @@ class MediaItemMetadata(DataClassDictMixin):
     ean: str | None = None
     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
@@ -120,14 +136,14 @@ class MediaItemMetadata(DataClassDictMixin):
             if new_val is None:
                 continue
             cur_val = getattr(self, fld.name)
-            if isinstance(cur_val, list):
+            if cur_val is None or allow_overwrite:  # noqa: SIM114
+                setattr(self, fld.name, new_val)
+            elif isinstance(cur_val, list):
                 new_val = merge_lists(cur_val, new_val)
                 setattr(self, fld.name, new_val)
             elif isinstance(cur_val, set):
                 new_val = cur_val.update(new_val)
                 setattr(self, fld.name, new_val)
-            elif cur_val is None or allow_overwrite:  # noqa: SIM114
-                setattr(self, fld.name, new_val)
             elif new_val and fld.name in ("checksum", "popularity", "last_refresh"):
                 # some fields are always allowed to be overwritten
                 # (such as checksum and last_refresh)
@@ -151,8 +167,9 @@ class MediaItem(DataClassDictMixin):
     # sort_name and uri are auto generated, do not override unless really needed
     sort_name: str | None = None
     uri: str | None = None
-    # timestamp is used to determine when the item was added to the library
-    timestamp: int = 0
+    # timestamps to determine when the item was added/modified to the db
+    timestamp_added: int = 0
+    timestamp_modified: int = 0
 
     def __post_init__(self):
         """Call after init."""
@@ -169,6 +186,11 @@ class MediaItem(DataClassDictMixin):
         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"):
@@ -180,8 +202,17 @@ class MediaItem(DataClassDictMixin):
 
     def to_db_row(self) -> dict:
         """Create dict from item suitable for db."""
+
+        def get_db_value(key, value) -> Any:
+            """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 {
-            key: json_dumps(value) if key in JSON_KEYS else value
+            key: get_db_value(key, value)
             for key, value in self.to_dict().items()
             if key
             not in [
@@ -220,11 +251,6 @@ class MediaItem(DataClassDictMixin):
         }
         self.provider_mappings.add(prov_mapping)
 
-    @property
-    def last_refresh(self) -> int:
-        """Return timestamp the metadata was last refreshed (0 if full data never retrieved)."""
-        return self.metadata.last_refresh or 0
-
     def __hash__(self):
         """Return custom hash."""
         return hash((self.media_type, self.provider, self.item_id))
@@ -273,7 +299,7 @@ class Album(MediaItem):
     year: int | None = None
     artists: list[Artist | ItemMapping] = field(default_factory=list)
     album_type: AlbumType = AlbumType.UNKNOWN
-    upc: str | None = None
+    barcode: set[str] = field(default_factory=set)
     musicbrainz_id: str | None = None  # release group id
 
     @property
@@ -308,7 +334,7 @@ class Track(MediaItem):
     media_type: MediaType = MediaType.TRACK
     duration: int = 0
     version: str = ""
-    isrc: str | None = None
+    isrc: set[str] = field(default_factory=set)
     musicbrainz_id: str | None = None  # Recording ID
     artists: list[Artist | ItemMapping] = field(default_factory=list)
     # album track only
@@ -333,14 +359,6 @@ class Track(MediaItem):
             return getattr(self.album, "image", None)
         return None
 
-    @property
-    def isrcs(self) -> tuple[str, ...]:
-        """Split multiple values in isrc field."""
-        # sometimes the isrc contains multiple values, split by semicolon
-        if not self.isrc:
-            return tuple()
-        return tuple(self.isrc.split(";"))
-
     @property
     def artist(self) -> Artist | ItemMapping | None:
         """Return (first) artist of track."""
@@ -353,6 +371,16 @@ class Track(MediaItem):
         """Set (first/only) artist of track."""
         self.artists = [artist]
 
+    @property
+    def has_chapters(self) -> bool:
+        """
+        Return boolean if this Track has chapters.
+
+        This is often an indicator that this track is an episode from a
+        Podcast or AudioBook.
+        """
+        return self.metadata and self.metadata.chapters and len(self.metadata.chapters) > 1
+
 
 @dataclass
 class Playlist(MediaItem):
index 8b4bb0c0824cb1456b708a96fb147a01721fd001..85c1615fc46b82e34aac7130eb04b0792f44ffff 100755 (executable)
@@ -5,7 +5,7 @@ from typing import Final
 
 __version__: Final[str] = "2.0.0b12"
 
-SCHEMA_VERSION: Final[int] = 19
+SCHEMA_VERSION: Final[int] = 20
 
 ROOT_LOGGER_NAME: Final[str] = "music_assistant"
 
index 65a33fe1b38a12e9dabe978549ecc32a163d4bd4..6a9909a1b668984b1c131f39a3487e8e66b68657 100644 (file)
@@ -16,6 +16,7 @@ from music_assistant.constants import (
     DB_TABLE_SETTINGS,
     DEFAULT_DB_CACHE,
     ROOT_LOGGER_NAME,
+    SCHEMA_VERSION,
 )
 from music_assistant.server.helpers.database import DatabaseConnection
 
@@ -23,7 +24,6 @@ if TYPE_CHECKING:
     from music_assistant.server import MusicAssistant
 
 LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.cache")
-SCHEMA_VERSION = 1
 
 
 class CacheController:
index 1489fdbea5072a2bde21d3241f56a8c4ccf8f2d6..c05118638a6e812228897165905e39f1a7881258 100644 (file)
@@ -6,6 +6,7 @@ import contextlib
 from random import choice, random
 from typing import TYPE_CHECKING
 
+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, ProviderFeature
 from music_assistant.common.models.errors import MediaNotFoundError, UnsupportedFeaturedException
@@ -145,19 +146,24 @@ class AlbumsController(MediaControllerBase[Album]):
         )
         return db_item
 
-    async def add_db_item(self, item: Album, overwrite_existing: bool = False) -> Album:
+    async def add_db_item(self, item: Album) -> Album:
         """Add a new record to the database."""
-        assert item.provider_mappings, f"Album {item.name} is missing provider id(s)"
+        assert item.provider_mappings, "Item is missing provider mapping(s)"
         assert item.artist, f"Album {item.name} is missing artist"
         async with self._db_add_lock:
             cur_item = None
-            # always try to grab existing item by musicbrainz_id/upc
+            # always try to grab existing item by musicbrainz_id
             if item.musicbrainz_id:
                 match = {"musicbrainz_id": item.musicbrainz_id}
                 cur_item = await self.mass.music.database.get_row(self.db_table, match)
-            if not cur_item and item.upc:
-                match = {"upc": item.upc}
-                cur_item = await self.mass.music.database.get_row(self.db_table, match)
+            # 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])
+                        break
             if not cur_item:
                 # fallback to search and match
                 for row in await self.mass.music.database.search(self.db_table, item.name):
@@ -167,9 +173,7 @@ class AlbumsController(MediaControllerBase[Album]):
                         break
             if cur_item:
                 # update existing
-                return await self.update_db_item(
-                    cur_item.item_id, item, overwrite=overwrite_existing
-                )
+                return await self.update_db_item(cur_item.item_id, item)
 
             # insert new item
             album_artists = await self._get_album_artists(item, cur_item)
@@ -180,6 +184,8 @@ class AlbumsController(MediaControllerBase[Album]):
                     **item.to_db_row(),
                     "artists": serialize_to_json(album_artists) or None,
                     "sort_artist": sort_artist,
+                    "timestamp_added": int(utc_timestamp()),
+                    "timestamp_modified": int(utc_timestamp()),
                 },
             )
             item_id = new_item["item_id"]
@@ -193,24 +199,19 @@ class AlbumsController(MediaControllerBase[Album]):
         self,
         item_id: int,
         item: Album,
-        overwrite: bool = False,
     ) -> Album:
         """Update Album record in the database."""
-        assert item.provider_mappings, f"Album {item.name} is missing provider id(s)"
+        assert item.provider_mappings, "Item is missing provider mapping(s)"
         assert item.artist, f"Album {item.name} is missing artist"
         cur_item = await self.get_db_item(item_id)
-
-        if overwrite:
-            metadata = item.metadata
-            metadata.last_refresh = None
-            provider_mappings = item.provider_mappings
-            album_artists = await self._get_album_artists(item, overwrite=True)
+        is_file_provider = item.provider.startswith("filesystem")
+        metadata = cur_item.metadata.update(item.metadata, is_file_provider)
+        provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
+        if is_file_provider:
+            album_artists = await self._get_album_artists(cur_item)
         else:
-            is_file_provider = item.provider.startswith("filesystem")
-            metadata = cur_item.metadata.update(item.metadata, is_file_provider)
-            provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
-            album_artists = await self._get_album_artists(item, cur_item)
-
+            album_artists = await self._get_album_artists(cur_item, item)
+        cur_item.barcode.update(item.barcode)
         if item.album_type != AlbumType.UNKNOWN:
             album_type = item.album_type
         else:
@@ -222,17 +223,18 @@ class AlbumsController(MediaControllerBase[Album]):
             self.db_table,
             {"item_id": item_id},
             {
-                "name": item.name if overwrite else cur_item.name,
-                "sort_name": item.sort_name if overwrite else cur_item.sort_name,
+                "name": item.name if is_file_provider else cur_item.name,
+                "sort_name": item.sort_name if is_file_provider else cur_item.sort_name,
                 "sort_artist": sort_artist,
-                "version": item.version if overwrite else cur_item.version,
+                "version": item.version if is_file_provider else cur_item.version,
                 "year": item.year or cur_item.year,
-                "upc": item.upc or cur_item.upc,
-                "album_type": album_type,
+                "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
@@ -411,7 +413,6 @@ class AlbumsController(MediaControllerBase[Album]):
         self,
         db_album: Album,
         updated_album: Album | None = None,
-        overwrite: bool = False,
     ) -> list[ItemMapping]:
         """Extract (database) album artist(s) as ItemMapping."""
         album_artists = set()
@@ -419,19 +420,15 @@ class AlbumsController(MediaControllerBase[Album]):
             if not album:
                 continue
             for artist in album.artists:
-                album_artists.add(await self._get_artist_mapping(artist, overwrite))
+                album_artists.add(await self._get_artist_mapping(artist))
         # use intermediate set to prevent duplicates
         # filter various artists if multiple artists
         if len(album_artists) > 1:
             album_artists = {x for x in album_artists if (x.name != VARIOUS_ARTISTS)}
         return list(album_artists)
 
-    async def _get_artist_mapping(
-        self, artist: Artist | ItemMapping, overwrite: bool = False
-    ) -> ItemMapping:
+    async def _get_artist_mapping(self, artist: Artist | ItemMapping) -> ItemMapping:
         """Extract (database) track artist as ItemMapping."""
-        if overwrite:
-            artist = await self.mass.music.artists.add_db_item(artist, overwrite_existing=True)
         if artist.provider == "database":
             if isinstance(artist, ItemMapping):
                 return artist
index eef4e287319ec7da2f9a7779b25c4f2c1294febd..1766a7b525e9b677d0ea1e1cb73809e42e5be2ef 100644 (file)
@@ -5,9 +5,9 @@ import asyncio
 import contextlib
 import itertools
 from random import choice, random
-from time import time
 from typing import TYPE_CHECKING, Any
 
+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, ProviderFeature
 from music_assistant.common.models.errors import MediaNotFoundError, UnsupportedFeaturedException
@@ -250,10 +250,10 @@ class ArtistsController(MediaControllerBase[Artist]):
         )
         return items
 
-    async def add_db_item(self, item: Artist, overwrite_existing: bool = False) -> Artist:
+    async def add_db_item(self, item: Artist) -> Artist:
         """Add a new item record to the database."""
         assert isinstance(item, Artist), "Not a full Artist object"
-        assert item.provider_mappings, "Artist is missing provider id(s)"
+        assert item.provider_mappings, "Item is missing provider mapping(s)"
         # enforce various artists name + id
         if compare_strings(item.name, VARIOUS_ARTISTS):
             item.musicbrainz_id = VARIOUS_ARTISTS_ID
@@ -279,13 +279,11 @@ class ArtistsController(MediaControllerBase[Artist]):
                         break
             if cur_item:
                 # update existing
-                return await self.update_db_item(
-                    cur_item.item_id, item, overwrite=overwrite_existing
-                )
+                return await self.update_db_item(cur_item.item_id, item)
 
             # insert item
-            if item.in_library and not item.timestamp:
-                item.timestamp = int(time())
+            item.timestamp_added = int(utc_timestamp())
+            item.timestamp_modified = int(utc_timestamp())
             new_item = await self.mass.music.database.insert(self.db_table, item.to_db_row())
             item_id = new_item["item_id"]
             # update/set provider_mappings table
@@ -298,17 +296,13 @@ class ArtistsController(MediaControllerBase[Artist]):
         self,
         item_id: int,
         item: Artist,
-        overwrite: bool = False,
     ) -> Artist:
         """Update Artist record in the database."""
+        assert item.provider_mappings, "Item is missing provider mapping(s)"
         cur_item = await self.get_db_item(item_id)
-        if overwrite:
-            metadata = item.metadata
-            provider_mappings = item.provider_mappings
-        else:
-            is_file_provider = item.provider.startswith("filesystem")
-            metadata = cur_item.metadata.update(item.metadata, is_file_provider)
-            provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
+        is_file_provider = item.provider.startswith("filesystem")
+        metadata = cur_item.metadata.update(item.metadata, is_file_provider)
+        provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
 
         # enforce various artists name + id
         if compare_strings(item.name, VARIOUS_ARTISTS):
@@ -320,11 +314,12 @@ class ArtistsController(MediaControllerBase[Artist]):
             self.db_table,
             {"item_id": item_id},
             {
-                "name": item.name if overwrite else cur_item.name,
-                "sort_name": item.sort_name if overwrite else cur_item.sort_name,
+                "name": item.name if is_file_provider else cur_item.name,
+                "sort_name": item.sort_name if is_file_provider else cur_item.sort_name,
                 "musicbrainz_id": item.musicbrainz_id or cur_item.musicbrainz_id,
                 "metadata": serialize_to_json(metadata),
                 "provider_mappings": serialize_to_json(provider_mappings),
+                "timestamp_modified": int(utc_timestamp()),
             },
         )
         # update/set provider_mappings table
index deb9a1880509dd1d1ff00f55617c74174d386838..f219b9bd9b141257a280ec27bddbec1e2488b48c 100644 (file)
@@ -5,6 +5,7 @@ import asyncio
 import logging
 from abc import ABCMeta, abstractmethod
 from collections.abc import AsyncGenerator
+from contextlib import suppress
 from time import time
 from typing import TYPE_CHECKING, Generic, TypeVar
 
@@ -138,7 +139,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             provider_domain=provider_domain,
             provider_instance=provider_instance,
         )
-        if db_item and (time() - db_item.last_refresh) > REFRESH_INTERVAL:
+        if db_item and (time() - (db_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:
@@ -399,10 +400,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
     async def set_db_library(self, item_id: int, in_library: bool) -> None:
         """Set the in-library bool on a database item."""
         match = {"item_id": item_id}
-        timestamp = int(time()) if in_library else 0
-        await self.mass.music.database.update(
-            self.db_table, match, {"in_library": in_library, "timestamp": timestamp}
-        )
+        await self.mass.music.database.update(self.db_table, match, {"in_library": in_library})
         db_item = await self.get_db_item(item_id)
         self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
 
@@ -446,17 +444,18 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             x for x in db_item.provider_mappings if x.provider_instance != provider_instance
         }
         match = {"item_id": item_id}
-        await self.mass.music.database.update(
-            self.db_table,
-            match,
-            {"provider_mappings": serialize_to_json(db_item.provider_mappings)},
-        )
-        self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
-
-        # NOTE: If the item has no providers left we leave an orphan item in the db
-        # to easily reinstate when a new provider attaches to it.
-
-        self.logger.debug("removed provider %s from item id %s", provider_instance, item_id)
+        if db_item.provider_mappings:
+            await self.mass.music.database.update(
+                self.db_table,
+                match,
+                {"provider_mappings": serialize_to_json(db_item.provider_mappings)},
+            )
+            self.logger.debug("removed provider %s from item id %s", provider_instance, item_id)
+            self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item)
+        else:
+            # delete item if it has no more providers
+            with suppress(AssertionError):
+                await self.delete_db_item(item_id)
 
     async def delete_db_item(self, item_id: int, recursive: bool = False) -> None:  # noqa: ARG002
         """Delete record from the database."""
index 2ee28b8e7498e92d5ee267f555b42b1b850e65ad..b2aa474a3b262d9aa5919fa27b561c8fb4b63b33 100644 (file)
@@ -5,6 +5,7 @@ import random
 from time import time
 from typing import Any
 
+from music_assistant.common.helpers.datetime import utc_timestamp
 from music_assistant.common.helpers.json import serialize_to_json
 from music_assistant.common.helpers.uri import create_uri
 from music_assistant.common.models.enums import EventType, MediaType, ProviderFeature
@@ -183,17 +184,18 @@ class PlaylistController(MediaControllerBase[Playlist]):
         # invalidate cache by updating the checksum
         await self.get(db_playlist_id, "database", force_refresh=True)
 
-    async def add_db_item(self, item: Playlist, overwrite_existing: bool = False) -> Playlist:
+    async def add_db_item(self, item: Playlist) -> Playlist:
         """Add a new record to the database."""
+        assert item.provider_mappings, "Item is missing provider mapping(s)"
         async with self._db_add_lock:
             match = {"name": item.name, "owner": item.owner}
             if cur_item := await self.mass.music.database.get_row(self.db_table, match):
                 # update existing
-                return await self.update_db_item(
-                    cur_item["item_id"], item, overwrite=overwrite_existing
-                )
+                return await self.update_db_item(cur_item["item_id"], item)
 
             # insert new item
+            item.timestamp_added = int(utc_timestamp())
+            item.timestamp_modified = int(utc_timestamp())
             new_item = await self.mass.music.database.insert(self.db_table, item.to_db_row())
             item_id = new_item["item_id"]
             # update/set provider_mappings table
@@ -206,17 +208,12 @@ class PlaylistController(MediaControllerBase[Playlist]):
         self,
         item_id: int,
         item: Playlist,
-        overwrite: bool = False,
     ) -> Playlist:
         """Update Playlist record in the database."""
+        assert item.provider_mappings, "Item is missing provider mapping(s)"
         cur_item = await self.get_db_item(item_id)
-        if overwrite:
-            metadata = item.metadata
-            provider_mappings = item.provider_mappings
-        else:
-            metadata = cur_item.metadata.update(item.metadata)
-            provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
-
+        metadata = cur_item.metadata.update(item.metadata)
+        provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
         await self.mass.music.database.update(
             self.db_table,
             {"item_id": item_id},
@@ -228,6 +225,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
                 "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
index c87138ad1c45da61b5ea5c0228f5e18ba0536b09..1f304a69d15c9ed0f724c208f76348523bb27683 100644 (file)
@@ -4,6 +4,7 @@ from __future__ import annotations
 import asyncio
 from time import time
 
+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.media_items import Radio, Track
@@ -72,18 +73,18 @@ class RadioController(MediaControllerBase[Radio]):
         )
         return db_item
 
-    async def add_db_item(self, item: Radio, overwrite_existing: bool = False) -> Radio:
+    async def add_db_item(self, item: Radio) -> Radio:
         """Add a new item record to the database."""
-        assert item.provider_mappings
+        assert item.provider_mappings, "Item is missing provider mapping(s)"
         async with self._db_add_lock:
             match = {"name": item.name}
             if cur_item := await self.mass.music.database.get_row(self.db_table, match):
                 # update existing
-                return await self.update_db_item(
-                    cur_item["item_id"], item, overwrite=overwrite_existing
-                )
+                return await self.update_db_item(cur_item["item_id"], item)
 
             # insert new item
+            item.timestamp_added = int(utc_timestamp())
+            item.timestamp_modified = int(utc_timestamp())
             new_item = await self.mass.music.database.insert(self.db_table, item.to_db_row())
             item_id = new_item["item_id"]
             # update/set provider_mappings table
@@ -96,17 +97,12 @@ class RadioController(MediaControllerBase[Radio]):
         self,
         item_id: int,
         item: Radio,
-        overwrite: bool = False,
     ) -> Radio:
         """Update Radio record in the database."""
+        assert item.provider_mappings, "Item is missing provider mapping(s)"
         cur_item = await self.get_db_item(item_id)
-        if overwrite:
-            metadata = item.metadata
-            provider_mappings = item.provider_mappings
-        else:
-            metadata = cur_item.metadata.update(item.metadata)
-            provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
-
+        metadata = cur_item.metadata.update(item.metadata)
+        provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
         match = {"item_id": item_id}
         await self.mass.music.database.update(
             self.db_table,
@@ -117,6 +113,7 @@ class RadioController(MediaControllerBase[Radio]):
                 "sort_name": 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
index 9001e7735473d7b9385ad63440926aaf2acf515c..2e06cdf6855960568a268e48b63ba3871a306286 100644 (file)
@@ -3,6 +3,7 @@ from __future__ import annotations
 
 import asyncio
 
+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, ProviderFeature
 from music_assistant.common.models.errors import MediaNotFoundError, UnsupportedFeaturedException
@@ -209,7 +210,7 @@ class TracksController(MediaControllerBase[Track]):
             "No Music Provider found that supports requesting similar tracks."
         )
 
-    async def add_db_item(self, item: Track, overwrite_existing: bool = False) -> Track:
+    async def add_db_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)"
@@ -221,9 +222,12 @@ class TracksController(MediaControllerBase[Track]):
             if item.musicbrainz_id:
                 match = {"musicbrainz_id": item.musicbrainz_id}
                 cur_item = await self.mass.music.database.get_row(self.db_table, match)
-            for isrc in item.isrcs:
-                match = {"isrc": isrc}
-                cur_item = await self.mass.music.database.get_row(self.db_table, match)
+            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])
+                    break
             if not cur_item:
                 # fallback to matching
                 match = {"sort_name": item.sort_name}
@@ -234,13 +238,11 @@ class TracksController(MediaControllerBase[Track]):
                         break
             if cur_item:
                 # update existing
-                return await self.update_db_item(
-                    cur_item.item_id, item, overwrite=overwrite_existing
-                )
+                return await self.update_db_item(cur_item.item_id, item)
 
             # no existing match found: insert new item
             track_artists = await self._get_track_artists(item)
-            track_albums = await self._get_track_albums(item, overwrite=overwrite_existing)
+            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 ""
             new_item = await self.mass.music.database.insert(
@@ -251,6 +253,8 @@ class TracksController(MediaControllerBase[Track]):
                     "albums": serialize_to_json(track_albums),
                     "sort_artist": sort_artist,
                     "sort_album": sort_album,
+                    "timestamp_added": int(utc_timestamp()),
+                    "timestamp_modified": int(utc_timestamp()),
                 },
             )
             item_id = new_item["item_id"]
@@ -264,21 +268,18 @@ class TracksController(MediaControllerBase[Track]):
         self,
         item_id: int,
         item: Track,
-        overwrite: bool = False,
     ) -> Track:
         """Update Track record in the database, merging data."""
         cur_item = await self.get_db_item(item_id)
-
-        if overwrite:
-            metadata = item.metadata
-            provider_mappings = item.provider_mappings
-            metadata.last_refresh = None
-            # we store a mapping to artists/albums on the item for easier access/listings
-            track_artists = await self._get_track_artists(item, overwrite=True)
-            track_albums = await self._get_track_albums(item, overwrite=True)
+        is_file_provider = item.provider.startswith("filesystem")
+        metadata = cur_item.metadata.update(item.metadata, is_file_provider)
+        provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
+        cur_item.isrc.update(item.isrc)
+        # ID3 tags from file providers are leading for core metadata
+        if is_file_provider:
+            track_artists = await self._get_track_artists(item)
+            track_albums = await self._get_track_albums(item)
         else:
-            metadata = cur_item.metadata.update(item.metadata, "file" in item.provider)
-            provider_mappings = {*cur_item.provider_mappings, *item.provider_mappings}
             track_artists = await self._get_track_artists(cur_item, item)
             track_albums = await self._get_track_albums(cur_item, item)
 
@@ -286,15 +287,16 @@ class TracksController(MediaControllerBase[Track]):
             self.db_table,
             {"item_id": item_id},
             {
-                "name": item.name if overwrite else cur_item.name,
-                "sort_name": item.sort_name if overwrite else cur_item.sort_name,
-                "version": item.version if overwrite else cur_item.version,
-                "duration": item.duration if overwrite else cur_item.duration,
+                "name": item.name if is_file_provider else cur_item.name,
+                "sort_name": item.sort_name if is_file_provider else cur_item.sort_name,
+                "version": item.version if is_file_provider else cur_item.version,
+                "duration": item.duration 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": item.isrc or cur_item.isrc,
+                "isrc": ";".join(cur_item.isrc),
+                "timestamp_modified": int(utc_timestamp()),
             },
         )
         # update/set provider_mappings table
@@ -306,18 +308,16 @@ class TracksController(MediaControllerBase[Track]):
         self,
         base_track: Track,
         upd_track: Track | None = None,
-        overwrite: bool = False,
     ) -> list[ItemMapping]:
         """Extract all (unique) artists of track as ItemMapping."""
         track_artists = upd_track.artists if upd_track and upd_track.artists else base_track.artists
         # use intermediate set to clear out duplicates
-        return list({await self._get_artist_mapping(x, overwrite) for x in track_artists})
+        return list({await self._get_artist_mapping(x) for x in track_artists})
 
     async def _get_track_albums(
         self,
         base_track: Track,
         upd_track: Track | None = None,
-        overwrite: bool = False,
     ) -> list[TrackAlbumMapping]:
         """Extract all (unique) albums of track as TrackAlbumMapping."""
         track_albums: list[TrackAlbumMapping] = []
@@ -328,7 +328,7 @@ class TracksController(MediaControllerBase[Track]):
             track_albums = upd_track.albums
         # append update item album if needed
         if upd_track and upd_track.album:
-            mapping = await self._get_album_mapping(upd_track.album, overwrite=overwrite)
+            mapping = await self._get_album_mapping(upd_track.album)
             mapping = TrackAlbumMapping.from_dict(
                 {
                     **mapping.to_dict(),
@@ -340,7 +340,7 @@ class TracksController(MediaControllerBase[Track]):
                 track_albums.append(mapping)
         # append base item album if needed
         elif base_track and base_track.album:
-            mapping = await self._get_album_mapping(base_track.album, overwrite=overwrite)
+            mapping = await self._get_album_mapping(base_track.album)
             mapping = TrackAlbumMapping.from_dict(
                 {
                     **mapping.to_dict(),
@@ -356,7 +356,6 @@ class TracksController(MediaControllerBase[Track]):
     async def _get_album_mapping(
         self,
         album: Album | ItemMapping,
-        overwrite: bool = False,
     ) -> ItemMapping:
         """Extract (database) album as ItemMapping."""
         if album.provider == "database":
@@ -364,29 +363,21 @@ class TracksController(MediaControllerBase[Track]):
                 return album
             return ItemMapping.from_item(album)
 
-        if overwrite:
-            db_album = await self.mass.music.albums.add_db_item(album, overwrite_existing=True)
-
         if db_album := await self.mass.music.albums.get_db_item_by_prov_id(
             album.item_id, provider_domain=album.provider
         ):
             return ItemMapping.from_item(db_album)
 
-        db_album = await self.mass.music.albums.add_db_item(album, overwrite_existing=overwrite)
+        db_album = await self.mass.music.albums.add_db_item(album)
         return ItemMapping.from_item(db_album)
 
-    async def _get_artist_mapping(
-        self, artist: Artist | ItemMapping, overwrite: bool = False
-    ) -> ItemMapping:
+    async def _get_artist_mapping(self, artist: Artist | ItemMapping) -> ItemMapping:
         """Extract (database) track artist as ItemMapping."""
         if artist.provider == "database":
             if isinstance(artist, ItemMapping):
                 return artist
             return ItemMapping.from_item(artist)
 
-        if overwrite:
-            artist = await self.mass.music.artists.add_db_item(artist, overwrite_existing=True)
-
         if db_artist := await self.mass.music.artists.get_db_item_by_prov_id(
             artist.item_id, provider_domain=artist.provider
         ):
index 835a1be4c7e8ef64f09a56732800b86121b51962..bdd18b7db9ff8fe6b62d4173ea013b1736a06091 100755 (executable)
@@ -273,9 +273,9 @@ class MetaDataController:
             for img in media_item.metadata.images:
                 if img.type != img_type:
                     continue
-                if img.is_file and not allow_local:
+                if img.source != "http" and not allow_local:
                     continue
-                if img.is_file and resolve_local:
+                if img.source != "http" and resolve_local:
                     # return imageproxy url for local filesystem items
                     # the original path is double encoded
                     encoded_url = urllib.parse.quote(urllib.parse.quote(img.url))
@@ -298,10 +298,10 @@ class MetaDataController:
         return None
 
     async def get_thumbnail(
-        self, path: str, size: int | None = None, base64: bool = False
+        self, path_or_url: str, size: int | None = None, source: str = "http", base64: bool = False
     ) -> bytes | str:
         """Get/create thumbnail image for path (image url or local path)."""
-        thumbnail = await get_image_thumb(self.mass, path, size)
+        thumbnail = await get_image_thumb(self.mass, path_or_url, size=size, source=source)
         if base64:
             enc_image = b64encode(thumbnail).decode()
             thumbnail = f"data:image/png;base64,{enc_image}"
@@ -310,24 +310,19 @@ class MetaDataController:
     async def _handle_imageproxy(self, request: web.Request) -> web.Response:
         """Handle request for image proxy."""
         path = request.query["path"]
+        source = request.query.get("source", "http")
         size = int(request.query.get("size", "0"))
         if "%" in path:
             # assume (double) encoded url, decode it
             path = urllib.parse.unquote(path)
 
-        try:
-            image_data = await self.get_thumbnail(path, size)
-        except Exception as err:
-            LOGGER.exception(str(err), exc_info=err)
-            image_data = None
-
-        if not image_data:
-            return web.Response(status=404)
-
-        # we set the cache header to 1 year (forever)
-        # the client can use the checksum value to refresh when content changes
-        return web.Response(
-            body=image_data,
-            headers={"Cache-Control": "max-age=31536000"},
-            content_type="image/png",
-        )
+        with suppress(FileNotFoundError):
+            image_data = await self.get_thumbnail(path, size=size, source=source)
+            # we set the cache header to 1 year (forever)
+            # the client can use the checksum value to refresh when content changes
+            return web.Response(
+                body=image_data,
+                headers={"Cache-Control": "max-age=31536000"},
+                content_type="image/png",
+            )
+        return web.Response(status=404)
index 7b2d2069e3b75b5afb2449635ac837a367227485..673b07f74b88b475f0b25434ebbe93e63dcbc7d8 100755 (executable)
@@ -599,6 +599,8 @@ class MusicController:
             DB_TABLE_SETTINGS,
             {"key": "version", "value": str(SCHEMA_VERSION), "type": "str"},
         )
+        # create indexes if needed
+        await self.__create_database_indexes()
         # compact db
         await self.database.execute("VACUUM")
 
@@ -635,12 +637,13 @@ class MusicController:
                     year INTEGER,
                     version TEXT,
                     in_library BOOLEAN DEFAULT 0,
-                    upc TEXT,
+                    barcode TEXT,
                     musicbrainz_id TEXT,
                     artists json,
                     metadata json,
                     provider_mappings json,
-                    timestamp INTEGER DEFAULT 0
+                    timestamp_added INTEGER NOT NULL,
+                    timestamp_modified INTEGER NOT NULL
                 );"""
         )
         await self.database.execute(
@@ -652,7 +655,8 @@ class MusicController:
                     in_library BOOLEAN DEFAULT 0,
                     metadata json,
                     provider_mappings json,
-                    timestamp INTEGER DEFAULT 0
+                    timestamp_added INTEGER NOT NULL,
+                    timestamp_modified INTEGER NOT NULL
                     );"""
         )
         await self.database.execute(
@@ -671,7 +675,8 @@ class MusicController:
                     albums json,
                     metadata json,
                     provider_mappings json,
-                    timestamp INTEGER DEFAULT 0
+                    timestamp_added INTEGER NOT NULL,
+                    timestamp_modified INTEGER NOT NULL
                 );"""
         )
         await self.database.execute(
@@ -684,8 +689,8 @@ class MusicController:
                     in_library BOOLEAN DEFAULT 0,
                     metadata json,
                     provider_mappings json,
-                    timestamp INTEGER DEFAULT 0,
-                    UNIQUE(name, owner)
+                    timestamp_added INTEGER NOT NULL,
+                    timestamp_modified INTEGER NOT NULL
                 );"""
         )
         await self.database.execute(
@@ -696,7 +701,8 @@ class MusicController:
                     in_library BOOLEAN DEFAULT 0,
                     metadata json,
                     provider_mappings json,
-                    timestamp INTEGER DEFAULT 0
+                    timestamp_added INTEGER NOT NULL,
+                    timestamp_modified INTEGER NOT NULL
                 );"""
         )
         await self.database.execute(
@@ -711,7 +717,8 @@ class MusicController:
                 );"""
         )
 
-        # create indexes
+    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);"
         )
@@ -752,4 +759,6 @@ class MusicController:
             "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_upc_idx on albums(upc);")
+        await self.database.execute(
+            "CREATE INDEX IF NOT EXISTS albums_barcode_idx on albums(barcode);"
+        )
index 2db9893386502f34674ee1373f6fc821ca67d447..ea3c0a0de85f46f7ea62aba531d8cb1d91b8736d 100644 (file)
@@ -741,10 +741,12 @@ async def _get_ffmpeg_args(
     # generic args
     generic_args = [
         "ffmpeg",
-        "-hide_banner",
-        "-loglevel",
-        "quiet",
-        "-ignore_unknown",
+        # "-hide_banner",
+        # "-loglevel",
+        # "quiet",
+        # "-ignore_unknown",
+        "-movflags",
+        "faststart",
     ]
     # collect input args
     input_args = []
index 48a8d54e9e0a6e2b8aa860ba33178a439390ad24..8b9128c8654d39d9ef80019d7cb76aa02272ac88 100644 (file)
@@ -147,6 +147,33 @@ def compare_albums(
     return False
 
 
+def compare_barcode(
+    left_barcodes: set[str],
+    right_barcodes: set[str],
+):
+    """Compare two sets of barcodes and return True if a match was found."""
+    for left_barcode in left_barcodes:
+        for right_barcode in right_barcodes:
+            # 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):
+                return True
+    return False
+
+
+def compare_isrc(
+    left_isrcs: set[str],
+    right_isrcs: set[str],
+):
+    """Compare two sets of isrc codes and return True if a match was found."""
+    for left_isrc in left_isrcs:
+        for right_isrc in right_isrcs:
+            if compare_strings(left_isrc, right_isrc):
+                return True
+    return False
+
+
 def compare_album(
     left_album: Album | ItemMapping,
     right_album: Album | ItemMapping,
@@ -157,14 +184,12 @@ def compare_album(
     # return early on exact item_id match
     if compare_item_ids(left_album, right_album):
         return True
-
-    # prefer match on UPC
+    # prefer match on barcode/upc
+    # not present on ItemMapping
     if (
-        isinstance(left_album, Album)
-        and isinstance(right_album, Album)
-        and left_album.upc
-        and right_album.upc
-        and ((left_album.upc in right_album.upc) or (right_album.upc in left_album.upc))
+        getattr(left_album, "barcode", None)
+        and getattr(right_album, "barcode", None)
+        and compare_barcode(left_album.barcode, right_album.barcode)
     ):
         return True
     # prefer match on musicbrainz_id
@@ -177,6 +202,12 @@ def compare_album(
         return False
     if not compare_version(left_album.version, right_album.version):
         return False
+    if (
+        hasattr(left_album, "metadata")
+        and hasattr(right_album, "metadata")
+        and not compare_explicit(left_album.metadata, right_album.metadata)
+    ):
+        return False
     # compare album artist
     # Note: Not present on ItemMapping
     if (
@@ -192,20 +223,13 @@ def compare_track(left_track: Track, right_track: Track):
     """Compare two track items and return True if they match."""
     if left_track is None or right_track is None:
         return False
+    assert isinstance(left_track, Track) and isinstance(right_track, Track)
     # return early on exact item_id match
     if compare_item_ids(left_track, right_track):
         return True
-    for left_isrc in left_track.isrcs:
-        for right_isrc in right_track.isrcs:
-            # ISRC is always 100% accurate match
-            if left_isrc == right_isrc:
-                return True
-    if (
-        left_track.musicbrainz_id
-        and right_track.musicbrainz_id
-        and left_track.musicbrainz_id == right_track.musicbrainz_id
-    ):
-        # musicbrainz_id is always 100% accurate match
+    if compare_isrc(left_track.isrc, right_track.isrc):
+        return True
+    if compare_strings(left_track.musicbrainz_id, right_track.musicbrainz_id):
         return True
     # album is required for track linking
     if left_track.album is None or right_track.album is None:
@@ -218,7 +242,7 @@ def compare_track(left_track: Track, right_track: Track):
         compare_album(left_track.album, right_track.album)
         and left_track.track_number
         and right_track.track_number
-        and left_track.disc_number == right_track.disc_number
+        and ((left_track.disc_number or 1) == (right_track.disc_number or 1))
         and left_track.track_number == right_track.track_number
     ):
         return True
index 308bae29b3f2035ca2d1f8b56b3dae4935207b6f..d47f5627eb039346ba82a7d9049bd3a68ac8c347 100644 (file)
@@ -3,7 +3,7 @@ from __future__ import annotations
 
 import asyncio
 import random
-from base64 import b64encode
+from base64 import b64decode, b64encode
 from io import BytesIO
 from typing import TYPE_CHECKING
 
@@ -14,30 +14,43 @@ from music_assistant.server.helpers.tags import get_embedded_image
 
 if TYPE_CHECKING:
     from music_assistant.server import MusicAssistant
+    from music_assistant.server.providers.filesystem_local.base import FileSystemProviderBase
 
 
-async def get_image_data(mass: MusicAssistant, path: str) -> bytes:
+async def get_image_data(mass: MusicAssistant, path_or_url: str, source: str = "http") -> bytes:
     """Create thumbnail from image url."""
-    # always try ffmpeg first to get the image because it supports
+    if source != "http" and (prov := mass.get_provider(source)):
+        prov: FileSystemProviderBase
+        file_item = await prov.resolve(path_or_url)
+        # store images in cache db if file larger than 5mb and we have no direct access to the file
+        use_cache = not file_item.local_path and (
+            not file_item.file_size or file_item.file_size > 5000000
+        )
+        cache_key = f"embedded_image.{path_or_url}.{source}"
+        if use_cache and (
+            cache_data := await mass.cache.get(cache_key, checksum=file_item.checksum)
+        ):
+            return b64decode(cache_data)
+        # read from file
+        input_file = file_item.local_path or prov.read_file_content(file_item.absolute_path)
+        if img_data := await get_embedded_image(input_file):
+            if use_cache:
+                await mass.cache.set(
+                    cache_key, b64encode(img_data).decode(), checksum=file_item.checksum
+                )
+            return img_data
+    # always use ffmpeg to get the image because it supports
     # both online and offline image files as well as embedded images in media files
-    img_data = await get_embedded_image(path)
-    if img_data:
+    if img_data := await get_embedded_image(path_or_url):
         return img_data
-    # assume file from file provider, we need to fetch it here...
-    for prov in mass.music.providers:
-        if not prov.domain.startswith("filesystem"):
-            continue
-        if not await prov.exists(path):
-            continue
-        img_data = await get_embedded_image(prov.read_file_content(path))
-        if img_data:
-            return img_data
-    raise FileNotFoundError(f"Image not found: {path}")
+    raise FileNotFoundError(f"Image not found: {path_or_url}")
 
 
-async def get_image_thumb(mass: MusicAssistant, path: str, size: int | None) -> bytes:
+async def get_image_thumb(
+    mass: MusicAssistant, path_or_url: str, size: int | None, source: str = "http"
+) -> bytes:
     """Get (optimized) PNG thumbnail from image url."""
-    img_data = await get_image_data(mass, path)
+    img_data = await get_image_data(mass, path_or_url, source)
 
     def _create_image():
         data = BytesIO()
index 5094669699d8a486e00e98f478866999cd48080c..6e9c60a8b0101ca213076003f3a3c1e02d8a8037 100644 (file)
@@ -46,6 +46,7 @@ class AsyncProcess:
                 stdout=asyncio.subprocess.PIPE if self._enable_stdout else None,
                 stderr=asyncio.subprocess.PIPE if self._enable_stderr else None,
                 close_fds=True,
+                limit=32000000,
             )
         else:
             self._proc = await asyncio.create_subprocess_exec(
@@ -54,6 +55,7 @@ class AsyncProcess:
                 stdout=asyncio.subprocess.PIPE if self._enable_stdout else None,
                 stderr=asyncio.subprocess.PIPE if self._enable_stderr else None,
                 close_fds=True,
+                limit=32000000,
             )
 
             # Fix BrokenPipeError due to a race condition
index 22f674c6696f44e6638da6724cde16a05b598938..8585ebf2c55c207542827ad7055eda93530fdbac 100644 (file)
@@ -9,7 +9,9 @@ from json import JSONDecodeError
 from typing import Any
 
 from music_assistant.common.helpers.util import try_parse_int
+from music_assistant.common.models.enums import AlbumType
 from music_assistant.common.models.errors import InvalidDataError
+from music_assistant.common.models.media_items import MediaItemChapter
 from music_assistant.constants import UNKNOWN_ARTIST
 from music_assistant.server.helpers.process import AsyncProcess
 
@@ -20,13 +22,18 @@ from music_assistant.server.helpers.process import AsyncProcess
 TAG_SPLITTER = ";"
 
 
-def split_items(org_str: str) -> tuple[str, ...]:
+def split_items(org_str: str, split_slash: bool = False) -> tuple[str, ...]:
     """Split up a tags string by common splitter."""
-    if not org_str:
+    if org_str is None:
         return tuple()
     if isinstance(org_str, list):
-        return org_str
-    return tuple(x.strip() for x in org_str.split(TAG_SPLITTER))
+        return (x.strip() for x in org_str)
+    org_str = org_str.strip()
+    if TAG_SPLITTER in org_str:
+        return tuple(x.strip() for x in org_str.split(TAG_SPLITTER))
+    if split_slash and "/" in org_str:
+        return tuple(x.strip() for x in org_str.split("/"))
+    return (org_str.strip(),)
 
 
 def split_artists(org_artists: str | tuple[str, ...]) -> tuple[str, ...]:
@@ -71,6 +78,16 @@ class AudioTags:
                 return title_parts[1].strip()
         return title
 
+    @property
+    def version(self) -> str:
+        """Return version tag (as-is)."""
+        if tag := self.tags.get("version"):
+            return tag
+        if (tag := self.tags.get("album_type")) and "live" in tag.lower():
+            # yes, this can happen
+            return "Live"
+        return ""
+
     @property
     def album(self) -> str:
         """Return album tag (as-is) if present."""
@@ -84,7 +101,7 @@ class AudioTags:
             return split_items(tag)
         # fallback to regular artist string
         if tag := self.tags.get("artist"):
-            if ";" in tag:
+            if TAG_SPLITTER in tag:
                 return split_items(tag)
             return split_artists(tag)
         # fallback to parsing from filename
@@ -103,7 +120,7 @@ class AudioTags:
             return split_items(tag)
         # fallback to regular artist string
         if tag := self.tags.get("albumartist"):
-            if ";" in tag:
+            if TAG_SPLITTER in tag:
                 return split_items(tag)
             return split_artists(tag)
         return tuple()
@@ -141,12 +158,12 @@ class AudioTags:
     @property
     def musicbrainz_artistids(self) -> tuple[str, ...]:
         """Return musicbrainz_artistid tag(s) if present."""
-        return split_items(self.tags.get("musicbrainzartistid"))
+        return split_items(self.tags.get("musicbrainzartistid"), True)
 
     @property
     def musicbrainz_albumartistids(self) -> tuple[str, ...]:
         """Return musicbrainz_albumartistid tag if present."""
-        return split_items(self.tags.get("musicbrainzalbumartistid"))
+        return split_items(self.tags.get("musicbrainzalbumartistid"), True)
 
     @property
     def musicbrainz_releasegroupid(self) -> str | None:
@@ -161,11 +178,67 @@ class AudioTags:
         return self.tags.get("musicbrainzreleasetrackid")
 
     @property
-    def album_type(self) -> str | None:
+    def album_type(self) -> AlbumType:
         """Return albumtype tag if present."""
-        if tag := self.tags.get("musicbrainzalbumtype"):
-            return tag
-        return self.tags.get("releasetype")
+        # handle audiobook/podcast
+        if self.filename.endswith("m4b") and len(self.chapters) > 1:
+            return AlbumType.AUDIOBOOK
+        if "podcast" in self.tags.get("genre", "").lower() and len(self.chapters) > 1:
+            return AlbumType.PODCAST
+        tag = self.tags.get("musicbrainzalbumtype", self.tags.get("musicbrainzalbumtype"))
+        if tag is None:
+            return AlbumType.UNKNOWN
+        # the album type tag is messy within id3 and may even contain multiple types
+        # try to parse one in order of preference
+        for album_type in (
+            AlbumType.PODCAST,
+            AlbumType.AUDIOBOOK,
+            AlbumType.COMPILATION,
+            AlbumType.EP,
+            AlbumType.SINGLE,
+            AlbumType.ALBUM,
+        ):
+            if album_type.value in tag.lower():
+                return album_type
+
+        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()
+
+    @property
+    def barcode(self) -> tuple[str, ...]:
+        """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()
+
+    @property
+    def chapters(self) -> list[MediaItemChapter]:
+        """Return chapters in MediaItem (if any)."""
+        chapters: list[MediaItemChapter] = []
+        if raw_chapters := self.raw.get("chapters"):
+            for chapter_data in raw_chapters:
+                chapters.append(
+                    MediaItemChapter(
+                        chapter_id=chapter_data["id"],
+                        position_start=chapter_data["start"],
+                        position_end=chapter_data["end"],
+                        title=chapter_data.get("tags", {}).get("title"),
+                    )
+                )
+        return chapters
 
     @classmethod
     def parse(cls, raw: dict) -> AudioTags:
@@ -214,9 +287,12 @@ async def parse_tags(
         "-hide_banner",
         "-loglevel",
         "fatal",
+        "-threads",
+        "0",
         "-show_error",
         "-show_format",
         "-show_streams",
+        "-show_chapters",
         "-print_format",
         "json",
         "-i",
@@ -228,18 +304,11 @@ async def parse_tags(
     ) as proc:
         if file_path == "-":
             # feed the file contents to the process
+
             async def chunk_feeder():
-                bytes_written = 0
                 async for chunk in input_file:
-                    try:
-                        await proc.write(chunk)
-                    except BrokenPipeError:
-                        break  # race-condition: read enough data for tags
-
-                    # grabbing the first 5MB is enough to get the embedded tags
-                    bytes_written += len(chunk)
-                    if bytes_written > (5 * 1024000):
-                        break
+                    await proc.write(chunk)
+
                 proc.write_eof()
 
             proc.attach_task(chunk_feeder())
@@ -287,17 +356,9 @@ async def get_embedded_image(input_file: str | AsyncGenerator[bytes, None]) -> b
         if file_path == "-":
             # feed the file contents to the process
             async def chunk_feeder():
-                bytes_written = 0
                 async for chunk in input_file:
-                    try:
-                        await proc.write(chunk)
-                    except BrokenPipeError:
-                        break  # race-condition: read enough data for tags
-
-                    # grabbing the first 5MB is enough to get the embedded image
-                    bytes_written += len(chunk)
-                    if bytes_written > (5 * 1024000):
-                        break
+                    await proc.write(chunk)
+
                 proc.write_eof()
 
             proc.attach_task(chunk_feeder())
index 22ec5b13e62180d60854bd5d9389a272a81c11f7..879c57b6d883d92f135ff51b032be62d1ec5d967 100644 (file)
@@ -49,6 +49,18 @@ if TYPE_CHECKING:
 
 CONF_ALT_APP = "alt_app"
 
+BASE_PLAYER_CONFIG_ENTRIES = (
+    ConfigEntry(
+        key=CONF_ALT_APP,
+        type=ConfigEntryType.BOOLEAN,
+        label="Use alternate Media app",
+        default_value=False,
+        description="Using the BubbleUPNP Media controller for playback improves "
+        "the playback experience but may not work on non-Google hardware.",
+        advanced=True,
+    ),
+)
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -132,19 +144,7 @@ class ChromecastProvider(PlayerProvider):
     def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]:
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
         cast_player = self.castplayers.get(player_id)
-        entries = (
-            ConfigEntry(
-                key=CONF_ALT_APP,
-                type=ConfigEntryType.BOOLEAN,
-                label="Use alternate Media app",
-                default_value=cast_player
-                and not cast_player.cast_info.is_audio_group
-                and cast_player.cast_info.manufacturer == "Google Inc.",
-                description="Using the BubbleUPNP Media controller for playback improves "
-                "the playback experience but may not work on non-Google hardware.",
-                advanced=True,
-            ),
-        )
+        entries = BASE_PLAYER_CONFIG_ENTRIES
         if (
             cast_player
             and cast_player.cast_info.is_audio_group
index 61798aed4f175cb66627a5a0f344ee713128f2f9..1a0d72367b936c534c746f1b4f7b0639e3a133cb 100644 (file)
@@ -7,7 +7,6 @@ import os
 from abc import abstractmethod
 from collections.abc import AsyncGenerator
 from dataclasses import dataclass
-from time import time
 
 import xmltodict
 
@@ -25,7 +24,6 @@ from music_assistant.common.models.errors import (
 )
 from music_assistant.common.models.media_items import (
     Album,
-    AlbumType,
     Artist,
     BrowseFolder,
     ContentType,
@@ -66,7 +64,7 @@ CONF_ENTRY_MISSING_ALBUM_ARTIST = ConfigEntry(
     ),
 )
 
-TRACK_EXTENSIONS = ("mp3", "m4a", "mp4", "flac", "wav", "ogg", "aiff", "wma", "dsf")
+TRACK_EXTENSIONS = ("mp3", "m4a", "m4b", "mp4", "flac", "wav", "ogg", "aiff", "wma", "dsf")
 PLAYLIST_EXTENSIONS = ("m3u", "pls")
 SUPPORTED_EXTENSIONS = TRACK_EXTENSIONS + PLAYLIST_EXTENSIONS
 IMAGE_EXTENSIONS = ("jpg", "jpeg", "JPG", "JPEG", "png", "PNG", "gif", "GIF")
@@ -151,6 +149,7 @@ class FileSystemProviderBase(MusicProvider):
             AsyncGenerator yielding FileSystemItem objects.
 
         """
+        yield
 
     @abstractmethod
     async def resolve(self, file_path: str) -> FileSystemItem:
@@ -300,12 +299,8 @@ class FileSystemProviderBase(MusicProvider):
 
                 if item.ext in TRACK_EXTENSIONS:
                     # add/update track to db
-                    track = await self.get_track(item.path)
-                    # if the track was edited on disk, always overwrite existing db details
-                    overwrite_existing = item.path in prev_checksums
-                    await self.mass.music.tracks.add_db_item(
-                        track, overwrite_existing=overwrite_existing
-                    )
+                    track = await self._parse_track(item)
+                    await self.mass.music.tracks.add_db_item(track)
                 elif item.ext in PLAYLIST_EXTENSIONS:
                     playlist = await self.get_playlist(item.path)
                     # add/update] playlist to db
@@ -313,8 +308,6 @@ class FileSystemProviderBase(MusicProvider):
                     # playlist is always in-library
                     playlist.in_library = True
                     await self.mass.music.playlists.add_db_item(playlist)
-            except MusicAssistantError as err:
-                self.logger.error("Error processing %s - %s", item.path, str(err))
             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))
@@ -366,15 +359,13 @@ class FileSystemProviderBase(MusicProvider):
 
     async def get_album(self, prov_album_id: str) -> Album:
         """Get full album details by id."""
-        db_album = await self.mass.music.albums.get_db_item_by_prov_id(
-            item_id=prov_album_id, provider_instance=self.instance_id
-        )
-        if db_album is None:
-            raise MediaNotFoundError(f"Album not found: {prov_album_id}")
-        if await self.exists(prov_album_id):
-            # if path exists on disk allow parsing full details to allow refresh of metadata
-            return await self._parse_album(db_album.name, prov_album_id, db_album.artists)
-        return db_album
+        # all data is originated from the actual files (tracks) so grab the data from there
+        for track in await self.get_album_tracks(prov_album_id):
+            for prov_mapping in track.provider_mappings:
+                if prov_mapping.provider_instance == self.instance_id:
+                    full_track = await self.get_track(prov_mapping.item_id)
+                    return full_track.album
+        raise MediaNotFoundError(f"Album not found: {prov_album_id}")
 
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
@@ -383,137 +374,7 @@ class FileSystemProviderBase(MusicProvider):
             raise MediaNotFoundError(f"Track path does not exist: {prov_track_id}")
 
         file_item = await self.resolve(prov_track_id)
-
-        # 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)
-        track = Track(
-            item_id=file_item.path,
-            provider=self.domain,
-            name=name,
-            version=version,
-        )
-
-        # album
-        if tags.album:
-            # work out if we have an album folder
-            album_dir = get_parentdir(file_item.path, tags.album)
-
-            # album artist(s)
-            if tags.album_artists:
-                album_artists = []
-                for index, album_artist_str in enumerate(tags.album_artists):
-                    # work out if we have an artist folder
-                    artist_dir = get_parentdir(file_item.path, album_artist_str)
-                    artist = await self._parse_artist(album_artist_str, artist_path=artist_dir)
-                    if not artist.musicbrainz_id:
-                        with contextlib.suppress(IndexError):
-                            artist.musicbrainz_id = tags.musicbrainz_albumartistids[index]
-                    album_artists.append(artist)
-            else:
-                # album artist tag is missing, determine fallback
-                fallback_action = self.config.get_value(CONF_MISSING_ALBUM_ARTIST_ACTION)
-                if fallback_action == "various_artists":
-                    self.logger.warning(
-                        "%s is missing ID3 tag [albumartist], using %s as fallback",
-                        file_item.path,
-                        VARIOUS_ARTISTS,
-                    )
-                    album_artists = [await self._parse_artist(name=VARIOUS_ARTISTS)]
-                elif fallback_action == "track_artist":
-                    self.logger.warning(
-                        "%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
-                        file_item.path,
-                    )
-                    album_artists = [
-                        await self._parse_artist(name=track_artist_str)
-                        for track_artist_str in tags.artists
-                    ]
-                else:
-                    # default action is to skip the track
-                    raise InvalidDataError("missing ID3 tag [albumartist]")
-
-            track.album = await self._parse_album(
-                tags.album,
-                album_dir,
-                artists=album_artists,
-            )
-        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)
-            ):
-                track.artists.append(artist)
-            else:
-                artist = await self._parse_artist(track_artist_str)
-            if not artist.musicbrainz_id:
-                with contextlib.suppress(IndexError):
-                    artist.musicbrainz_id = tags.musicbrainz_artistids[index]
-            track.artists.append(artist)
-
-        # cover image - prefer album image, fallback to embedded
-        if track.album and track.album.image:
-            track.metadata.images = [track.album.image]
-        elif tags.has_cover_image:
-            # we do not actually embed the image in the metadata because that would consume too
-            # much space and bandwidth. Instead we set the filename as value so the image can
-            # be retrieved later in realtime.
-            track.metadata.images = [MediaItemImage(ImageType.THUMB, file_item.path, True)]
-            if track.album:
-                # set embedded cover on album
-                track.album.metadata.images = track.metadata.images
-
-        # parse other info
-        track.duration = tags.duration or 0
-        track.metadata.genres = tags.genres
-        track.disc_number = tags.disc
-        track.track_number = tags.track
-        track.isrc = tags.get("isrc")
-        track.metadata.copyright = tags.get("copyright")
-        track.metadata.lyrics = tags.get("lyrics")
-        track.musicbrainz_id = tags.musicbrainz_trackid
-        if track.album:
-            if not track.album.musicbrainz_id:
-                track.album.musicbrainz_id = tags.musicbrainz_releasegroupid
-            if not track.album.year:
-                track.album.year = tags.year
-            if not track.album.upc:
-                track.album.upc = tags.get("barcode")
-        # try to parse albumtype
-        if track.album and track.album.album_type == AlbumType.UNKNOWN:
-            album_type = tags.album_type
-            try:
-                track.album.album_type = AlbumType(album_type)
-            except (ValueError, KeyError):
-                if track.album.sort_name in track.sort_name:
-                    track.album.album_type = AlbumType.SINGLE
-
-        # set checksum to invalidate any cached listings
-        checksum_timestamp = str(int(time()))
-        track.metadata.checksum = checksum_timestamp
-        if track.album:
-            track.album.metadata.checksum = checksum_timestamp
-            for artist in track.album.artists:
-                artist.metadata.checksum = checksum_timestamp
-
-        track.add_provider_mapping(
-            ProviderMapping(
-                item_id=file_item.path,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                content_type=ContentType.try_parse(tags.format),
-                sample_rate=tags.sample_rate,
-                bit_depth=tags.bits_per_sample,
-                bit_rate=tags.bit_rate,
-            )
-        )
-        return track
+        return await self._parse_track(file_item)
 
     async def get_playlist(self, prov_playlist_id: str) -> Playlist:
         """Get full playlist details by id."""
@@ -695,6 +556,159 @@ class FileSystemProviderBase(MusicProvider):
         async for chunk in self.read_file_content(streamdetails.item_id, seek_bytes):
             yield chunk
 
+    async def _parse_track(self, file_item: FileSystemItem) -> Track:
+        """Get full track details by id."""
+        # ruff: noqa: PLR0915, PLR0912
+
+        # m4a files are nasty because in 99% of the cases the metadata is
+        # at the end of the file (moov atom) so in order to read tags
+        # we need to read the entire file, which is not practically do-able with
+        # remote connections, so we ignore those files
+        large_m4a_file = (
+            file_item.ext in ("m4a", "m4b")
+            and not file_item.local_path
+            and file_item.file_size > 100000000
+        )
+        if large_m4a_file:
+            self.logger.warning(
+                "Large m4a file detected which is unsuitable for remote storage: %s"
+                " - consider converting this file to another file format or make sure "
+                "that `moov atom` metadata is at the beginning of the file. - "
+                "loading info for this file is going to take a long time!",
+                file_item.path,
+            )
+
+        # 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)
+        if large_m4a_file:
+            tags.has_cover_image = False
+
+        name, version = parse_title_and_version(tags.title, tags.version)
+        track = Track(
+            item_id=file_item.path,
+            provider=self.domain,
+            name=name,
+            version=version,
+        )
+
+        # album
+        if tags.album:
+            # work out if we have an album folder
+            album_dir = get_parentdir(file_item.path, tags.album)
+
+            # album artist(s)
+            if tags.album_artists:
+                album_artists = []
+                for index, album_artist_str in enumerate(tags.album_artists):
+                    # 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:
+                        with contextlib.suppress(IndexError):
+                            artist.musicbrainz_id = tags.musicbrainz_albumartistids[index]
+                    album_artists.append(artist)
+            else:
+                # album artist tag is missing, determine fallback
+                fallback_action = self.config.get_value(CONF_MISSING_ALBUM_ARTIST_ACTION)
+                if fallback_action == "various_artists":
+                    self.logger.warning(
+                        "%s is missing ID3 tag [albumartist], using %s as fallback",
+                        file_item.path,
+                        VARIOUS_ARTISTS,
+                    )
+                    album_artists = [await self._parse_artist(name=VARIOUS_ARTISTS)]
+                elif fallback_action == "track_artist":
+                    self.logger.warning(
+                        "%s is missing ID3 tag [albumartist], using track artist(s) as fallback",
+                        file_item.path,
+                    )
+                    album_artists = [
+                        await self._parse_artist(name=track_artist_str)
+                        for track_artist_str in tags.artists
+                    ]
+                else:
+                    # default action is to skip the track
+                    raise InvalidDataError("missing ID3 tag [albumartist]")
+
+            track.album = await self._parse_album(
+                tags.album,
+                album_dir,
+                artists=album_artists,
+            )
+        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)
+            ):
+                track.artists.append(artist)
+            else:
+                artist = await self._parse_artist(track_artist_str)
+            if not artist.musicbrainz_id:
+                with contextlib.suppress(IndexError):
+                    artist.musicbrainz_id = tags.musicbrainz_artistids[index]
+            track.artists.append(artist)
+
+        # cover image - prefer album image, fallback to embedded
+        if track.album and track.album.image:
+            track.metadata.images = [track.album.image]
+        elif tags.has_cover_image:
+            # we do not actually embed the image in the metadata because that would consume too
+            # much space and bandwidth. Instead we set the filename as value so the image can
+            # be retrieved later in realtime.
+            track.metadata.images = [
+                MediaItemImage(ImageType.THUMB, file_item.path, self.instance_id)
+            ]
+            if track.album:
+                # set embedded cover on album
+                track.album.metadata.images = track.metadata.images
+
+        # parse other info
+        track.duration = tags.duration or 0
+        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.metadata.chapters = tags.chapters
+        if track.album:
+            if not track.album.musicbrainz_id:
+                track.album.musicbrainz_id = 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
+        track.metadata.checksum = file_item.checksum
+        if track.album:
+            # use track checksum for album(artists) too
+            track.album.metadata.checksum = track.metadata.checksum
+            for artist in track.album.artists:
+                artist.metadata.checksum = track.metadata.checksum
+
+        track.add_provider_mapping(
+            ProviderMapping(
+                item_id=file_item.path,
+                provider_domain=self.domain,
+                provider_instance=self.instance_id,
+                content_type=ContentType.try_parse(tags.format),
+                sample_rate=tags.sample_rate,
+                bit_depth=tags.bits_per_sample,
+                bit_rate=tags.bit_rate,
+            )
+        )
+        return track
+
     async def _parse_artist(
         self,
         name: str | None = None,
@@ -812,8 +826,12 @@ class FileSystemProviderBase(MusicProvider):
                 if item.ext != ext:
                     continue
                 try:
-                    images.append(MediaItemImage(ImageType(item.name), item.path, True))
+                    images.append(MediaItemImage(ImageType(item.name), item.path, self.instance_id))
                 except ValueError:
-                    if "folder" in item.name or "AlbumArt" in item.name or "Artist" in item.name:
-                        images.append(MediaItemImage(ImageType.THUMB, item.path, True))
+                    for filename in ("folder", "cover", "albumart", "artist"):
+                        if item.name.lower().startswith(filename):
+                            images.append(
+                                MediaItemImage(ImageType.THUMB, item.path, self.instance_id)
+                            )
+                            break
         return images
index 3559933a37bee85abad114ac7bb1517e1cf040bc..9ad90a506024c2be414de50affc861b0270faaba 100644 (file)
@@ -6,10 +6,12 @@ import os
 from music_assistant.server.helpers.compare import compare_strings
 
 
-def get_parentdir(base_path: str, name: str) -> str | None:
+def get_parentdir(base_path: str, name: str, skip: int = 0) -> str | None:
     """Look for folder name in path (to find dedicated artist or album folder)."""
+    if not base_path:
+        return None
     parentdir = os.path.dirname(base_path)
-    for _ in range(3):
+    for _ in range(skip, 3):
         dirname = parentdir.rsplit(os.sep)[-1]
         dirname = dirname.split("(")[0].split("[")[0].strip()
         if compare_strings(name, dirname, False):
index 4a57bdf92836f50a6fd397289aca552b6e7d33e5..631df43062d2f15cfdf4b04870235e73ad775dcc 100644 (file)
@@ -74,18 +74,17 @@ class MusicbrainzProvider(MetadataProvider):
                     artistname=artist.name, album_mbid=ref_album.musicbrainz_id
                 ):
                     return musicbrainz_id
-            # try matching on album upc
-            if ref_album.upc and (
-                musicbrainz_id := await self._search_artist_by_album(
+            # try matching on album barcode
+            for barcode in ref_album.barcode:
+                if musicbrainz_id := await self._search_artist_by_album(
                     artistname=artist.name,
-                    album_upc=ref_album.upc,
-                )
-            ):
-                return musicbrainz_id
+                    album_barcode=barcode,
+                ):
+                    return musicbrainz_id
 
         # try again with matching on track isrc
         for ref_track in ref_tracks:
-            for isrc in ref_track.isrcs:
+            for isrc in ref_track.isrc:
                 if musicbrainz_id := await self._search_artist_by_track(
                     artistname=artist.name,
                     track_isrc=isrc,
@@ -106,17 +105,17 @@ class MusicbrainzProvider(MetadataProvider):
         self,
         artistname: str,
         albumname: str | None = None,
-        album_upc: str | None = None,
+        album_barcode: str | None = None,
     ) -> str | None:
-        """Retrieve musicbrainz artist id by providing the artist name and albumname or upc."""
-        assert albumname or album_upc
+        """Retrieve musicbrainz artist id by providing the artist name and albumname or barcode."""
+        assert albumname or album_barcode
         for searchartist in (
             artistname,
             re.sub(LUCENE_SPECIAL, r"\\\1", create_sort_name(artistname)),
         ):
-            if album_upc:
-                # search by album UPC (barcode)
-                query = f"barcode:{album_upc}"
+            if album_barcode:
+                # search by album barcode (EAN or UPC)
+                query = f"barcode:{album_barcode}"
             elif albumname:
                 # search by name
                 searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname)
@@ -126,7 +125,7 @@ class MusicbrainzProvider(MetadataProvider):
                 for strict in (True, False):
                     for item in result["releases"]:
                         if not (
-                            album_upc
+                            album_barcode
                             or (albumname and compare_strings(item["title"], albumname, strict))
                         ):
                             continue
@@ -171,7 +170,7 @@ class MusicbrainzProvider(MetadataProvider):
         return None
 
     async def _search_artist_by_album_mbid(self, artistname: str, album_mbid: str) -> str | None:
-        """Retrieve musicbrainz artist id by providing the artist name and albumname or upc."""
+        """Retrieve musicbrainz artist id by providing the artist name or album id."""
         result = await self.get_data(f"release-group/{album_mbid}?inc=artist-credits")
         if result and "artist-credit" in result:
             for item in result["artist-credit"]:
index 678b1be56e8f67cfe2381a50b258a66e14cb8871..2e693f3529bc1afa43ae6903a2eea757241f97c0 100644 (file)
@@ -473,11 +473,7 @@ class QobuzProvider(MusicProvider):
             album.metadata.genres = {album_obj["genre"]["name"]}
         if img := self.__get_image(album_obj):
             album.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
-        if len(album_obj["upc"]) == 13:
-            # qobuz writes ean as upc ?!
-            album.upc = album_obj["upc"][1:]
-        else:
-            album.upc = album_obj["upc"]
+        album.barcode.add(album_obj["upc"])
         if "label" in album_obj:
             album.metadata.label = album_obj["label"]["name"]
         if album_obj.get("released_at"):
@@ -530,7 +526,7 @@ class QobuzProvider(MusicProvider):
             if album:
                 track.album = album
         if track_obj.get("isrc"):
-            track.isrc = track_obj["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"):
index a1a22a7c036fc1ead1d00227ebb324d2ac8d12a1..cbf484ad57c499f2482cbb3c10ba10d9e6b90545 100644 (file)
@@ -13,6 +13,9 @@ BASE_URL = "https://api-v2.soundcloud.com"
 if TYPE_CHECKING:
     from aiohttp.client import ClientSession
 
+# TODO: Fix docstring
+# TODO: Add annotations
+
 
 class SoundcloudAsyncAPI:
     """Soundcloud."""
@@ -31,9 +34,8 @@ class SoundcloudAsyncAPI:
 
     async def get(self, url, headers=None, params=None):
         """Async get."""
-        async with self.http_session as session:
-            async with session.get(url=url, params=params, headers=headers) as response:
-                return await response.json()
+        async with self.http_session.get(url=url, params=params, headers=headers) as response:
+            return await response.json()
 
     async def login(self):
         """Login to soundcloud."""
index dddfbf472ce15809c336edfeaec0f3d0bc1399bd..df077639b1d4a58be29e0a1b4961315a56246c48 100644 (file)
@@ -421,7 +421,9 @@ class SpotifyProvider(MusicProvider):
         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.upc = album_obj["external_ids"]["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"):
@@ -466,7 +468,7 @@ class SpotifyProvider(MusicProvider):
         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 = track_obj["external_ids"]["isrc"]
+            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"):
index 9b9b307ca953d747775593e1f6779285ca665bb3..407e6478cb8b45be2e8604eb8066cef4770ede4b 100644 (file)
@@ -508,6 +508,8 @@ class YoutubeMusicProvider(MusicProvider):
             album.metadata.images = await self._parse_thumbnails(album_obj["thumbnails"])
         if "description" in album_obj:
             album.metadata.description = unquote(album_obj["description"])
+        if "isExplicit" in album_obj:
+            album.metadata.explicit = album_obj["isExplicit"]
         if "artists" in album_obj:
             album.artists = [
                 await self._parse_artist(artist)
index 9b28442560fbf8f5357c900d08e25398bd7a347d..091981fb73bcfd662e0263a8f1730d4e6a1b241c 100644 (file)
@@ -34,7 +34,7 @@ server = [
   "python-slugify==8.0.1",
   "mashumaro==3.5.0",
   "memory-tempfile==2.2.3",
-  "music-assistant-frontend==20230323.1",
+  "music-assistant-frontend==20230324.0",
   "pillow==9.4.0",
   "unidecode==1.3.6",
   "xmltodict==0.13.0",
index f2b245281312460bbc176c4ad3211f110c3eb5d0..1ee73863877108ef6d41eb6d762f97592facd00d 100644 (file)
@@ -13,7 +13,7 @@ databases==0.7.0
 git+https://github.com/pytube/pytube.git@refs/pull/1501/head
 mashumaro==3.5.0
 memory-tempfile==2.2.3
-music-assistant-frontend==20230323.1
+music-assistant-frontend==20230324.0
 orjson==3.8.7
 pillow==9.4.0
 PyChromecast==13.0.5