Browse feature (#396)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 6 Jul 2022 14:23:29 +0000 (16:23 +0200)
committerGitHub <noreply@github.com>
Wed, 6 Jul 2022 14:23:29 +0000 (16:23 +0200)
* initial browse support

* browse feature base

* adjust functions to retrieve db items

* use server side paging for large lists

17 files changed:
examples/full.py
examples/simple.py
music_assistant/controllers/music/__init__.py
music_assistant/controllers/music/albums.py
music_assistant/controllers/music/artists.py
music_assistant/controllers/music/playlists.py
music_assistant/controllers/music/radio.py
music_assistant/controllers/music/tracks.py
music_assistant/helpers/database.py
music_assistant/helpers/tags.py
music_assistant/models/enums.py
music_assistant/models/media_controller.py
music_assistant/models/media_items.py
music_assistant/models/music_provider.py
music_assistant/music_providers/filesystem.py
music_assistant/music_providers/tunein.py
music_assistant/music_providers/url.py

index 75b4118830eda6eac03cbd2ba93921f8e42f61c3..af14765c9e0654c53afaebbbd107631395b32a19 100644 (file)
@@ -188,7 +188,7 @@ async def main():
         print(f"Got {track_count} tracks ({track_count_lib} in library)")
         radio_count = await mass.music.radio.count(True)
         print(f"Got {radio_count} radio stations in library")
-        playlist_count = await mass.music.playlists.library(True)
+        playlist_count = await mass.music.playlists.db_items(True)
         print(f"Got {len(playlist_count)} playlists in library")
         # register a player
         test_player1 = TestPlayer("test1")
index 0563c548f66ad25cd9017765dc5080ce1a79a82c..6ae0d56fda2fefd27ef3ec4c0a78324f01ecda2a 100644 (file)
@@ -78,9 +78,9 @@ async def main():
     await mass.music.start_sync(schedule=3)
 
     # get some data
-    await mass.music.artists.library()
-    await mass.music.tracks.library()
-    await mass.music.radio.library()
+    await mass.music.artists.db_items()
+    await mass.music.tracks.db_items()
+    await mass.music.radio.db_items()
 
     # run for an hour until someone hits CTRL+C
     await asyncio.sleep(3600)
index c9b2a59154b270435b1b8496c4c65bfc2c73f4ba..092469e6a46a0aaf7a064fbc0ebc4e61a6f353b8 100755 (executable)
@@ -24,7 +24,12 @@ from music_assistant.models.errors import (
     ProviderUnavailableError,
     SetupFailedError,
 )
-from music_assistant.models.media_items import MediaItem, MediaItemType, media_from_dict
+from music_assistant.models.media_items import (
+    BrowseFolder,
+    MediaItem,
+    MediaItemType,
+    media_from_dict,
+)
 from music_assistant.models.music_provider import MusicProvider
 from music_assistant.music_providers.filesystem import FileSystemProvider
 from music_assistant.music_providers.qobuz import QobuzProvider
@@ -187,6 +192,20 @@ class MusicController:
         )
         return items
 
+    async def browse(self, uri: Optional[str] = None) -> List[BrowseFolder]:
+        """Browse Music providers."""
+        # root level; folder per provider
+        if not uri:
+            return [
+                BrowseFolder(prov.id, prov.type, prov.name, uri=f"{prov.id}://")
+                for prov in self.providers
+                if prov.supports_browse
+            ]
+        # provider level
+        provider_id, path = uri.split("://", 1)
+        prov = self.get_provider(provider_id)
+        return await prov.browse(path)
+
     async def get_item_by_uri(
         self, uri: str, force_refresh: bool = False, lazy: bool = True
     ) -> MediaItemType:
index 3c38fc1159d13df4b2dca16981dddbc5d6cfc245..dc420ccad36c1c0ec2e0e46ec8294a1410a3968e 100644 (file)
@@ -9,8 +9,7 @@ from music_assistant.helpers.compare import compare_album, compare_artist
 from music_assistant.helpers.database import TABLE_ALBUMS, TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.helpers.tags import FALLBACK_ARTIST
