Various small bugfixes and improvements (#807)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 1 Aug 2023 20:39:36 +0000 (22:39 +0200)
committerGitHub <noreply@github.com>
Tue, 1 Aug 2023 20:39:36 +0000 (22:39 +0200)
* set player state to idle on buffer underrun

* do not send group max sample rate if group childs empty

* fix typo in sonos

* fix playlist track position attribute

* make models more strict

* fix track image

* better handling of duplicate providers

* Add recently played items listing

26 files changed:
music_assistant/common/models/media_items.py
music_assistant/constants.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/music.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/helpers/audio.py
music_assistant/server/helpers/database.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/sonos/__init__.py
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/tunein/__init__.py
music_assistant/server/providers/ugp/__init__.py
music_assistant/server/providers/url/__init__.py
music_assistant/server/providers/ytmusic/__init__.py

index a1f4ab4b353802427f328c99cdf5a5c4a2913e69..a7887de04e7bf6d8a01cc7f1cc5421bb7819d2d1 100755 (executable)
@@ -1,14 +1,12 @@
 """Models and helpers for media items."""
 from __future__ import annotations
 
-from collections.abc import Mapping
 from dataclasses import dataclass, field, fields
 from time import time
 from typing import Any
 
 from mashumaro import DataClassDictMixin
 
-from music_assistant.common.helpers.json import json_dumps, json_loads
 from music_assistant.common.helpers.uri import create_uri
 from music_assistant.common.helpers.util import create_sort_name, merge_lists
 from music_assistant.common.models.enums import (
@@ -21,8 +19,6 @@ from music_assistant.common.models.enums import (
 
 MetadataTypes = int | bool | str | list[str]
 
-JSON_KEYS = ("artists", "metadata", "provider_mappings")
-
 
 @dataclass
 class AudioFormat(DataClassDictMixin):
@@ -84,11 +80,16 @@ class ProviderMapping(DataClassDictMixin):
 
     def __hash__(self) -> int:
         """Return custom hash."""
-        return hash((self.provider_instance, self.item_id))
+        return hash((self.provider_instance, self.item_id.lower()))
 
     def __eq__(self, other: ProviderMapping) -> bool:
         """Check equality of two items."""
-        return self.provider_instance == other.provider_instance and self.item_id == other.item_id
+        if not other:
+            return False
+        return (
+            self.provider_instance == other.provider_instance
+            and self.item_id.lower() == other.item_id.lower()
+        )
 
 
 @dataclass(frozen=True)
@@ -198,16 +199,19 @@ class MediaItemMetadata(DataClassDictMixin):
         return self
 
 
-@dataclass
+@dataclass(kw_only=True)
 class MediaItem(DataClassDictMixin):
     """Base representation of a media item."""
 
+    media_type: MediaType
     item_id: str
     provider: str  # provider instance id or provider domain
     name: str
-    provider_mappings: set[ProviderMapping] = field(default_factory=set)
+    metadata: MediaItemMetadata
+    provider_mappings: set[ProviderMapping]
 
     # optional fields below
+    # provider_mappings: set[ProviderMapping] = field(default_factory=set)
     metadata: MediaItemMetadata = field(default_factory=MediaItemMetadata)
     favorite: bool = False
     media_type: MediaType = MediaType.UNKNOWN
@@ -225,44 +229,6 @@ class MediaItem(DataClassDictMixin):
         if not self.sort_name:
             self.sort_name = create_sort_name(self.name)
 
-    @classmethod
-    def from_db_row(cls, db_row: Mapping):
-        """Create MediaItem object from database row."""
-        db_row = dict(db_row)
-        db_row["provider"] = "library"
-        for key in JSON_KEYS:
-            if key in db_row and db_row[key] is not None:
-                db_row[key] = json_loads(db_row[key])
-        if "favorite" in db_row:
-            db_row["favorite"] = bool(db_row["favorite"])
-        db_row["item_id"] = str(db_row["item_id"])
-        return cls.from_dict(db_row)
-
-    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)
-            return value
-
-        return {
-            key: get_db_value(key, value)
-            for key, value in self.to_dict().items()
-            if key
-            not in [
-                "item_id",
-                "provider",
-                "media_type",
-                "uri",
-                "album",
-                "position",
-                "track_number",
-                "disc_number",
-            ]
-        }
-
     @property
     def available(self):
         """Return (calculated) availability."""
@@ -275,18 +241,6 @@ class MediaItem(DataClassDictMixin):
             return None
         return next((x for x in self.metadata.images if x.type == ImageType.THUMB), None)
 
-    def add_provider_mapping(self, prov_mapping: ProviderMapping) -> None:
-        """Add provider ID, overwrite existing entry."""
-        self.provider_mappings = {
-            x
-            for x in self.provider_mappings
-            if not (
-                x.item_id == prov_mapping.item_id
-                and x.provider_instance == prov_mapping.provider_instance
-            )
-        }
-        self.provider_mappings.add(prov_mapping)
-
     def __hash__(self) -> int:
         """Return custom hash."""
         return hash(self.uri)
@@ -334,7 +288,7 @@ class ItemMapping(DataClassDictMixin):
         return self.uri == other.uri
 
 
-@dataclass
+@dataclass(kw_only=True)
 class Artist(MediaItem):
     """Model for an artist."""
 
@@ -342,7 +296,7 @@ class Artist(MediaItem):
     mbid: str | None = None
 
 
-@dataclass
+@dataclass(kw_only=True)
 class Album(MediaItem):
     """Model for an album."""
 
@@ -354,7 +308,7 @@ class Album(MediaItem):
     mbid: str | None = None  # release group id
 
 
-@dataclass
+@dataclass(kw_only=True)
 class Track(MediaItem):
     """Model for a track."""
 
@@ -369,16 +323,6 @@ class Track(MediaItem):
         """Return custom hash."""
         return hash((self.provider, self.item_id))
 
-    @property
-    def image(self) -> MediaItemImage | None:
-        """Return (first/random) image/thumb from metadata (if any)."""
-        if image := super().image:
-            return image
-        # fallback to album image (use getattr to guard for ItemMapping)
-        if self.album:
-            return getattr(self.album, "image", None)
-        return None
-
     @property
     def has_chapters(self) -> bool:
         """
@@ -406,7 +350,7 @@ class PlaylistTrack(Track):
     position: int  # required
 
 
-@dataclass
+@dataclass(kw_only=True)
 class Playlist(MediaItem):
     """Model for a playlist."""
 
@@ -414,30 +358,16 @@ class Playlist(MediaItem):
     owner: str = ""
     is_editable: bool = False
 
-    def __hash__(self):
-        """Return custom hash."""
-        return hash((self.provider, self.item_id))
-
 
-@dataclass
+@dataclass(kw_only=True)
 class Radio(MediaItem):
     """Model for a radio station."""
 
     media_type: MediaType = MediaType.RADIO
     duration: int = 172800
 
-    def to_db_row(self) -> dict:
-        """Create dict from item suitable for db."""
-        val = super().to_db_row()
-        val.pop("duration", None)
-        return val
 
-    def __hash__(self):
-        """Return custom hash."""
-        return hash((self.provider, self.item_id))
-
-
-@dataclass
+@dataclass(kw_only=True)
 class BrowseFolder(MediaItem):
     """Representation of a Folder used in Browse (which contains media items)."""
 
@@ -448,12 +378,21 @@ class BrowseFolder(MediaItem):
     label: str = ""
     # subitems of this folder when expanding
     items: list[MediaItemType | BrowseFolder] | None = None
+    provider_mappings: set[ProviderMapping] = field(default_factory=set)
 
     def __post_init__(self):
         """Call after init."""
         super().__post_init__()
         if not self.path:
             self.path = f"{self.provider}://{self.item_id}"
+        if not self.provider_mappings:
+            self.provider_mappings.add(
+                ProviderMapping(
+                    item_id=self.item_id,
+                    provider_domain=self.provider,
+                    provider_instance=self.provider,
+                )
+            )
 
 
 MediaItemType = Artist | Album | Track | Radio | Playlist | BrowseFolder
index 38c06e89bcbc3fb4fcd8d4a3a635136b931abdb6..c7536ba93d99ed30da5bcb74ae334ee0f7548816 100755 (executable)
@@ -5,7 +5,7 @@ from typing import Final
 
 API_SCHEMA_VERSION: Final[int] = 23
 MIN_SCHEMA_VERSION: Final[int] = 23
-DB_SCHEMA_VERSION: Final[int] = 24
+DB_SCHEMA_VERSION: Final[int] = 25
 
 ROOT_LOGGER_NAME: Final[str] = "music_assistant"
 
index 9f84988d751aea4412e7453204a183dfd9921918..2ed149eff6a9da8a6b3f0b1f177a40181e9b8ffe 100644 (file)
@@ -236,13 +236,13 @@ class AlbumsController(MediaControllerBase[Album]):
         if item.mbid:
             match = {"mbid": item.mbid}
             if db_row := await self.mass.music.database.get_row(self.db_table, match):
-                cur_item = Album.from_db_row(db_row)
+                cur_item = Album.from_dict(self._parse_db_row(db_row))
                 # existing item found: update it
                 return await self.update_item_in_library(cur_item.item_id, item)
         # fallback to search and match
         match = {"sort_name": item.sort_name}
-        for row in await self.mass.music.database.get_rows(self.db_table, match):
-            row_album = Album.from_db_row(row)
+        for db_row in await self.mass.music.database.get_rows(self.db_table, match):
+            row_album = Album.from_dict(self._parse_db_row(db_row))
             if compare_album(row_album, item):
                 cur_item = row_album
                 # existing item found: update it
@@ -254,7 +254,15 @@ class AlbumsController(MediaControllerBase[Album]):
         new_item = await self.mass.music.database.insert(
             self.db_table,
             {
-                **item.to_db_row(),
+                "name": item.name,
+                "sort_name": item.sort_name,
+                "version": item.version,
+                "favorite": item.favorite,
+                "album_type": item.album_type,
+                "year": item.year,
+                "mbid": item.mbid,
+                "metadata": serialize_to_json(item.metadata),
+                "provider_mappings": serialize_to_json(item.provider_mappings),
                 "artists": serialize_to_json(album_artists),
                 "sort_artist": sort_artist,
                 "timestamp_added": int(utc_timestamp()),
@@ -358,16 +366,14 @@ class AlbumsController(MediaControllerBase[Album]):
         db_id = int(item_id)  # ensure integer
         db_album = await self.get_library_item(db_id)
         result: list[AlbumTrack] = []
-        async for album_track_row in self.mass.music.database.iter_items(
-            DB_TABLE_ALBUM_TRACKS, {"album_id": db_id}
-        ):
-            # TODO: make this a nice join query
-            track_id = album_track_row["track_id"]
-            track_row = await self.mass.music.database.get_row(
-                DB_TABLE_TRACKS, {"item_id": track_id}
-            )
-            album_track = AlbumTrack.from_db_row(
-                {**track_row, **album_track_row, "album": db_album.to_dict()}
+        query = (
+            f"SELECT * FROM {DB_TABLE_TRACKS} INNER JOIN albumtracks "
+            "ON albumtracks.track_id = tracks.item_id WHERE albumtracks.album_id = :album_id"
+        )
+        track_rows = await self.mass.music.database.get_rows_from_query(query, {"album_id": db_id})
+        for album_track_row in track_rows:
+            album_track = AlbumTrack.from_dict(
+                self._parse_db_row({**album_track_row, "album": db_album.to_dict()})
             )
             if db_album.metadata.images:
                 album_track.metadata.images = db_album.metadata.images
index 3af399e9efc2b18217be7a8edf7ba9d2ed140727..02ecc8b856bf1afe8773885204b56d10b98e2ca0 100644 (file)
@@ -4,7 +4,7 @@ from __future__ import annotations
 import asyncio
 import contextlib
 from random import choice, random
-from typing import TYPE_CHECKING
+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
@@ -128,18 +128,22 @@ class ArtistsController(MediaControllerBase[Artist]):
         limit: int = 500,
         offset: int = 0,
         order_by: str = "sort_name",
+        extra_query: str | None = None,
+        extra_query_params: dict[str, Any] | None = None,
         album_artists_only: bool = False,
     ) -> PagedItems:
-        """Get in-database album artists."""
+        """Get in-database (album) artists."""
+        if album_artists_only:
+            artist_query = "artists.sort_name in (select albums.sort_artist from albums)"
+            extra_query = f"{extra_query} AND {artist_query}" if extra_query else artist_query
         return await super().library_items(
             favorite=favorite,
             search=search,
             limit=limit,
             offset=offset,
             order_by=order_by,
-            query_parts=["artists.sort_name in (select albums.sort_artist from albums)"]
-            if album_artists_only
-            else None,
+            extra_query=extra_query,
+            extra_query_params=extra_query_params,
         )
 
     async def tracks(
@@ -247,9 +251,12 @@ class ArtistsController(MediaControllerBase[Artist]):
                 provider_instance_id_or_domain,
             ):
                 # TODO: adjust to json query instead of text search?
-                query = f"SELECT * FROM tracks WHERE artists LIKE '%\"{db_artist.item_id}\"%'"
-                query += f" AND provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'"
-                items = await self.mass.music.tracks.get_library_items_by_query(query)
+                query = f"WHERE tracks.artists LIKE '%\"{db_artist.item_id}\"%'"
+                query += (
+                    f" AND tracks.provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'"
+                )
+                if paged_list := await self.mass.music.tracks.library_items(extra_query=query):
+                    items = paged_list.items
         # store (serializable items) in cache
         self.mass.create_task(
             self.mass.cache.set(cache_key, [x.to_dict() for x in items], checksum=cache_checksum)
@@ -262,8 +269,9 @@ class ArtistsController(MediaControllerBase[Artist]):
     ) -> list[Track]:
         """Return all tracks for an artist in the library."""
         # TODO: adjust to json query instead of text search?
-        query = f"SELECT * FROM tracks WHERE artists LIKE '%\"{item_id}\"%'"
-        return await self.mass.music.tracks.get_library_items_by_query(query)
+        query = f"WHERE tracks.artists LIKE '%\"{item_id}\"%'"
+        paged_list = await self.mass.music.tracks.library_items(extra_query=query)
+        return paged_list.items
 
     async def get_provider_artist_albums(
         self,
@@ -292,9 +300,12 @@ class ArtistsController(MediaControllerBase[Artist]):
                 provider_instance_id_or_domain,
             ):
                 # TODO: adjust to json query instead of text search?
-                query = f"SELECT * FROM albums WHERE artists LIKE '%\"{db_artist.item_id}\"%'"
-                query += f" AND provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'"
-                items = await self.mass.music.albums.get_library_items_by_query(query)
+                query = f"WHERE albums.artists LIKE '%\"{db_artist.item_id}\"%'"
+                query += (
+                    f" AND albums.provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'"
+                )
+                paged_list = await self.mass.music.albums.library_items(extra_query=query)
+                items = paged_list.items
             else:
                 # edge case
                 items = []
@@ -310,8 +321,9 @@ class ArtistsController(MediaControllerBase[Artist]):
     ) -> list[Album]:
         """Return all in-library albums for an artist."""
         # TODO: adjust to json query instead of text search?
-        query = f"SELECT * FROM albums WHERE artists LIKE '%\"{item_id}\"%'"
-        return await self.mass.music.albums.get_library_items_by_query(query)
+        query = f"WHERE albums.artists LIKE '%\"{item_id}\"%'"
+        paged_list = await self.mass.music.albums.library_items(extra_query=query)
+        return paged_list.items
 
     async def _add_library_item(self, item: Artist | ItemMapping) -> Artist:
         """Add a new item record to the database."""
@@ -335,15 +347,15 @@ class ArtistsController(MediaControllerBase[Artist]):
             match = {"mbid": mbid}
             if db_row := await self.mass.music.database.get_row(self.db_table, match):
                 # existing item found: update it
-                cur_item = Artist.from_db_row(db_row)
+                cur_item = Artist.from_dict(self._parse_db_row(db_row))
                 return await self.update_item_in_library(cur_item.item_id, item)
         # fallback to exact name match
         # NOTE: we match an artist by name which could theoretically lead to collisions
         # but the chance is so small it is not worth the additional overhead of grabbing
         # the musicbrainz id upfront
         match = {"sort_name": item.sort_name}
-        for row in await self.mass.music.database.get_rows(self.db_table, match):
-            row_artist = Artist.from_db_row(row)
+        for db_row in await self.mass.music.database.get_rows(self.db_table, match):
+            row_artist = Artist.from_dict(self._parse_db_row(db_row))
             if row_artist.sort_name == item.sort_name:
                 cur_item = row_artist
                 # existing item found: update it
@@ -356,7 +368,19 @@ class ArtistsController(MediaControllerBase[Artist]):
         # try to construct (a half baken) Artist object from it
         if isinstance(item, ItemMapping):
             item = Artist.from_dict(item.to_dict())
-        new_item = await self.mass.music.database.insert(self.db_table, item.to_db_row())
+        new_item = await self.mass.music.database.insert(
+            self.db_table,
+            {
+                "name": item.name,
+                "sort_name": item.sort_name,
+                "favorite": item.favorite,
+                "mbid": item.mbid,
+                "metadata": serialize_to_json(item.metadata),
+                "provider_mappings": serialize_to_json(item.provider_mappings),
+                "timestamp_added": int(utc_timestamp()),
+                "timestamp_modified": int(utc_timestamp()),
+            },
+        )
         db_id = new_item["item_id"]
         # update/set provider_mappings table
         await self._set_provider_mappings(db_id, item.provider_mappings)
index fc5d09bd15c4b6526a8cede2566c571245000082..9b8189435440c8f2b663b87bbda40e5314b256bc 100644 (file)
@@ -3,12 +3,12 @@ from __future__ import annotations
 
 import logging
 from abc import ABCMeta, abstractmethod
-from collections.abc import AsyncGenerator, Iterable
+from collections.abc import AsyncGenerator, Iterable, Mapping
 from contextlib import suppress
 from time import time
-from typing import TYPE_CHECKING, Generic, TypeVar
+from typing import TYPE_CHECKING, Any, Generic, TypeVar
 
-from music_assistant.common.helpers.json import serialize_to_json
+from music_assistant.common.helpers.json import json_loads, serialize_to_json
 from music_assistant.common.models.enums import EventType, MediaType, ProviderFeature
 from music_assistant.common.models.errors import InvalidDataError, MediaNotFoundError
 from music_assistant.common.models.media_items import (
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
 ItemCls = TypeVar("ItemCls", bound="MediaItemType")
 
 REFRESH_INTERVAL = 60 * 60 * 24 * 30
+JSON_KEYS = ("artists", "metadata", "provider_mappings")
 
 
 class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
@@ -41,6 +42,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
     def __init__(self, mass: MusicAssistant):
         """Initialize class."""
         self.mass = mass
+        self.base_query = f"SELECT * FROM {self.db_table}"
         self.logger = logging.getLogger(f"{ROOT_LOGGER_NAME}.music.{self.media_type.value}")
 
     @abstractmethod
@@ -83,25 +85,37 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         limit: int = 500,
         offset: int = 0,
         order_by: str = "sort_name",
-        query_parts: list[str] | None = None,
+        extra_query: str | None = None,
+        extra_query_params: dict[str, Any] | None = None,
     ) -> PagedItems:
         """Get in-database items."""
-        sql_query = f"SELECT * FROM {self.db_table}"
-        params = {}
-        query_parts = query_parts or []
+        sql_query = self.base_query
+        params = extra_query_params or {}
+        query_parts: list[str] = []
+        if extra_query:
+            # prevent duplicate where statement
+            if extra_query.lower().startswith("where "):
+                extra_query = extra_query[5:]
+            query_parts.append(extra_query)
         if search:
             params["search"] = f"%{search}%"
             if self.media_type in (MediaType.ALBUM, MediaType.TRACK):
-                query_parts.append("(name LIKE :search or artists LIKE :search)")
+                query_parts.append(
+                    f"({self.db_table}.name LIKE :search "
+                    f" OR {self.db_table}.artists LIKE :search)"
+                )
             else:
-                query_parts.append("name LIKE :search")
+                query_parts.append(f"{self.db_table}.name LIKE :search")
         if favorite is not None:
-            query_parts.append("favorite = :favorite")
+            query_parts.append(f"{self.db_table}.favorite = :favorite")
             params["favorite"] = favorite
         if query_parts:
+            # concetenate all where queries
             sql_query += " WHERE " + " AND ".join(query_parts)
         sql_query += f" ORDER BY {order_by}"
-        items = await self.get_library_items_by_query(sql_query, params, limit=limit, offset=offset)
+        items = await self._get_library_items_by_query(
+            sql_query, params, limit=limit, offset=offset
+        )
         count = len(items)
         if 0 < count < limit:
             total = offset + count
@@ -213,7 +227,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         search_query = search_query.replace("/", " ").replace("'", "")
         if provider_instance_id_or_domain == "library":
             return [
-                self.item_cls.from_db_row(db_row)
+                self.item_cls.from_dict(self._parse_db_row(db_row))
                 for db_row in await self.mass.music.database.search(self.db_table, search_query)
             ]
         prov = self.mass.get_provider(provider_instance_id_or_domain)
@@ -268,29 +282,12 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
                     return (prov_mapping.provider_instance, prov_mapping.item_id)
         return (None, None)
 
-    async def get_library_items_by_query(
-        self,
-        custom_query: str | None = None,
-        query_params: dict | None = None,
-        limit: int = 500,
-        offset: int = 0,
-    ) -> list[ItemCls]:
-        """Fetch MediaItem records from database given a custom query."""
-        if custom_query is None:
-            custom_query = f"SELECT * FROM {self.db_table}"
-        return [
-            self.item_cls.from_db_row(db_row)
-            for db_row in await self.mass.music.database.get_rows_from_query(
-                custom_query, query_params, limit=limit, offset=offset
-            )
-        ]
-
     async def get_library_item(self, item_id: int | str) -> ItemCls:
         """Get record by id."""
         db_id = int(item_id)  # ensure integer
         match = {"item_id": db_id}
         if db_row := await self.mass.music.database.get_row(self.db_table, match):
-            return self.item_cls.from_db_row(db_row)
+            return self.item_cls.from_dict(self._parse_db_row(db_row))
         raise MediaNotFoundError(f"{self.media_type.value} not found in library: {db_id}")
 
     async def get_library_item_by_prov_id(
@@ -340,26 +337,37 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
     ) -> list[ItemCls]:
         """Fetch all records from library for given provider."""
         if provider_instance_id_or_domain == "library":
-            return await self.get_library_items_by_query(limit=limit, offset=offset)
+            if provider_item_ids is not None:
+                prov_ids_string = str(tuple(int(x) for x in provider_item_ids))
+                if prov_ids_string.endswith(",)"):
+                    prov_ids_string = prov_ids_string.replace(",)", ")")
+                extra_query = f"item_id in {prov_ids_string}"
+            else:
+                extra_query = None
+            paged_list = await self.library_items(
+                limit=limit, offset=offset, extra_query=extra_query
+            )
+            return paged_list.items
 
         # we use the separate provider_mappings table to perform quick lookups
         # from provider id's to database id's because this is faster
         # (and more compatible) than querying the provider_mappings json column
         subquery = (
-            f"SELECT item_id FROM {DB_TABLE_PROVIDER_MAPPINGS} "
-            "WHERE ( "
+            f"SELECT item_id FROM {DB_TABLE_PROVIDER_MAPPINGS} WHERE "
+            f" media_type = '{self.media_type.value}' AND "
             f"(provider_instance = '{provider_instance_id_or_domain}' "
-            f"OR provider_domain = '{provider_instance_id_or_domain}') "
-            ")"
+            f"OR provider_domain = '{provider_instance_id_or_domain}')"
         )
         if provider_item_ids is not None:
             prov_ids = str(tuple(provider_item_ids))
             if prov_ids.endswith(",)"):
                 prov_ids = prov_ids.replace(",)", ")")
             subquery += f" AND provider_item_id in {prov_ids}"
-        subquery += f" AND media_type = '{self.media_type.value}'"
-        query = f"SELECT * FROM {self.db_table} WHERE item_id in ({subquery})"
-        return await self.get_library_items_by_query(query, limit=limit, offset=offset)
+        # final query is a where query from the subquery
+        # that queries the provider_mappings table
+        query = f"WHERE {self.db_table}.item_id in ({subquery})"
+        paged_list = await self.library_items(limit=limit, offset=offset, extra_query=query)
+        return paged_list.items
 
     async def iter_library_items_by_prov_id(
         self,
@@ -577,6 +585,23 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
     async def _get_dynamic_tracks(self, media_item: ItemCls, limit: int = 25) -> list[Track]:
         """Get dynamic list of tracks for given item, fallback/default implementation."""
 
+    async def _get_library_items_by_query(
+        self,
+        query: str,
+        query_params: dict | None = None,
+        limit: int = 500,
+        offset: int = 0,
+    ) -> list[ItemCls]:
+        """Fetch MediaItem records from database given a custom (WHERE) clause."""
+        if query_params is None:
+            query_params = {}
+        return [
+            self.item_cls.from_dict(self._parse_db_row(db_row))
+            for db_row in await self.mass.music.database.get_rows_from_query(
+                query, query_params, limit=limit, offset=offset
+            )
+        ]
+
     async def _set_provider_mappings(
         self, item_id: str | int, provider_mappings: Iterable[ProviderMapping]
     ) -> None:
@@ -676,3 +701,31 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
             # this can happen for unavailable items
             return artist
         return ItemMapping.from_item(artist)
+
+    @staticmethod
+    def _parse_db_row(db_row: Mapping) -> dict[str, Any]:
+        """Parse raw db Mapping into a dict."""
+        db_row_dict = dict(db_row)
+        db_row_dict["provider"] = "library"
+        for key in JSON_KEYS:
+            if key in db_row_dict and db_row_dict[key] not in (None, ""):
+                db_row_dict[key] = json_loads(db_row_dict[key])
+        if "favorite" in db_row_dict:
+            db_row_dict["favorite"] = bool(db_row_dict["favorite"])
+        if "item_id" in db_row_dict:
+            db_row_dict["item_id"] = str(db_row_dict["item_id"])
+        if "album" not in db_row_dict and (album_id := db_row_dict.get("album_id")):
+            # handle joined result with (limited) album data as ItemMapping
+            db_row_dict["album"] = {
+                "media_type": "album",
+                "item_id": str(album_id),
+                "provider": "library",
+                "name": db_row_dict["album_name"],
+                "version": db_row_dict["album_version"],
+            }
+            db_row_dict["album"] = ItemMapping.from_dict(db_row_dict["album"])
+            if not db_row_dict["metadata"]["images"]:
+                # copy album image in case the track has no image
+                album_metadata = json_loads(db_row_dict["album_metadata"])
+                db_row_dict["metadata"]["images"] = album_metadata["images"]
+        return db_row_dict
index d3b419a87a1a6655f0c4f0f6b3df36d87a837cef..5a202b629a9e8b01fc63a632e8d12a6df1ffbc0d 100644 (file)
@@ -17,7 +17,7 @@ from music_assistant.common.models.errors import (
     ProviderUnavailableError,
     UnsupportedFeaturedException,
 )
-from music_assistant.common.models.media_items import Playlist, Track
+from music_assistant.common.models.media_items import Playlist, PlaylistTrack, Track
 from music_assistant.constants import DB_TABLE_PLAYLISTS
 
 from .base import MediaControllerBase
@@ -119,7 +119,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
 
     async def tracks(
         self, item_id: str, provider_instance_id_or_domain: str, force_refresh: bool = False
-    ) -> AsyncGenerator[Track, None]:
+    ) -> AsyncGenerator[PlaylistTrack, None]:
         """Return playlist tracks for the given provider playlist id."""
         playlist = await self.get(
             item_id, provider_instance_id_or_domain, force_refresh=force_refresh
@@ -260,13 +260,26 @@ class PlaylistController(MediaControllerBase[Playlist]):
         # try name matching
         match = {"name": item.name, "owner": item.owner}
         if db_row := await self.mass.music.database.get_row(self.db_table, match):
-            cur_item = Playlist.from_db_row(db_row)
+            cur_item = Playlist.from_dict(self._parse_db_row(db_row))
             # existing item found: update it
             return await self.update_item_in_library(cur_item.item_id, item)
         # 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())
+        new_item = await self.mass.music.database.insert(
+            self.db_table,
+            {
+                "name": item.name,
+                "sort_name": item.sort_name,
+                "owner": item.owner,
+                "is_editable": item.is_editable,
+                "favorite": item.favorite,
+                "metadata": serialize_to_json(item.metadata),
+                "provider_mappings": serialize_to_json(item.provider_mappings),
+                "timestamp_added": int(utc_timestamp()),
+                "timestamp_modified": int(utc_timestamp()),
+            },
+        )
         db_id = new_item["item_id"]
         # update/set provider_mappings table
         await self._set_provider_mappings(db_id, item.provider_mappings)
@@ -279,7 +292,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
         item_id: str,
         provider_instance_id_or_domain: str,
         cache_checksum: Any = None,
-    ) -> AsyncGenerator[Track, None]:
+    ) -> AsyncGenerator[PlaylistTrack, None]:
         """Return album tracks for the given provider album id."""
         assert provider_instance_id_or_domain != "library"
         provider = self.mass.get_provider(provider_instance_id_or_domain)
@@ -289,7 +302,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
         cache_key = f"{provider.instance_id}.playlist.{item_id}.tracks"
         if cache := await self.mass.cache.get(cache_key, checksum=cache_checksum):
             for track_dict in cache:
-                yield Track.from_dict(track_dict)
+                yield PlaylistTrack.from_dict(track_dict)
             return
         # no items in cache - get listing from provider
         all_items = []
index 79c554c4f0ec2fd0f29009fcf716c56f8bfc0849..af0da213c91c86129c022b5e2aa551fbb95af1a8 100644 (file)
@@ -125,13 +125,24 @@ class RadioController(MediaControllerBase[Radio]):
         # try name matching
         match = {"name": item.name}
         if db_row := await self.mass.music.database.get_row(self.db_table, match):
-            cur_item = Radio.from_db_row(db_row)
+            cur_item = Radio.from_dict(self._parse_db_row(db_row))
             # existing item found: update it
             return await self.update_item_in_library(cur_item.item_id, item)
         # 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())
+        new_item = await self.mass.music.database.insert(
+            self.db_table,
+            {
+                "name": item.name,
+                "sort_name": item.sort_name,
+                "favorite": item.favorite,
+                "metadata": serialize_to_json(item.metadata),
+                "provider_mappings": serialize_to_json(item.provider_mappings),
+                "timestamp_added": int(utc_timestamp()),
+                "timestamp_modified": int(utc_timestamp()),
+            },
+        )
         db_id = new_item["item_id"]
         # update/set provider_mappings table
         await self._set_provider_mappings(db_id, item.provider_mappings)
index 13dda96c9f0869ad9428e952093ed90277a8998f..941b8f75983c9770f390333208c55df44e06b00b 100644 (file)
@@ -6,7 +6,7 @@ import urllib.parse
 
 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.enums import AlbumType, EventType, MediaType, ProviderFeature
 from music_assistant.common.models.errors import (
     InvalidDataError,
     MediaNotFoundError,
@@ -33,6 +33,13 @@ class TracksController(MediaControllerBase[Track]):
     def __init__(self, *args, **kwargs):
         """Initialize class."""
         super().__init__(*args, **kwargs)
+        self.base_query = (
+            "SELECT tracks.*, albums.item_id as album_id, "
+            "albums.name AS album_name, albums.version as album_version, "
+            "albums.metadata as album_metadata FROM tracks "
+            "LEFT JOIN albumtracks on albumtracks.track_id = tracks.item_id  "
+            "LEFT JOIN albums on albums.item_id = albumtracks.album_id"
+        )
         self._db_add_lock = asyncio.Lock()
         # register api handlers
         self.mass.register_api_command("music/tracks/library_items", self.library_items)
@@ -92,9 +99,13 @@ class TracksController(MediaControllerBase[Track]):
         except MediaNotFoundError:
             # edge case where playlist track has invalid albumdetails
             self.logger.warning("Unable to fetch album details %s", track.album.uri)
-        # prefer album image (otherwise it may look weird)
-        if track.album and track.album.image and track.metadata.images:
-            track.metadata.images = [track.album.image] + track.metadata.images
+        # prefer album image if album explicitly given or track has no image on its own
+        if (
+            (album_uri or not track.metadata.images)
+            and isinstance(track.album, Album)
+            and track.album.image
+        ):
+            track.metadata.images = [track.album.image]
         # append full artist details to full track item
         full_artists = []
         for artist in track.artists:
@@ -151,8 +162,13 @@ class TracksController(MediaControllerBase[Track]):
         # grab additional metadata
         if not skip_metadata_lookup:
             await self.mass.metadata.get_track_metadata(item)
-        # fallback track image from album
-        if not item.image and isinstance(item.album, Album) and item.album.image:
+        # fallback track image from album (only if albumtype = single)
+        if (
+            not item.image
+            and isinstance(item.album, Album)
+            and item.album.image
+            and item.album.album_type == AlbumType.SINGLE
+        ):
             item.metadata.images.append(item.album.image)
         # actually add (or update) the item in the library db
         # use the lock to prevent a race condition of the same item being added twice
@@ -376,11 +392,11 @@ class TracksController(MediaControllerBase[Track]):
         if item.mbid:
             match = {"mbid": item.mbid}
             if db_row := await self.mass.music.database.get_row(self.db_table, match):
-                cur_item = Track.from_db_row(db_row)
+                cur_item = Track.from_dict(self._parse_db_row(db_row))
                 return await self.update_item_in_library(cur_item.item_id, item)
         match = {"sort_name": item.sort_name}
-        for row in await self.mass.music.database.get_rows(self.db_table, match):
-            row_track = Track.from_db_row(row)
+        for db_row in await self.mass.music.database.get_rows(self.db_table, match):
+            row_track = Track.from_dict(self._parse_db_row(db_row))
             track_albums = await self.albums(row_track.item_id, row_track.provider)
             if compare_track(row_track, item, strict=True, track_albums=track_albums):
                 cur_item = row_track
@@ -390,7 +406,14 @@ class TracksController(MediaControllerBase[Track]):
         new_item = await self.mass.music.database.insert(
             self.db_table,
             {
-                **item.to_db_row(),
+                "name": item.name,
+                "sort_name": item.sort_name,
+                "version": item.version,
+                "duration": item.duration,
+                "favorite": item.favorite,
+                "mbid": item.mbid,
+                "metadata": serialize_to_json(item.metadata),
+                "provider_mappings": serialize_to_json(item.provider_mappings),
                 "artists": serialize_to_json(track_artists),
                 "sort_artist": sort_artist,
                 "timestamp_added": int(utc_timestamp()),
index eced162dc8b4eae36e310b989df397c5fc504238..d636e13260991b9aeb20ab5a3547614f4405600f 100755 (executable)
@@ -20,7 +20,7 @@ from music_assistant.common.models.enums import (
     ProviderFeature,
     ProviderType,
 )
-from music_assistant.common.models.errors import MusicAssistantError
+from music_assistant.common.models.errors import MediaNotFoundError, MusicAssistantError
 from music_assistant.common.models.media_items import BrowseFolder, MediaItemType, SearchResults
 from music_assistant.common.models.provider import SyncTask
 from music_assistant.constants import (
@@ -281,6 +281,27 @@ class MusicController(CoreController):
         prov = self.mass.get_provider(provider_instance)
         return await prov.browse(path)
 
+    @api_command("music/recently_played_items")
+    async def recently_played(
+        self, limit: int = 10, media_types: list[MediaType] | None = None
+    ) -> list[MediaItemType]:
+        """Return a list of the last played items."""
+        if media_types is None:
+            media_types = [MediaType.TRACK, MediaType.RADIO]
+        media_types_str = "(" + ",".join(f'"{x}"' for x in media_types) + ")"
+        query = (
+            f"SELECT * FROM {DB_TABLE_PLAYLOG} WHERE media_type "
+            f"in {media_types_str} ORDER BY timestamp DESC"
+        )
+        db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit)
+        result: list[MediaItemType] = []
+        for db_row in db_rows:
+            with suppress(MediaNotFoundError):
+                media_type = MediaType(db_row["media_type"])
+                item = await self.get_item(media_type, db_row["item_id"], db_row["provider"])
+                result.append(item)
+        return result
+
     @api_command("music/item_by_uri")
     async def get_item_by_uri(self, uri: str) -> MediaItemType:
         """Fetch MediaItem by uri."""
@@ -474,7 +495,9 @@ class MusicController(CoreController):
             return statistics.fmean(all_items)
         return None
 
-    async def mark_item_played(self, item_id: str, provider_instance_id_or_domain: str):
+    async def mark_item_played(
+        self, media_type: MediaType, item_id: str, provider_instance_id_or_domain: str
+    ):
         """Mark item as played in playlog."""
         timestamp = utc_timestamp()
         await self.database.insert(
@@ -482,6 +505,7 @@ class MusicController(CoreController):
             {
                 "item_id": item_id,
                 "provider": provider_instance_id_or_domain,
+                "media_type": media_type.value,
                 "timestamp": timestamp,
             },
             allow_replace=True,
@@ -684,6 +708,13 @@ class MusicController(CoreController):
                     for item_id in item_ids_to_delete:
                         await self.database.delete(table, {"item_id": item_id})
 
+            if prev_version > 22 and prev_version < 25:
+                # extend playlog table with media_type column
+                await self.database.execute(
+                    f"ALTER TABLE {DB_TABLE_PLAYLOG} "
+                    "ADD COLUMN media_type TEXT NOT NULL DEFAULT 'track'"
+                )
+
             self.logger.info(
                 "Database migration to version %s completed",
                 DB_SCHEMA_VERSION,
@@ -719,8 +750,9 @@ class MusicController(CoreController):
             f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_PLAYLOG}(
                 item_id INTEGER NOT NULL,
                 provider TEXT NOT NULL,
+                media_type TEXT NOT NULL DEFAULT 'track',
                 timestamp INTEGER DEFAULT 0,
-                UNIQUE(item_id, provider));"""
+                UNIQUE(item_id, provider, media_type));"""
         )
         await self.database.execute(
             f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_ALBUMS}(
index d39656e2fd94da542c809c011e102b4880f766a5..71bbb504937e5f4ebadc9685a53b339c158c10a8 100755 (executable)
@@ -202,11 +202,17 @@ class PlayerQueuesController(CoreController):
             elif media_item.media_type == MediaType.PLAYLIST:
                 async for playlist_track in ctrl.tracks(media_item.item_id, media_item.provider):
                     tracks.append(playlist_track)
+                await self.mass.music.mark_item_played(
+                    media_item.media_type, media_item.item_id, media_item.provider
+                )
             elif media_item.media_type in (
                 MediaType.ARTIST,
                 MediaType.ALBUM,
             ):
                 tracks += await ctrl.tracks(media_item.item_id, media_item.provider)
+                await self.mass.music.mark_item_played(
+                    media_item.media_type, media_item.item_id, media_item.provider
+                )
             else:
                 # single track or radio item
                 tracks += [media_item]
index 56d6703088b7bd7d6a3dae4757d66235aa18a4ee..590424a281de30e72bc5f621e796c8db79d22ffb 100644 (file)
@@ -491,9 +491,11 @@ async def get_media_stream(
             raise err
         else:
             LOGGER.debug("finished media stream for: %s", streamdetails.uri)
-            await mass.music.mark_item_played(streamdetails.item_id, streamdetails.provider)
         finally:
             # report playback
+            await mass.music.mark_item_played(
+                streamdetails.media_type, streamdetails.item_id, streamdetails.provider
+            )
             if streamdetails.callback:
                 mass.create_task(streamdetails.callback, streamdetails)
             # send analyze job to background worker
index c143e57d79221f9ac12d53ef22ff55cc344b71e3..f50726751b3c007167686db82570d21b6c8f39cc 100755 (executable)
@@ -78,14 +78,14 @@ class DatabaseConnection:
 
     async def search(self, table: str, search: str, column: str = "name") -> list[Mapping]:
         """Search table by column."""
-        sql_query = f"SELECT * FROM {table} WHERE {column} LIKE :search"
+        sql_query = f"SELECT * FROM {table} WHERE {table}.{column} LIKE :search"
         params = {"search": f"%{search}%"}
         return await self._db.execute_fetchall(sql_query, params)
 
     async def get_row(self, table: str, match: dict[str, Any]) -> Mapping | None:
         """Get single row for given table where column matches keys/values."""
         sql_query = f"SELECT * FROM {table} WHERE "
-        sql_query += " AND ".join(f"{x} = :{x}" for x in match)
+        sql_query += " AND ".join(f"{table}.{x} = :{x}" for x in match)
         async with self._db.execute(sql_query, match) as cursor:
             return await cursor.fetchone()
 
index 14da2528621670c88fbef125fc191d38470df5a3..c5f26cbbe74d2cd2b8e5a22666ab7c02c859cf4c 100644 (file)
@@ -591,11 +591,6 @@ class DLNAPlayerProvider(PlayerProvider):
                 "Player does not support next transport uri feature, "
                 "gapless playback is not possible."
             )
-        else:
-            # log once if we detected that the player supports the next transport uri
-            if dlna_player.supports_next_uri is None:
-                dlna_player.supports_next_uri = True
-                self.logger.debug("Player supports the next transport uri feature.")
 
         self.logger.debug(
             "Enqued next track (%s) to player %s",
@@ -629,7 +624,10 @@ class DLNAPlayerProvider(PlayerProvider):
             self.mass.create_task(self._enqueue_next_track(dlna_player))
         # if player does not support next uri, manual play it
         if (
-            not dlna_player.supports_next_uri
+            (
+                dlna_player.supports_next_uri is False
+                or (dlna_player.supports_next_uri is None and dlna_player.end_of_track_reached)
+            )
             and prev_state == PlayerState.PLAYING
             and current_state == PlayerState.IDLE
             and dlna_player.next_url
@@ -641,3 +639,4 @@ class DLNAPlayerProvider(PlayerProvider):
             await self.cmd_play_url(dlna_player.udn, dlna_player.next_url, dlna_player.next_item)
             dlna_player.end_of_track_reached = False
             dlna_player.next_url = None
+            dlna_player.supports_next_uri = False
index 292f85575d26cfd4ea835f35da38dcd7819b06c1..f9bc6d605832394f398ea737a2123472767d7e64 100644 (file)
@@ -41,12 +41,7 @@ from music_assistant.common.models.media_items import (
     StreamDetails,
     Track,
 )
-from music_assistant.constants import (
-    DB_TABLE_ALBUM_TRACKS,
-    DB_TABLE_TRACKS,
-    VARIOUS_ARTISTS_ID_MBID,
-    VARIOUS_ARTISTS_NAME,
-)
+from music_assistant.constants import VARIOUS_ARTISTS_ID_MBID, VARIOUS_ARTISTS_NAME
 from music_assistant.server.controllers.cache import use_cache
 from music_assistant.server.controllers.music import DB_SCHEMA_VERSION
 from music_assistant.server.helpers.compare import compare_strings
@@ -211,19 +206,35 @@ class FileSystemProviderBase(MusicProvider):
         }
         # ruff: noqa: E501
         if media_types is None or MediaType.TRACK in media_types:
-            query = "SELECT * FROM tracks WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
-            result.tracks = await self.mass.music.tracks.get_library_items_by_query(query, params)
+            query = (
+                "WHERE tracks.name LIKE :name AND tracks.provider_mappings LIKE :provider_instance"
+            )
+            result.tracks = (
+                await self.mass.music.tracks.library_items(
+                    extra_query=query, extra_query_params=params
+                )
+            ).items
         if media_types is None or MediaType.ALBUM in media_types:
-            query = "SELECT * FROM albums WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
-            result.albums = await self.mass.music.albums.get_library_items_by_query(query, params)
+            query = "WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
+            result.albums = (
+                await self.mass.music.albums.library_items(
+                    extra_query=query, extra_query_params=params
+                )
+            ).items
         if media_types is None or MediaType.ARTIST in media_types:
-            query = "SELECT * FROM artists WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
-            result.artists = await self.mass.music.artists.get_library_items_by_query(query, params)
+            query = "WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
+            result.artists = (
+                await self.mass.music.artists.library_items(
+                    extra_query=query, extra_query_params=params
+                )
+            ).items
         if media_types is None or MediaType.PLAYLIST in media_types:
-            query = "SELECT * FROM playlists WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
-            result.playlists = await self.mass.music.playlists.get_library_items_by_query(
-                query, params
-            )
+            query = "WHERE name LIKE :name AND provider_mappings LIKE :provider_instance"
+            result.playlists = (
+                await self.mass.music.playlists.library_items(
+                    extra_query=query, extra_query_params=params
+                )
+            ).items
         return result
 
     async def browse(self, path: str) -> BrowseFolder:
@@ -372,7 +383,11 @@ class FileSystemProviderBase(MusicProvider):
                 if library_item.media_type == MediaType.TRACK:
                     if library_item.album:
                         album_ids.add(library_item.album.item_id)
-                        for artist in library_item.album.artists:
+                        # need to fetch the library album to resolve the itemmapping
+                        db_album = await self.mass.music.albums.get_library_item(
+                            library_item.album.item_id
+                        )
+                        for artist in db_album.artists:
                             artist_ids.add(artist.item_id)
                     for artist in library_item.artists:
                         artist_ids.add(artist.item_id)
@@ -429,16 +444,15 @@ class FileSystemProviderBase(MusicProvider):
             item_id=file_item.path,
             provider=self.instance_id,
             name=file_item.name.replace(f".{file_item.ext}", ""),
+            provider_mappings={
+                ProviderMapping(
+                    item_id=file_item.path,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
         )
         playlist.is_editable = file_item.ext != "pls"  # can only edit m3u playlists
-
-        playlist.add_provider_mapping(
-            ProviderMapping(
-                item_id=file_item.path,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-            )
-        )
         playlist.owner = self.name
         checksum = f"{DB_SCHEMA_VERSION}.{file_item.checksum}"
         playlist.metadata.checksum = checksum
@@ -452,22 +466,12 @@ class FileSystemProviderBase(MusicProvider):
         )
         if db_album is None:
             raise MediaNotFoundError(f"Album not found: {prov_album_id}")
-        result: list[AlbumTrack] = []
-        async for album_track_row in self.mass.music.database.iter_items(
-            DB_TABLE_ALBUM_TRACKS, {"album_id": db_album.item_id}
-        ):
-            track_row = await self.mass.music.database.get_row(
-                DB_TABLE_TRACKS, {"item_id": album_track_row["track_id"]}
-            )
-            if f'"{self.instance_id}"' not in track_row["provider_mappings"]:
-                continue
-            album_track = AlbumTrack.from_db_row(
-                {**track_row, **album_track_row, "album": db_album.to_dict()}
-            )
-            if db_album.metadata.images:
-                album_track.metadata.images = db_album.metadata.images
-            result.append(album_track)
-        return sorted(result, key=lambda x: (x.disc_number, x.track_number))
+        album_tracks = await self.mass.music.albums.tracks(db_album.item_id, db_album.provider)
+        return [
+            track
+            for track in album_tracks
+            if any(x.provider_instance == self.instance_id for x in track.provider_mappings)
+        ]
 
     async def get_playlist_tracks(self, prov_playlist_id: str) -> AsyncGenerator[Track, None]:
         """Get playlist tracks for given playlist id."""
@@ -642,6 +646,20 @@ class FileSystemProviderBase(MusicProvider):
             "provider": self.instance_id,
             "name": name,
             "version": version,
+            "provider_mappings": {
+                ProviderMapping(
+                    item_id=file_item.path,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(
+                        content_type=ContentType.try_parse(tags.format),
+                        sample_rate=tags.sample_rate,
+                        bit_depth=tags.bits_per_sample,
+                        bit_rate=tags.bit_rate,
+                    ),
+                    isrc=tags.isrc,
+                )
+            },
         }
         if playlist_position is not None:
             track = PlaylistTrack(
@@ -722,7 +740,7 @@ class FileSystemProviderBase(MusicProvider):
                     artist.mbid = tags.musicbrainz_artistids[index]
             track.artists.append(artist)
 
-        # cover image - prefer embedded image, fallback to album cover
+        # handle embedded cover image
         if 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
@@ -730,8 +748,6 @@ class FileSystemProviderBase(MusicProvider):
             track.metadata.images = [
                 MediaItemImage(ImageType.THUMB, file_item.path, self.instance_id)
             ]
-        elif track.album and track.album.image:
-            track.metadata.images = [track.album.image]
 
         if track.album and not track.album.metadata.images:
             # set embedded cover on album if it does not have one yet
@@ -764,20 +780,6 @@ class FileSystemProviderBase(MusicProvider):
             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,
-                audio_format=AudioFormat(
-                    content_type=ContentType.try_parse(tags.format),
-                    sample_rate=tags.sample_rate,
-                    bit_depth=tags.bits_per_sample,
-                    bit_rate=tags.bit_rate,
-                ),
-                isrc=tags.isrc,
-            )
-        )
         return track
 
     async def _parse_artist(
index 979123b573eadc45c63f6b0bdff451f5d63d82b3..278efbf1c3363ce5fffeaffda1d66a2b5b5f74c7 100644 (file)
@@ -218,25 +218,29 @@ class PlexProvider(MusicProvider):
         )
 
     async def _get_or_create_artist_by_name(self, artist_name) -> Artist:
-        query = (
-            "SELECT * FROM artists WHERE name = :name AND provider_mappings = :provider_instance"
-        )
+        query = "WHERE name = :name AND provider_mappings = :provider_instance"
         params = {
             "name": f"%{artist_name}%",
             "provider_instance": f"%{self.instance_id}%",
         }
-        db_artists = await self.mass.music.artists.get_library_items_by_query(query, params)
-        if db_artists:
-            return ItemMapping.from_item(db_artists[0])
+        paged_list = await self.mass.music.artists.library_items(
+            extra_query=query, extra_query_params=params
+        )
+        if paged_list and paged_list.items:
+            return ItemMapping.from_item(paged_list.items[0])
 
         artist_id = FAKE_ARTIST_PREFIX + artist_name
-        artist = Artist(item_id=artist_id, name=artist_name, provider=self.domain)
-        artist.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(artist_id),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-            )
+        artist = Artist(
+            item_id=artist_id,
+            name=artist_name,
+            provider=self.domain,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(artist_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
         )
         return artist
 
@@ -302,6 +306,14 @@ class PlexProvider(MusicProvider):
             item_id=album_id,
             provider=self.domain,
             name=plex_album.title,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(album_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=plex_album.getWebURL(),
+                )
+            },
         )
         if plex_album.year:
             album.year = plex_album.year
@@ -313,15 +325,6 @@ class PlexProvider(MusicProvider):
         album.artists.append(
             self._get_item_mapping(MediaType.ARTIST, plex_album.parentKey, plex_album.parentTitle)
         )
-
-        album.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(album_id),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=plex_album.getWebURL(),
-            )
-        )
         return album
 
     async def _parse_artist(self, plex_artist: PlexArtist) -> Artist:
@@ -329,39 +332,45 @@ class PlexProvider(MusicProvider):
         artist_id = plex_artist.key
         if not artist_id:
             raise InvalidDataError("Artist does not have a valid ID")
-        artist = Artist(item_id=artist_id, name=plex_artist.title, provider=self.domain)
+        artist = Artist(
+            item_id=artist_id,
+            name=plex_artist.title,
+            provider=self.domain,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(artist_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=plex_artist.getWebURL(),
+                )
+            },
+        )
         if plex_artist.summary:
             artist.metadata.description = plex_artist.summary
         if thumb := plex_artist.firstAttr("thumb", "parentThumb", "grandparentThumb"):
             artist.metadata.images = [MediaItemImage(ImageType.THUMB, thumb, self.instance_id)]
-        artist.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(artist_id),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=plex_artist.getWebURL(),
-            )
-        )
         return artist
 
     async def _parse_playlist(self, plex_playlist: PlexPlaylist) -> Playlist:
         """Parse a Plex Playlist response to a Playlist object."""
         playlist = Playlist(
-            item_id=plex_playlist.key, provider=self.domain, name=plex_playlist.title
+            item_id=plex_playlist.key,
+            provider=self.domain,
+            name=plex_playlist.title,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=plex_playlist.key,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=plex_playlist.getWebURL(),
+                )
+            },
         )
         if plex_playlist.summary:
             playlist.metadata.description = plex_playlist.summary
         if thumb := plex_playlist.firstAttr("thumb", "parentThumb", "grandparentThumb"):
             playlist.metadata.images = [MediaItemImage(ImageType.THUMB, thumb, self.instance_id)]
         playlist.is_editable = True
-        playlist.add_provider_mapping(
-            ProviderMapping(
-                item_id=plex_playlist.key,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=plex_playlist.getWebURL(),
-            )
-        )
         return playlist
 
     async def _parse_track(
@@ -378,11 +387,31 @@ class PlexProvider(MusicProvider):
             track_class = AlbumTrack
         else:
             track_class = Track
+        if plex_track.media:
+            available = True
+            content = plex_track.media[0].container
+        else:
+            available = False
+            content = None
         track = track_class(
             item_id=plex_track.key,
             provider=self.instance_id,
             name=plex_track.title,
             **extra_init_kwargs or {},
+            provider_mappings={
+                ProviderMapping(
+                    item_id=plex_track.key,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    available=available,
+                    audio_format=AudioFormat(
+                        content_type=ContentType.try_parse(content)
+                        if content
+                        else ContentType.UNKNOWN,
+                    ),
+                    url=plex_track.getWebURL(),
+                )
+            },
         )
 
         if plex_track.originalTitle and plex_track.originalTitle != plex_track.grandparentTitle:
@@ -418,22 +447,6 @@ class PlexProvider(MusicProvider):
         available = False
         content = None
 
-        if plex_track.media:
-            available = True
-            content = plex_track.media[0].container
-
-        track.add_provider_mapping(
-            ProviderMapping(
-                item_id=plex_track.key,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                available=available,
-                audio_format=AudioFormat(
-                    content_type=ContentType.try_parse(content) if content else ContentType.UNKNOWN,
-                ),
-                url=plex_track.getWebURL(),
-            )
-        )
         return track
 
     async def search(
index dae1b2f872fb123649e5f58bb48a771f86f955b7..b77f543a831aaeb006e58c0ff92d7f2b8b82fb4d 100644 (file)
@@ -438,19 +438,21 @@ class QobuzProvider(MusicProvider):
     async def _parse_artist(self, artist_obj: dict):
         """Parse qobuz artist object to generic layout."""
         artist = Artist(
-            item_id=str(artist_obj["id"]), provider=self.domain, name=artist_obj["name"]
+            item_id=str(artist_obj["id"]),
+            provider=self.domain,
+            name=artist_obj["name"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(artist_obj["id"]),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=artist_obj.get("url", f'https://open.qobuz.com/artist/{artist_obj["id"]}'),
+                )
+            },
         )
         if artist.item_id == VARIOUS_ARTISTS_ID:
             artist.mbid = VARIOUS_ARTISTS_ID_MBID
             artist.name = VARIOUS_ARTISTS_NAME
-        artist.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(artist_obj["id"]),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=artist_obj.get("url", f'https://open.qobuz.com/artist/{artist_obj["id"]}'),
-            )
-        )
         if img := self.__get_image(artist_obj):
             artist.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
         if artist_obj.get("biography"):
@@ -468,23 +470,22 @@ class QobuzProvider(MusicProvider):
             provider=self.domain,
             name=name,
             version=version,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(album_obj["id"]),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    available=album_obj["streamable"] and album_obj["displayable"],
+                    barcode=album_obj["upc"],
+                    audio_format=AudioFormat(
+                        content_type=ContentType.FLAC,
+                        sample_rate=album_obj["maximum_sampling_rate"] * 1000,
+                        bit_depth=album_obj["maximum_bit_depth"],
+                    ),
+                    url=album_obj.get("url", f'https://open.qobuz.com/album/{album_obj["id"]}'),
+                )
+            },
         )
-        album.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(album_obj["id"]),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                available=album_obj["streamable"] and album_obj["displayable"],
-                barcode=album_obj["upc"],
-                audio_format=AudioFormat(
-                    content_type=ContentType.FLAC,
-                    sample_rate=album_obj["maximum_sampling_rate"] * 1000,
-                    bit_depth=album_obj["maximum_bit_depth"],
-                ),
-                url=album_obj.get("url", f'https://open.qobuz.com/album/{album_obj["id"]}'),
-            )
-        )
-
         album.artists.append(await self._parse_artist(artist_obj or album_obj["artist"]))
         if (
             album_obj.get("product_type", "") == "single"
@@ -538,6 +539,21 @@ class QobuzProvider(MusicProvider):
             name=name,
             version=version,
             duration=track_obj["duration"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(track_obj["id"]),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    available=track_obj["streamable"] and track_obj["displayable"],
+                    audio_format=AudioFormat(
+                        content_type=ContentType.FLAC,
+                        sample_rate=track_obj["maximum_sampling_rate"] * 1000,
+                        bit_depth=track_obj["maximum_bit_depth"],
+                    ),
+                    url=track_obj.get("url", f'https://open.qobuz.com/track/{track_obj["id"]}'),
+                    isrc=track_obj.get("isrc"),
+                )
+            },
             **extra_init_kwargs,
         )
         if track_obj.get("performer") and "Various " not in track_obj["performer"]:
@@ -578,21 +594,6 @@ class QobuzProvider(MusicProvider):
         if img := self.__get_image(track_obj):
             track.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
 
-        track.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(track_obj["id"]),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                available=track_obj["streamable"] and track_obj["displayable"],
-                audio_format=AudioFormat(
-                    content_type=ContentType.FLAC,
-                    sample_rate=track_obj["maximum_sampling_rate"] * 1000,
-                    bit_depth=track_obj["maximum_bit_depth"],
-                ),
-                url=track_obj.get("url", f'https://open.qobuz.com/track/{track_obj["id"]}'),
-                isrc=track_obj.get("isrc"),
-            )
-        )
         return track
 
     async def _parse_playlist(self, playlist_obj):
@@ -602,16 +603,16 @@ class QobuzProvider(MusicProvider):
             provider=self.domain,
             name=playlist_obj["name"],
             owner=playlist_obj["owner"]["name"],
-        )
-        playlist.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(playlist_obj["id"]),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=playlist_obj.get(
-                    "url", f'https://open.qobuz.com/playlist/{playlist_obj["id"]}'
-                ),
-            )
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(playlist_obj["id"]),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=playlist_obj.get(
+                        "url", f'https://open.qobuz.com/playlist/{playlist_obj["id"]}'
+                    ),
+                )
+            },
         )
         playlist.is_editable = (
             playlist_obj["owner"]["id"] == self._user_auth_info["user"]["id"]
index 20907dfc2e1e4309de5aed302f02e574fd677bc6..3be46a9ef21c0994928b4015177a305630d88655 100644 (file)
@@ -299,13 +299,17 @@ class RadioBrowserProvider(MusicProvider):
 
     async def _parse_radio(self, radio_obj: dict) -> Radio:
         """Parse Radio object from json obj returned from api."""
-        radio = Radio(item_id=radio_obj.uuid, provider=self.domain, name=radio_obj.name)
-        radio.add_provider_mapping(
-            ProviderMapping(
-                item_id=radio_obj.uuid,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-            )
+        radio = Radio(
+            item_id=radio_obj.uuid,
+            provider=self.domain,
+            name=radio_obj.name,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=radio_obj.uuid,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
         )
         radio.metadata.label = radio_obj.tags
         radio.metadata.popularity = radio_obj.votes
index 2c90bd6cae800e4dda144d3241c7a02a6e625159..013589b5490ef47a04fb33d549b49b42275362a2 100644 (file)
@@ -626,11 +626,8 @@ class SlimprotoProvider(PlayerProvider):
         """Process SlimClient Output Underrun Event."""
         player = self.mass.players.get(client.player_id)
         self.logger.error("Player %s ran out of buffer", player.display_name)
-        if player.synced_to:
-            # if player is synced, resync it
-            await self.cmd_sync(player.player_id, player.synced_to)
-        else:
-            await self.cmd_stop(client.player_id)
+        player.state = PlayerState.IDLE
+        self.mass.players.update(client.player_id)
 
     def _handle_client_sync(self, client: SlimClient) -> None:
         """Synchronize audio of a sync client."""
index 200be689e9e259fd78acba8f63e2d5eecabb83a5..8ed7fb2afdcccb3f8061890c5b781501790a8d18 100644 (file)
@@ -325,7 +325,7 @@ class SonosPlayerProvider(PlayerProvider):
             return
         if sonos_player.need_elapsed_time_workaround:
             # no pause allowed when radio/flow mode is active
-            await self.cmd_stop()
+            await self.cmd_stop(player_id)
             return
         await asyncio.to_thread(sonos_player.soco.pause)
 
index efe2e9a4129d2751cebb8389af60715e2810bb02..bbb1c6a1fc03be12e834a42ddc3c379214e3dd02 100644 (file)
@@ -301,20 +301,24 @@ class SoundcloudMusicProvider(MusicProvider):
             artist_id = artist_obj["id"]
         if not artist_id:
             raise InvalidDataError("Artist does not have a valid ID")
-        artist = Artist(item_id=artist_id, name=artist_obj["username"], provider=self.domain)
+        artist = Artist(
+            item_id=artist_id,
+            name=artist_obj["username"],
+            provider=self.domain,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(artist_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=f"https://soundcloud.com/{permalink}",
+                )
+            },
+        )
         if artist_obj.get("description"):
             artist.metadata.description = artist_obj["description"]
         if artist_obj.get("avatar_url"):
             img_url = artist_obj["avatar_url"]
             artist.metadata.images = [MediaItemImage(ImageType.THUMB, img_url)]
-        artist.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(artist_id),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=f"https://soundcloud.com/{permalink}",
-            )
-        )
         return artist
 
     async def _parse_playlist(self, playlist_obj: dict) -> Playlist:
@@ -323,13 +327,13 @@ class SoundcloudMusicProvider(MusicProvider):
             item_id=playlist_obj["id"],
             provider=self.domain,
             name=playlist_obj["title"],
-        )
-        playlist.add_provider_mapping(
-            ProviderMapping(
-                item_id=playlist_obj["id"],
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-            )
+            provider_mappings={
+                ProviderMapping(
+                    item_id=playlist_obj["id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
         )
         playlist.is_editable = False
         if playlist_obj.get("description"):
@@ -356,6 +360,17 @@ class SoundcloudMusicProvider(MusicProvider):
             name=name,
             version=version,
             duration=track_obj["duration"] / 1000,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=track_obj["id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(
+                        content_type=ContentType.MP3,
+                    ),
+                    url=track_obj["permalink_url"],
+                )
+            },
             **{"position": playlist_position} if playlist_position else {},
         )
         user_id = track_obj["user"]["id"]
@@ -372,15 +387,4 @@ class SoundcloudMusicProvider(MusicProvider):
             track.metadata.genres = track_obj["genre"]
         if track_obj.get("tag_list"):
             track.metadata.style = track_obj["tag_list"]
-        track.add_provider_mapping(
-            ProviderMapping(
-                item_id=track_obj["id"],
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                audio_format=AudioFormat(
-                    content_type=ContentType.MP3,
-                ),
-                url=track_obj["permalink_url"],
-            )
-        )
         return track
index cc7a360dff1ee07be33d6cb1911900bbb74997a3..da95d07b361eeb1a2cc15b87f96660001b66efcd 100644 (file)
@@ -403,14 +403,18 @@ class SpotifyProvider(MusicProvider):
 
     async def _parse_artist(self, artist_obj):
         """Parse spotify artist object to generic layout."""
-        artist = Artist(item_id=artist_obj["id"], provider=self.domain, name=artist_obj["name"])
-        artist.add_provider_mapping(
-            ProviderMapping(
-                item_id=artist_obj["id"],
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=artist_obj["external_urls"]["spotify"],
-            )
+        artist = Artist(
+            item_id=artist_obj["id"],
+            provider=self.domain,
+            name=artist_obj["name"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=artist_obj["id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=artist_obj["external_urls"]["spotify"],
+                )
+            },
         )
         if "genres" in artist_obj:
             artist.metadata.genres = set(artist_obj["genres"])
@@ -425,7 +429,27 @@ class SpotifyProvider(MusicProvider):
     async def _parse_album(self, album_obj: dict):
         """Parse spotify album object to generic layout."""
         name, version = parse_title_and_version(album_obj["name"])
-        album = Album(item_id=album_obj["id"], provider=self.domain, name=name, version=version)
+        barcode = None
+        if "external_ids" in album_obj and album_obj["external_ids"].get("upc"):
+            barcode = album_obj["external_ids"]["upc"]
+        if "external_ids" in album_obj and album_obj["external_ids"].get("ean"):
+            barcode = album_obj["external_ids"]["ean"]
+        album = Album(
+            item_id=album_obj["id"],
+            provider=self.domain,
+            name=name,
+            version=version,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=album_obj["id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320),
+                    url=album_obj["external_urls"]["spotify"],
+                    barcode=barcode,
+                )
+            },
+        )
         for artist_obj in album_obj["artists"]:
             album.artists.append(await self._parse_artist(artist_obj))
 
@@ -444,21 +468,6 @@ class SpotifyProvider(MusicProvider):
             album.metadata.copyright = album_obj["copyrights"][0]["text"]
         if album_obj.get("explicit"):
             album.metadata.explicit = album_obj["explicit"]
-        barcode = None
-        if "external_ids" in album_obj and album_obj["external_ids"].get("upc"):
-            barcode = album_obj["external_ids"]["upc"]
-        if "external_ids" in album_obj and album_obj["external_ids"].get("ean"):
-            barcode = album_obj["external_ids"]["ean"]
-        album.add_provider_mapping(
-            ProviderMapping(
-                item_id=album_obj["id"],
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320),
-                url=album_obj["external_urls"]["spotify"],
-                barcode=barcode,
-            )
-        )
         return album
 
     async def _parse_track(
@@ -487,6 +496,20 @@ class SpotifyProvider(MusicProvider):
             name=name,
             version=version,
             duration=track_obj["duration_ms"] / 1000,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=track_obj["id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(
+                        content_type=ContentType.OGG,
+                        bit_rate=320,
+                    ),
+                    isrc=track_obj.get("external_ids", {}).get("isrc"),
+                    url=track_obj["external_urls"]["spotify"],
+                    available=not track_obj["is_local"] and track_obj["is_playable"],
+                )
+            },
             **extra_init_kwargs,
         )
 
@@ -512,20 +535,6 @@ class SpotifyProvider(MusicProvider):
             track.metadata.explicit = True
         if track_obj.get("popularity"):
             track.metadata.popularity = track_obj["popularity"]
-        track.add_provider_mapping(
-            ProviderMapping(
-                item_id=track_obj["id"],
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                audio_format=AudioFormat(
-                    content_type=ContentType.OGG,
-                    bit_rate=320,
-                ),
-                isrc=track_obj.get("external_ids", {}).get("isrc"),
-                url=track_obj["external_urls"]["spotify"],
-                available=not track_obj["is_local"] and track_obj["is_playable"],
-            )
-        )
         return track
 
     async def _parse_playlist(self, playlist_obj):
@@ -535,14 +544,14 @@ class SpotifyProvider(MusicProvider):
             provider=self.domain,
             name=playlist_obj["name"],
             owner=playlist_obj["owner"]["display_name"],
-        )
-        playlist.add_provider_mapping(
-            ProviderMapping(
-                item_id=playlist_obj["id"],
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=playlist_obj["external_urls"]["spotify"],
-            )
+            provider_mappings={
+                ProviderMapping(
+                    item_id=playlist_obj["id"],
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=playlist_obj["external_urls"]["spotify"],
+                )
+            },
         )
         playlist.is_editable = (
             playlist_obj["owner"]["id"] == self._sp_user["id"] or playlist_obj["collaborative"]
index ed98313dfc3bc118888964d7f0ba627a611a6a5b..e024348a8c4640fc94e589af5430193676fbe501 100644 (file)
@@ -479,14 +479,18 @@ class TidalProvider(MusicProvider):
     async def _parse_artist(self, artist_obj: TidalArtist, full_details: bool = False) -> Artist:
         """Parse tidal artist object to generic layout."""
         artist_id = artist_obj.id
-        artist = Artist(item_id=artist_id, provider=self.instance_id, name=artist_obj.name)
-        artist.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(artist_id),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=f"http://www.tidal.com/artist/{artist_id}",
-            )
+        artist = Artist(
+            item_id=artist_id,
+            provider=self.instance_id,
+            name=artist_obj.name,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(artist_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=f"http://www.tidal.com/artist/{artist_id}",
+                )
+            },
         )
         # metadata
         if full_details and artist_obj.name != "Various Artists":
@@ -508,7 +512,24 @@ class TidalProvider(MusicProvider):
         name = album_obj.name
         version = album_obj.version if album_obj.version is not None else None
         album_id = album_obj.id
-        album = Album(item_id=album_id, provider=self.instance_id, name=name, version=version)
+        album = Album(
+            item_id=album_id,
+            provider=self.instance_id,
+            name=name,
+            version=version,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=album_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(
+                        content_type=ContentType.FLAC,
+                    ),
+                    url=f"http://www.tidal.com/album/{album_id}",
+                    available=album_obj.available,
+                )
+            },
+        )
         for artist_obj in album_obj.artists:
             album.artists.append(await self._parse_artist(artist_obj=artist_obj))
         if album_obj.type == "ALBUM":
@@ -522,19 +543,6 @@ class TidalProvider(MusicProvider):
 
         album.upc = album_obj.universal_product_number
         album.year = int(album_obj.year)
-        available = album_obj.available
-        album.add_provider_mapping(
-            ProviderMapping(
-                item_id=album_id,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                audio_format=AudioFormat(
-                    content_type=ContentType.FLAC,
-                ),
-                url=f"http://www.tidal.com/album/{album_id}",
-                available=available,
-            )
-        )
         # metadata
         album.metadata.copyright = album_obj.copyright
         album.metadata.explicit = album_obj.explicit
@@ -576,7 +584,22 @@ class TidalProvider(MusicProvider):
             name=track_obj.name,
             version=version,
             duration=track_obj.duration,
-            **extra_init_kwargs,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=track_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(
+                        content_type=ContentType.FLAC,
+                        sample_rate=44100,
+                        bit_depth=16,
+                    ),
+                    isrc=track_obj.isrc,
+                    url=f"http://www.tidal.com/tracks/{track_id}",
+                    available=track_obj.available,
+                )
+            }
+            ** extra_init_kwargs,
         )
         track.album = self.get_item_mapping(
             media_type=MediaType.ALBUM,
@@ -587,22 +610,6 @@ class TidalProvider(MusicProvider):
         for track_artist in track_obj.artists:
             artist = await self._parse_artist(artist_obj=track_artist)
             track.artists.append(artist)
-        available = track_obj.available
-        track.add_provider_mapping(
-            ProviderMapping(
-                item_id=track_id,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                audio_format=AudioFormat(
-                    content_type=ContentType.FLAC,
-                    sample_rate=44100,
-                    bit_depth=16,
-                ),
-                isrc=track_obj.isrc,
-                url=f"http://www.tidal.com/tracks/{track_id}",
-                available=available,
-            )
-        )
         # metadata
         track.metadata.explicit = track_obj.explicit
         track.metadata.popularity = track_obj.popularity
@@ -627,14 +634,14 @@ class TidalProvider(MusicProvider):
             provider=self.instance_id,
             name=playlist_obj.name,
             owner=creator_name,
-        )
-        playlist.add_provider_mapping(
-            ProviderMapping(
-                item_id=playlist_id,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=f"http://www.tidal.com/playlists/{playlist_id}",
-            )
+            provider_mappings={
+                ProviderMapping(
+                    item_id=playlist_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=f"http://www.tidal.com/playlists/{playlist_id}",
+                )
+            },
         )
         is_editable = bool(creator_id and str(creator_id) == self._tidal_user_id)
         playlist.is_editable = is_editable
index 1b7d0cac41c2555ae64b18f3f33be7202fa92c7c..46379fc0e602e2c8540cbbce4e62832f21cc8aee 100644 (file)
@@ -175,18 +175,22 @@ class TuneInProvider(MusicProvider):
             content_type = ContentType.try_parse(stream["media_type"])
             bit_rate = stream.get("bitrate", 128)  # TODO !
 
-        radio = Radio(item_id=item_id, provider=self.domain, name=name)
-        radio.add_provider_mapping(
-            ProviderMapping(
-                item_id=item_id,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                audio_format=AudioFormat(
-                    content_type=content_type,
-                    bit_rate=bit_rate,
-                ),
-                details=url,
-            )
+        radio = Radio(
+            item_id=item_id,
+            provider=self.domain,
+            name=name,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=item_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    audio_format=AudioFormat(
+                        content_type=content_type,
+                        bit_rate=bit_rate,
+                    ),
+                    details=url,
+                )
+            },
         )
         # preset number is used for sorting (not present at stream time)
         preset_number = details.get("preset_number")
@@ -218,8 +222,8 @@ class TuneInProvider(MusicProvider):
                 media_type=MediaType.RADIO,
                 data=item_id,
             )
-        item_id, media_type = item_id.split("--", 1)
-        stream_info = await self.__get_data("Tune.ashx", id=item_id)
+        stream_item_id, media_type = item_id.split("--", 1)
+        stream_info = await self.__get_data("Tune.ashx", id=stream_item_id)
         for stream in stream_info["body"]:
             if stream["media_type"] != media_type:
                 continue
index fdc5d93361ecb6ee5d3d7f7368c9a90c006cc51a..ca4561c5a28e11ee0afc6c3cc4b840303c8ffff2 100644 (file)
@@ -384,7 +384,8 @@ class UniversalGroupProvider(PlayerProvider):
         all_members = self._get_active_members(
             player_id, only_powered=False, skip_sync_childs=False
         )
-        group_player.max_sample_rate = max(x.max_sample_rate for x in all_members)
+        if all_members:
+            group_player.max_sample_rate = max(x.max_sample_rate for x in all_members)
         group_player.group_childs = list(x.player_id for x in all_members)
         # read the state from the first active group member
         for member in all_members:
index 9c54e091223d476d355f306ead7e61a65cc65357..65bdd12f3a916102286e44acc8f4b571ac196c5c 100644 (file)
@@ -105,12 +105,26 @@ class URLProvider(MusicProvider):
         """Parse plain URL to MediaItem of type Radio or Track."""
         item_id, url, media_info = await self._get_media_info(item_id_or_url, force_refresh)
         is_radio = media_info.get("icy-name") or not media_info.duration
+        provider_mappings = {
+            ProviderMapping(
+                item_id=item_id,
+                provider_domain=self.domain,
+                provider_instance=self.instance_id,
+                audio_format=AudioFormat(
+                    content_type=ContentType.try_parse(media_info.format),
+                    sample_rate=media_info.sample_rate,
+                    bit_depth=media_info.bits_per_sample,
+                    bit_rate=media_info.bit_rate,
+                ),
+            )
+        }
         if is_radio or force_radio:
             # treat as radio
             media_item = Radio(
                 item_id=item_id,
                 provider=self.domain,
                 name=media_info.get("icy-name") or media_info.title,
+                provider_mappings=provider_mappings,
             )
         else:
             media_item = Track(
@@ -119,21 +133,9 @@ class URLProvider(MusicProvider):
                 name=media_info.title,
                 duration=int(media_info.duration or 0),
                 artists=[await self.get_artist(artist) for artist in media_info.artists],
+                provider_mappings=provider_mappings,
             )
 
-        media_item.provider_mappings = {
-            ProviderMapping(
-                item_id=item_id,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                audio_format=AudioFormat(
-                    content_type=ContentType.try_parse(media_info.format),
-                    sample_rate=media_info.sample_rate,
-                    bit_depth=media_info.bits_per_sample,
-                    bit_rate=media_info.bit_rate,
-                ),
-            )
-        }
         if media_info.has_cover_image:
             media_item.metadata.images = [MediaItemImage(ImageType.THUMB, url, True)]
         return media_item
index e5820045319157fb2e7376470eeccc6c7e596900..6544189054cbed46ffa9e471501355955340ab16 100644 (file)
@@ -599,6 +599,13 @@ class YoutubeMusicProvider(MusicProvider):
             item_id=album_id,
             name=name,
             provider=self.domain,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(album_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
         )
         if album_obj.get("year") and album_obj["year"].isdigit():
             album.year = album_obj["year"]
@@ -626,13 +633,6 @@ class YoutubeMusicProvider(MusicProvider):
             else:
                 album_type = AlbumType.UNKNOWN
             album.album_type = album_type
-        album.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(album_id),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-            )
-        )
         return album
 
     async def _parse_artist(self, artist_obj: dict) -> Artist:
@@ -646,19 +646,23 @@ class YoutubeMusicProvider(MusicProvider):
             artist_id = VARIOUS_ARTISTS_YTM_ID
         if not artist_id:
             raise InvalidDataError("Artist does not have a valid ID")
-        artist = Artist(item_id=artist_id, name=artist_obj["name"], provider=self.domain)
+        artist = Artist(
+            item_id=artist_id,
+            name=artist_obj["name"],
+            provider=self.domain,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(artist_id),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    url=f"https://music.youtube.com/channel/{artist_id}",
+                )
+            },
+        )
         if "description" in artist_obj:
             artist.metadata.description = artist_obj["description"]
         if "thumbnails" in artist_obj and artist_obj["thumbnails"]:
             artist.metadata.images = await self._parse_thumbnails(artist_obj["thumbnails"])
-        artist.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(artist_id),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                url=f"https://music.youtube.com/channel/{artist_id}",
-            )
-        )
         return artist
 
     async def _parse_playlist(self, playlist_obj: dict) -> Playlist:
@@ -670,7 +674,18 @@ class YoutubeMusicProvider(MusicProvider):
         if playlist_id in YT_PERSONAL_PLAYLISTS:
             playlist_id = f"{playlist_id}{YT_PLAYLIST_ID_DELIMITER}{self.instance_id}"
             playlist_name = f"{playlist_name} ({self.name})"
-        playlist = Playlist(item_id=playlist_id, provider=self.domain, name=playlist_name)
+        playlist = Playlist(
+            item_id=playlist_id,
+            provider=self.domain,
+            name=playlist_name,
+            provider_mappings={
+                ProviderMapping(
+                    item_id=playlist_id,
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                )
+            },
+        )
         if "description" in playlist_obj:
             playlist.metadata.description = playlist_obj["description"]
         if "thumbnails" in playlist_obj and playlist_obj["thumbnails"]:
@@ -679,13 +694,6 @@ class YoutubeMusicProvider(MusicProvider):
         if playlist_obj.get("privacy") and playlist_obj.get("privacy") == "PRIVATE":
             is_editable = True
         playlist.is_editable = is_editable
-        playlist.add_provider_mapping(
-            ProviderMapping(
-                item_id=playlist_id,
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-            )
-        )
         if authors := playlist_obj.get("author"):
             if isinstance(authors, str):
                 playlist.owner = authors
@@ -719,6 +727,17 @@ class YoutubeMusicProvider(MusicProvider):
             item_id=track_obj["videoId"],
             provider=self.domain,
             name=track_obj["title"],
+            provider_mappings={
+                ProviderMapping(
+                    item_id=str(track_obj["videoId"]),
+                    provider_domain=self.domain,
+                    provider_instance=self.instance_id,
+                    available=track_obj.get("isAvailable", True),
+                    audio_format=AudioFormat(
+                        content_type=ContentType.M4A,
+                    ),
+                )
+            },
             **extra_init_kwargs,
         )
 
@@ -748,20 +767,6 @@ class YoutubeMusicProvider(MusicProvider):
             track.duration = int(track_obj["duration"])
         elif "duration_seconds" in track_obj and str(track_obj["duration_seconds"]).isdigit():
             track.duration = int(track_obj["duration_seconds"])
-        available = True
-        if "isAvailable" in track_obj:
-            available = track_obj["isAvailable"]
-        track.add_provider_mapping(
-            ProviderMapping(
-                item_id=str(track_obj["videoId"]),
-                provider_domain=self.domain,
-                provider_instance=self.instance_id,
-                available=available,
-                audio_format=AudioFormat(
-                    content_type=ContentType.M4A,
-                ),
-            )
-        )
         return track
 
     async def _get_signature_timestamp(self):