-from music_assistant.models.enums import EventType, ProviderType
-from music_assistant.models.event import MassEvent
+from music_assistant.models.enums import ProviderType
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
     Album,
@@ -154,20 +153,22 @@ class AlbumsController(MediaControllerBase[Album]):
 
         # insert new item
         album_artists = await self._get_album_artists(item, cur_item)
+        if album_artists:
+            sort_artist = album_artists[0].sort_name
+        else:
+            sort_artist = ""
         new_item = await self.mass.database.insert(
             self.db_table,
             {
                 **item.to_db_row(),
                 "artists": json_serializer(album_artists) or None,
+                "sort_artist": sort_artist,
             },
         )
         item_id = new_item["item_id"]
         self.logger.debug("added %s to database", item.name)
         # return created object
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_ADDED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
 
     async def update_db_item(
@@ -196,12 +197,18 @@ class AlbumsController(MediaControllerBase[Album]):
         else:
             album_type = cur_item.album_type
 
+        if album_artists:
+            sort_artist = album_artists[0].sort_name
+        else:
+            sort_artist = ""
+
         await self.mass.database.update(
             self.db_table,
             {"item_id": item_id},
             {
                 "name": item.name if overwrite else cur_item.name,
                 "sort_name": item.sort_name if overwrite else cur_item.sort_name,
+                "sort_artist": sort_artist,
                 "version": item.version if overwrite else cur_item.version,
                 "year": item.year or cur_item.year,
                 "upc": item.upc or cur_item.upc,
@@ -214,9 +221,6 @@ class AlbumsController(MediaControllerBase[Album]):
         )
         self.logger.debug("updated %s in database: %s", item.name, item_id)
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_UPDATED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
 
     async def delete_db_item(self, item_id: int) -> None:
index 56e11f0aa95d00dd9d2789ca762ecbda2f655587..2a1fb7b5219c05d3bd3c74420c42804008ecdf74 100644 (file)
@@ -2,12 +2,12 @@
 
 import asyncio
 import itertools
+from time import time
 from typing import Any, Dict, List, Optional
 
 from music_assistant.helpers.database import TABLE_ALBUMS, TABLE_ARTISTS, TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
-from music_assistant.models.enums import EventType, ProviderType
-from music_assistant.models.event import MassEvent
+from music_assistant.models.enums import ProviderType
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
     Album,
@@ -201,14 +201,13 @@ class ArtistsController(MediaControllerBase[Artist]):
             )
 
         # insert item
+        if item.in_library and not item.timestamp:
+            item.timestamp = int(time())
         new_item = await self.mass.database.insert(self.db_table, item.to_db_row())
         item_id = new_item["item_id"]
         self.logger.debug("added %s to database", item.name)
         # return created object
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_ADDED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
 
     async def update_db_item(
@@ -239,9 +238,6 @@ class ArtistsController(MediaControllerBase[Artist]):
         )
         self.logger.debug("updated %s in database: %s", item.name, item_id)
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_UPDATED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
 
     async def delete_db_item(self, item_id: int) -> None:
index 00d7808037f9edfb57c98bab3def24ecb5942477..a383561a51425fb9196faa84aa193e7cca6ffba4 100644 (file)
@@ -7,9 +7,8 @@ from typing import List, Optional
 from music_assistant.helpers.database import TABLE_PLAYLISTS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.helpers.uri import create_uri
-from music_assistant.models.enums import EventType, MediaType, ProviderType
+from music_assistant.models.enums import MediaType, ProviderType
 from music_assistant.models.errors import InvalidDataError, MediaNotFoundError
-from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import Playlist, Track
 
@@ -56,8 +55,8 @@ class PlaylistController(MediaControllerBase[Playlist]):
     async def add(self, item: Playlist, overwrite_existing: bool = False) -> Playlist:
         """Add playlist to local db and return the new database item."""
         item.metadata.last_refresh = int(time())
-        await self.mass.metadata.get_playlist_metadata(item, overwrite_existing)
-        return await self.add_db_item(item)
+        await self.mass.metadata.get_playlist_metadata(item)
+        return await self.add_db_item(item, overwrite_existing)
 
     async def add_playlist_tracks(self, db_playlist_id: str, uris: List[str]) -> None:
         """Add multiple tracks to playlist. Creates background tasks to process the action."""
@@ -132,14 +131,6 @@ class PlaylistController(MediaControllerBase[Playlist]):
         # actually add the tracks to the playlist on the provider
         provider = self.mass.music.get_provider(playlist_prov.prov_id)
         await provider.add_playlist_tracks(playlist_prov.item_id, [track_id_to_add])
-        # update local db entry
-        self.mass.signal_event(
-            MassEvent(
-                type=EventType.MEDIA_ITEM_UPDATED,
-                object_id=db_playlist_id,
-                data=playlist,
-            )
-        )
 
     async def remove_playlist_tracks(
         self, db_playlist_id: str, positions: List[int]
@@ -161,13 +152,6 @@ class PlaylistController(MediaControllerBase[Playlist]):
             if track_ids_to_remove:
                 provider = self.mass.music.get_provider(prov.prov_id)
                 await provider.remove_playlist_tracks(prov.item_id, track_ids_to_remove)
-        self.mass.signal_event(
-            MassEvent(
-                type=EventType.MEDIA_ITEM_UPDATED,
-                object_id=db_playlist_id,
-                data=playlist,
-            )
-        )
 
     async def add_db_item(
         self, item: Playlist, overwrite_existing: bool = False
@@ -186,9 +170,6 @@ class PlaylistController(MediaControllerBase[Playlist]):
         self.logger.debug("added %s to database", item.name)
         # return created object
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_ADDED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
 
     async def update_db_item(
@@ -219,8 +200,4 @@ class PlaylistController(MediaControllerBase[Playlist]):
             },
         )
         self.logger.debug("updated %s in database: %s", item.name, item_id)
-        db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_UPDATED, object_id=db_item.uri, data=db_item)
-        )
-        return db_item
+        return await self.get_db_item(item_id)
index 485751866f01db882ab18878120121b0e6f7c0f1..bde89d4b1cb2cd8c391e21c571b12dca3a4c5463 100644 (file)
@@ -5,8 +5,7 @@ from time import time
 
 from music_assistant.helpers.database import TABLE_RADIOS
 from music_assistant.helpers.json import json_serializer
-from music_assistant.models.enums import EventType, MediaType
-from music_assistant.models.event import MassEvent
+from music_assistant.models.enums import MediaType
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import Radio
 
@@ -44,9 +43,6 @@ class RadioController(MediaControllerBase[Radio]):
         self.logger.debug("added %s to database", item.name)
         # return created object
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_ADDED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
 
     async def update_db_item(
@@ -77,7 +73,4 @@ class RadioController(MediaControllerBase[Radio]):
         )
         self.logger.debug("updated %s in database: %s", item.name, item_id)
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_UPDATED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
index f906aaa67c2a1770b00e7d7b796f9dffdd44d5e6..be0ae9b24109fa28099783cd15a112be1c3711cc 100644 (file)
@@ -7,8 +7,7 @@ from typing import List, Optional, Union
 from music_assistant.helpers.compare import compare_artists, compare_track
 from music_assistant.helpers.database import TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
-from music_assistant.models.enums import EventType, MediaType, ProviderType
-from music_assistant.models.event import MassEvent
+from music_assistant.models.enums import MediaType, ProviderType
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
     Album,
@@ -143,21 +142,28 @@ class TracksController(MediaControllerBase[Track]):
         # no existing match found: insert new item
         track_artists = await self._get_track_artists(item)
         track_albums = await self._get_track_albums(item, overwrite=overwrite_existing)
+        if track_artists:
+            sort_artist = track_artists[0].sort_name
+        else:
+            sort_artist = ""
+        if track_albums:
+            sort_album = track_albums[0].sort_name
+        else:
+            sort_album = ""
         new_item = await self.mass.database.insert(
             self.db_table,
             {
                 **item.to_db_row(),
                 "artists": json_serializer(track_artists),
                 "albums": json_serializer(track_albums),
+                "sort_artist": sort_artist,
+                "sort_album": sort_album,
             },
         )
         item_id = new_item["item_id"]
         # return created object
         self.logger.debug("added %s to database: %s", item.name, item_id)
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_ADDED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
 
     async def update_db_item(
@@ -199,9 +205,6 @@ class TracksController(MediaControllerBase[Track]):
         )
         self.logger.debug("updated %s in database: %s", item.name, item_id)
         db_item = await self.get_db_item(item_id)
-        self.mass.signal_event(
-            MassEvent(EventType.MEDIA_ITEM_UPDATED, object_id=db_item.uri, data=db_item)
-        )
         return db_item
 
     async def _get_track_artists(
index fecc62afa1b07f4d6b1af40e2d08cb55f910367b..bddb5978f44e241c94ffdb2c890ae02505b6120d 100755 (executable)
@@ -114,9 +114,8 @@ class Database:
         params = {"search": f"%{search}%"}
         return await self._db.fetch_all(sql_query, params)
 
-    async def get_row(self, table: str, match: Dict[str, Any] = None) -> Mapping | None:
+    async def get_row(self, table: str, match: Dict[str, Any]) -> Mapping | None:
         """Get single row for given table where column matches keys/values."""
-        # async with Db(self.url, timeout=360) as db:
         sql_query = f"SELECT * FROM {table} WHERE "
         sql_query += " AND ".join((f"{x} = :{x}" for x in match))
         return await self._db.fetch_one(sql_query, match)
@@ -236,6 +235,7 @@ class Database:
                     item_id INTEGER PRIMARY KEY AUTOINCREMENT,
                     name TEXT NOT NULL,
                     sort_name TEXT NOT NULL,
+                    sort_artist TEXT,
                     album_type TEXT,
                     year INTEGER,
                     version TEXT,
@@ -265,6 +265,8 @@ class Database:
                     item_id INTEGER PRIMARY KEY AUTOINCREMENT,
                     name TEXT NOT NULL,
                     sort_name TEXT NOT NULL,
+                    sort_artist TEXT,
+                    sort_album TEXT,
                     version TEXT,
                     duration INTEGER,
                     in_library BOOLEAN DEFAULT 0,
index 7ebe7b13161b300da0494f723449bf93e8889b96..72f54ca6abea9ccdbd999e1d408bea524570c773 100644 (file)
@@ -201,7 +201,21 @@ async def parse_tags(file_path: str) -> AudioTags:
 
 async def get_embedded_image(file_path: str) -> bytes | None:
     """Return embedded image data."""
-    args = ("ffmpeg", "-i", file_path, "-map", "0:v", "-c", "copy", "-f", "mjpeg", "-")
+    args = (
+        "ffmpeg",
+        "-hide_banner",
+        "-loglevel",
+        "fatal",
+        "-i",
+        file_path,
+        "-map",
+        "0:v",
+        "-c",
+        "copy",
+        "-f",
+        "mjpeg",
+        "-",
+    )
 
     async with AsyncProcess(
         args, enable_stdin=False, enable_stdout=True, enable_stderr=False
index d9ecee76927263e77a68a6ba368ab708f2a833cf..5312f82d4609cbca077e407bd4aa553806d533e0 100644 (file)
@@ -12,6 +12,7 @@ class MediaType(Enum):
     PLAYLIST = "playlist"
     RADIO = "radio"
     URL = "url"
+    FOLDER = "folder"
     UNKNOWN = "unknown"
 
 
@@ -185,8 +186,6 @@ class EventType(Enum):
     QUEUE_ITEMS_UPDATED = "queue_items_updated"
     QUEUE_TIME_UPDATED = "queue_time_updated"
     SHUTDOWN = "application_shutdown"
-    MEDIA_ITEM_ADDED = "media_item_added"
-    MEDIA_ITEM_UPDATED = "media_item_updated"
     BACKGROUND_JOB_UPDATED = "background_job_updated"
 
 
index bce677650479c8b8097f46b1caab6c93ca95f866..7d5a497975da76ecbb29bc4a695032f60c6dfd74 100644 (file)
@@ -6,9 +6,8 @@ from time import time
 from typing import TYPE_CHECKING, Generic, List, Optional, Tuple, TypeVar
 
 from music_assistant.models.errors import MediaNotFoundError
-from music_assistant.models.event import MassEvent
 
-from .enums import EventType, MediaType, ProviderType
+from .enums import MediaType, ProviderType
 from .media_items import MediaItemType, media_from_dict
 
 if TYPE_CHECKING:
@@ -53,16 +52,38 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         """Update record in the database, merging data."""
         raise NotImplementedError
 
-    async def library(self, limit: int = 500, offset: int = 0) -> List[ItemCls]:
-        """Get all in-library items."""
-        match = {"in_library": True}
-        return await self.get_db_items(match=match, limit=limit, offset=offset)
+    async def db_items(
+        self,
+        in_library: Optional[bool] = None,
+        search: Optional[str] = None,
+        limit: int = 500,
+        offset: int = 0,
+        order_by: str = "sort_name",
+    ) -> List[ItemCls]:
+        """Get in-database items."""
+        sql_query = f"SELECT * FROM {self.db_table}"
+        params = {}
+        query_parts = []
+        if search:
+            params["search"] = f"%{search}%"
+            query_parts.append("name LIKE :search")
+        if in_library is not None:
+            query_parts.append("in_library = :in_library")
+            params["in_library"] = in_library
+        if query_parts:
+            sql_query += " WHERE " + " AND ".join(query_parts)
+        sql_query += f" ORDER BY {order_by}"
+        return await self.get_db_items_by_query(
+            sql_query, params, limit=limit, offset=offset
+        )
 
-    async def count(self, in_library: bool = False) -> int:
+    async def count(self, in_library: Optional[bool] = None) -> int:
         """Return number of in-library items for this MediaType."""
-        return await self.mass.database.get_count(
-            self.db_table, {"in_library": in_library}
-        )
+        if in_library is not None:
+            match = {"in_library": in_library}
+        else:
+            match = None
+        return await self.mass.database.get_count(self.db_table, match)
 
     async def get(
         self,
@@ -193,11 +214,6 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         if not db_item.in_library:
             db_item.in_library = True
             await self.set_db_library(db_item.item_id, True)
-            self.mass.signal_event(
-                MassEvent(
-                    EventType.MEDIA_ITEM_UPDATED, object_id=db_item.uri, data=db_item
-                )
-            )
 
     async def remove_from_library(
         self,
@@ -219,11 +235,6 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         if db_item.in_library:
             db_item.in_library = False
             await self.set_db_library(db_item.item_id, False)
-            self.mass.signal_event(
-                MassEvent(
-                    EventType.MEDIA_ITEM_UPDATED, object_id=db_item.uri, data=db_item
-                )
-            )
 
     async def get_provider_id(self, item: ItemCls) -> Tuple[str, str]:
         """Return provider and item id."""
@@ -238,25 +249,20 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
                 return (prov.prov_id, prov.item_id)
         return None, None
 
-    async def get_db_items(
+    async def get_db_items_by_query(
         self,
-        query: Optional[str] = None,
+        custom_query: Optional[str] = None,
         query_params: Optional[dict] = None,
-        match: Optional[dict] = None,
         limit: int = 500,
         offset: int = 0,
     ) -> List[ItemCls]:
-        """Fetch all records from database."""
-        assert not (query and match), "query and match are mutually exclusive"
-        if query is not None:
-            func = self.mass.database.get_rows_from_query(
-                query, query_params, limit=limit, offset=offset
-            )
-        else:
-            func = self.mass.database.get_rows(
-                self.db_table, match, limit=limit, offset=offset
+        """Fetch MediaItem records from database given a custom query."""
+        return [
+            self.item_cls.from_db_row(db_row)
+            for db_row in await self.mass.database.get_rows_from_query(
+                custom_query, query_params, limit=limit, offset=offset
             )
-        return [self.item_cls.from_db_row(db_row) for db_row in await func]
+        ]
 
     async def get_db_item(self, item_id: int) -> ItemCls:
         """Get record by id."""
@@ -298,7 +304,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         if isinstance(provider, str):
             provider = ProviderType(provider)
         if provider == ProviderType.DATABASE or provider_id == "database":
-            return await self.get_db_items(limit=limit, offset=offset)
+            return await self.get_db_items_by_query(limit=limit, offset=offset)
 
         query = f"SELECT * FROM {self.db_table}, json_each(provider_ids)"
         if provider_id is not None:
@@ -313,13 +319,14 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
                 prov_ids = prov_ids.replace(",)", ")")
             query += f" AND json_extract(json_each.value, '$.item_id') in {prov_ids}"
 
-        return await self.get_db_items(query, limit=limit, offset=offset)
+        return await self.get_db_items_by_query(query, limit=limit, offset=offset)
 
     async def set_db_library(self, item_id: int, in_library: bool) -> None:
         """Set the in-library bool on a database item."""
         match = {"item_id": item_id}
+        timestamp = int(time()) if in_library else 0
         await self.mass.database.update(
-            self.db_table, match, {"in_library": in_library}
+            self.db_table, match, {"in_library": in_library, "timestamp": timestamp}
         )
 
     async def get_provider_item(
index 1155479a5da3ac6bc0cb3c772934b67d0f1621cc..f398c9a8bea944a6da0d56fab97c0b5c46a361f3 100755 (executable)
@@ -351,7 +351,18 @@ class Radio(MediaItem):
         return hash((self.provider, self.item_id))
 
 
-MediaItemType = Union[Artist, Album, Track, Radio, Playlist]
+@dataclass
+class BrowseFolder(MediaItem):
+    """Representation of a Folder used in Browse (which contains media items)."""
+
+    media_type: MediaType = MediaType.FOLDER
+    # label: a labelid that needs to be translated by the frontend
+    label: str = ""
+    # items (max 25) to provide in recommendation listings
+    items: Optional[List[MediaItemType]] = None
+
+
+MediaItemType = Union[Artist, Album, Track, Radio, Playlist, BrowseFolder]
 
 
 def media_from_dict(media_item: dict) -> MediaItemType:
index 613264509239177ae4c9d7ac5bf25aa232be9375..936e24da7c04b1db78cc41122feac418b6480a33 100644 (file)
@@ -9,6 +9,7 @@ from music_assistant.models.enums import MediaType, ProviderType
 from music_assistant.models.media_items import (
     Album,
     Artist,
+    BrowseFolder,
     MediaItemType,
     Playlist,
     Radio,
@@ -26,6 +27,7 @@ class MusicProvider:
     _attr_name: str = None
     _attr_type: ProviderType = None
     _attr_available: bool = True
+    _attr_supports_browse: bool = True
     _attr_supported_mediatypes: List[MediaType] = []
 
     def __init__(self, mass: MusicAssistant, config: MusicProviderConfig) -> None:
@@ -61,6 +63,11 @@ class MusicProvider:
         """Return boolean if this provider is available/initialized."""
         return self._attr_available
 
+    @property
+    def supports_browse(self) -> bool:
+        """Return boolean if this provider supports browsing."""
+        return self._attr_supports_browse
+
     @property
     def supported_mediatypes(self) -> List[MediaType]:
         """Return MediaTypes the provider supports."""
@@ -193,6 +200,88 @@ class MusicProvider:
         if media_type == MediaType.RADIO:
             return await self.get_radio(prov_item_id)
 
+    async def browse(self, path: Optional[str] = None) -> List[MediaItemType]:
+        """
+        Browse this provider's items.
+
+            :param path: The path to browse, (e.g. artists) or None for root level.
+        """
+        # this reference implementation can be overridden with provider specific approach
+        if not path:
+            # return main listing
+            root_items = []
+            if MediaType.ARTIST in self.supported_mediatypes:
+                root_items.append(
+                    BrowseFolder(
+                        item_id="artists",
+                        provider=self.type,
+                        name="",
+                        label="artists",
+                        uri=f"{self.type.value}://artists",
+                    )
+                )
+            if MediaType.ALBUM in self.supported_mediatypes:
+                root_items.append(
+                    BrowseFolder(
+                        item_id="albums",
+                        provider=self.type,
+                        name="",
+                        label="albums",
+                        uri=f"{self.type.value}://albums",
+                    )
+                )
+            if MediaType.TRACK in self.supported_mediatypes:
+                root_items.append(
+                    BrowseFolder(
+                        item_id="tracks",
+                        provider=self.type,
+                        name="",
+                        label="tracks",
+                        uri=f"{self.type.value}://tracks",
+                    )
+                )
+            if MediaType.PLAYLIST in self.supported_mediatypes:
+                root_items.append(
+                    BrowseFolder(
+                        item_id="playlists",
+                        provider=self.type,
+                        name="",
+                        label="playlists",
+                        uri=f"{self.type.value}://playlists",
+                    )
+                )
+            if MediaType.RADIO in self.supported_mediatypes:
+                root_items.append(
+                    BrowseFolder(
+                        item_id="radios",
+                        provider=self.type,
+                        name="",
+                        label="radios",
+                        uri=f"{self.type.value}://radios",
+                    )
+                )
+            return root_items
+        # sublevel
+        if path == "artists":
+            return [x async for x in self.get_library_artists()]
+        if path == "albums":
+            return [x async for x in self.get_library_albums()]
+        if path == "tracks":
+            return [x async for x in self.get_library_tracks()]
+        if path == "radios":
+            return [x async for x in self.get_library_radios()]
+        if path == "playlists":
+            return [x async for x in self.get_library_playlists()]
+
+    @abstractmethod
+    async def recommendations(self) -> List[BrowseFolder]:
+        """
+        Get this provider's recommendations.
+
+            Returns a list of BrowseFolder items with (max 25) mediaitems in the items attribute.
+        """
+        return []
+
     async def sync_library(
         self, media_types: Optional[Tuple[MediaType]] = None
     ) -> None:
@@ -215,7 +304,7 @@ class MusicProvider:
             # Bottomline this means that we don't do a full 2 way sync if multiple
             # providers are attached to the same media item.
             prev_ids = set()
-            for db_item in await controller.library():
+            for db_item in await controller.db_items(True):
                 prov_types = {x.prov_type for x in db_item.provider_ids}
                 if len(prov_types) > 1:
                     continue
index f631ec4ecf9510a7d1836e75eec44fa2a7a10dee..de89255958088a5017f2baf1aaf8edd056a898dc 100644 (file)
@@ -25,6 +25,7 @@ from music_assistant.models.media_items import (
     Album,
     AlbumType,
     Artist,
+    BrowseFolder,
     ContentType,
     ImageType,
     ItemMapping,
@@ -43,6 +44,10 @@ VALID_EXTENSIONS = ("mp3", "m4a", "mp4", "flac", "wav", "ogg", "aiff", "wma", "d
 SCHEMA_VERSION = 17
 LOGGER = logging.getLogger(__name__)
 
+listdir = wrap(os.listdir)
+isdir = wrap(os.path.isdir)
+isfile = wrap(os.path.isfile)
+
 
 async def scantree(path: str) -> AsyncGenerator[os.DirEntry, None]:
     """Recursively yield DirEntry objects for given directory."""
@@ -97,8 +102,6 @@ class FileSystemProvider(MusicProvider):
     async def setup(self) -> bool:
         """Handle async initialization of the provider."""
 
-        isdir = wrap(os.path.exists)
-
         if not await isdir(self.config.path):
             raise MediaNotFoundError(
                 f"Music Directory {self.config.path} does not exist"
@@ -116,22 +119,51 @@ class FileSystemProvider(MusicProvider):
         params = {"name": f"%{search_query}%", "prov_type": f"%{self.type.value}%"}
         if media_types is None or MediaType.TRACK in media_types:
             query = "SELECT * FROM tracks WHERE name LIKE :name AND provider_ids LIKE :prov_type"
-            tracks = await self.mass.music.tracks.get_db_items(query, params)
+            tracks = await self.mass.music.tracks.get_db_items_by_query(query, params)
             result += tracks
         if media_types is None or MediaType.ALBUM in media_types:
             query = "SELECT * FROM albums WHERE name LIKE :name AND provider_ids LIKE :prov_type"
-            albums = await self.mass.music.albums.get_db_items(query, params)
+            albums = await self.mass.music.albums.get_db_items_by_query(query, params)
             result += albums
         if media_types is None or MediaType.ARTIST in media_types:
             query = "SELECT * FROM artists WHERE name LIKE :name AND provider_ids LIKE :prov_type"
-            artists = await self.mass.music.artists.get_db_items(query, params)
+            artists = await self.mass.music.artists.get_db_items_by_query(query, params)
             result += artists
         if media_types is None or MediaType.PLAYLIST in media_types:
             query = "SELECT * FROM playlists WHERE name LIKE :name AND provider_ids LIKE :prov_type"
-            playlists = await self.mass.music.playlists.get_db_items(query, params)
+            playlists = await self.mass.music.playlists.get_db_items_by_query(
+                query, params
+            )
             result += playlists
         return result
 
+    async def browse(self, path: Optional[str] = None) -> List[MediaItemType]:
+        """
+        Browse this provider's items.
+
+            :param path: The path to browse, (e.g. artists) or None for root level.
+        """
+        if not path:
+            path = self.config.path
+        else:
+            path = os.path.join(self.config.path, path)
+        result = []
+        for filename in await listdir(path):
+            full_path: str = os.path.join(path, filename)
+            rel_path = full_path.replace(self.config.path + os.sep, "")
+            if await isdir(full_path):
+                result.append(
+                    BrowseFolder(
+                        item_id=rel_path,
+                        provider=self.type,
+                        name=filename,
+                        uri=f"{self.type.value}://{rel_path}",
+                    )
+                )
+            elif track := await self._parse_track(full_path):
+                result.append(track)
+        return result
+
     async def sync_library(
         self, media_types: Optional[Tuple[MediaType]] = None
     ) -> None:
@@ -277,7 +309,7 @@ class FileSystemProvider(MusicProvider):
         query = f"SELECT * FROM tracks WHERE albums LIKE '%\"{db_album.item_id}\"%'"
         query += f" AND provider_ids LIKE '%\"{self.type.value}\"%'"
         result = []
-        for track in await self.mass.music.tracks.get_db_items(query):
+        for track in await self.mass.music.tracks.get_db_items_by_query(query):
             track.album = db_album
             album_mapping = next(
                 (x for x in track.albums if x.item_id == db_album.item_id), None
@@ -343,7 +375,7 @@ class FileSystemProvider(MusicProvider):
         # 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_ids LIKE '%\"{self.type.value}\"%'"
-        return await self.mass.music.albums.get_db_items(query)
+        return await self.mass.music.albums.get_db_items_by_query(query)
 
     async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
         """Get a list of all tracks as we have no clue about preference."""
@@ -356,7 +388,7 @@ class FileSystemProvider(MusicProvider):
         # 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_ids LIKE '%\"{self.type.value}\"%'"
-        return await self.mass.music.tracks.get_db_items(query)
+        return await self.mass.music.tracks.get_db_items_by_query(query)
 
     async def library_add(self, *args, **kwargs) -> bool:
         """Add item to provider's library. Return true on succes."""
index 58c5805f9e0f978c8307fc7708aeba87dbed7807..f55b08b0d7416638a3abd1784eeca903462bcf64 100644 (file)
@@ -28,6 +28,7 @@ class TuneInProvider(MusicProvider):
 
     _attr_type = ProviderType.TUNEIN
     _attr_name = "Tune-in Radio"
+    _attr_supports_browse: bool = False
     _attr_supported_mediatypes = [MediaType.RADIO]
     _throttler = Throttler(rate_limit=1, period=1)
 
index 1e82483fdfc2d9d8ae11e0971394d3c5ebb4df8a..64bf491ecfd371f968286fac9112ba6ce89b0ff9 100644 (file)
@@ -23,6 +23,7 @@ class URLProvider(MusicProvider):
     _attr_name: str = "URL"
     _attr_type: ProviderType = ProviderType.URL
     _attr_available: bool = True
+    _attr_supports_browse: bool = False
     _attr_supported_mediatypes: List[MediaType] = []
 
     async def setup(self) -> bool: