move music providers into top level folder
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 15 Jun 2022 13:40:43 +0000 (15:40 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 15 Jun 2022 13:40:43 +0000 (15:40 +0200)
31 files changed:
music_assistant/controllers/music/__init__.py
music_assistant/controllers/music/albums.py
music_assistant/controllers/music/artists.py
music_assistant/controllers/music/providers/__init__.py [deleted file]
music_assistant/controllers/music/providers/filesystem.py [deleted file]
music_assistant/controllers/music/providers/librespot/linux/librespot-aarch64 [deleted file]
music_assistant/controllers/music/providers/librespot/linux/librespot-arm [deleted file]
music_assistant/controllers/music/providers/librespot/linux/librespot-armhf [deleted file]
music_assistant/controllers/music/providers/librespot/linux/librespot-armv7 [deleted file]
music_assistant/controllers/music/providers/librespot/linux/librespot-x86_64 [deleted file]
music_assistant/controllers/music/providers/librespot/osx/librespot [deleted file]
music_assistant/controllers/music/providers/librespot/windows/librespot.exe [deleted file]
music_assistant/controllers/music/providers/qobuz.py [deleted file]
music_assistant/controllers/music/providers/spotify.py [deleted file]
music_assistant/controllers/music/providers/tunein.py [deleted file]
music_assistant/controllers/music/providers/url.py [deleted file]
music_assistant/models/music_provider.py [new file with mode: 0644]
music_assistant/models/provider.py [deleted file]
music_assistant/music_providers/__init__.py [new file with mode: 0644]
music_assistant/music_providers/filesystem.py [new file with mode: 0644]
music_assistant/music_providers/librespot/linux/librespot-aarch64 [new file with mode: 0755]
music_assistant/music_providers/librespot/linux/librespot-arm [new file with mode: 0755]
music_assistant/music_providers/librespot/linux/librespot-armhf [new file with mode: 0755]
music_assistant/music_providers/librespot/linux/librespot-armv7 [new file with mode: 0755]
music_assistant/music_providers/librespot/linux/librespot-x86_64 [new file with mode: 0755]
music_assistant/music_providers/librespot/osx/librespot [new file with mode: 0755]
music_assistant/music_providers/librespot/windows/librespot.exe [new file with mode: 0755]
music_assistant/music_providers/qobuz.py [new file with mode: 0644]
music_assistant/music_providers/spotify.py [new file with mode: 0644]
music_assistant/music_providers/tunein.py [new file with mode: 0644]
music_assistant/music_providers/url.py [new file with mode: 0644]

index abd4796136a11a1e2a96b446272731bbe448a573..8f6a4d88245c020a15e32be5833d311108eda2d0 100755 (executable)
@@ -21,14 +21,13 @@ from music_assistant.models.config import MusicProviderConfig
 from music_assistant.models.enums import MediaType, ProviderType
 from music_assistant.models.errors import MusicAssistantError, SetupFailedError
 from music_assistant.models.media_items import MediaItem, MediaItemType
-from music_assistant.models.provider import MusicProvider
-
-from .providers.filesystem import FileSystemProvider
-from .providers.qobuz import QobuzProvider
-from .providers.spotify import SpotifyProvider
-from .providers.tunein import TuneInProvider
-from .providers.url import PROVIDER_CONFIG as URL_CONFIG
-from .providers.url import URLProvider
+from music_assistant.models.music_provider import MusicProvider
+from music_assistant.music_providers.filesystem import FileSystemProvider
+from music_assistant.music_providers.qobuz import QobuzProvider
+from music_assistant.music_providers.spotify import SpotifyProvider
+from music_assistant.music_providers.tunein import TuneInProvider
+from music_assistant.music_providers.url import PROVIDER_CONFIG as URL_CONFIG
+from music_assistant.music_providers.url import URLProvider
 
 if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
index 7b5df9739f51f19239073aabc9bbe5f58530a3b8..043f44459e749c21b381957147416379d21320d2 100644 (file)
@@ -21,7 +21,7 @@ from music_assistant.models.media_items import (
     MediaType,
     Track,
 )
-from music_assistant.models.provider import MusicProvider
+from music_assistant.models.music_provider import MusicProvider
 
 
 class AlbumsController(MediaControllerBase[Album]):
index 56fd477d26d3fb25e07f7b8d6d1151a07e88b323..1476153d3cd9b52a604817db68949ab0f6d6029b 100644 (file)
@@ -19,7 +19,7 @@ from music_assistant.models.media_items import (
     MediaType,
     Track,
 )
-from music_assistant.models.provider import MusicProvider
+from music_assistant.models.music_provider import MusicProvider
 
 
 class ArtistsController(MediaControllerBase[Artist]):
diff --git a/music_assistant/controllers/music/providers/__init__.py b/music_assistant/controllers/music/providers/__init__.py
deleted file mode 100644 (file)
index 01895ef..0000000
+++ /dev/null
@@ -1 +0,0 @@
-"""Package with Music Providers."""
diff --git a/music_assistant/controllers/music/providers/filesystem.py b/music_assistant/controllers/music/providers/filesystem.py
deleted file mode 100644 (file)
index e5ad97d..0000000
+++ /dev/null
@@ -1,861 +0,0 @@
-"""Filesystem musicprovider support for MusicAssistant."""
-from __future__ import annotations
-
-import asyncio
-import os
-import urllib.parse
-from contextlib import asynccontextmanager
-from pathlib import Path
-from typing import AsyncGenerator, List, Optional, Set, Tuple
-
-import aiofiles
-import xmltodict
-from aiofiles.os import wrap
-from aiofiles.threadpool.binary import AsyncFileIO
-from tinytag.tinytag import TinyTag
-
-from music_assistant.helpers.audio import get_file_stream
-from music_assistant.helpers.compare import compare_strings
-from music_assistant.helpers.database import SCHEMA_VERSION
-from music_assistant.helpers.util import (
-    create_clean_string,
-    parse_title_and_version,
-    try_parse_int,
-)
-from music_assistant.models.enums import ProviderType
-from music_assistant.models.errors import MediaNotFoundError, MusicAssistantError
-from music_assistant.models.media_items import (
-    Album,
-    AlbumType,
-    Artist,
-    ContentType,
-    ImageType,
-    ItemMapping,
-    MediaItemImage,
-    MediaItemProviderId,
-    MediaItemType,
-    MediaQuality,
-    MediaType,
-    Playlist,
-    StreamDetails,
-    Track,
-)
-from music_assistant.models.provider import MusicProvider
-
-FALLBACK_ARTIST = "Various Artists"
-SPLITTERS = (";", ",", "Featuring", " Feat. ", " Feat ", "feat.", " & ", " / ")
-CONTENT_TYPE_EXT = {
-    # map of supported file extensions (mapped to ContentType)
-    "mp3": ContentType.MP3,
-    "m4a": ContentType.M4A,
-    "flac": ContentType.FLAC,
-    "wav": ContentType.WAV,
-    "ogg": ContentType.OGG,
-    "wma": ContentType.WMA,
-}
-
-
-async def scantree(path: str) -> AsyncGenerator[os.DirEntry, None]:
-    """Recursively yield DirEntry objects for given directory."""
-
-    def is_dir(entry: os.DirEntry) -> bool:
-        return entry.is_dir(follow_symlinks=False)
-
-    loop = asyncio.get_running_loop()
-    for entry in await loop.run_in_executor(None, os.scandir, path):
-        if await loop.run_in_executor(None, is_dir, entry):
-            async for subitem in scantree(entry.path):
-                yield subitem
-        else:
-            yield entry
-
-
-def split_items(org_str: str) -> Tuple[str]:
-    """Split up a tags string by common splitter."""
-    if isinstance(org_str, list):
-        return org_str
-    if org_str is None:
-        return tuple()
-    for splitter in SPLITTERS:
-        if splitter in org_str:
-            return tuple((x.strip() for x in org_str.split(splitter)))
-    return (org_str,)
-
-
-class FileSystemProvider(MusicProvider):
-    """
-    Implementation of a musicprovider for local files.
-
-    Reads ID3 tags from file and falls back to parsing filename.
-    Optionally reads metadata from nfo files and images in folder structure <artist>/<album>.
-    Supports m3u files only for playlists.
-    Supports having URI's from streaming providers within m3u playlist.
-    """
-
-    _attr_name = "Filesystem"
-    _attr_type = ProviderType.FILESYSTEM_LOCAL
-    _attr_supported_mediatypes = [
-        MediaType.TRACK,
-        MediaType.PLAYLIST,
-        MediaType.ARTIST,
-        MediaType.ALBUM,
-    ]
-
-    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"
-            )
-
-        return True
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> List[MediaItemType]:
-        """Perform search on musicprovider."""
-        result = []
-        # searching the filesystem is slow and unreliable,
-        # instead we make some (slow) freaking queries to the db ;-)
-        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)
-            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)
-            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)
-            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)
-            result += playlists
-        return result
-
-    async def sync_library(
-        self, media_types: Optional[Tuple[MediaType]] = None
-    ) -> None:
-        """Run library sync for this provider."""
-        cache_key = f"{self.id}.checksums"
-        prev_checksums = await self.mass.cache.get(cache_key, SCHEMA_VERSION)
-        if prev_checksums is None:
-            prev_checksums = {}
-        # find all music files in the music directory and all subfolders
-        # we work bottom up, as-in we derive all info from the tracks
-        cur_checksums = {}
-        async with self.mass.database.get_db() as db:
-            async for entry in scantree(self.config.path):
-
-                # mtime is used as file checksum
-                stat = await asyncio.get_running_loop().run_in_executor(
-                    None, entry.stat
-                )
-                checksum = int(stat.st_mtime)
-                cur_checksums[entry.path] = checksum
-                if checksum == prev_checksums.get(entry.path):
-                    continue
-                try:
-                    if track := await self._parse_track(entry.path):
-                        # process album
-                        if track.album:
-                            db_album = await self.mass.music.albums.add_db_item(
-                                track.album, db=db
-                            )
-                            if not db_album.in_library:
-                                await self.mass.music.albums.set_db_library(
-                                    db_album.item_id, True, db=db
-                                )
-                            # process (album)artist
-                            if track.album.artist:
-                                db_artist = await self.mass.music.artists.add_db_item(
-                                    track.album.artist, db=db
-                                )
-                                if not db_artist.in_library:
-                                    await self.mass.music.artists.set_db_library(
-                                        db_artist.item_id, True, db=db
-                                    )
-                        # add/update track to db
-                        db_track = await self.mass.music.tracks.add_db_item(
-                            track, db=db
-                        )
-                        if not db_track.in_library:
-                            await self.mass.music.tracks.set_db_library(
-                                db_track.item_id, True, db=db
-                            )
-                    elif playlist := await self._parse_playlist(entry.path):
-                        # add/update] playlist to db
-                        playlist.metadata.checksum = checksum
-                        await self.mass.music.playlists.add_db_item(playlist, db=db)
-                except Exception:  # pylint: disable=broad-except
-                    # we don't want the whole sync to crash on one file so we catch all exceptions here
-                    self.logger.exception("Error processing %s", entry.path)
-
-        # save checksums for next sync
-        await self.mass.cache.set(cache_key, cur_checksums, SCHEMA_VERSION)
-
-        # work out deletions
-        deleted_files = set(prev_checksums.keys()) - set(cur_checksums.keys())
-        artists: Set[ItemMapping] = set()
-        albums: Set[ItemMapping] = set()
-        # process deleted tracks
-        for file_path in deleted_files:
-            item_id = self._get_item_id(file_path)
-            if db_item := await self.mass.music.tracks.get_db_item_by_prov_id(
-                item_id, self.type
-            ):
-                await self.mass.music.tracks.remove_prov_mapping(
-                    db_item.item_id, self.id
-                )
-                # gather artists(s) attached to this track
-                for artist in db_item.artists:
-                    artists.add(artist.item_id)
-                # gather album and albumartist(s) attached to this track
-                if db_item.album:
-                    albums.add(db_item.album.item_id)
-                    for artist in db_item.album.artists:
-                        artists.add(artist.item_id)
-        # check if albums are deleted
-        for album_id in albums:
-            album = await self.mass.music.albums.get_db_item(album_id)
-            if not album:
-                continue
-            prov_album_id = next(
-                x.item_id for x in album.provider_ids if x.prov_id == self.id
-            )
-            album_tracks = await self.get_album_tracks(prov_album_id)
-            if album_tracks:
-                continue
-            # album has no more tracks attached, delete prov mapping
-            await self.mass.music.albums.remove_prov_mapping(album_id)
-        # check if artists are deleted
-        for artist_id in artists:
-            artist = await self.mass.music.artists.get_db_item(artist_id)
-            prov_artist_id = next(
-                x.item_id for x in artist.provider_ids if x.prov_id == self.id
-            )
-            artist_tracks = await self.get_artist_toptracks(prov_artist_id)
-            if artist_tracks:
-                continue
-            artist_albums = await self.get_artist_albums(prov_artist_id)
-            if artist_albums:
-                continue
-            # artist has no more tracks attached, delete prov mapping
-            await self.mass.music.artists.remove_prov_mapping(artist_id)
-
-    async def get_artist(self, prov_artist_id: str) -> Artist:
-        """Get full artist details by id."""
-        itempath = await self.get_filepath(MediaType.ARTIST, prov_artist_id)
-        if await self.exists(itempath):
-            # if path exists on disk allow parsing full details to allow refresh of metadata
-            return await self._parse_artist(artist_path=itempath)
-        return await self.mass.music.artists.get_db_item_by_prov_id(
-            provider_item_id=prov_artist_id, provider_id=self.id
-        )
-
-    async def get_album(self, prov_album_id: str) -> Album:
-        """Get full album details by id."""
-        db_album = await self.mass.music.albums.get_db_item_by_prov_id(
-            provider_item_id=prov_album_id, provider_id=self.id
-        )
-        if db_album is None:
-            raise MediaNotFoundError(f"Album not found: {prov_album_id}")
-        itempath = await self.get_filepath(MediaType.ALBUM, prov_album_id)
-        if await self.exists(itempath):
-            # if path exists on disk allow parsing full details to allow refresh of metadata
-            return await self._parse_album(None, itempath, db_album.artists)
-        return db_album
-
-    async def get_track(self, prov_track_id: str) -> Track:
-        """Get full track details by id."""
-        itempath = await self.get_filepath(MediaType.TRACK, prov_track_id)
-        return await self._parse_track(itempath)
-
-    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
-        """Get full playlist details by id."""
-        itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id)
-        return await self._parse_playlist(itempath)
-
-    async def get_album_tracks(self, prov_album_id: str) -> List[Track]:
-        """Get album tracks for given album id."""
-        # filesystem items are always stored in db so we can query the database
-        db_album = await self.mass.music.albums.get_db_item_by_prov_id(
-            prov_album_id, provider_id=self.id
-        )
-        if db_album is None:
-            raise MediaNotFoundError(f"Album not found: {prov_album_id}")
-        # TODO: adjust to json query instead of text search
-        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):
-            track.album = db_album
-            album_mapping = next(
-                (x for x in track.albums if x.item_id == db_album.item_id), None
-            )
-            track.disc_number = album_mapping.disc_number
-            track.track_number = album_mapping.track_number
-            result.append(track)
-        return result
-
-    async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
-        """Get playlist tracks for given playlist id."""
-        result = []
-        playlist_path = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id)
-        if not await self.exists(playlist_path):
-            raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}")
-        getmtime = wrap(os.path.getmtime)
-        mtime = await getmtime(playlist_path)
-        checksum = f"{SCHEMA_VERSION}.{int(mtime)}"
-        cache_key = f"playlist_{self.id}_tracks_{prov_playlist_id}"
-        if cache := await self.mass.cache.get(cache_key, checksum):
-            return [Track.from_dict(x) for x in cache]
-        playlist_base_path = Path(playlist_path).parent
-        index = 0
-        try:
-            async with self.open_file(playlist_path, "r") as _file:
-                for line in await _file.readlines():
-                    line = urllib.parse.unquote(line.strip())
-                    if line and not line.startswith("#"):
-                        # TODO: add support for .pls playlist files
-                        if track := await self._parse_playlist_line(
-                            line, playlist_base_path
-                        ):
-                            track.position = index
-                            result.append(track)
-                            index += 1
-        except Exception as err:  # pylint: disable=broad-except
-            self.logger.warning(
-                "Error while parsing playlist %s", playlist_path, exc_info=err
-            )
-        await self.mass.cache.set(cache_key, [x.to_dict() for x in result], checksum)
-        return result
-
-    async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None:
-        """Try to parse a track from a playlist line."""
-        if "://" in line:
-            # track is uri from external provider?
-            try:
-                return await self.mass.music.get_item_by_uri(line)
-            except MusicAssistantError as err:
-                self.logger.warning(
-                    "Could not parse uri %s to track: %s", line, str(err)
-                )
-                return None
-        # try to treat uri as filename
-        if await self.exists(line):
-            return await self._parse_track(line)
-        rel_path = os.path.join(playlist_path, line)
-        if await self.exists(rel_path):
-            return await self._parse_track(rel_path)
-        return None
-
-    async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
-        """Get a list of albums for the given artist."""
-        # filesystem items are always stored in db so we can query the database
-        db_artist = await self.mass.music.artists.get_db_item_by_prov_id(
-            prov_artist_id, provider_id=self.id
-        )
-        if db_artist is None:
-            raise MediaNotFoundError(f"Artist not found: {prov_artist_id}")
-        # 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)
-
-    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."""
-        # filesystem items are always stored in db so we can query the database
-        db_artist = await self.mass.music.artists.get_db_item_by_prov_id(
-            prov_artist_id, provider_id=self.id
-        )
-        if db_artist is None:
-            raise MediaNotFoundError(f"Artist not found: {prov_artist_id}")
-        # 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)
-
-    async def library_add(self, *args, **kwargs) -> bool:
-        """Add item to provider's library. Return true on succes."""
-        # already handled by database
-
-    async def library_remove(self, *args, **kwargs) -> bool:
-        """Remove item from provider's library. Return true on succes."""
-        # already handled by database
-        # TODO: do we want to process/offer deletions here ?
-
-    async def add_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> None:
-        """Add track(s) to playlist."""
-        itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id)
-        if not await self.exists(itempath):
-            raise MediaNotFoundError(f"Playlist path does not exist: {itempath}")
-        async with self.open_file(itempath, "r") as _file:
-            cur_data = await _file.read()
-        async with self.open_file(itempath, "w") as _file:
-            await _file.write(cur_data)
-            for uri in prov_track_ids:
-                await _file.write(f"\n{uri}")
-
-    async def remove_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> None:
-        """Remove track(s) from playlist."""
-        itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id)
-        if not await self.exists(itempath):
-            raise MediaNotFoundError(f"Playlist path does not exist: {itempath}")
-        cur_lines = []
-        async with self.open_file(itempath, "r") as _file:
-            for line in await _file.readlines():
-                line = urllib.parse.unquote(line.strip())
-                if line not in prov_track_ids:
-                    cur_lines.append(line)
-        async with self.open_file(itempath, "w") as _file:
-            for uri in cur_lines:
-                await _file.write(f"{uri}\n")
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
-        """Return the content details for the given track when it will be streamed."""
-        itempath = await self.get_filepath(MediaType.TRACK, item_id)
-        if not await self.exists(itempath):
-            raise MediaNotFoundError(f"Track path does not exist: {itempath}")
-
-        def parse_tag():
-            return TinyTag.get(itempath)
-
-        tags = await self.mass.loop.run_in_executor(None, parse_tag)
-        _, ext = Path(itempath).name.rsplit(".", 1)
-        content_type = CONTENT_TYPE_EXT.get(ext.lower())
-
-        stat = await self.mass.loop.run_in_executor(None, os.stat, itempath)
-
-        return StreamDetails(
-            provider=self.type,
-            item_id=item_id,
-            content_type=content_type,
-            media_type=MediaType.TRACK,
-            duration=tags.duration,
-            size=stat.st_size,
-            sample_rate=tags.samplerate or 44100,
-            bit_depth=16,  # TODO: parse bitdepth
-            data=itempath,
-        )
-
-    async def get_audio_stream(
-        self, streamdetails: StreamDetails, seek_position: int = 0
-    ) -> AsyncGenerator[bytes, None]:
-        """Return the audio stream for the provider item."""
-        async for chunk in get_file_stream(
-            self.mass, streamdetails.data, streamdetails, seek_position
-        ):
-            yield chunk
-
-    async def _parse_track(self, track_path: str) -> Track | None:
-        """Try to parse a track from a filename by reading its tags."""
-
-        if not await self.exists(track_path):
-            raise MediaNotFoundError(f"Track path does not exist: {track_path}")
-
-        if "." not in track_path or track_path.startswith("."):
-            # skip system files and files without extension
-            return None
-
-        filename_base, ext = Path(track_path).name.rsplit(".", 1)
-        content_type = CONTENT_TYPE_EXT.get(ext.lower())
-        if content_type is None:
-            # unsupported file extension
-            return None
-
-        track_item_id = self._get_item_id(track_path)
-
-        # parse ID3 tags with TinyTag
-        def parse_tags():
-            return TinyTag.get(track_path, image=True, ignore_errors=True)
-
-        tags = await self.mass.loop.run_in_executor(None, parse_tags)
-
-        # prefer title from tags, fallback to filename
-        if not tags.title or not tags.artist:
-            self.logger.warning(
-                "%s is missing ID3 tags, using filename as fallback", track_path
-            )
-            filename_parts = filename_base.split(" - ", 1)
-            if len(filename_parts) == 2:
-                tags.artist = tags.artist or filename_parts[0]
-                tags.title = tags.title or filename_parts[1]
-            else:
-                tags.artist = tags.artist or FALLBACK_ARTIST
-                tags.title = tags.title or filename_base
-
-        name, version = parse_title_and_version(tags.title)
-        track = Track(
-            item_id=track_item_id,
-            provider=self.type,
-            name=name,
-            version=version,
-            # a track on disk is always in library
-            in_library=True,
-        )
-
-        # album
-        # work out if we have an artist/album/track.ext structure
-        if tags.album:
-            track_parts = track_path.rsplit(os.sep)
-            album_folder = None
-            artist_folder = None
-            parentdir = os.path.dirname(track_path)
-            for _ in range(len(track_parts)):
-                dirname = parentdir.rsplit(os.sep)[-1]
-                if compare_strings(dirname, tags.albumartist):
-                    artist_folder = parentdir
-                if compare_strings(dirname, tags.album):
-                    album_folder = parentdir
-                parentdir = os.path.dirname(parentdir)
-
-            # album artist
-            if artist_folder:
-                album_artists = [
-                    await self._parse_artist(
-                        name=tags.albumartist,
-                        artist_path=artist_folder,
-                        in_library=True,
-                    )
-                ]
-            elif tags.albumartist:
-                album_artists = [
-                    await self._parse_artist(name=item, in_library=True)
-                    for item in split_items(tags.albumartist)
-                ]
-
-            else:
-                # always fallback to various artists as album artist if user did not tag album artist
-                # ID3 tag properly because we must have an album artist
-                album_artists = [await self._parse_artist(name=FALLBACK_ARTIST)]
-                self.logger.warning(
-                    "%s is missing ID3 tag [albumartist], using %s as fallback",
-                    track_path,
-                    FALLBACK_ARTIST,
-                )
-
-            track.album = await self._parse_album(
-                tags.album,
-                album_folder,
-                artists=album_artists,
-                in_library=True,
-            )
-        else:
-            self.logger.warning("%s is missing ID3 tag [album]", track_path)
-
-        # track artist(s)
-        if tags.artist == tags.albumartist and track.album:
-            track.artists = track.album.artists
-        else:
-            # Parse track artist(s) from artist string using common splitters used in ID3 tags
-            # NOTE: do not use a '/' or '&' to prevent artists like AC/DC become messed up
-            track_artists_str = tags.artist or FALLBACK_ARTIST
-            track.artists = [
-                await self._parse_artist(item, in_library=False)
-                for item in split_items(track_artists_str)
-            ]
-
-        # Check if track has embedded metadata
-        img = await self.mass.loop.run_in_executor(None, tags.get_image)
-        if not track.metadata.images and img:
-            # we do not actually embed the image in the metadata because that would consume too
-            # much space and bandwidth. Instead we set the filename as value so the image can
-            # be retrieved later in realtime.
-            track.metadata.images = [MediaItemImage(ImageType.THUMB, track_path, True)]
-            if track.album and not track.album.metadata.images:
-                track.album.metadata.images = track.metadata.images
-
-        # parse other info
-        track.duration = tags.duration
-        track.metadata.genres = set(split_items(tags.genre))
-        track.disc_number = try_parse_int(tags.disc)
-        track.track_number = try_parse_int(tags.track)
-        track.isrc = tags.extra.get("isrc", "")
-        if "copyright" in tags.extra:
-            track.metadata.copyright = tags.extra["copyright"]
-        if "lyrics" in tags.extra:
-            track.metadata.lyrics = tags.extra["lyrics"]
-
-        quality_details = ""
-        if content_type == ContentType.FLAC:
-            # TODO: get bit depth
-            quality = MediaQuality.FLAC_LOSSLESS
-            if tags.samplerate > 192000:
-                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
-            elif tags.samplerate > 96000:
-                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
-            elif tags.samplerate > 48000:
-                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
-            quality_details = f"{tags.samplerate / 1000} Khz"
-        elif track_path.endswith(".ogg"):
-            quality = MediaQuality.LOSSY_OGG
-            quality_details = f"{tags.bitrate} kbps"
-        elif track_path.endswith(".m4a"):
-            quality = MediaQuality.LOSSY_AAC
-            quality_details = f"{tags.bitrate} kbps"
-        else:
-            quality = MediaQuality.LOSSY_MP3
-            quality_details = f"{tags.bitrate} kbps"
-        track.add_provider_id(
-            MediaItemProviderId(
-                item_id=track_item_id,
-                prov_type=self.type,
-                prov_id=self.id,
-                quality=quality,
-                details=quality_details,
-                url=track_path,
-            )
-        )
-        return track
-
-    async def _parse_artist(
-        self,
-        name: Optional[str] = None,
-        artist_path: Optional[str] = None,
-        in_library: bool = True,
-    ) -> Artist | None:
-        """Lookup metadata in Artist folder."""
-        assert name or artist_path
-        if not artist_path:
-            # create fake path
-            artist_path = os.path.join(self.config.path, name)
-
-        artist_item_id = self._get_item_id(artist_path)
-        if not name:
-            name = artist_path.split(os.sep)[-1]
-
-        artist = Artist(
-            artist_item_id,
-            self.type,
-            name,
-            provider_ids={
-                MediaItemProviderId(artist_item_id, self.type, self.id, url=artist_path)
-            },
-            in_library=in_library,
-        )
-
-        if not await self.exists(artist_path):
-            # return basic object if there is no dedicated artist folder
-            return artist
-
-        # always mark artist as in-library when it exists as folder on disk
-        artist.in_library = True
-
-        nfo_file = os.path.join(artist_path, "artist.nfo")
-        if await self.exists(nfo_file):
-            # found NFO file with metadata
-            # https://kodi.wiki/view/NFO_files/Artists
-            async with self.open_file(nfo_file, "r") as _file:
-                data = await _file.read()
-            info = await self.mass.loop.run_in_executor(None, xmltodict.parse, data)
-            info = info["artist"]
-            artist.name = info.get("title", info.get("name", name))
-            if sort_name := info.get("sortname"):
-                artist.sort_name = sort_name
-            if musicbrainz_id := info.get("musicbrainzartistid"):
-                artist.musicbrainz_id = musicbrainz_id
-            if descripton := info.get("biography"):
-                artist.metadata.description = descripton
-            if genre := info.get("genre"):
-                artist.metadata.genres = set(split_items(genre))
-        # find local images
-        images = []
-        async for _path in scantree(artist_path):
-            _filename = _path.path
-            ext = _filename.split(".")[-1]
-            if ext not in ("jpg", "png"):
-                continue
-            _filepath = os.path.join(artist_path, _filename)
-            for img_type in ImageType:
-                if img_type.value in _filepath:
-                    images.append(MediaItemImage(img_type, _filepath, True))
-                elif _filename == "folder.jpg":
-                    images.append(MediaItemImage(ImageType.THUMB, _filepath, True))
-        if images:
-            artist.metadata.images = images
-
-        return artist
-
-    async def _parse_album(
-        self,
-        name: Optional[str],
-        album_path: Optional[str],
-        artists: List[Artist],
-        in_library: bool = True,
-    ) -> Album | None:
-        """Lookup metadata in Album folder."""
-        assert (name or album_path) and artists
-        if not album_path:
-            # create fake path
-            album_path = os.path.join(self.config.path, artists[0].name, name)
-
-        album_item_id = self._get_item_id(album_path)
-        if not name:
-            name = album_path.split(os.sep)[-1]
-
-        album = Album(
-            album_item_id,
-            self.type,
-            name,
-            artists=artists,
-            provider_ids={
-                MediaItemProviderId(album_item_id, self.type, self.id, url=album_path)
-            },
-            in_library=in_library,
-        )
-
-        if not await self.exists(album_path):
-            # return basic object if there is no dedicated album folder
-            return album
-
-        # always mark as in-library when it exists as folder on disk
-        album.in_library = True
-
-        nfo_file = os.path.join(album_path, "album.nfo")
-        if await self.exists(nfo_file):
-            # found NFO file with metadata
-            # https://kodi.wiki/view/NFO_files/Artists
-            async with self.open_file(nfo_file) as _file:
-                data = await _file.read()
-            info = await self.mass.loop.run_in_executor(None, xmltodict.parse, data)
-            info = info["album"]
-            album.name = info.get("title", info.get("name", name))
-            if sort_name := info.get("sortname"):
-                album.sort_name = sort_name
-            if musicbrainz_id := info.get("musicbrainzreleasegroupid"):
-                album.musicbrainz_id = musicbrainz_id
-            if mb_artist_id := info.get("musicbrainzalbumartistid"):
-                if album.artist and not album.artist.musicbrainz_id:
-                    album.artist.musicbrainz_id = mb_artist_id
-            if description := info.get("review"):
-                album.metadata.description = description
-            if year := info.get("label"):
-                album.year = int(year)
-            if genre := info.get("genre"):
-                album.metadata.genres = set(split_items(genre))
-        # parse name/version
-        album.name, album.version = parse_title_and_version(album.name)
-
-        # try to guess the album type
-        album_tracks = [
-            x async for x in scantree(album_path) if TinyTag.is_supported(x.path)
-        ]
-        if album.artist.sort_name == "variousartists":
-            album.album_type = AlbumType.COMPILATION
-        elif len(album_tracks) <= 5:
-            album.album_type = AlbumType.SINGLE
-        else:
-            album.album_type = AlbumType.ALBUM
-
-        # find local images
-        images = []
-        async for _path in scantree(album_path):
-            _filename = _path.path
-            ext = _filename.split(".")[-1]
-            if ext not in ("jpg", "png"):
-                continue
-            _filepath = os.path.join(album_path, _filename)
-            for img_type in ImageType:
-                if img_type.value in _filepath:
-                    images.append(MediaItemImage(img_type, _filepath, True))
-                elif _filename == "folder.jpg":
-                    images.append(MediaItemImage(ImageType.THUMB, _filepath, True))
-        if images:
-            album.metadata.images = images
-
-        return album
-
-    async def _parse_playlist(self, playlist_path: str) -> Playlist | None:
-        """Parse playlist from file."""
-        playlist_item_id = self._get_item_id(playlist_path)
-
-        if not playlist_path.endswith(".m3u"):
-            return None
-
-        if not await self.exists(playlist_path):
-            raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}")
-
-        name = playlist_path.split(os.sep)[-1].replace(".m3u", "")
-
-        playlist = Playlist(playlist_item_id, provider=self.type, name=name)
-        playlist.is_editable = True
-        playlist.in_library = True
-        playlist.add_provider_id(
-            MediaItemProviderId(
-                item_id=playlist_item_id,
-                prov_type=self.type,
-                prov_id=self.id,
-                url=playlist_path,
-            )
-        )
-        playlist.owner = self._attr_name
-        return playlist
-
-    async def exists(self, file_path: str) -> bool:
-        """Return bool is this FileSystem musicprovider has given file/dir."""
-        if not file_path:
-            return False  # guard
-        # ensure we have a full path and not relative
-        if self.config.path not in file_path:
-            file_path = os.path.join(self.config.path, file_path)
-        _exists = wrap(os.path.exists)
-        return await _exists(file_path)
-
-    @asynccontextmanager
-    async def open_file(self, file_path: str, mode="rb") -> AsyncFileIO:
-        """Return (async) handle to given file."""
-        # ensure we have a full path and not relative
-        if self.config.path not in file_path:
-            file_path = os.path.join(self.config.path, file_path)
-        # remote file locations should return a tempfile here ?
-        async with aiofiles.open(file_path, mode) as _file:
-            yield _file
-
-    async def get_embedded_image(self, file_path) -> bytes | None:
-        """Return embedded image data."""
-        if not TinyTag.is_supported(file_path):
-            return None
-
-        # embedded image in music file
-        def _get_data():
-            tags = TinyTag.get(file_path, image=True)
-            return tags.get_image()
-
-        return await self.mass.loop.run_in_executor(None, _get_data)
-
-    async def get_filepath(
-        self, media_type: MediaType, prov_item_id: str
-    ) -> str | None:
-        """Get full filepath on disk for item_id."""
-        if prov_item_id is None:
-            return None  # guard
-        # funky sql queries go here ;-)
-        table = f"{media_type.value}s"
-        query = (
-            f"SELECT json_extract(json_each.value, '$.url') as url FROM {table}"
-            " ,json_each(provider_ids) WHERE"
-            f" json_extract(json_each.value, '$.prov_id') = '{self.id}'"
-            f" AND json_extract(json_each.value, '$.item_id') = '{prov_item_id}'"
-        )
-        for db_row in await self.mass.database.get_rows_from_query(query):
-            file_path = db_row["url"]
-            # ensure we have a full path and not relative
-            if self.config.path not in file_path:
-                file_path = os.path.join(self.config.path, file_path)
-            return file_path
-        return None
-
-    def _get_item_id(self, file_path: str) -> str:
-        """Create item id from filename."""
-        return create_clean_string(file_path.replace(self.config.path, ""))
diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-aarch64 b/music_assistant/controllers/music/providers/librespot/linux/librespot-aarch64
deleted file mode 100755 (executable)
index 5359098..0000000
Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-aarch64 and /dev/null differ
diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-arm b/music_assistant/controllers/music/providers/librespot/linux/librespot-arm
deleted file mode 100755 (executable)
index 5cd38c7..0000000
Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-arm and /dev/null differ
diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-armhf b/music_assistant/controllers/music/providers/librespot/linux/librespot-armhf
deleted file mode 100755 (executable)
index 18c2e05..0000000
Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-armhf and /dev/null differ
diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-armv7 b/music_assistant/controllers/music/providers/librespot/linux/librespot-armv7
deleted file mode 100755 (executable)
index 0a792b2..0000000
Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-armv7 and /dev/null differ
diff --git a/music_assistant/controllers/music/providers/librespot/linux/librespot-x86_64 b/music_assistant/controllers/music/providers/librespot/linux/librespot-x86_64
deleted file mode 100755 (executable)
index e025abd..0000000
Binary files a/music_assistant/controllers/music/providers/librespot/linux/librespot-x86_64 and /dev/null differ
diff --git a/music_assistant/controllers/music/providers/librespot/osx/librespot b/music_assistant/controllers/music/providers/librespot/osx/librespot
deleted file mode 100755 (executable)
index c1b3754..0000000
Binary files a/music_assistant/controllers/music/providers/librespot/osx/librespot and /dev/null differ
diff --git a/music_assistant/controllers/music/providers/librespot/windows/librespot.exe b/music_assistant/controllers/music/providers/librespot/windows/librespot.exe
deleted file mode 100755 (executable)
index a973f4e..0000000
Binary files a/music_assistant/controllers/music/providers/librespot/windows/librespot.exe and /dev/null differ
diff --git a/music_assistant/controllers/music/providers/qobuz.py b/music_assistant/controllers/music/providers/qobuz.py
deleted file mode 100644 (file)
index 65bbc48..0000000
+++ /dev/null
@@ -1,733 +0,0 @@
-"""Qobuz musicprovider support for MusicAssistant."""
-from __future__ import annotations
-
-import datetime
-import hashlib
-import time
-from json import JSONDecodeError
-from typing import AsyncGenerator, List, Optional
-
-import aiohttp
-from asyncio_throttle import Throttler
-
-from music_assistant.helpers.app_vars import (  # pylint: disable=no-name-in-module
-    app_var,
-)
-from music_assistant.helpers.audio import get_http_stream
-from music_assistant.helpers.cache import use_cache
-from music_assistant.helpers.util import parse_title_and_version, try_parse_int
-from music_assistant.models.enums import ProviderType
-from music_assistant.models.errors import LoginFailed, MediaNotFoundError
-from music_assistant.models.media_items import (
-    Album,
-    AlbumType,
-    Artist,
-    ContentType,
-    ImageType,
-    MediaItemImage,
-    MediaItemProviderId,
-    MediaItemType,
-    MediaQuality,
-    MediaType,
-    Playlist,
-    StreamDetails,
-    Track,
-)
-from music_assistant.models.provider import MusicProvider
-
-
-class QobuzProvider(MusicProvider):
-    """Provider for the Qobux music service."""
-
-    _attr_type = ProviderType.QOBUZ
-    _attr_name = "Qobuz"
-    _attr_supported_mediatypes = [
-        MediaType.ARTIST,
-        MediaType.ALBUM,
-        MediaType.TRACK,
-        MediaType.PLAYLIST,
-    ]
-    _user_auth_info = None
-    _throttler = Throttler(rate_limit=4, period=1)
-
-    async def setup(self) -> bool:
-        """Handle async initialization of the provider."""
-        if not self.config.enabled:
-            return False
-        if not self.config.username or not self.config.password:
-            raise LoginFailed("Invalid login credentials")
-        # try to get a token, raise if that fails
-        token = await self._auth_token()
-        if not token:
-            raise LoginFailed(f"Login failed for user {self.config.username}")
-        return True
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> List[MediaItemType]:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        result = []
-        params = {"query": search_query, "limit": limit}
-        if len(media_types) == 1:
-            # qobuz does not support multiple searchtypes, falls back to all if no type given
-            if media_types[0] == MediaType.ARTIST:
-                params["type"] = "artists"
-            if media_types[0] == MediaType.ALBUM:
-                params["type"] = "albums"
-            if media_types[0] == MediaType.TRACK:
-                params["type"] = "tracks"
-            if media_types[0] == MediaType.PLAYLIST:
-                params["type"] = "playlists"
-        if searchresult := await self._get_data("catalog/search", **params):
-            if "artists" in searchresult:
-                result += [
-                    await self._parse_artist(item)
-                    for item in searchresult["artists"]["items"]
-                    if (item and item["id"])
-                ]
-            if "albums" in searchresult:
-                result += [
-                    await self._parse_album(item)
-                    for item in searchresult["albums"]["items"]
-                    if (item and item["id"])
-                ]
-            if "tracks" in searchresult:
-                result += [
-                    await self._parse_track(item)
-                    for item in searchresult["tracks"]["items"]
-                    if (item and item["id"])
-                ]
-            if "playlists" in searchresult:
-                result += [
-                    await self._parse_playlist(item)
-                    for item in searchresult["playlists"]["items"]
-                    if (item and item["id"])
-                ]
-        return result
-
-    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
-        """Retrieve all library artists from Qobuz."""
-        endpoint = "favorite/getUserFavorites"
-        for item in await self._get_all_items(endpoint, key="artists", type="artists"):
-            if item and item["id"]:
-                yield await self._parse_artist(item)
-
-    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
-        """Retrieve all library albums from Qobuz."""
-        endpoint = "favorite/getUserFavorites"
-        for item in await self._get_all_items(endpoint, key="albums", type="albums"):
-            if item and item["id"]:
-                yield await self._parse_album(item)
-
-    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
-        """Retrieve library tracks from Qobuz."""
-        endpoint = "favorite/getUserFavorites"
-        for item in await self._get_all_items(endpoint, key="tracks", type="tracks"):
-            if item and item["id"]:
-                yield await self._parse_track(item)
-
-    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
-        """Retrieve all library playlists from the provider."""
-        endpoint = "playlist/getUserPlaylists"
-        for item in await self._get_all_items(endpoint, key="playlists"):
-            if item and item["id"]:
-                yield await self._parse_playlist(item)
-
-    async def get_artist(self, prov_artist_id) -> Artist:
-        """Get full artist details by id."""
-        params = {"artist_id": prov_artist_id}
-        artist_obj = await self._get_data("artist/get", **params)
-        return (
-            await self._parse_artist(artist_obj)
-            if artist_obj and artist_obj["id"]
-            else None
-        )
-
-    async def get_album(self, prov_album_id) -> Album:
-        """Get full album details by id."""
-        params = {"album_id": prov_album_id}
-        album_obj = await self._get_data("album/get", **params)
-        return (
-            await self._parse_album(album_obj)
-            if album_obj and album_obj["id"]
-            else None
-        )
-
-    async def get_track(self, prov_track_id) -> Track:
-        """Get full track details by id."""
-        params = {"track_id": prov_track_id}
-        track_obj = await self._get_data("track/get", **params)
-        return (
-            await self._parse_track(track_obj)
-            if track_obj and track_obj["id"]
-            else None
-        )
-
-    async def get_playlist(self, prov_playlist_id) -> Playlist:
-        """Get full playlist details by id."""
-        params = {"playlist_id": prov_playlist_id}
-        playlist_obj = await self._get_data("playlist/get", **params)
-        return (
-            await self._parse_playlist(playlist_obj)
-            if playlist_obj and playlist_obj["id"]
-            else None
-        )
-
-    async def get_album_tracks(self, prov_album_id) -> List[Track]:
-        """Get all album tracks for given album id."""
-        params = {"album_id": prov_album_id}
-        return [
-            await self._parse_track(item)
-            for item in await self._get_all_items("album/get", **params, key="tracks")
-            if (item and item["id"])
-        ]
-
-    async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]:
-        """Get all playlist tracks for given playlist id."""
-        playlist = await self.get_playlist(prov_playlist_id)
-        endpoint = "playlist/get"
-        return [
-            await self._parse_track(item)
-            for item in await self._get_all_items(
-                endpoint,
-                key="tracks",
-                playlist_id=prov_playlist_id,
-                extra="tracks",
-                cache_checksum=playlist.metadata.checksum,
-            )
-            if (item and item["id"])
-        ]
-
-    async def get_artist_albums(self, prov_artist_id) -> List[Album]:
-        """Get a list of albums for the given artist."""
-        endpoint = "artist/get"
-        return [
-            await self._parse_album(item)
-            for item in await self._get_all_items(
-                endpoint, key="albums", artist_id=prov_artist_id, extra="albums"
-            )
-            if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id)
-        ]
-
-    async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
-        """Get a list of most popular tracks for the given artist."""
-        result = await self._get_data(
-            "artist/get",
-            artist_id=prov_artist_id,
-            extra="playlists",
-            offset=0,
-            limit=25,
-        )
-        if result and result["playlists"]:
-            return [
-                await self._parse_track(item)
-                for item in result["playlists"][0]["tracks"]["items"]
-                if (item and item["id"])
-            ]
-        # fallback to search
-        artist = await self.get_artist(prov_artist_id)
-        searchresult = await self._get_data(
-            "catalog/search", query=artist.name, limit=25, type="tracks"
-        )
-        return [
-            await self._parse_track(item)
-            for item in searchresult["tracks"]["items"]
-            if (
-                item
-                and item["id"]
-                and "performer" in item
-                and str(item["performer"]["id"]) == str(prov_artist_id)
-            )
-        ]
-
-    async def get_similar_artists(self, prov_artist_id):
-        """Get similar artists for given artist."""
-        # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3
-
-    async def library_add(self, prov_item_id, media_type: MediaType):
-        """Add item to library."""
-        result = None
-        if media_type == MediaType.ARTIST:
-            result = await self._get_data("favorite/create", artist_id=prov_item_id)
-        elif media_type == MediaType.ALBUM:
-            result = await self._get_data("favorite/create", album_ids=prov_item_id)
-        elif media_type == MediaType.TRACK:
-            result = await self._get_data("favorite/create", track_ids=prov_item_id)
-        elif media_type == MediaType.PLAYLIST:
-            result = await self._get_data(
-                "playlist/subscribe", playlist_id=prov_item_id
-            )
-        return result
-
-    async def library_remove(self, prov_item_id, media_type: MediaType):
-        """Remove item from library."""
-        result = None
-        if media_type == MediaType.ARTIST:
-            result = await self._get_data("favorite/delete", artist_ids=prov_item_id)
-        elif media_type == MediaType.ALBUM:
-            result = await self._get_data("favorite/delete", album_ids=prov_item_id)
-        elif media_type == MediaType.TRACK:
-            result = await self._get_data("favorite/delete", track_ids=prov_item_id)
-        elif media_type == MediaType.PLAYLIST:
-            playlist = await self.get_playlist(prov_item_id)
-            if playlist.is_editable:
-                result = await self._get_data(
-                    "playlist/delete", playlist_id=prov_item_id
-                )
-            else:
-                result = await self._get_data(
-                    "playlist/unsubscribe", playlist_id=prov_item_id
-                )
-        return result
-
-    async def add_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> None:
-        """Add track(s) to playlist."""
-        return await self._get_data(
-            "playlist/addTracks",
-            playlist_id=prov_playlist_id,
-            track_ids=",".join(prov_track_ids),
-            playlist_track_ids=",".join(prov_track_ids),
-        )
-
-    async def remove_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> None:
-        """Remove track(s) from playlist."""
-        playlist_track_ids = set()
-        for track in await self._get_all_items(
-            "playlist/get", key="tracks", playlist_id=prov_playlist_id, extra="tracks"
-        ):
-            if str(track["id"]) in prov_track_ids:
-                playlist_track_ids.add(str(track["playlist_track_id"]))
-        return await self._get_data(
-            "playlist/deleteTracks",
-            playlist_id=prov_playlist_id,
-            playlist_track_ids=",".join(playlist_track_ids),
-        )
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
-        """Return the content details for the given track when it will be streamed."""
-        streamdata = None
-        for format_id in [27, 7, 6, 5]:
-            # it seems that simply requesting for highest available quality does not work
-            # from time to time the api response is empty for this request ?!
-            result = await self._get_data(
-                "track/getFileUrl",
-                sign_request=True,
-                format_id=format_id,
-                track_id=item_id,
-                intent="stream",
-                skip_cache=True,
-            )
-            if result and result.get("url"):
-                streamdata = result
-                break
-        if not streamdata:
-            raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}")
-        if streamdata["mime_type"] == "audio/mpeg":
-            content_type = ContentType.MPEG
-        elif streamdata["mime_type"] == "audio/flac":
-            content_type = ContentType.FLAC
-        else:
-            raise MediaNotFoundError(f"Unsupported mime type for {item_id}")
-        return StreamDetails(
-            item_id=str(item_id),
-            provider=self.type,
-            content_type=content_type,
-            duration=streamdata["duration"],
-            sample_rate=int(streamdata["sampling_rate"] * 1000),
-            bit_depth=streamdata["bit_depth"],
-            data=streamdata,  # we need these details for reporting playback
-            expires=time.time() + 1800,  # not sure about the real allowed value
-        )
-
-    async def get_audio_stream(
-        self, streamdetails: StreamDetails, seek_position: int = 0
-    ) -> AsyncGenerator[bytes, None]:
-        """Return the audio stream for the provider item."""
-        self.mass.create_task(self._report_playback_started(streamdetails))
-        bytes_sent = 0
-        try:
-            url = streamdetails.data["url"]
-            async for chunk in get_http_stream(
-                self.mass, url, streamdetails, seek_position
-            ):
-                yield chunk
-                bytes_sent += len(chunk)
-        finally:
-            if bytes_sent:
-                self.mass.create_task(
-                    self._report_playback_stopped(streamdetails, bytes_sent)
-                )
-
-    async def _report_playback_started(self, streamdetails: StreamDetails) -> None:
-        """Report playback start to qobuz."""
-        # TODO: need to figure out if the streamed track is purchased by user
-        # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx
-        # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}}
-        device_id = self._user_auth_info["user"]["device"]["id"]
-        credential_id = self._user_auth_info["user"]["credential"]["id"]
-        user_id = self._user_auth_info["user"]["id"]
-        format_id = streamdetails.data["format_id"]
-        timestamp = int(time.time())
-        events = [
-            {
-                "online": True,
-                "sample": False,
-                "intent": "stream",
-                "device_id": device_id,
-                "track_id": str(streamdetails.item_id),
-                "purchase": False,
-                "date": timestamp,
-                "credential_id": credential_id,
-                "user_id": user_id,
-                "local": False,
-                "format_id": format_id,
-            }
-        ]
-        await self._post_data("track/reportStreamingStart", data=events)
-
-    async def _report_playback_stopped(
-        self, streamdetails: StreamDetails, bytes_sent: int
-    ) -> None:
-        """Report playback stop to qobuz."""
-        user_id = self._user_auth_info["user"]["id"]
-        await self._get_data(
-            "/track/reportStreamingEnd",
-            user_id=user_id,
-            track_id=str(streamdetails.item_id),
-            duration=try_parse_int(streamdetails.seconds_streamed),
-        )
-
-    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.type, name=artist_obj["name"]
-        )
-        artist.add_provider_id(
-            MediaItemProviderId(
-                item_id=str(artist_obj["id"]),
-                prov_type=self.type,
-                prov_id=self.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"):
-            artist.metadata.description = artist_obj["biography"].get("content")
-        return artist
-
-    async def _parse_album(self, album_obj: dict, artist_obj: dict = None):
-        """Parse qobuz album object to generic layout."""
-        if not artist_obj and "artist" not in album_obj:
-            # artist missing in album info, return full abum instead
-            return await self.get_album(album_obj["id"])
-        name, version = parse_title_and_version(
-            album_obj["title"], album_obj.get("version")
-        )
-        album = Album(
-            item_id=str(album_obj["id"]), provider=self.type, name=name, version=version
-        )
-        if album_obj["maximum_sampling_rate"] > 192:
-            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
-        elif album_obj["maximum_sampling_rate"] > 96:
-            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
-        elif album_obj["maximum_sampling_rate"] > 48:
-            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
-        elif album_obj["maximum_bit_depth"] > 16:
-            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1
-        elif album_obj.get("format_id", 0) == 5:
-            quality = MediaQuality.LOSSY_AAC
-        else:
-            quality = MediaQuality.FLAC_LOSSLESS
-        album.add_provider_id(
-            MediaItemProviderId(
-                item_id=str(album_obj["id"]),
-                prov_type=self.type,
-                prov_id=self.id,
-                quality=quality,
-                url=album_obj.get(
-                    "url", f'https://open.qobuz.com/album/{album_obj["id"]}'
-                ),
-                details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit',
-                available=album_obj["streamable"] and album_obj["displayable"],
-            )
-        )
-
-        album.artist = await self._parse_artist(artist_obj or album_obj["artist"])
-        if (
-            album_obj.get("product_type", "") == "single"
-            or album_obj.get("release_type", "") == "single"
-        ):
-            album.album_type = AlbumType.SINGLE
-        elif (
-            album_obj.get("product_type", "") == "compilation"
-            or "Various" in album.artist.name
-        ):
-            album.album_type = AlbumType.COMPILATION
-        elif (
-            album_obj.get("product_type", "") == "album"
-            or album_obj.get("release_type", "") == "album"
-        ):
-            album.album_type = AlbumType.ALBUM
-        if "genre" in album_obj:
-            album.metadata.genres = {album_obj["genre"]["name"]}
-        if img := self.__get_image(album_obj):
-            album.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
-        if len(album_obj["upc"]) == 13:
-            # qobuz writes ean as upc ?!
-            album.upc = album_obj["upc"][1:]
-        else:
-            album.upc = album_obj["upc"]
-        if "label" in album_obj:
-            album.metadata.label = album_obj["label"]["name"]
-        if album_obj.get("released_at"):
-            album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year
-        if album_obj.get("copyright"):
-            album.metadata.copyright = album_obj["copyright"]
-        if album_obj.get("description"):
-            album.metadata.description = album_obj["description"]
-        return album
-
-    async def _parse_track(self, track_obj: dict):
-        """Parse qobuz track object to generic layout."""
-        name, version = parse_title_and_version(
-            track_obj["title"], track_obj.get("version")
-        )
-        track = Track(
-            item_id=str(track_obj["id"]),
-            provider=self.type,
-            name=name,
-            version=version,
-            disc_number=track_obj["media_number"],
-            track_number=track_obj["track_number"],
-            duration=track_obj["duration"],
-            position=track_obj.get("position"),
-        )
-        if track_obj.get("performer") and "Various " not in track_obj["performer"]:
-            artist = await self._parse_artist(track_obj["performer"])
-            if artist:
-                track.artists.append(artist)
-        if not track.artists:
-            # try to grab artist from album
-            if (
-                track_obj.get("album")
-                and track_obj["album"].get("artist")
-                and "Various " not in track_obj["album"]["artist"]
-            ):
-                artist = await self._parse_artist(track_obj["album"]["artist"])
-                if artist:
-                    track.artists.append(artist)
-        if not track.artists:
-            # last resort: parse from performers string
-            for performer_str in track_obj["performers"].split(" - "):
-                role = performer_str.split(", ")[1]
-                name = performer_str.split(", ")[0]
-                if "artist" in role.lower():
-                    artist = Artist(name, self.type, name)
-                track.artists.append(artist)
-        # TODO: fix grabbing composer from details
-
-        if "album" in track_obj:
-            album = await self._parse_album(track_obj["album"])
-            if album:
-                track.album = album
-        if track_obj.get("isrc"):
-            track.isrc = track_obj["isrc"]
-        if track_obj.get("performers"):
-            track.metadata.performers = {
-                x.strip() for x in track_obj["performers"].split("-")
-            }
-        if track_obj.get("copyright"):
-            track.metadata.copyright = track_obj["copyright"]
-        if track_obj.get("audio_info"):
-            track.metadata.replaygain = track_obj["audio_info"]["replaygain_track_gain"]
-        if track_obj.get("parental_warning"):
-            track.metadata.explicit = True
-        if img := self.__get_image(track_obj):
-            track.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
-        # get track quality
-        if track_obj["maximum_sampling_rate"] > 192:
-            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
-        elif track_obj["maximum_sampling_rate"] > 96:
-            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
-        elif track_obj["maximum_sampling_rate"] > 48:
-            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
-        elif track_obj["maximum_bit_depth"] > 16:
-            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1
-        elif track_obj.get("format_id", 0) == 5:
-            quality = MediaQuality.LOSSY_AAC
-        else:
-            quality = MediaQuality.FLAC_LOSSLESS
-        track.add_provider_id(
-            MediaItemProviderId(
-                item_id=str(track_obj["id"]),
-                prov_type=self.type,
-                prov_id=self.id,
-                quality=quality,
-                url=track_obj.get(
-                    "url", f'https://open.qobuz.com/track/{track_obj["id"]}'
-                ),
-                details=f'{track_obj["maximum_sampling_rate"]}kHz {track_obj["maximum_bit_depth"]}bit',
-                available=track_obj["streamable"] and track_obj["displayable"],
-            )
-        )
-        return track
-
-    async def _parse_playlist(self, playlist_obj):
-        """Parse qobuz playlist object to generic layout."""
-        playlist = Playlist(
-            item_id=str(playlist_obj["id"]),
-            provider=self.type,
-            name=playlist_obj["name"],
-            owner=playlist_obj["owner"]["name"],
-        )
-        playlist.add_provider_id(
-            MediaItemProviderId(
-                item_id=str(playlist_obj["id"]),
-                prov_type=self.type,
-                prov_id=self.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"]
-            or playlist_obj["is_collaborative"]
-        )
-        if img := self.__get_image(playlist_obj):
-            playlist.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
-        playlist.metadata.checksum = str(playlist_obj["updated_at"])
-        return playlist
-
-    async def _auth_token(self):
-        """Login to qobuz and store the token."""
-        if self._user_auth_info:
-            return self._user_auth_info["user_auth_token"]
-        params = {
-            "username": self.config.username,
-            "password": self.config.password,
-            "device_manufacturer_id": "music_assistant",
-        }
-        details = await self._get_data("user/login", **params)
-        if details and "user" in details:
-            self._user_auth_info = details
-            self.logger.info(
-                "Succesfully logged in to Qobuz as %s", details["user"]["display_name"]
-            )
-            self.mass.metadata.preferred_language = details["user"]["country_code"]
-            return details["user_auth_token"]
-
-    @use_cache(3600 * 24)
-    async def _get_all_items(self, endpoint, key="tracks", **kwargs):
-        """Get all items from a paged list."""
-        limit = 50
-        offset = 0
-        all_items = []
-        while True:
-            kwargs["limit"] = limit
-            kwargs["offset"] = offset
-            result = await self._get_data(endpoint, skip_cache=True, **kwargs)
-            offset += limit
-            if not result:
-                break
-            if not result.get(key) or not result[key].get("items"):
-                break
-            for item in result[key]["items"]:
-                item["position"] = len(all_items) + 1
-                all_items.append(item)
-            if len(result[key]["items"]) < limit:
-                break
-        return all_items
-
-    @use_cache(3600 * 2)
-    async def _get_data(self, endpoint, sign_request=False, **kwargs):
-        """Get data from api."""
-        url = f"http://www.qobuz.com/api.json/0.2/{endpoint}"
-        headers = {"X-App-Id": app_var(0)}
-        if endpoint != "user/login":
-            auth_token = await self._auth_token()
-            if not auth_token:
-                self.logger.debug("Not logged in")
-                return None
-            headers["X-User-Auth-Token"] = auth_token
-        if sign_request:
-            signing_data = "".join(endpoint.split("/"))
-            keys = list(kwargs.keys())
-            keys.sort()
-            for key in keys:
-                signing_data += f"{key}{kwargs[key]}"
-            request_ts = str(time.time())
-            request_sig = signing_data + request_ts + app_var(1)
-            request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
-            kwargs["request_ts"] = request_ts
-            kwargs["request_sig"] = request_sig
-            kwargs["app_id"] = app_var(0)
-            kwargs["user_auth_token"] = await self._auth_token()
-        async with self._throttler:
-            async with self.mass.http_session.get(
-                url, headers=headers, params=kwargs, verify_ssl=False
-            ) as response:
-                try:
-                    result = await response.json()
-                    if "error" in result or (
-                        "status" in result and "error" in result["status"]
-                    ):
-                        self.logger.error("%s - %s", endpoint, result)
-                        return None
-                except (
-                    aiohttp.ContentTypeError,
-                    JSONDecodeError,
-                ) as err:
-                    self.logger.error("%s - %s", endpoint, str(err))
-                    return None
-                return result
-
-    async def _post_data(self, endpoint, params=None, data=None):
-        """Post data to api."""
-        if not params:
-            params = {}
-        if not data:
-            data = {}
-        url = f"http://www.qobuz.com/api.json/0.2/{endpoint}"
-        params["app_id"] = app_var(0)
-        params["user_auth_token"] = await self._auth_token()
-        async with self.mass.http_session.post(
-            url, params=params, json=data, verify_ssl=False
-        ) as response:
-            result = await response.json()
-            if "error" in result or (
-                "status" in result and "error" in result["status"]
-            ):
-                self.logger.error("%s - %s", endpoint, result)
-                return None
-            return result
-
-    def __get_image(self, obj: dict) -> Optional[str]:
-        """Try to parse image from Qobuz media object."""
-        if obj.get("image"):
-            for key in ["extralarge", "large", "medium", "small"]:
-                if obj["image"].get(key):
-                    if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]:
-                        continue
-                    return obj["image"][key]
-        if obj.get("images300"):
-            # playlists seem to use this strange format
-            return obj["images300"][0]
-        if obj.get("album"):
-            return self.__get_image(obj["album"])
-        if obj.get("artist"):
-            return self.__get_image(obj["artist"])
-        return None
diff --git a/music_assistant/controllers/music/providers/spotify.py b/music_assistant/controllers/music/providers/spotify.py
deleted file mode 100644 (file)
index 83d1f85..0000000
+++ /dev/null
@@ -1,694 +0,0 @@
-"""Spotify musicprovider support for MusicAssistant."""
-from __future__ import annotations
-
-import asyncio
-import json
-import os
-import platform
-import time
-from json.decoder import JSONDecodeError
-from tempfile import gettempdir
-from typing import AsyncGenerator, List, Optional
-
-import aiohttp
-from asyncio_throttle import Throttler
-
-from music_assistant.helpers.app_vars import (  # noqa # pylint: disable=no-name-in-module
-    app_var,
-)
-from music_assistant.helpers.cache import use_cache
-from music_assistant.helpers.process import AsyncProcess
-from music_assistant.helpers.util import parse_title_and_version
-from music_assistant.models.enums import ProviderType
-from music_assistant.models.errors import LoginFailed, MediaNotFoundError
-from music_assistant.models.media_items import (
-    Album,
-    AlbumType,
-    Artist,
-    ContentType,
-    ImageType,
-    MediaItemImage,
-    MediaItemProviderId,
-    MediaItemType,
-    MediaQuality,
-    MediaType,
-    Playlist,
-    StreamDetails,
-    Track,
-)
-from music_assistant.models.provider import MusicProvider
-
-CACHE_DIR = gettempdir()
-
-
-class SpotifyProvider(MusicProvider):
-    """Implementation of a Spotify MusicProvider."""
-
-    _attr_type = ProviderType.SPOTIFY
-    _attr_name = "Spotify"
-    _attr_supported_mediatypes = [
-        MediaType.ARTIST,
-        MediaType.ALBUM,
-        MediaType.TRACK,
-        MediaType.PLAYLIST
-        # TODO: Return spotify radio
-    ]
-    _auth_token = None
-    _sp_user = None
-    _librespot_bin = None
-    _throttler = Throttler(rate_limit=4, period=1)
-    _cache_dir = CACHE_DIR
-
-    async def setup(self) -> bool:
-        """Handle async initialization of the provider."""
-        if not self.config.enabled:
-            return False
-        if not self.config.username or not self.config.password:
-            raise LoginFailed("Invalid login credentials")
-        # try to get a token, raise if that fails
-        self._cache_dir = os.path.join(CACHE_DIR, self.id)
-        token = await self.get_token()
-        if not token:
-            raise LoginFailed(f"Login failed for user {self.config.username}")
-        return True
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> List[MediaItemType]:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        result = []
-        searchtypes = []
-        if MediaType.ARTIST in media_types:
-            searchtypes.append("artist")
-        if MediaType.ALBUM in media_types:
-            searchtypes.append("album")
-        if MediaType.TRACK in media_types:
-            searchtypes.append("track")
-        if MediaType.PLAYLIST in media_types:
-            searchtypes.append("playlist")
-        searchtype = ",".join(searchtypes)
-        if searchresult := await self._get_data(
-            "search", q=search_query, type=searchtype, limit=limit
-        ):
-            if "artists" in searchresult:
-                result += [
-                    await self._parse_artist(item)
-                    for item in searchresult["artists"]["items"]
-                    if (item and item["id"])
-                ]
-            if "albums" in searchresult:
-                result += [
-                    await self._parse_album(item)
-                    for item in searchresult["albums"]["items"]
-                    if (item and item["id"])
-                ]
-            if "tracks" in searchresult:
-                result += [
-                    await self._parse_track(item)
-                    for item in searchresult["tracks"]["items"]
-                    if (item and item["id"])
-                ]
-            if "playlists" in searchresult:
-                result += [
-                    await self._parse_playlist(item)
-                    for item in searchresult["playlists"]["items"]
-                    if (item and item["id"])
-                ]
-        return result
-
-    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
-        """Retrieve library artists from spotify."""
-        endpoint = "me/following"
-        while True:
-            spotify_artists = await self._get_data(
-                endpoint, type="artist", limit=50, skip_cache=True
-            )
-            for item in spotify_artists["artists"]["items"]:
-                if item and item["id"]:
-                    yield await self._parse_artist(item)
-            if spotify_artists["artists"]["next"]:
-                endpoint = spotify_artists["artists"]["next"]
-                endpoint = endpoint.replace("https://api.spotify.com/v1/", "")
-            else:
-                break
-
-    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
-        """Retrieve library albums from the provider."""
-        for item in await self._get_all_items("me/albums", skip_cache=True):
-            if item["album"] and item["album"]["id"]:
-                yield await self._parse_album(item["album"])
-
-    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
-        """Retrieve library tracks from the provider."""
-        for item in await self._get_all_items("me/tracks", skip_cache=True):
-            if item and item["track"]["id"]:
-                yield await self._parse_track(item["track"])
-
-    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
-        """Retrieve playlists from the provider."""
-        for item in await self._get_all_items("me/playlists", skip_cache=True):
-            if item and item["id"]:
-                yield await self._parse_playlist(item)
-
-    async def get_artist(self, prov_artist_id) -> Artist:
-        """Get full artist details by id."""
-        artist_obj = await self._get_data(f"artists/{prov_artist_id}")
-        return await self._parse_artist(artist_obj) if artist_obj else None
-
-    async def get_album(self, prov_album_id) -> Album:
-        """Get full album details by id."""
-        album_obj = await self._get_data(f"albums/{prov_album_id}")
-        return await self._parse_album(album_obj) if album_obj else None
-
-    async def get_track(self, prov_track_id) -> Track:
-        """Get full track details by id."""
-        track_obj = await self._get_data(f"tracks/{prov_track_id}")
-        return await self._parse_track(track_obj) if track_obj else None
-
-    async def get_playlist(self, prov_playlist_id) -> Playlist:
-        """Get full playlist details by id."""
-        playlist_obj = await self._get_data(f"playlists/{prov_playlist_id}")
-        return await self._parse_playlist(playlist_obj) if playlist_obj else None
-
-    async def get_album_tracks(self, prov_album_id) -> List[Track]:
-        """Get all album tracks for given album id."""
-        return [
-            await self._parse_track(item)
-            for item in await self._get_all_items(f"albums/{prov_album_id}/tracks")
-            if (item and item["id"])
-        ]
-
-    async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]:
-        """Get all playlist tracks for given playlist id."""
-        playlist = await self.get_playlist(prov_playlist_id)
-        return [
-            await self._parse_track(item["track"])
-            for item in await self._get_all_items(
-                f"playlists/{prov_playlist_id}/tracks",
-                cache_checksum=playlist.metadata.checksum,
-            )
-            if (item and item["track"] and item["track"]["id"])
-        ]
-
-    async def get_artist_albums(self, prov_artist_id) -> List[Album]:
-        """Get a list of all albums for the given artist."""
-        return [
-            await self._parse_album(item)
-            for item in await self._get_all_items(
-                f"artists/{prov_artist_id}/albums?include_groups=album,single,compilation"
-            )
-            if (item and item["id"])
-        ]
-
-    async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
-        """Get a list of 10 most popular tracks for the given artist."""
-        artist = await self.get_artist(prov_artist_id)
-        endpoint = f"artists/{prov_artist_id}/top-tracks"
-        items = await self._get_data(endpoint)
-        return [
-            await self._parse_track(item, artist=artist)
-            for item in items["tracks"]
-            if (item and item["id"])
-        ]
-
-    async def library_add(self, prov_item_id, media_type: MediaType):
-        """Add item to library."""
-        result = False
-        if media_type == MediaType.ARTIST:
-            result = await self._put_data(
-                "me/following", {"ids": prov_item_id, "type": "artist"}
-            )
-        elif media_type == MediaType.ALBUM:
-            result = await self._put_data("me/albums", {"ids": prov_item_id})
-        elif media_type == MediaType.TRACK:
-            result = await self._put_data("me/tracks", {"ids": prov_item_id})
-        elif media_type == MediaType.PLAYLIST:
-            result = await self._put_data(
-                f"playlists/{prov_item_id}/followers", data={"public": False}
-            )
-        return result
-
-    async def library_remove(self, prov_item_id, media_type: MediaType):
-        """Remove item from library."""
-        result = False
-        if media_type == MediaType.ARTIST:
-            result = await self._delete_data(
-                "me/following", {"ids": prov_item_id, "type": "artist"}
-            )
-        elif media_type == MediaType.ALBUM:
-            result = await self._delete_data("me/albums", {"ids": prov_item_id})
-        elif media_type == MediaType.TRACK:
-            result = await self._delete_data("me/tracks", {"ids": prov_item_id})
-        elif media_type == MediaType.PLAYLIST:
-            result = await self._delete_data(f"playlists/{prov_item_id}/followers")
-        return result
-
-    async def add_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ):
-        """Add track(s) to playlist."""
-        track_uris = []
-        for track_id in prov_track_ids:
-            track_uris.append(f"spotify:track:{track_id}")
-        data = {"uris": track_uris}
-        return await self._post_data(f"playlists/{prov_playlist_id}/tracks", data=data)
-
-    async def remove_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> None:
-        """Remove track(s) from playlist."""
-        track_uris = []
-        for track_id in prov_track_ids:
-            track_uris.append({"uri": f"spotify:track:{track_id}"})
-        data = {"tracks": track_uris}
-        return await self._delete_data(
-            f"playlists/{prov_playlist_id}/tracks", data=data
-        )
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
-        """Return the content details for the given track when it will be streamed."""
-        # make sure a valid track is requested.
-        track = await self.get_track(item_id)
-        if not track:
-            raise MediaNotFoundError(f"track {item_id} not found")
-        # make sure that the token is still valid by just requesting it
-        await self.get_token()
-        return StreamDetails(
-            item_id=track.item_id,
-            provider=self.type,
-            content_type=ContentType.OGG,
-            duration=track.duration,
-        )
-
-    async def get_audio_stream(
-        self, streamdetails: StreamDetails, seek_position: int = 0
-    ) -> AsyncGenerator[bytes, None]:
-        """Return the audio stream for the provider item."""
-        # make sure that the token is still valid by just requesting it
-        await self.get_token()
-        librespot = await self.get_librespot_binary()
-        args = [
-            librespot,
-            "-c",
-            self._cache_dir,
-            "--pass-through",
-            "-b",
-            "320",
-            "--single-track",
-            f"spotify://track:{streamdetails.item_id}",
-        ]
-        if seek_position:
-            args += ["--start-position", str(int(seek_position))]
-        async with AsyncProcess(args) as librespot_proc:
-            async for chunk in librespot_proc.iterate_chunks():
-                yield chunk
-
-    async def _parse_artist(self, artist_obj):
-        """Parse spotify artist object to generic layout."""
-        artist = Artist(
-            item_id=artist_obj["id"], provider=self.type, name=artist_obj["name"]
-        )
-        artist.add_provider_id(
-            MediaItemProviderId(
-                item_id=artist_obj["id"],
-                prov_type=self.type,
-                prov_id=self.id,
-                url=artist_obj["external_urls"]["spotify"],
-            )
-        )
-        if "genres" in artist_obj:
-            artist.metadata.genres = set(artist_obj["genres"])
-        if artist_obj.get("images"):
-            for img in artist_obj["images"]:
-                img_url = img["url"]
-                if "2a96cbd8b46e442fc41c2b86b821562f" not in img_url:
-                    artist.metadata.images = [MediaItemImage(ImageType.THUMB, img_url)]
-                    break
-        return artist
-
-    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.type, name=name, version=version
-        )
-        for artist_obj in album_obj["artists"]:
-            album.artists.append(await self._parse_artist(artist_obj))
-        if album_obj["album_type"] == "single":
-            album.album_type = AlbumType.SINGLE
-        elif album_obj["album_type"] == "compilation":
-            album.album_type = AlbumType.COMPILATION
-        elif album_obj["album_type"] == "album":
-            album.album_type = AlbumType.ALBUM
-        if "genres" in album_obj:
-            album.metadata.genre = set(album_obj["genres"])
-        if album_obj.get("images"):
-            album.metadata.images = [
-                MediaItemImage(ImageType.THUMB, album_obj["images"][0]["url"])
-            ]
-        if "external_ids" in album_obj and album_obj["external_ids"].get("upc"):
-            album.upc = album_obj["external_ids"]["upc"]
-        if "label" in album_obj:
-            album.metadata.label = album_obj["label"]
-        if album_obj.get("release_date"):
-            album.year = int(album_obj["release_date"].split("-")[0])
-        if album_obj.get("copyrights"):
-            album.metadata.copyright = album_obj["copyrights"][0]["text"]
-        if album_obj.get("explicit"):
-            album.metadata.explicit = album_obj["explicit"]
-        album.add_provider_id(
-            MediaItemProviderId(
-                item_id=album_obj["id"],
-                prov_type=self.type,
-                prov_id=self.id,
-                quality=MediaQuality.LOSSY_OGG,
-                url=album_obj["external_urls"]["spotify"],
-            )
-        )
-        return album
-
-    async def _parse_track(self, track_obj, artist=None):
-        """Parse spotify track object to generic layout."""
-        name, version = parse_title_and_version(track_obj["name"])
-        track = Track(
-            item_id=track_obj["id"],
-            provider=self.type,
-            name=name,
-            version=version,
-            duration=track_obj["duration_ms"] / 1000,
-            disc_number=track_obj["disc_number"],
-            track_number=track_obj["track_number"],
-            position=track_obj.get("position"),
-        )
-        if artist:
-            track.artists.append(artist)
-        for track_artist in track_obj.get("artists", []):
-            artist = await self._parse_artist(track_artist)
-            if artist and artist.item_id not in {x.item_id for x in track.artists}:
-                track.artists.append(artist)
-
-        track.metadata.explicit = track_obj["explicit"]
-        if "preview_url" in track_obj:
-            track.metadata.preview = track_obj["preview_url"]
-        if "external_ids" in track_obj and "isrc" in track_obj["external_ids"]:
-            track.isrc = track_obj["external_ids"]["isrc"]
-        if "album" in track_obj:
-            track.album = await self._parse_album(track_obj["album"])
-            if track_obj["album"].get("images"):
-                track.metadata.images = [
-                    MediaItemImage(
-                        ImageType.THUMB, track_obj["album"]["images"][0]["url"]
-                    )
-                ]
-        if track_obj.get("copyright"):
-            track.metadata.copyright = track_obj["copyright"]
-        if track_obj.get("explicit"):
-            track.metadata.explicit = True
-        if track_obj.get("popularity"):
-            track.metadata.popularity = track_obj["popularity"]
-        track.add_provider_id(
-            MediaItemProviderId(
-                item_id=track_obj["id"],
-                prov_type=self.type,
-                prov_id=self.id,
-                quality=MediaQuality.LOSSY_OGG,
-                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):
-        """Parse spotify playlist object to generic layout."""
-        playlist = Playlist(
-            item_id=playlist_obj["id"],
-            provider=self.type,
-            name=playlist_obj["name"],
-            owner=playlist_obj["owner"]["display_name"],
-        )
-        playlist.add_provider_id(
-            MediaItemProviderId(
-                item_id=playlist_obj["id"],
-                prov_type=self.type,
-                prov_id=self.id,
-                url=playlist_obj["external_urls"]["spotify"],
-            )
-        )
-        playlist.is_editable = (
-            playlist_obj["owner"]["id"] == self._sp_user["id"]
-            or playlist_obj["collaborative"]
-        )
-        if playlist_obj.get("images"):
-            playlist.metadata.images = [
-                MediaItemImage(ImageType.THUMB, playlist_obj["images"][0]["url"])
-            ]
-        playlist.metadata.checksum = str(playlist_obj["snapshot_id"])
-        return playlist
-
-    async def get_token(self):
-        """Get auth token on spotify."""
-        # return existing token if we have one in memory
-        if (
-            self._auth_token
-            and os.path.isdir(self._cache_dir)
-            and (self._auth_token["expiresAt"] > int(time.time()) + 20)
-        ):
-            return self._auth_token
-        tokeninfo = {}
-        if not self.config.username or not self.config.password:
-            return tokeninfo
-        # retrieve token with librespot
-        retries = 0
-        while retries < 4:
-            try:
-                tokeninfo = await asyncio.wait_for(self._get_token(), 5)
-                if tokeninfo:
-                    break
-                retries += 1
-                await asyncio.sleep(2)
-            except TimeoutError:
-                pass
-        if tokeninfo:
-            self._auth_token = tokeninfo
-            self._sp_user = await self._get_data("me")
-            self.mass.metadata.preferred_language = self._sp_user["country"]
-            self.logger.info(
-                "Succesfully logged in to Spotify as %s", self._sp_user["id"]
-            )
-            self._auth_token = tokeninfo
-        else:
-            self.logger.error("Login failed for user %s", self.config.username)
-        return tokeninfo
-
-    async def _get_token(self):
-        """Get spotify auth token with librespot bin."""
-        # authorize with username and password (NOTE: this can also be Spotify Connect)
-        args = [
-            await self.get_librespot_binary(),
-            "-O",
-            "-c",
-            self._cache_dir,
-            "-a",
-            "-u",
-            self.config.username,
-            "-p",
-            self.config.password,
-        ]
-        librespot = await asyncio.create_subprocess_exec(*args)
-        await librespot.wait()
-        # get token with (authorized) librespot
-        scopes = [
-            "user-read-playback-state",
-            "user-read-currently-playing",
-            "user-modify-playback-state",
-            "playlist-read-private",
-            "playlist-read-collaborative",
-            "playlist-modify-public",
-            "playlist-modify-private",
-            "user-follow-modify",
-            "user-follow-read",
-            "user-library-read",
-            "user-library-modify",
-            "user-read-private",
-            "user-read-email",
-            "user-read-birthdate",
-            "user-top-read",
-        ]
-        scope = ",".join(scopes)
-        args = [
-            await self.get_librespot_binary(),
-            "-O",
-            "-t",
-            "--client-id",
-            app_var(2),
-            "--scope",
-            scope,
-            "-c",
-            self._cache_dir,
-        ]
-        librespot = await asyncio.create_subprocess_exec(
-            *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
-        )
-        stdout, _ = await librespot.communicate()
-        try:
-            result = json.loads(stdout)
-        except JSONDecodeError:
-            self.logger.warning(
-                "Error while retrieving Spotify token, details: %s", stdout
-            )
-            return None
-        # transform token info to spotipy compatible format
-        if result and "accessToken" in result:
-            tokeninfo = result
-            tokeninfo["expiresAt"] = tokeninfo["expiresIn"] + int(time.time())
-            return tokeninfo
-        return None
-
-    @use_cache(3600 * 24)
-    async def _get_all_items(self, endpoint, key="items", **kwargs) -> List[dict]:
-        """Get all items from a paged list."""
-        limit = 50
-        offset = 0
-        all_items = []
-        while True:
-            kwargs["limit"] = limit
-            kwargs["offset"] = offset
-            result = await self._get_data(endpoint, skip_cache=True, **kwargs)
-            offset += limit
-            if not result or key not in result or not result[key]:
-                break
-            for item in result[key]:
-                item["position"] = len(all_items) + 1
-                all_items.append(item)
-            if len(result[key]) < limit:
-                break
-        return all_items
-
-    @use_cache(3600 * 2)
-    async def _get_data(self, endpoint, **kwargs):
-        """Get data from api."""
-        url = f"https://api.spotify.com/v1/{endpoint}"
-        kwargs["market"] = "from_token"
-        kwargs["country"] = "from_token"
-        token = await self.get_token()
-        if not token:
-            return None
-        headers = {"Authorization": f'Bearer {token["accessToken"]}'}
-        async with self._throttler:
-            async with self.mass.http_session.get(
-                url, headers=headers, params=kwargs, verify_ssl=False
-            ) as response:
-                try:
-                    result = await response.json()
-                    if "error" in result or (
-                        "status" in result and "error" in result["status"]
-                    ):
-                        self.logger.error("%s - %s", endpoint, result)
-                        return None
-                except (
-                    aiohttp.ContentTypeError,
-                    JSONDecodeError,
-                ) as err:
-                    self.logger.error("%s - %s", endpoint, str(err))
-                    return None
-                return result
-
-    async def _delete_data(self, endpoint, data=None, **kwargs):
-        """Delete data from api."""
-        url = f"https://api.spotify.com/v1/{endpoint}"
-        token = await self.get_token()
-        if not token:
-            return None
-        headers = {"Authorization": f'Bearer {token["accessToken"]}'}
-        async with self.mass.http_session.delete(
-            url, headers=headers, params=kwargs, json=data, verify_ssl=False
-        ) as response:
-            return await response.text()
-
-    async def _put_data(self, endpoint, data=None, **kwargs):
-        """Put data on api."""
-        url = f"https://api.spotify.com/v1/{endpoint}"
-        token = await self.get_token()
-        if not token:
-            return None
-        headers = {"Authorization": f'Bearer {token["accessToken"]}'}
-        async with self.mass.http_session.put(
-            url, headers=headers, params=kwargs, json=data, verify_ssl=False
-        ) as response:
-            return await response.text()
-
-    async def _post_data(self, endpoint, data=None, **kwargs):
-        """Post data on api."""
-        url = f"https://api.spotify.com/v1/{endpoint}"
-        token = await self.get_token()
-        if not token:
-            return None
-        headers = {"Authorization": f'Bearer {token["accessToken"]}'}
-        async with self.mass.http_session.post(
-            url, headers=headers, params=kwargs, json=data, verify_ssl=False
-        ) as response:
-            return await response.text()
-
-    async def get_librespot_binary(self):
-        """Find the correct librespot binary belonging to the platform."""
-        if self._librespot_bin is not None:
-            return self._librespot_bin
-
-        async def check_librespot(librespot_path: str) -> str | None:
-            try:
-                librespot = await asyncio.create_subprocess_exec(
-                    *[librespot_path, "-V"], stdout=asyncio.subprocess.PIPE
-                )
-                stdout, _ = await librespot.communicate()
-                if librespot.returncode == 0 and b"librespot" in stdout:
-                    self._librespot_bin = librespot_path
-                    return librespot_path
-            except OSError:
-                return None
-
-        base_path = os.path.join(os.path.dirname(__file__), "librespot")
-        if platform.system() == "Windows":
-            if librespot := await check_librespot(
-                os.path.join(base_path, "windows", "librespot.exe")
-            ):
-                return librespot
-        if platform.system() == "Darwin":
-            # macos binary is x86_64 intel
-            if librespot := await check_librespot(
-                os.path.join(base_path, "osx", "librespot")
-            ):
-                return librespot
-
-        if platform.system() == "Linux":
-            architecture = platform.machine()
-            if architecture in ["AMD64", "x86_64"]:
-                # generic linux x86_64 binary
-                if librespot := await check_librespot(
-                    os.path.join(
-                        base_path,
-                        "linux",
-                        "librespot-x86_64",
-                    )
-                ):
-                    return librespot
-
-            # arm architecture... try all options one by one...
-            for arch in ["aarch64", "armv7", "armhf", "arm"]:
-                if librespot := await check_librespot(
-                    os.path.join(
-                        base_path,
-                        "linux",
-                        f"librespot-{arch}",
-                    )
-                ):
-                    return librespot
-
-        raise RuntimeError(
-            f"Unable to locate Libespot for platform {platform.system()}"
-        )
diff --git a/music_assistant/controllers/music/providers/tunein.py b/music_assistant/controllers/music/providers/tunein.py
deleted file mode 100644 (file)
index fe6ef60..0000000
+++ /dev/null
@@ -1,194 +0,0 @@
-"""Tune-In musicprovider support for MusicAssistant."""
-from __future__ import annotations
-
-from typing import AsyncGenerator, List, Optional
-
-from asyncio_throttle import Throttler
-
-from music_assistant.helpers.audio import get_radio_stream
-from music_assistant.helpers.cache import use_cache
-from music_assistant.helpers.util import create_clean_string
-from music_assistant.models.enums import ProviderType
-from music_assistant.models.errors import LoginFailed, MediaNotFoundError
-from music_assistant.models.media_items import (
-    ContentType,
-    ImageType,
-    MediaItemImage,
-    MediaItemProviderId,
-    MediaItemType,
-    MediaQuality,
-    MediaType,
-    Radio,
-    StreamDetails,
-)
-from music_assistant.models.provider import MusicProvider
-
-
-class TuneInProvider(MusicProvider):
-    """Provider implementation for Tune In."""
-
-    _attr_type = ProviderType.TUNEIN
-    _attr_name = "Tune-in Radio"
-    _attr_supported_mediatypes = [MediaType.RADIO]
-    _throttler = Throttler(rate_limit=1, period=1)
-
-    async def setup(self) -> bool:
-        """Handle async initialization of the provider."""
-        if not self.config.enabled:
-            return False
-        if not self.config.username:
-            raise LoginFailed("Username is invalid")
-        return True
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> List[MediaItemType]:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        # pylint: disable=no-self-use
-        # TODO: search for radio stations
-        return []
-
-    async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
-        """Retrieve library/subscribed radio stations from the provider."""
-
-        async def parse_items(
-            items: List[dict], folder: str = None
-        ) -> AsyncGenerator[Radio, None]:
-            for item in items:
-                item_type = item.get("type", "")
-                if item_type == "audio":
-                    if "preset_id" not in item:
-                        continue
-                    # each radio station can have multiple streams add each one as different quality
-                    stream_info = await self.__get_data(
-                        "Tune.ashx", id=item["preset_id"]
-                    )
-                    for stream in stream_info["body"]:
-                        yield await self._parse_radio(item, stream, folder)
-                elif item_type == "link":
-                    # stations are in sublevel (new style)
-                    if sublevel := await self.__get_data(item["URL"], render="json"):
-                        async for subitem in parse_items(
-                            sublevel["body"], item["text"]
-                        ):
-                            yield subitem
-                elif item.get("children"):
-                    # stations are in sublevel (old style ?)
-                    async for subitem in parse_items(item["children"], item["text"]):
-                        yield subitem
-
-        data = await self.__get_data("Browse.ashx", c="presets")
-        if data and "body" in data:
-            async for item in parse_items(data["body"]):
-                yield item
-
-    async def get_radio(self, prov_radio_id: str) -> Radio:
-        """Get radio station details."""
-        prov_radio_id, media_type = prov_radio_id.split("--", 1)
-        params = {"c": "composite", "detail": "listing", "id": prov_radio_id}
-        result = await self.__get_data("Describe.ashx", **params)
-        if result and result.get("body") and result["body"][0].get("children"):
-            item = result["body"][0]["children"][0]
-            stream_info = await self.__get_data("Tune.ashx", id=prov_radio_id)
-            for stream in stream_info["body"]:
-                if stream["media_type"] != media_type:
-                    continue
-                return await self._parse_radio(item, stream)
-        return None
-
-    async def _parse_radio(
-        self, details: dict, stream: dict, folder: Optional[str] = None
-    ) -> Radio:
-        """Parse Radio object from json obj returned from api."""
-        if "name" in details:
-            name = details["name"]
-        else:
-            # parse name from text attr
-            name = details["text"]
-            if " | " in name:
-                name = name.split(" | ")[1]
-            name = name.split(" (")[0]
-        item_id = f'{details["preset_id"]}--{stream["media_type"]}'
-        radio = Radio(item_id=item_id, provider=self.type, name=name)
-        if stream["media_type"] == "aac":
-            quality = MediaQuality.LOSSY_AAC
-        elif stream["media_type"] == "ogg":
-            quality = MediaQuality.LOSSY_OGG
-        else:
-            quality = MediaQuality.LOSSY_MP3
-        radio.add_provider_id(
-            MediaItemProviderId(
-                item_id=item_id,
-                prov_type=self.type,
-                prov_id=self.id,
-                quality=quality,
-                details=stream["url"],
-            )
-        )
-        # preset number is used for sorting (not present at stream time)
-        preset_number = details.get("preset_number")
-        if preset_number and folder:
-            radio.sort_name = f'{folder}-{details["preset_number"]}'
-        elif preset_number:
-            radio.sort_name = details["preset_number"]
-        radio.sort_name += create_clean_string(name)
-        if "text" in details:
-            radio.metadata.description = details["text"]
-        # images
-        if img := details.get("image"):
-            radio.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
-        if img := details.get("logo"):
-            radio.metadata.images = [MediaItemImage(ImageType.LOGO, img)]
-        return radio
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails:
-        """Get streamdetails for a radio station."""
-        item_id, media_type = item_id.split("--", 1)
-        stream_info = await self.__get_data("Tune.ashx", id=item_id)
-        for stream in stream_info["body"]:
-            if stream["media_type"] == media_type:
-                return StreamDetails(
-                    provider=self.type,
-                    item_id=item_id,
-                    content_type=ContentType(stream["media_type"]),
-                    media_type=MediaType.RADIO,
-                    data=stream,
-                )
-        raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}")
-
-    async def get_audio_stream(
-        self, streamdetails: StreamDetails, seek_position: int = 0
-    ) -> AsyncGenerator[bytes, None]:
-        """Return the audio stream for the provider item."""
-        async for chunk in get_radio_stream(
-            self.mass, streamdetails.data["url"], streamdetails
-        ):
-            yield chunk
-
-    @use_cache(3600 * 2)
-    async def __get_data(self, endpoint: str, **kwargs):
-        """Get data from api."""
-        if endpoint.startswith("http"):
-            url = endpoint
-        else:
-            url = f"https://opml.radiotime.com/{endpoint}"
-            kwargs["formats"] = "ogg,aac,wma,mp3"
-            kwargs["username"] = self.config.username
-            kwargs["partnerId"] = "1"
-            kwargs["render"] = "json"
-        async with self._throttler:
-            async with self.mass.http_session.get(
-                url, params=kwargs, verify_ssl=False
-            ) as response:
-                result = await response.json()
-                if not result or "error" in result:
-                    self.logger.error(url)
-                    self.logger.error(kwargs)
-                    result = None
-                return result
diff --git a/music_assistant/controllers/music/providers/url.py b/music_assistant/controllers/music/providers/url.py
deleted file mode 100644 (file)
index 1dbb50f..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-"""Basic provider allowing for external URL's to be streamed."""
-from __future__ import annotations
-
-import os
-from typing import AsyncGenerator, List, Optional
-
-from music_assistant.helpers.audio import (
-    get_file_stream,
-    get_http_stream,
-    get_radio_stream,
-)
-from music_assistant.models.config import MusicProviderConfig
-from music_assistant.models.enums import ContentType, MediaType, ProviderType
-from music_assistant.models.media_items import MediaItemType, StreamDetails
-from music_assistant.models.provider import MusicProvider
-
-PROVIDER_CONFIG = MusicProviderConfig(ProviderType.URL)
-
-
-class URLProvider(MusicProvider):
-    """Music Provider for manual URL's/files added to the queue."""
-
-    _attr_name: str = "URL"
-    _attr_type: ProviderType = ProviderType.URL
-    _attr_available: bool = True
-    _attr_supported_mediatypes: List[MediaType] = []
-
-    async def setup(self) -> bool:
-        """
-        Handle async initialization of the provider.
-
-        Called when provider is registered.
-        """
-        return True
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> List[MediaItemType]:
-        """Perform search on musicprovider."""
-        return []
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails | None:
-        """Get streamdetails for a track/radio."""
-        url = item_id
-        return StreamDetails(
-            provider=ProviderType.URL,
-            item_id=item_id,
-            content_type=ContentType.try_parse(url),
-            media_type=MediaType.URL,
-            data=url,
-        )
-
-    async def get_audio_stream(
-        self, streamdetails: StreamDetails, seek_position: int = 0
-    ) -> AsyncGenerator[bytes, None]:
-        """Return the audio stream for the provider item."""
-        if streamdetails.media_type == MediaType.RADIO:
-            # radio stream url
-            async for chunk in get_radio_stream(
-                self.mass, streamdetails.data, streamdetails
-            ):
-                yield chunk
-        elif os.path.isfile(streamdetails.data):
-            # local file
-            async for chunk in get_file_stream(
-                self.mass, streamdetails.data, streamdetails, seek_position
-            ):
-                yield chunk
-        else:
-            # regular stream url (without icy meta and reconnect)
-            async for chunk in get_http_stream(
-                self.mass, streamdetails.data, streamdetails, seek_position
-            ):
-                yield chunk
diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py
new file mode 100644 (file)
index 0000000..3c6038f
--- /dev/null
@@ -0,0 +1,282 @@
+"""Model for a Music Providers."""
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Tuple
+
+from music_assistant.models.config import MusicProviderConfig
+from music_assistant.models.enums import MediaType, ProviderType
+from music_assistant.models.media_items import (
+    Album,
+    Artist,
+    MediaItemType,
+    Playlist,
+    Radio,
+    StreamDetails,
+    Track,
+)
+
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
+
+class MusicProvider:
+    """Model for a Music Provider."""
+
+    _attr_name: str = None
+    _attr_type: ProviderType = None
+    _attr_available: bool = True
+    _attr_supported_mediatypes: List[MediaType] = []
+
+    def __init__(self, mass: MusicAssistant, config: MusicProviderConfig) -> None:
+        """Initialize MusicProvider."""
+        self.mass = mass
+        self.config = config
+        self.logger = mass.logger
+        self.cache = mass.cache
+
+    @abstractmethod
+    async def setup(self) -> bool:
+        """
+        Handle async initialization of the provider.
+
+        Called when provider is registered.
+        """
+
+    @property
+    def type(self) -> ProviderType:
+        """Return provider type for this provider."""
+        return self._attr_type
+
+    @property
+    def name(self) -> str:
+        """Return provider Name for this provider."""
+        if sum(1 for x in self.mass.music.providers if x.type == self.type) > 1:
+            append_str = self.config.path or self.config.username
+            return f"{self._attr_name} ({append_str})"
+        return self._attr_name
+
+    @property
+    def available(self) -> bool:
+        """Return boolean if this provider is available/initialized."""
+        return self._attr_available
+
+    @property
+    def supported_mediatypes(self) -> List[MediaType]:
+        """Return MediaTypes the provider supports."""
+        return self._attr_supported_mediatypes
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """
+        Perform search on musicprovider.
+
+            :param search_query: Search query.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: Number of items to return in the search (per type).
+        """
+        raise NotImplementedError
+
+    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+        """Retrieve library artists from the provider."""
+        if MediaType.ARTIST in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+        """Retrieve library albums from the provider."""
+        if MediaType.ALBUM in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
+        """Retrieve library tracks from the provider."""
+        if MediaType.TRACK in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+        """Retrieve library/subscribed playlists from the provider."""
+        if MediaType.PLAYLIST in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
+        """Retrieve library/subscribed radio stations from the provider."""
+        if MediaType.RADIO in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_artist(self, prov_artist_id: str) -> Artist:
+        """Get full artist details by id."""
+        if MediaType.ARTIST in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
+        """Get a list of all albums for the given artist."""
+        if MediaType.ALBUM in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
+        """Get a list of most popular tracks for the given artist."""
+        if MediaType.TRACK in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_album(self, prov_album_id: str) -> Album:
+        """Get full album details by id."""
+        if MediaType.ALBUM in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_track(self, prov_track_id: str) -> Track:
+        """Get full track details by id."""
+        if MediaType.TRACK in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
+        """Get full playlist details by id."""
+        if MediaType.PLAYLIST in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_radio(self, prov_radio_id: str) -> Radio:
+        """Get full radio details by id."""
+        if MediaType.RADIO in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_album_tracks(self, prov_album_id: str) -> List[Track]:
+        """Get album tracks for given album id."""
+        if MediaType.ALBUM in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
+        """Get all playlist tracks for given playlist id."""
+        if MediaType.PLAYLIST in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool:
+        """Add item to provider's library. Return true on succes."""
+        raise NotImplementedError
+
+    async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
+        """Remove item from provider's library. Return true on succes."""
+        raise NotImplementedError
+
+    async def add_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> None:
+        """Add track(s) to playlist."""
+        if MediaType.PLAYLIST in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def remove_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> None:
+        """Remove track(s) from playlist."""
+        if MediaType.PLAYLIST in self.supported_mediatypes:
+            raise NotImplementedError
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails | None:
+        """Get streamdetails for a track/radio."""
+        raise NotImplementedError
+
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """Return the audio stream for the provider item."""
+        raise NotImplementedError
+
+    async def get_item(self, media_type: MediaType, prov_item_id: str) -> MediaItemType:
+        """Get single MediaItem from provider."""
+        if media_type == MediaType.ARTIST:
+            return await self.get_artist(prov_item_id)
+        if media_type == MediaType.ALBUM:
+            return await self.get_album(prov_item_id)
+        if media_type == MediaType.TRACK:
+            return await self.get_track(prov_item_id)
+        if media_type == MediaType.PLAYLIST:
+            return await self.get_playlist(prov_item_id)
+        if media_type == MediaType.RADIO:
+            return await self.get_radio(prov_item_id)
+
+    async def sync_library(
+        self, media_types: Optional[Tuple[MediaType]] = None
+    ) -> None:
+        """Run library sync for this provider."""
+        # this reference implementation can be overridden with provider specific approach
+        # this logic is aimed at streaming/online providers,
+        # which all have more or less the same structure.
+        # filesystem implementation(s) just override this.
+        async with self.mass.database.get_db() as db:
+            for media_type in self.supported_mediatypes:
+                if media_types is not None and media_type not in media_types:
+                    continue
+                self.logger.debug("Start sync of %s items.", media_type.value)
+                controller = self.mass.music.get_controller(media_type)
+
+                # create a set of all previous and current db id's
+                # note we only store the items in the prev_ids list that are
+                # unique to this provider to avoid getting into a mess where
+                # for example an item still exists on disk (in case of file provider)
+                # and no longer favorite on streaming provider.
+                # 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():
+                    prov_types = {x.prov_type for x in db_item.provider_ids}
+                    if len(prov_types) > 1:
+                        continue
+                    for prov_id in db_item.provider_ids:
+                        if prov_id.prov_id == self.id:
+                            prev_ids.add(db_item.item_id)
+                cur_ids = set()
+                async for prov_item in self._get_library_gen(media_type)():
+                    prov_item: MediaItemType = prov_item
+
+                    db_item: MediaItemType = await controller.get_db_item_by_prov_id(
+                        provider_item_id=prov_item.item_id,
+                        provider=prov_item.provider,
+                        db=db,
+                    )
+                    if not db_item:
+                        # dump the item in the db, rich metadata is lazy loaded later
+                        db_item = await controller.add_db_item(prov_item, db=db)
+                    elif (
+                        db_item.metadata.checksum and prov_item.metadata.checksum
+                    ) and db_item.metadata.checksum != prov_item.metadata.checksum:
+                        # item checksum changed
+                        db_item = await controller.update_db_item(
+                            db_item.item_id, prov_item, db=db
+                        )
+                    cur_ids.add(db_item.item_id)
+                    if not db_item.in_library:
+                        await controller.set_db_library(db_item.item_id, True, db=db)
+
+                # process deletions
+                for item_id in prev_ids:
+                    if item_id not in cur_ids:
+                        # only mark the item as not in library and leave the metadata in db
+                        await controller.set_db_library(item_id, False, db=db)
+
+    # DO NOT OVERRIDE BELOW
+
+    @property
+    def id(self) -> str:
+        """Return unique provider id to distinguish multiple instances of the same provider."""
+        return self.config.id
+
+    def to_dict(self) -> Dict[str, Any]:
+        """Return (serializable) dict representation of MusicProvider."""
+        return {
+            "type": self.type.value,
+            "name": self.name,
+            "id": self.id,
+            "supported_mediatypes": [x.value for x in self.supported_mediatypes],
+        }
+
+    def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType]:
+        """Return library generator for given media_type."""
+        if media_type == MediaType.ARTIST:
+            return self.get_library_artists
+        if media_type == MediaType.ALBUM:
+            return self.get_library_albums
+        if media_type == MediaType.TRACK:
+            return self.get_library_tracks
+        if media_type == MediaType.PLAYLIST:
+            return self.get_library_playlists
+        if media_type == MediaType.RADIO:
+            return self.get_library_radios
diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py
deleted file mode 100644 (file)
index 3c6038f..0000000
+++ /dev/null
@@ -1,282 +0,0 @@
-"""Model for a Music Providers."""
-from __future__ import annotations
-
-from abc import abstractmethod
-from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Tuple
-
-from music_assistant.models.config import MusicProviderConfig
-from music_assistant.models.enums import MediaType, ProviderType
-from music_assistant.models.media_items import (
-    Album,
-    Artist,
-    MediaItemType,
-    Playlist,
-    Radio,
-    StreamDetails,
-    Track,
-)
-
-if TYPE_CHECKING:
-    from music_assistant.mass import MusicAssistant
-
-
-class MusicProvider:
-    """Model for a Music Provider."""
-
-    _attr_name: str = None
-    _attr_type: ProviderType = None
-    _attr_available: bool = True
-    _attr_supported_mediatypes: List[MediaType] = []
-
-    def __init__(self, mass: MusicAssistant, config: MusicProviderConfig) -> None:
-        """Initialize MusicProvider."""
-        self.mass = mass
-        self.config = config
-        self.logger = mass.logger
-        self.cache = mass.cache
-
-    @abstractmethod
-    async def setup(self) -> bool:
-        """
-        Handle async initialization of the provider.
-
-        Called when provider is registered.
-        """
-
-    @property
-    def type(self) -> ProviderType:
-        """Return provider type for this provider."""
-        return self._attr_type
-
-    @property
-    def name(self) -> str:
-        """Return provider Name for this provider."""
-        if sum(1 for x in self.mass.music.providers if x.type == self.type) > 1:
-            append_str = self.config.path or self.config.username
-            return f"{self._attr_name} ({append_str})"
-        return self._attr_name
-
-    @property
-    def available(self) -> bool:
-        """Return boolean if this provider is available/initialized."""
-        return self._attr_available
-
-    @property
-    def supported_mediatypes(self) -> List[MediaType]:
-        """Return MediaTypes the provider supports."""
-        return self._attr_supported_mediatypes
-
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> List[MediaItemType]:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        raise NotImplementedError
-
-    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
-        """Retrieve library artists from the provider."""
-        if MediaType.ARTIST in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
-        """Retrieve library albums from the provider."""
-        if MediaType.ALBUM in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
-        """Retrieve library tracks from the provider."""
-        if MediaType.TRACK in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
-        """Retrieve library/subscribed playlists from the provider."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
-        """Retrieve library/subscribed radio stations from the provider."""
-        if MediaType.RADIO in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_artist(self, prov_artist_id: str) -> Artist:
-        """Get full artist details by id."""
-        if MediaType.ARTIST in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
-        """Get a list of all albums for the given artist."""
-        if MediaType.ALBUM in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
-        """Get a list of most popular tracks for the given artist."""
-        if MediaType.TRACK in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_album(self, prov_album_id: str) -> Album:
-        """Get full album details by id."""
-        if MediaType.ALBUM in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_track(self, prov_track_id: str) -> Track:
-        """Get full track details by id."""
-        if MediaType.TRACK in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
-        """Get full playlist details by id."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_radio(self, prov_radio_id: str) -> Radio:
-        """Get full radio details by id."""
-        if MediaType.RADIO in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_album_tracks(self, prov_album_id: str) -> List[Track]:
-        """Get album tracks for given album id."""
-        if MediaType.ALBUM in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
-        """Get all playlist tracks for given playlist id."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool:
-        """Add item to provider's library. Return true on succes."""
-        raise NotImplementedError
-
-    async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
-        """Remove item from provider's library. Return true on succes."""
-        raise NotImplementedError
-
-    async def add_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> None:
-        """Add track(s) to playlist."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def remove_playlist_tracks(
-        self, prov_playlist_id: str, prov_track_ids: List[str]
-    ) -> None:
-        """Remove track(s) from playlist."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
-            raise NotImplementedError
-
-    async def get_stream_details(self, item_id: str) -> StreamDetails | None:
-        """Get streamdetails for a track/radio."""
-        raise NotImplementedError
-
-    async def get_audio_stream(
-        self, streamdetails: StreamDetails, seek_position: int = 0
-    ) -> AsyncGenerator[bytes, None]:
-        """Return the audio stream for the provider item."""
-        raise NotImplementedError
-
-    async def get_item(self, media_type: MediaType, prov_item_id: str) -> MediaItemType:
-        """Get single MediaItem from provider."""
-        if media_type == MediaType.ARTIST:
-            return await self.get_artist(prov_item_id)
-        if media_type == MediaType.ALBUM:
-            return await self.get_album(prov_item_id)
-        if media_type == MediaType.TRACK:
-            return await self.get_track(prov_item_id)
-        if media_type == MediaType.PLAYLIST:
-            return await self.get_playlist(prov_item_id)
-        if media_type == MediaType.RADIO:
-            return await self.get_radio(prov_item_id)
-
-    async def sync_library(
-        self, media_types: Optional[Tuple[MediaType]] = None
-    ) -> None:
-        """Run library sync for this provider."""
-        # this reference implementation can be overridden with provider specific approach
-        # this logic is aimed at streaming/online providers,
-        # which all have more or less the same structure.
-        # filesystem implementation(s) just override this.
-        async with self.mass.database.get_db() as db:
-            for media_type in self.supported_mediatypes:
-                if media_types is not None and media_type not in media_types:
-                    continue
-                self.logger.debug("Start sync of %s items.", media_type.value)
-                controller = self.mass.music.get_controller(media_type)
-
-                # create a set of all previous and current db id's
-                # note we only store the items in the prev_ids list that are
-                # unique to this provider to avoid getting into a mess where
-                # for example an item still exists on disk (in case of file provider)
-                # and no longer favorite on streaming provider.
-                # 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():
-                    prov_types = {x.prov_type for x in db_item.provider_ids}
-                    if len(prov_types) > 1:
-                        continue
-                    for prov_id in db_item.provider_ids:
-                        if prov_id.prov_id == self.id:
-                            prev_ids.add(db_item.item_id)
-                cur_ids = set()
-                async for prov_item in self._get_library_gen(media_type)():
-                    prov_item: MediaItemType = prov_item
-
-                    db_item: MediaItemType = await controller.get_db_item_by_prov_id(
-                        provider_item_id=prov_item.item_id,
-                        provider=prov_item.provider,
-                        db=db,
-                    )
-                    if not db_item:
-                        # dump the item in the db, rich metadata is lazy loaded later
-                        db_item = await controller.add_db_item(prov_item, db=db)
-                    elif (
-                        db_item.metadata.checksum and prov_item.metadata.checksum
-                    ) and db_item.metadata.checksum != prov_item.metadata.checksum:
-                        # item checksum changed
-                        db_item = await controller.update_db_item(
-                            db_item.item_id, prov_item, db=db
-                        )
-                    cur_ids.add(db_item.item_id)
-                    if not db_item.in_library:
-                        await controller.set_db_library(db_item.item_id, True, db=db)
-
-                # process deletions
-                for item_id in prev_ids:
-                    if item_id not in cur_ids:
-                        # only mark the item as not in library and leave the metadata in db
-                        await controller.set_db_library(item_id, False, db=db)
-
-    # DO NOT OVERRIDE BELOW
-
-    @property
-    def id(self) -> str:
-        """Return unique provider id to distinguish multiple instances of the same provider."""
-        return self.config.id
-
-    def to_dict(self) -> Dict[str, Any]:
-        """Return (serializable) dict representation of MusicProvider."""
-        return {
-            "type": self.type.value,
-            "name": self.name,
-            "id": self.id,
-            "supported_mediatypes": [x.value for x in self.supported_mediatypes],
-        }
-
-    def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType]:
-        """Return library generator for given media_type."""
-        if media_type == MediaType.ARTIST:
-            return self.get_library_artists
-        if media_type == MediaType.ALBUM:
-            return self.get_library_albums
-        if media_type == MediaType.TRACK:
-            return self.get_library_tracks
-        if media_type == MediaType.PLAYLIST:
-            return self.get_library_playlists
-        if media_type == MediaType.RADIO:
-            return self.get_library_radios
diff --git a/music_assistant/music_providers/__init__.py b/music_assistant/music_providers/__init__.py
new file mode 100644 (file)
index 0000000..01895ef
--- /dev/null
@@ -0,0 +1 @@
+"""Package with Music Providers."""
diff --git a/music_assistant/music_providers/filesystem.py b/music_assistant/music_providers/filesystem.py
new file mode 100644 (file)
index 0000000..6658cb3
--- /dev/null
@@ -0,0 +1,861 @@
+"""Filesystem musicprovider support for MusicAssistant."""
+from __future__ import annotations
+
+import asyncio
+import os
+import urllib.parse
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import AsyncGenerator, List, Optional, Set, Tuple
+
+import aiofiles
+import xmltodict
+from aiofiles.os import wrap
+from aiofiles.threadpool.binary import AsyncFileIO
+from tinytag.tinytag import TinyTag
+
+from music_assistant.helpers.audio import get_file_stream
+from music_assistant.helpers.compare import compare_strings
+from music_assistant.helpers.database import SCHEMA_VERSION
+from music_assistant.helpers.util import (
+    create_clean_string,
+    parse_title_and_version,
+    try_parse_int,
+)
+from music_assistant.models.enums import ProviderType
+from music_assistant.models.errors import MediaNotFoundError, MusicAssistantError
+from music_assistant.models.media_items import (
+    Album,
+    AlbumType,
+    Artist,
+    ContentType,
+    ImageType,
+    ItemMapping,
+    MediaItemImage,
+    MediaItemProviderId,
+    MediaItemType,
+    MediaQuality,
+    MediaType,
+    Playlist,
+    StreamDetails,
+    Track,
+)
+from music_assistant.models.music_provider import MusicProvider
+
+FALLBACK_ARTIST = "Various Artists"
+SPLITTERS = (";", ",", "Featuring", " Feat. ", " Feat ", "feat.", " & ", " / ")
+CONTENT_TYPE_EXT = {
+    # map of supported file extensions (mapped to ContentType)
+    "mp3": ContentType.MP3,
+    "m4a": ContentType.M4A,
+    "flac": ContentType.FLAC,
+    "wav": ContentType.WAV,
+    "ogg": ContentType.OGG,
+    "wma": ContentType.WMA,
+}
+
+
+async def scantree(path: str) -> AsyncGenerator[os.DirEntry, None]:
+    """Recursively yield DirEntry objects for given directory."""
+
+    def is_dir(entry: os.DirEntry) -> bool:
+        return entry.is_dir(follow_symlinks=False)
+
+    loop = asyncio.get_running_loop()
+    for entry in await loop.run_in_executor(None, os.scandir, path):
+        if await loop.run_in_executor(None, is_dir, entry):
+            async for subitem in scantree(entry.path):
+                yield subitem
+        else:
+            yield entry
+
+
+def split_items(org_str: str) -> Tuple[str]:
+    """Split up a tags string by common splitter."""
+    if isinstance(org_str, list):
+        return org_str
+    if org_str is None:
+        return tuple()
+    for splitter in SPLITTERS:
+        if splitter in org_str:
+            return tuple((x.strip() for x in org_str.split(splitter)))
+    return (org_str,)
+
+
+class FileSystemProvider(MusicProvider):
+    """
+    Implementation of a musicprovider for local files.
+
+    Reads ID3 tags from file and falls back to parsing filename.
+    Optionally reads metadata from nfo files and images in folder structure <artist>/<album>.
+    Supports m3u files only for playlists.
+    Supports having URI's from streaming providers within m3u playlist.
+    """
+
+    _attr_name = "Filesystem"
+    _attr_type = ProviderType.FILESYSTEM_LOCAL
+    _attr_supported_mediatypes = [
+        MediaType.TRACK,
+        MediaType.PLAYLIST,
+        MediaType.ARTIST,
+        MediaType.ALBUM,
+    ]
+
+    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"
+            )
+
+        return True
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """Perform search on musicprovider."""
+        result = []
+        # searching the filesystem is slow and unreliable,
+        # instead we make some (slow) freaking queries to the db ;-)
+        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)
+            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)
+            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)
+            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)
+            result += playlists
+        return result
+
+    async def sync_library(
+        self, media_types: Optional[Tuple[MediaType]] = None
+    ) -> None:
+        """Run library sync for this provider."""
+        cache_key = f"{self.id}.checksums"
+        prev_checksums = await self.mass.cache.get(cache_key, SCHEMA_VERSION)
+        if prev_checksums is None:
+            prev_checksums = {}
+        # find all music files in the music directory and all subfolders
+        # we work bottom up, as-in we derive all info from the tracks
+        cur_checksums = {}
+        async with self.mass.database.get_db() as db:
+            async for entry in scantree(self.config.path):
+
+                # mtime is used as file checksum
+                stat = await asyncio.get_running_loop().run_in_executor(
+                    None, entry.stat
+                )
+                checksum = int(stat.st_mtime)
+                cur_checksums[entry.path] = checksum
+                if checksum == prev_checksums.get(entry.path):
+                    continue
+                try:
+                    if track := await self._parse_track(entry.path):
+                        # process album
+                        if track.album:
+                            db_album = await self.mass.music.albums.add_db_item(
+                                track.album, db=db
+                            )
+                            if not db_album.in_library:
+                                await self.mass.music.albums.set_db_library(
+                                    db_album.item_id, True, db=db
+                                )
+                            # process (album)artist
+                            if track.album.artist:
+                                db_artist = await self.mass.music.artists.add_db_item(
+                                    track.album.artist, db=db
+                                )
+                                if not db_artist.in_library:
+                                    await self.mass.music.artists.set_db_library(
+                                        db_artist.item_id, True, db=db
+                                    )
+                        # add/update track to db
+                        db_track = await self.mass.music.tracks.add_db_item(
+                            track, db=db
+                        )
+                        if not db_track.in_library:
+                            await self.mass.music.tracks.set_db_library(
+                                db_track.item_id, True, db=db
+                            )
+                    elif playlist := await self._parse_playlist(entry.path):
+                        # add/update] playlist to db
+                        playlist.metadata.checksum = checksum
+                        await self.mass.music.playlists.add_db_item(playlist, db=db)
+                except Exception:  # pylint: disable=broad-except
+                    # we don't want the whole sync to crash on one file so we catch all exceptions here
+                    self.logger.exception("Error processing %s", entry.path)
+
+        # save checksums for next sync
+        await self.mass.cache.set(cache_key, cur_checksums, SCHEMA_VERSION)
+
+        # work out deletions
+        deleted_files = set(prev_checksums.keys()) - set(cur_checksums.keys())
+        artists: Set[ItemMapping] = set()
+        albums: Set[ItemMapping] = set()
+        # process deleted tracks
+        for file_path in deleted_files:
+            item_id = self._get_item_id(file_path)
+            if db_item := await self.mass.music.tracks.get_db_item_by_prov_id(
+                item_id, self.type
+            ):
+                await self.mass.music.tracks.remove_prov_mapping(
+                    db_item.item_id, self.id
+                )
+                # gather artists(s) attached to this track
+                for artist in db_item.artists:
+                    artists.add(artist.item_id)
+                # gather album and albumartist(s) attached to this track
+                if db_item.album:
+                    albums.add(db_item.album.item_id)
+                    for artist in db_item.album.artists:
+                        artists.add(artist.item_id)
+        # check if albums are deleted
+        for album_id in albums:
+            album = await self.mass.music.albums.get_db_item(album_id)
+            if not album:
+                continue
+            prov_album_id = next(
+                x.item_id for x in album.provider_ids if x.prov_id == self.id
+            )
+            album_tracks = await self.get_album_tracks(prov_album_id)
+            if album_tracks:
+                continue
+            # album has no more tracks attached, delete prov mapping
+            await self.mass.music.albums.remove_prov_mapping(album_id)
+        # check if artists are deleted
+        for artist_id in artists:
+            artist = await self.mass.music.artists.get_db_item(artist_id)
+            prov_artist_id = next(
+                x.item_id for x in artist.provider_ids if x.prov_id == self.id
+            )
+            artist_tracks = await self.get_artist_toptracks(prov_artist_id)
+            if artist_tracks:
+                continue
+            artist_albums = await self.get_artist_albums(prov_artist_id)
+            if artist_albums:
+                continue
+            # artist has no more tracks attached, delete prov mapping
+            await self.mass.music.artists.remove_prov_mapping(artist_id)
+
+    async def get_artist(self, prov_artist_id: str) -> Artist:
+        """Get full artist details by id."""
+        itempath = await self.get_filepath(MediaType.ARTIST, prov_artist_id)
+        if await self.exists(itempath):
+            # if path exists on disk allow parsing full details to allow refresh of metadata
+            return await self._parse_artist(artist_path=itempath)
+        return await self.mass.music.artists.get_db_item_by_prov_id(
+            provider_item_id=prov_artist_id, provider_id=self.id
+        )
+
+    async def get_album(self, prov_album_id: str) -> Album:
+        """Get full album details by id."""
+        db_album = await self.mass.music.albums.get_db_item_by_prov_id(
+            provider_item_id=prov_album_id, provider_id=self.id
+        )
+        if db_album is None:
+            raise MediaNotFoundError(f"Album not found: {prov_album_id}")
+        itempath = await self.get_filepath(MediaType.ALBUM, prov_album_id)
+        if await self.exists(itempath):
+            # if path exists on disk allow parsing full details to allow refresh of metadata
+            return await self._parse_album(None, itempath, db_album.artists)
+        return db_album
+
+    async def get_track(self, prov_track_id: str) -> Track:
+        """Get full track details by id."""
+        itempath = await self.get_filepath(MediaType.TRACK, prov_track_id)
+        return await self._parse_track(itempath)
+
+    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
+        """Get full playlist details by id."""
+        itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id)
+        return await self._parse_playlist(itempath)
+
+    async def get_album_tracks(self, prov_album_id: str) -> List[Track]:
+        """Get album tracks for given album id."""
+        # filesystem items are always stored in db so we can query the database
+        db_album = await self.mass.music.albums.get_db_item_by_prov_id(
+            prov_album_id, provider_id=self.id
+        )
+        if db_album is None:
+            raise MediaNotFoundError(f"Album not found: {prov_album_id}")
+        # TODO: adjust to json query instead of text search
+        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):
+            track.album = db_album
+            album_mapping = next(
+                (x for x in track.albums if x.item_id == db_album.item_id), None
+            )
+            track.disc_number = album_mapping.disc_number
+            track.track_number = album_mapping.track_number
+            result.append(track)
+        return result
+
+    async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
+        """Get playlist tracks for given playlist id."""
+        result = []
+        playlist_path = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id)
+        if not await self.exists(playlist_path):
+            raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}")
+        getmtime = wrap(os.path.getmtime)
+        mtime = await getmtime(playlist_path)
+        checksum = f"{SCHEMA_VERSION}.{int(mtime)}"
+        cache_key = f"playlist_{self.id}_tracks_{prov_playlist_id}"
+        if cache := await self.mass.cache.get(cache_key, checksum):
+            return [Track.from_dict(x) for x in cache]
+        playlist_base_path = Path(playlist_path).parent
+        index = 0
+        try:
+            async with self.open_file(playlist_path, "r") as _file:
+                for line in await _file.readlines():
+                    line = urllib.parse.unquote(line.strip())
+                    if line and not line.startswith("#"):
+                        # TODO: add support for .pls playlist files
+                        if track := await self._parse_playlist_line(
+                            line, playlist_base_path
+                        ):
+                            track.position = index
+                            result.append(track)
+                            index += 1
+        except Exception as err:  # pylint: disable=broad-except
+            self.logger.warning(
+                "Error while parsing playlist %s", playlist_path, exc_info=err
+            )
+        await self.mass.cache.set(cache_key, [x.to_dict() for x in result], checksum)
+        return result
+
+    async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | None:
+        """Try to parse a track from a playlist line."""
+        if "://" in line:
+            # track is uri from external provider?
+            try:
+                return await self.mass.music.get_item_by_uri(line)
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Could not parse uri %s to track: %s", line, str(err)
+                )
+                return None
+        # try to treat uri as filename
+        if await self.exists(line):
+            return await self._parse_track(line)
+        rel_path = os.path.join(playlist_path, line)
+        if await self.exists(rel_path):
+            return await self._parse_track(rel_path)
+        return None
+
+    async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
+        """Get a list of albums for the given artist."""
+        # filesystem items are always stored in db so we can query the database
+        db_artist = await self.mass.music.artists.get_db_item_by_prov_id(
+            prov_artist_id, provider_id=self.id
+        )
+        if db_artist is None:
+            raise MediaNotFoundError(f"Artist not found: {prov_artist_id}")
+        # 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)
+
+    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."""
+        # filesystem items are always stored in db so we can query the database
+        db_artist = await self.mass.music.artists.get_db_item_by_prov_id(
+            prov_artist_id, provider_id=self.id
+        )
+        if db_artist is None:
+            raise MediaNotFoundError(f"Artist not found: {prov_artist_id}")
+        # 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)
+
+    async def library_add(self, *args, **kwargs) -> bool:
+        """Add item to provider's library. Return true on succes."""
+        # already handled by database
+
+    async def library_remove(self, *args, **kwargs) -> bool:
+        """Remove item from provider's library. Return true on succes."""
+        # already handled by database
+        # TODO: do we want to process/offer deletions here ?
+
+    async def add_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> None:
+        """Add track(s) to playlist."""
+        itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id)
+        if not await self.exists(itempath):
+            raise MediaNotFoundError(f"Playlist path does not exist: {itempath}")
+        async with self.open_file(itempath, "r") as _file:
+            cur_data = await _file.read()
+        async with self.open_file(itempath, "w") as _file:
+            await _file.write(cur_data)
+            for uri in prov_track_ids:
+                await _file.write(f"\n{uri}")
+
+    async def remove_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> None:
+        """Remove track(s) from playlist."""
+        itempath = await self.get_filepath(MediaType.PLAYLIST, prov_playlist_id)
+        if not await self.exists(itempath):
+            raise MediaNotFoundError(f"Playlist path does not exist: {itempath}")
+        cur_lines = []
+        async with self.open_file(itempath, "r") as _file:
+            for line in await _file.readlines():
+                line = urllib.parse.unquote(line.strip())
+                if line not in prov_track_ids:
+                    cur_lines.append(line)
+        async with self.open_file(itempath, "w") as _file:
+            for uri in cur_lines:
+                await _file.write(f"{uri}\n")
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Return the content details for the given track when it will be streamed."""
+        itempath = await self.get_filepath(MediaType.TRACK, item_id)
+        if not await self.exists(itempath):
+            raise MediaNotFoundError(f"Track path does not exist: {itempath}")
+
+        def parse_tag():
+            return TinyTag.get(itempath)
+
+        tags = await self.mass.loop.run_in_executor(None, parse_tag)
+        _, ext = Path(itempath).name.rsplit(".", 1)
+        content_type = CONTENT_TYPE_EXT.get(ext.lower())
+
+        stat = await self.mass.loop.run_in_executor(None, os.stat, itempath)
+
+        return StreamDetails(
+            provider=self.type,
+            item_id=item_id,
+            content_type=content_type,
+            media_type=MediaType.TRACK,
+            duration=tags.duration,
+            size=stat.st_size,
+            sample_rate=tags.samplerate or 44100,
+            bit_depth=16,  # TODO: parse bitdepth
+            data=itempath,
+        )
+
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """Return the audio stream for the provider item."""
+        async for chunk in get_file_stream(
+            self.mass, streamdetails.data, streamdetails, seek_position
+        ):
+            yield chunk
+
+    async def _parse_track(self, track_path: str) -> Track | None:
+        """Try to parse a track from a filename by reading its tags."""
+
+        if not await self.exists(track_path):
+            raise MediaNotFoundError(f"Track path does not exist: {track_path}")
+
+        if "." not in track_path or track_path.startswith("."):
+            # skip system files and files without extension
+            return None
+
+        filename_base, ext = Path(track_path).name.rsplit(".", 1)
+        content_type = CONTENT_TYPE_EXT.get(ext.lower())
+        if content_type is None:
+            # unsupported file extension
+            return None
+
+        track_item_id = self._get_item_id(track_path)
+
+        # parse ID3 tags with TinyTag
+        def parse_tags():
+            return TinyTag.get(track_path, image=True, ignore_errors=True)
+
+        tags = await self.mass.loop.run_in_executor(None, parse_tags)
+
+        # prefer title from tags, fallback to filename
+        if not tags.title or not tags.artist:
+            self.logger.warning(
+                "%s is missing ID3 tags, using filename as fallback", track_path
+            )
+            filename_parts = filename_base.split(" - ", 1)
+            if len(filename_parts) == 2:
+                tags.artist = tags.artist or filename_parts[0]
+                tags.title = tags.title or filename_parts[1]
+            else:
+                tags.artist = tags.artist or FALLBACK_ARTIST
+                tags.title = tags.title or filename_base
+
+        name, version = parse_title_and_version(tags.title)
+        track = Track(
+            item_id=track_item_id,
+            provider=self.type,
+            name=name,
+            version=version,
+            # a track on disk is always in library
+            in_library=True,
+        )
+
+        # album
+        # work out if we have an artist/album/track.ext structure
+        if tags.album:
+            track_parts = track_path.rsplit(os.sep)
+            album_folder = None
+            artist_folder = None
+            parentdir = os.path.dirname(track_path)
+            for _ in range(len(track_parts)):
+                dirname = parentdir.rsplit(os.sep)[-1]
+                if compare_strings(dirname, tags.albumartist):
+                    artist_folder = parentdir
+                if compare_strings(dirname, tags.album):
+                    album_folder = parentdir
+                parentdir = os.path.dirname(parentdir)
+
+            # album artist
+            if artist_folder:
+                album_artists = [
+                    await self._parse_artist(
+                        name=tags.albumartist,
+                        artist_path=artist_folder,
+                        in_library=True,
+                    )
+                ]
+            elif tags.albumartist:
+                album_artists = [
+                    await self._parse_artist(name=item, in_library=True)
+                    for item in split_items(tags.albumartist)
+                ]
+
+            else:
+                # always fallback to various artists as album artist if user did not tag album artist
+                # ID3 tag properly because we must have an album artist
+                album_artists = [await self._parse_artist(name=FALLBACK_ARTIST)]
+                self.logger.warning(
+                    "%s is missing ID3 tag [albumartist], using %s as fallback",
+                    track_path,
+                    FALLBACK_ARTIST,
+                )
+
+            track.album = await self._parse_album(
+                tags.album,
+                album_folder,
+                artists=album_artists,
+                in_library=True,
+            )
+        else:
+            self.logger.warning("%s is missing ID3 tag [album]", track_path)
+
+        # track artist(s)
+        if tags.artist == tags.albumartist and track.album:
+            track.artists = track.album.artists
+        else:
+            # Parse track artist(s) from artist string using common splitters used in ID3 tags
+            # NOTE: do not use a '/' or '&' to prevent artists like AC/DC become messed up
+            track_artists_str = tags.artist or FALLBACK_ARTIST
+            track.artists = [
+                await self._parse_artist(item, in_library=False)
+                for item in split_items(track_artists_str)
+            ]
+
+        # Check if track has embedded metadata
+        img = await self.mass.loop.run_in_executor(None, tags.get_image)
+        if not track.metadata.images and img:
+            # we do not actually embed the image in the metadata because that would consume too
+            # much space and bandwidth. Instead we set the filename as value so the image can
+            # be retrieved later in realtime.
+            track.metadata.images = [MediaItemImage(ImageType.THUMB, track_path, True)]
+            if track.album and not track.album.metadata.images:
+                track.album.metadata.images = track.metadata.images
+
+        # parse other info
+        track.duration = tags.duration
+        track.metadata.genres = set(split_items(tags.genre))
+        track.disc_number = try_parse_int(tags.disc)
+        track.track_number = try_parse_int(tags.track)
+        track.isrc = tags.extra.get("isrc", "")
+        if "copyright" in tags.extra:
+            track.metadata.copyright = tags.extra["copyright"]
+        if "lyrics" in tags.extra:
+            track.metadata.lyrics = tags.extra["lyrics"]
+
+        quality_details = ""
+        if content_type == ContentType.FLAC:
+            # TODO: get bit depth
+            quality = MediaQuality.FLAC_LOSSLESS
+            if tags.samplerate > 192000:
+                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
+            elif tags.samplerate > 96000:
+                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
+            elif tags.samplerate > 48000:
+                quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
+            quality_details = f"{tags.samplerate / 1000} Khz"
+        elif track_path.endswith(".ogg"):
+            quality = MediaQuality.LOSSY_OGG
+            quality_details = f"{tags.bitrate} kbps"
+        elif track_path.endswith(".m4a"):
+            quality = MediaQuality.LOSSY_AAC
+            quality_details = f"{tags.bitrate} kbps"
+        else:
+            quality = MediaQuality.LOSSY_MP3
+            quality_details = f"{tags.bitrate} kbps"
+        track.add_provider_id(
+            MediaItemProviderId(
+                item_id=track_item_id,
+                prov_type=self.type,
+                prov_id=self.id,
+                quality=quality,
+                details=quality_details,
+                url=track_path,
+            )
+        )
+        return track
+
+    async def _parse_artist(
+        self,
+        name: Optional[str] = None,
+        artist_path: Optional[str] = None,
+        in_library: bool = True,
+    ) -> Artist | None:
+        """Lookup metadata in Artist folder."""
+        assert name or artist_path
+        if not artist_path:
+            # create fake path
+            artist_path = os.path.join(self.config.path, name)
+
+        artist_item_id = self._get_item_id(artist_path)
+        if not name:
+            name = artist_path.split(os.sep)[-1]
+
+        artist = Artist(
+            artist_item_id,
+            self.type,
+            name,
+            provider_ids={
+                MediaItemProviderId(artist_item_id, self.type, self.id, url=artist_path)
+            },
+            in_library=in_library,
+        )
+
+        if not await self.exists(artist_path):
+            # return basic object if there is no dedicated artist folder
+            return artist
+
+        # always mark artist as in-library when it exists as folder on disk
+        artist.in_library = True
+
+        nfo_file = os.path.join(artist_path, "artist.nfo")
+        if await self.exists(nfo_file):
+            # found NFO file with metadata
+            # https://kodi.wiki/view/NFO_files/Artists
+            async with self.open_file(nfo_file, "r") as _file:
+                data = await _file.read()
+            info = await self.mass.loop.run_in_executor(None, xmltodict.parse, data)
+            info = info["artist"]
+            artist.name = info.get("title", info.get("name", name))
+            if sort_name := info.get("sortname"):
+                artist.sort_name = sort_name
+            if musicbrainz_id := info.get("musicbrainzartistid"):
+                artist.musicbrainz_id = musicbrainz_id
+            if descripton := info.get("biography"):
+                artist.metadata.description = descripton
+            if genre := info.get("genre"):
+                artist.metadata.genres = set(split_items(genre))
+        # find local images
+        images = []
+        async for _path in scantree(artist_path):
+            _filename = _path.path
+            ext = _filename.split(".")[-1]
+            if ext not in ("jpg", "png"):
+                continue
+            _filepath = os.path.join(artist_path, _filename)
+            for img_type in ImageType:
+                if img_type.value in _filepath:
+                    images.append(MediaItemImage(img_type, _filepath, True))
+                elif _filename == "folder.jpg":
+                    images.append(MediaItemImage(ImageType.THUMB, _filepath, True))
+        if images:
+            artist.metadata.images = images
+
+        return artist
+
+    async def _parse_album(
+        self,
+        name: Optional[str],
+        album_path: Optional[str],
+        artists: List[Artist],
+        in_library: bool = True,
+    ) -> Album | None:
+        """Lookup metadata in Album folder."""
+        assert (name or album_path) and artists
+        if not album_path:
+            # create fake path
+            album_path = os.path.join(self.config.path, artists[0].name, name)
+
+        album_item_id = self._get_item_id(album_path)
+        if not name:
+            name = album_path.split(os.sep)[-1]
+
+        album = Album(
+            album_item_id,
+            self.type,
+            name,
+            artists=artists,
+            provider_ids={
+                MediaItemProviderId(album_item_id, self.type, self.id, url=album_path)
+            },
+            in_library=in_library,
+        )
+
+        if not await self.exists(album_path):
+            # return basic object if there is no dedicated album folder
+            return album
+
+        # always mark as in-library when it exists as folder on disk
+        album.in_library = True
+
+        nfo_file = os.path.join(album_path, "album.nfo")
+        if await self.exists(nfo_file):
+            # found NFO file with metadata
+            # https://kodi.wiki/view/NFO_files/Artists
+            async with self.open_file(nfo_file) as _file:
+                data = await _file.read()
+            info = await self.mass.loop.run_in_executor(None, xmltodict.parse, data)
+            info = info["album"]
+            album.name = info.get("title", info.get("name", name))
+            if sort_name := info.get("sortname"):
+                album.sort_name = sort_name
+            if musicbrainz_id := info.get("musicbrainzreleasegroupid"):
+                album.musicbrainz_id = musicbrainz_id
+            if mb_artist_id := info.get("musicbrainzalbumartistid"):
+                if album.artist and not album.artist.musicbrainz_id:
+                    album.artist.musicbrainz_id = mb_artist_id
+            if description := info.get("review"):
+                album.metadata.description = description
+            if year := info.get("label"):
+                album.year = int(year)
+            if genre := info.get("genre"):
+                album.metadata.genres = set(split_items(genre))
+        # parse name/version
+        album.name, album.version = parse_title_and_version(album.name)
+
+        # try to guess the album type
+        album_tracks = [
+            x async for x in scantree(album_path) if TinyTag.is_supported(x.path)
+        ]
+        if album.artist.sort_name == "variousartists":
+            album.album_type = AlbumType.COMPILATION
+        elif len(album_tracks) <= 5:
+            album.album_type = AlbumType.SINGLE
+        else:
+            album.album_type = AlbumType.ALBUM
+
+        # find local images
+        images = []
+        async for _path in scantree(album_path):
+            _filename = _path.path
+            ext = _filename.split(".")[-1]
+            if ext not in ("jpg", "png"):
+                continue
+            _filepath = os.path.join(album_path, _filename)
+            for img_type in ImageType:
+                if img_type.value in _filepath:
+                    images.append(MediaItemImage(img_type, _filepath, True))
+                elif _filename == "folder.jpg":
+                    images.append(MediaItemImage(ImageType.THUMB, _filepath, True))
+        if images:
+            album.metadata.images = images
+
+        return album
+
+    async def _parse_playlist(self, playlist_path: str) -> Playlist | None:
+        """Parse playlist from file."""
+        playlist_item_id = self._get_item_id(playlist_path)
+
+        if not playlist_path.endswith(".m3u"):
+            return None
+
+        if not await self.exists(playlist_path):
+            raise MediaNotFoundError(f"Playlist path does not exist: {playlist_path}")
+
+        name = playlist_path.split(os.sep)[-1].replace(".m3u", "")
+
+        playlist = Playlist(playlist_item_id, provider=self.type, name=name)
+        playlist.is_editable = True
+        playlist.in_library = True
+        playlist.add_provider_id(
+            MediaItemProviderId(
+                item_id=playlist_item_id,
+                prov_type=self.type,
+                prov_id=self.id,
+                url=playlist_path,
+            )
+        )
+        playlist.owner = self._attr_name
+        return playlist
+
+    async def exists(self, file_path: str) -> bool:
+        """Return bool is this FileSystem musicprovider has given file/dir."""
+        if not file_path:
+            return False  # guard
+        # ensure we have a full path and not relative
+        if self.config.path not in file_path:
+            file_path = os.path.join(self.config.path, file_path)
+        _exists = wrap(os.path.exists)
+        return await _exists(file_path)
+
+    @asynccontextmanager
+    async def open_file(self, file_path: str, mode="rb") -> AsyncFileIO:
+        """Return (async) handle to given file."""
+        # ensure we have a full path and not relative
+        if self.config.path not in file_path:
+            file_path = os.path.join(self.config.path, file_path)
+        # remote file locations should return a tempfile here ?
+        async with aiofiles.open(file_path, mode) as _file:
+            yield _file
+
+    async def get_embedded_image(self, file_path) -> bytes | None:
+        """Return embedded image data."""
+        if not TinyTag.is_supported(file_path):
+            return None
+
+        # embedded image in music file
+        def _get_data():
+            tags = TinyTag.get(file_path, image=True)
+            return tags.get_image()
+
+        return await self.mass.loop.run_in_executor(None, _get_data)
+
+    async def get_filepath(
+        self, media_type: MediaType, prov_item_id: str
+    ) -> str | None:
+        """Get full filepath on disk for item_id."""
+        if prov_item_id is None:
+            return None  # guard
+        # funky sql queries go here ;-)
+        table = f"{media_type.value}s"
+        query = (
+            f"SELECT json_extract(json_each.value, '$.url') as url FROM {table}"
+            " ,json_each(provider_ids) WHERE"
+            f" json_extract(json_each.value, '$.prov_id') = '{self.id}'"
+            f" AND json_extract(json_each.value, '$.item_id') = '{prov_item_id}'"
+        )
+        for db_row in await self.mass.database.get_rows_from_query(query):
+            file_path = db_row["url"]
+            # ensure we have a full path and not relative
+            if self.config.path not in file_path:
+                file_path = os.path.join(self.config.path, file_path)
+            return file_path
+        return None
+
+    def _get_item_id(self, file_path: str) -> str:
+        """Create item id from filename."""
+        return create_clean_string(file_path.replace(self.config.path, ""))
diff --git a/music_assistant/music_providers/librespot/linux/librespot-aarch64 b/music_assistant/music_providers/librespot/linux/librespot-aarch64
new file mode 100755 (executable)
index 0000000..5359098
Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-aarch64 differ
diff --git a/music_assistant/music_providers/librespot/linux/librespot-arm b/music_assistant/music_providers/librespot/linux/librespot-arm
new file mode 100755 (executable)
index 0000000..5cd38c7
Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-arm differ
diff --git a/music_assistant/music_providers/librespot/linux/librespot-armhf b/music_assistant/music_providers/librespot/linux/librespot-armhf
new file mode 100755 (executable)
index 0000000..18c2e05
Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-armhf differ
diff --git a/music_assistant/music_providers/librespot/linux/librespot-armv7 b/music_assistant/music_providers/librespot/linux/librespot-armv7
new file mode 100755 (executable)
index 0000000..0a792b2
Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-armv7 differ
diff --git a/music_assistant/music_providers/librespot/linux/librespot-x86_64 b/music_assistant/music_providers/librespot/linux/librespot-x86_64
new file mode 100755 (executable)
index 0000000..e025abd
Binary files /dev/null and b/music_assistant/music_providers/librespot/linux/librespot-x86_64 differ
diff --git a/music_assistant/music_providers/librespot/osx/librespot b/music_assistant/music_providers/librespot/osx/librespot
new file mode 100755 (executable)
index 0000000..c1b3754
Binary files /dev/null and b/music_assistant/music_providers/librespot/osx/librespot differ
diff --git a/music_assistant/music_providers/librespot/windows/librespot.exe b/music_assistant/music_providers/librespot/windows/librespot.exe
new file mode 100755 (executable)
index 0000000..a973f4e
Binary files /dev/null and b/music_assistant/music_providers/librespot/windows/librespot.exe differ
diff --git a/music_assistant/music_providers/qobuz.py b/music_assistant/music_providers/qobuz.py
new file mode 100644 (file)
index 0000000..ffb4c37
--- /dev/null
@@ -0,0 +1,733 @@
+"""Qobuz musicprovider support for MusicAssistant."""
+from __future__ import annotations
+
+import datetime
+import hashlib
+import time
+from json import JSONDecodeError
+from typing import AsyncGenerator, List, Optional
+
+import aiohttp
+from asyncio_throttle import Throttler
+
+from music_assistant.helpers.app_vars import (  # pylint: disable=no-name-in-module
+    app_var,
+)
+from music_assistant.helpers.audio import get_http_stream
+from music_assistant.helpers.cache import use_cache
+from music_assistant.helpers.util import parse_title_and_version, try_parse_int
+from music_assistant.models.enums import ProviderType
+from music_assistant.models.errors import LoginFailed, MediaNotFoundError
+from music_assistant.models.media_items import (
+    Album,
+    AlbumType,
+    Artist,
+    ContentType,
+    ImageType,
+    MediaItemImage,
+    MediaItemProviderId,
+    MediaItemType,
+    MediaQuality,
+    MediaType,
+    Playlist,
+    StreamDetails,
+    Track,
+)
+from music_assistant.models.music_provider import MusicProvider
+
+
+class QobuzProvider(MusicProvider):
+    """Provider for the Qobux music service."""
+
+    _attr_type = ProviderType.QOBUZ
+    _attr_name = "Qobuz"
+    _attr_supported_mediatypes = [
+        MediaType.ARTIST,
+        MediaType.ALBUM,
+        MediaType.TRACK,
+        MediaType.PLAYLIST,
+    ]
+    _user_auth_info = None
+    _throttler = Throttler(rate_limit=4, period=1)
+
+    async def setup(self) -> bool:
+        """Handle async initialization of the provider."""
+        if not self.config.enabled:
+            return False
+        if not self.config.username or not self.config.password:
+            raise LoginFailed("Invalid login credentials")
+        # try to get a token, raise if that fails
+        token = await self._auth_token()
+        if not token:
+            raise LoginFailed(f"Login failed for user {self.config.username}")
+        return True
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """
+        Perform search on musicprovider.
+
+            :param search_query: Search query.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: Number of items to return in the search (per type).
+        """
+        result = []
+        params = {"query": search_query, "limit": limit}
+        if len(media_types) == 1:
+            # qobuz does not support multiple searchtypes, falls back to all if no type given
+            if media_types[0] == MediaType.ARTIST:
+                params["type"] = "artists"
+            if media_types[0] == MediaType.ALBUM:
+                params["type"] = "albums"
+            if media_types[0] == MediaType.TRACK:
+                params["type"] = "tracks"
+            if media_types[0] == MediaType.PLAYLIST:
+                params["type"] = "playlists"
+        if searchresult := await self._get_data("catalog/search", **params):
+            if "artists" in searchresult:
+                result += [
+                    await self._parse_artist(item)
+                    for item in searchresult["artists"]["items"]
+                    if (item and item["id"])
+                ]
+            if "albums" in searchresult:
+                result += [
+                    await self._parse_album(item)
+                    for item in searchresult["albums"]["items"]
+                    if (item and item["id"])
+                ]
+            if "tracks" in searchresult:
+                result += [
+                    await self._parse_track(item)
+                    for item in searchresult["tracks"]["items"]
+                    if (item and item["id"])
+                ]
+            if "playlists" in searchresult:
+                result += [
+                    await self._parse_playlist(item)
+                    for item in searchresult["playlists"]["items"]
+                    if (item and item["id"])
+                ]
+        return result
+
+    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+        """Retrieve all library artists from Qobuz."""
+        endpoint = "favorite/getUserFavorites"
+        for item in await self._get_all_items(endpoint, key="artists", type="artists"):
+            if item and item["id"]:
+                yield await self._parse_artist(item)
+
+    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+        """Retrieve all library albums from Qobuz."""
+        endpoint = "favorite/getUserFavorites"
+        for item in await self._get_all_items(endpoint, key="albums", type="albums"):
+            if item and item["id"]:
+                yield await self._parse_album(item)
+
+    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
+        """Retrieve library tracks from Qobuz."""
+        endpoint = "favorite/getUserFavorites"
+        for item in await self._get_all_items(endpoint, key="tracks", type="tracks"):
+            if item and item["id"]:
+                yield await self._parse_track(item)
+
+    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+        """Retrieve all library playlists from the provider."""
+        endpoint = "playlist/getUserPlaylists"
+        for item in await self._get_all_items(endpoint, key="playlists"):
+            if item and item["id"]:
+                yield await self._parse_playlist(item)
+
+    async def get_artist(self, prov_artist_id) -> Artist:
+        """Get full artist details by id."""
+        params = {"artist_id": prov_artist_id}
+        artist_obj = await self._get_data("artist/get", **params)
+        return (
+            await self._parse_artist(artist_obj)
+            if artist_obj and artist_obj["id"]
+            else None
+        )
+
+    async def get_album(self, prov_album_id) -> Album:
+        """Get full album details by id."""
+        params = {"album_id": prov_album_id}
+        album_obj = await self._get_data("album/get", **params)
+        return (
+            await self._parse_album(album_obj)
+            if album_obj and album_obj["id"]
+            else None
+        )
+
+    async def get_track(self, prov_track_id) -> Track:
+        """Get full track details by id."""
+        params = {"track_id": prov_track_id}
+        track_obj = await self._get_data("track/get", **params)
+        return (
+            await self._parse_track(track_obj)
+            if track_obj and track_obj["id"]
+            else None
+        )
+
+    async def get_playlist(self, prov_playlist_id) -> Playlist:
+        """Get full playlist details by id."""
+        params = {"playlist_id": prov_playlist_id}
+        playlist_obj = await self._get_data("playlist/get", **params)
+        return (
+            await self._parse_playlist(playlist_obj)
+            if playlist_obj and playlist_obj["id"]
+            else None
+        )
+
+    async def get_album_tracks(self, prov_album_id) -> List[Track]:
+        """Get all album tracks for given album id."""
+        params = {"album_id": prov_album_id}
+        return [
+            await self._parse_track(item)
+            for item in await self._get_all_items("album/get", **params, key="tracks")
+            if (item and item["id"])
+        ]
+
+    async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]:
+        """Get all playlist tracks for given playlist id."""
+        playlist = await self.get_playlist(prov_playlist_id)
+        endpoint = "playlist/get"
+        return [
+            await self._parse_track(item)
+            for item in await self._get_all_items(
+                endpoint,
+                key="tracks",
+                playlist_id=prov_playlist_id,
+                extra="tracks",
+                cache_checksum=playlist.metadata.checksum,
+            )
+            if (item and item["id"])
+        ]
+
+    async def get_artist_albums(self, prov_artist_id) -> List[Album]:
+        """Get a list of albums for the given artist."""
+        endpoint = "artist/get"
+        return [
+            await self._parse_album(item)
+            for item in await self._get_all_items(
+                endpoint, key="albums", artist_id=prov_artist_id, extra="albums"
+            )
+            if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id)
+        ]
+
+    async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
+        """Get a list of most popular tracks for the given artist."""
+        result = await self._get_data(
+            "artist/get",
+            artist_id=prov_artist_id,
+            extra="playlists",
+            offset=0,
+            limit=25,
+        )
+        if result and result["playlists"]:
+            return [
+                await self._parse_track(item)
+                for item in result["playlists"][0]["tracks"]["items"]
+                if (item and item["id"])
+            ]
+        # fallback to search
+        artist = await self.get_artist(prov_artist_id)
+        searchresult = await self._get_data(
+            "catalog/search", query=artist.name, limit=25, type="tracks"
+        )
+        return [
+            await self._parse_track(item)
+            for item in searchresult["tracks"]["items"]
+            if (
+                item
+                and item["id"]
+                and "performer" in item
+                and str(item["performer"]["id"]) == str(prov_artist_id)
+            )
+        ]
+
+    async def get_similar_artists(self, prov_artist_id):
+        """Get similar artists for given artist."""
+        # https://www.qobuz.com/api.json/0.2/artist/getSimilarArtists?artist_id=220020&offset=0&limit=3
+
+    async def library_add(self, prov_item_id, media_type: MediaType):
+        """Add item to library."""
+        result = None
+        if media_type == MediaType.ARTIST:
+            result = await self._get_data("favorite/create", artist_id=prov_item_id)
+        elif media_type == MediaType.ALBUM:
+            result = await self._get_data("favorite/create", album_ids=prov_item_id)
+        elif media_type == MediaType.TRACK:
+            result = await self._get_data("favorite/create", track_ids=prov_item_id)
+        elif media_type == MediaType.PLAYLIST:
+            result = await self._get_data(
+                "playlist/subscribe", playlist_id=prov_item_id
+            )
+        return result
+
+    async def library_remove(self, prov_item_id, media_type: MediaType):
+        """Remove item from library."""
+        result = None
+        if media_type == MediaType.ARTIST:
+            result = await self._get_data("favorite/delete", artist_ids=prov_item_id)
+        elif media_type == MediaType.ALBUM:
+            result = await self._get_data("favorite/delete", album_ids=prov_item_id)
+        elif media_type == MediaType.TRACK:
+            result = await self._get_data("favorite/delete", track_ids=prov_item_id)
+        elif media_type == MediaType.PLAYLIST:
+            playlist = await self.get_playlist(prov_item_id)
+            if playlist.is_editable:
+                result = await self._get_data(
+                    "playlist/delete", playlist_id=prov_item_id
+                )
+            else:
+                result = await self._get_data(
+                    "playlist/unsubscribe", playlist_id=prov_item_id
+                )
+        return result
+
+    async def add_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> None:
+        """Add track(s) to playlist."""
+        return await self._get_data(
+            "playlist/addTracks",
+            playlist_id=prov_playlist_id,
+            track_ids=",".join(prov_track_ids),
+            playlist_track_ids=",".join(prov_track_ids),
+        )
+
+    async def remove_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> None:
+        """Remove track(s) from playlist."""
+        playlist_track_ids = set()
+        for track in await self._get_all_items(
+            "playlist/get", key="tracks", playlist_id=prov_playlist_id, extra="tracks"
+        ):
+            if str(track["id"]) in prov_track_ids:
+                playlist_track_ids.add(str(track["playlist_track_id"]))
+        return await self._get_data(
+            "playlist/deleteTracks",
+            playlist_id=prov_playlist_id,
+            playlist_track_ids=",".join(playlist_track_ids),
+        )
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Return the content details for the given track when it will be streamed."""
+        streamdata = None
+        for format_id in [27, 7, 6, 5]:
+            # it seems that simply requesting for highest available quality does not work
+            # from time to time the api response is empty for this request ?!
+            result = await self._get_data(
+                "track/getFileUrl",
+                sign_request=True,
+                format_id=format_id,
+                track_id=item_id,
+                intent="stream",
+                skip_cache=True,
+            )
+            if result and result.get("url"):
+                streamdata = result
+                break
+        if not streamdata:
+            raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}")
+        if streamdata["mime_type"] == "audio/mpeg":
+            content_type = ContentType.MPEG
+        elif streamdata["mime_type"] == "audio/flac":
+            content_type = ContentType.FLAC
+        else:
+            raise MediaNotFoundError(f"Unsupported mime type for {item_id}")
+        return StreamDetails(
+            item_id=str(item_id),
+            provider=self.type,
+            content_type=content_type,
+            duration=streamdata["duration"],
+            sample_rate=int(streamdata["sampling_rate"] * 1000),
+            bit_depth=streamdata["bit_depth"],
+            data=streamdata,  # we need these details for reporting playback
+            expires=time.time() + 1800,  # not sure about the real allowed value
+        )
+
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """Return the audio stream for the provider item."""
+        self.mass.create_task(self._report_playback_started(streamdetails))
+        bytes_sent = 0
+        try:
+            url = streamdetails.data["url"]
+            async for chunk in get_http_stream(
+                self.mass, url, streamdetails, seek_position
+            ):
+                yield chunk
+                bytes_sent += len(chunk)
+        finally:
+            if bytes_sent:
+                self.mass.create_task(
+                    self._report_playback_stopped(streamdetails, bytes_sent)
+                )
+
+    async def _report_playback_started(self, streamdetails: StreamDetails) -> None:
+        """Report playback start to qobuz."""
+        # TODO: need to figure out if the streamed track is purchased by user
+        # https://www.qobuz.com/api.json/0.2/purchase/getUserPurchasesIds?limit=5000&user_id=xxxxxxx
+        # {"albums":{"total":0,"items":[]},"tracks":{"total":0,"items":[]},"user":{"id":xxxx,"login":"xxxxx"}}
+        device_id = self._user_auth_info["user"]["device"]["id"]
+        credential_id = self._user_auth_info["user"]["credential"]["id"]
+        user_id = self._user_auth_info["user"]["id"]
+        format_id = streamdetails.data["format_id"]
+        timestamp = int(time.time())
+        events = [
+            {
+                "online": True,
+                "sample": False,
+                "intent": "stream",
+                "device_id": device_id,
+                "track_id": str(streamdetails.item_id),
+                "purchase": False,
+                "date": timestamp,
+                "credential_id": credential_id,
+                "user_id": user_id,
+                "local": False,
+                "format_id": format_id,
+            }
+        ]
+        await self._post_data("track/reportStreamingStart", data=events)
+
+    async def _report_playback_stopped(
+        self, streamdetails: StreamDetails, bytes_sent: int
+    ) -> None:
+        """Report playback stop to qobuz."""
+        user_id = self._user_auth_info["user"]["id"]
+        await self._get_data(
+            "/track/reportStreamingEnd",
+            user_id=user_id,
+            track_id=str(streamdetails.item_id),
+            duration=try_parse_int(streamdetails.seconds_streamed),
+        )
+
+    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.type, name=artist_obj["name"]
+        )
+        artist.add_provider_id(
+            MediaItemProviderId(
+                item_id=str(artist_obj["id"]),
+                prov_type=self.type,
+                prov_id=self.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"):
+            artist.metadata.description = artist_obj["biography"].get("content")
+        return artist
+
+    async def _parse_album(self, album_obj: dict, artist_obj: dict = None):
+        """Parse qobuz album object to generic layout."""
+        if not artist_obj and "artist" not in album_obj:
+            # artist missing in album info, return full abum instead
+            return await self.get_album(album_obj["id"])
+        name, version = parse_title_and_version(
+            album_obj["title"], album_obj.get("version")
+        )
+        album = Album(
+            item_id=str(album_obj["id"]), provider=self.type, name=name, version=version
+        )
+        if album_obj["maximum_sampling_rate"] > 192:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
+        elif album_obj["maximum_sampling_rate"] > 96:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
+        elif album_obj["maximum_sampling_rate"] > 48:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
+        elif album_obj["maximum_bit_depth"] > 16:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1
+        elif album_obj.get("format_id", 0) == 5:
+            quality = MediaQuality.LOSSY_AAC
+        else:
+            quality = MediaQuality.FLAC_LOSSLESS
+        album.add_provider_id(
+            MediaItemProviderId(
+                item_id=str(album_obj["id"]),
+                prov_type=self.type,
+                prov_id=self.id,
+                quality=quality,
+                url=album_obj.get(
+                    "url", f'https://open.qobuz.com/album/{album_obj["id"]}'
+                ),
+                details=f'{album_obj["maximum_sampling_rate"]}kHz {album_obj["maximum_bit_depth"]}bit',
+                available=album_obj["streamable"] and album_obj["displayable"],
+            )
+        )
+
+        album.artist = await self._parse_artist(artist_obj or album_obj["artist"])
+        if (
+            album_obj.get("product_type", "") == "single"
+            or album_obj.get("release_type", "") == "single"
+        ):
+            album.album_type = AlbumType.SINGLE
+        elif (
+            album_obj.get("product_type", "") == "compilation"
+            or "Various" in album.artist.name
+        ):
+            album.album_type = AlbumType.COMPILATION
+        elif (
+            album_obj.get("product_type", "") == "album"
+            or album_obj.get("release_type", "") == "album"
+        ):
+            album.album_type = AlbumType.ALBUM
+        if "genre" in album_obj:
+            album.metadata.genres = {album_obj["genre"]["name"]}
+        if img := self.__get_image(album_obj):
+            album.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
+        if len(album_obj["upc"]) == 13:
+            # qobuz writes ean as upc ?!
+            album.upc = album_obj["upc"][1:]
+        else:
+            album.upc = album_obj["upc"]
+        if "label" in album_obj:
+            album.metadata.label = album_obj["label"]["name"]
+        if album_obj.get("released_at"):
+            album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year
+        if album_obj.get("copyright"):
+            album.metadata.copyright = album_obj["copyright"]
+        if album_obj.get("description"):
+            album.metadata.description = album_obj["description"]
+        return album
+
+    async def _parse_track(self, track_obj: dict):
+        """Parse qobuz track object to generic layout."""
+        name, version = parse_title_and_version(
+            track_obj["title"], track_obj.get("version")
+        )
+        track = Track(
+            item_id=str(track_obj["id"]),
+            provider=self.type,
+            name=name,
+            version=version,
+            disc_number=track_obj["media_number"],
+            track_number=track_obj["track_number"],
+            duration=track_obj["duration"],
+            position=track_obj.get("position"),
+        )
+        if track_obj.get("performer") and "Various " not in track_obj["performer"]:
+            artist = await self._parse_artist(track_obj["performer"])
+            if artist:
+                track.artists.append(artist)
+        if not track.artists:
+            # try to grab artist from album
+            if (
+                track_obj.get("album")
+                and track_obj["album"].get("artist")
+                and "Various " not in track_obj["album"]["artist"]
+            ):
+                artist = await self._parse_artist(track_obj["album"]["artist"])
+                if artist:
+                    track.artists.append(artist)
+        if not track.artists:
+            # last resort: parse from performers string
+            for performer_str in track_obj["performers"].split(" - "):
+                role = performer_str.split(", ")[1]
+                name = performer_str.split(", ")[0]
+                if "artist" in role.lower():
+                    artist = Artist(name, self.type, name)
+                track.artists.append(artist)
+        # TODO: fix grabbing composer from details
+
+        if "album" in track_obj:
+            album = await self._parse_album(track_obj["album"])
+            if album:
+                track.album = album
+        if track_obj.get("isrc"):
+            track.isrc = track_obj["isrc"]
+        if track_obj.get("performers"):
+            track.metadata.performers = {
+                x.strip() for x in track_obj["performers"].split("-")
+            }
+        if track_obj.get("copyright"):
+            track.metadata.copyright = track_obj["copyright"]
+        if track_obj.get("audio_info"):
+            track.metadata.replaygain = track_obj["audio_info"]["replaygain_track_gain"]
+        if track_obj.get("parental_warning"):
+            track.metadata.explicit = True
+        if img := self.__get_image(track_obj):
+            track.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
+        # get track quality
+        if track_obj["maximum_sampling_rate"] > 192:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_4
+        elif track_obj["maximum_sampling_rate"] > 96:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_3
+        elif track_obj["maximum_sampling_rate"] > 48:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_2
+        elif track_obj["maximum_bit_depth"] > 16:
+            quality = MediaQuality.FLAC_LOSSLESS_HI_RES_1
+        elif track_obj.get("format_id", 0) == 5:
+            quality = MediaQuality.LOSSY_AAC
+        else:
+            quality = MediaQuality.FLAC_LOSSLESS
+        track.add_provider_id(
+            MediaItemProviderId(
+                item_id=str(track_obj["id"]),
+                prov_type=self.type,
+                prov_id=self.id,
+                quality=quality,
+                url=track_obj.get(
+                    "url", f'https://open.qobuz.com/track/{track_obj["id"]}'
+                ),
+                details=f'{track_obj["maximum_sampling_rate"]}kHz {track_obj["maximum_bit_depth"]}bit',
+                available=track_obj["streamable"] and track_obj["displayable"],
+            )
+        )
+        return track
+
+    async def _parse_playlist(self, playlist_obj):
+        """Parse qobuz playlist object to generic layout."""
+        playlist = Playlist(
+            item_id=str(playlist_obj["id"]),
+            provider=self.type,
+            name=playlist_obj["name"],
+            owner=playlist_obj["owner"]["name"],
+        )
+        playlist.add_provider_id(
+            MediaItemProviderId(
+                item_id=str(playlist_obj["id"]),
+                prov_type=self.type,
+                prov_id=self.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"]
+            or playlist_obj["is_collaborative"]
+        )
+        if img := self.__get_image(playlist_obj):
+            playlist.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
+        playlist.metadata.checksum = str(playlist_obj["updated_at"])
+        return playlist
+
+    async def _auth_token(self):
+        """Login to qobuz and store the token."""
+        if self._user_auth_info:
+            return self._user_auth_info["user_auth_token"]
+        params = {
+            "username": self.config.username,
+            "password": self.config.password,
+            "device_manufacturer_id": "music_assistant",
+        }
+        details = await self._get_data("user/login", **params)
+        if details and "user" in details:
+            self._user_auth_info = details
+            self.logger.info(
+                "Succesfully logged in to Qobuz as %s", details["user"]["display_name"]
+            )
+            self.mass.metadata.preferred_language = details["user"]["country_code"]
+            return details["user_auth_token"]
+
+    @use_cache(3600 * 24)
+    async def _get_all_items(self, endpoint, key="tracks", **kwargs):
+        """Get all items from a paged list."""
+        limit = 50
+        offset = 0
+        all_items = []
+        while True:
+            kwargs["limit"] = limit
+            kwargs["offset"] = offset
+            result = await self._get_data(endpoint, skip_cache=True, **kwargs)
+            offset += limit
+            if not result:
+                break
+            if not result.get(key) or not result[key].get("items"):
+                break
+            for item in result[key]["items"]:
+                item["position"] = len(all_items) + 1
+                all_items.append(item)
+            if len(result[key]["items"]) < limit:
+                break
+        return all_items
+
+    @use_cache(3600 * 2)
+    async def _get_data(self, endpoint, sign_request=False, **kwargs):
+        """Get data from api."""
+        url = f"http://www.qobuz.com/api.json/0.2/{endpoint}"
+        headers = {"X-App-Id": app_var(0)}
+        if endpoint != "user/login":
+            auth_token = await self._auth_token()
+            if not auth_token:
+                self.logger.debug("Not logged in")
+                return None
+            headers["X-User-Auth-Token"] = auth_token
+        if sign_request:
+            signing_data = "".join(endpoint.split("/"))
+            keys = list(kwargs.keys())
+            keys.sort()
+            for key in keys:
+                signing_data += f"{key}{kwargs[key]}"
+            request_ts = str(time.time())
+            request_sig = signing_data + request_ts + app_var(1)
+            request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
+            kwargs["request_ts"] = request_ts
+            kwargs["request_sig"] = request_sig
+            kwargs["app_id"] = app_var(0)
+            kwargs["user_auth_token"] = await self._auth_token()
+        async with self._throttler:
+            async with self.mass.http_session.get(
+                url, headers=headers, params=kwargs, verify_ssl=False
+            ) as response:
+                try:
+                    result = await response.json()
+                    if "error" in result or (
+                        "status" in result and "error" in result["status"]
+                    ):
+                        self.logger.error("%s - %s", endpoint, result)
+                        return None
+                except (
+                    aiohttp.ContentTypeError,
+                    JSONDecodeError,
+                ) as err:
+                    self.logger.error("%s - %s", endpoint, str(err))
+                    return None
+                return result
+
+    async def _post_data(self, endpoint, params=None, data=None):
+        """Post data to api."""
+        if not params:
+            params = {}
+        if not data:
+            data = {}
+        url = f"http://www.qobuz.com/api.json/0.2/{endpoint}"
+        params["app_id"] = app_var(0)
+        params["user_auth_token"] = await self._auth_token()
+        async with self.mass.http_session.post(
+            url, params=params, json=data, verify_ssl=False
+        ) as response:
+            result = await response.json()
+            if "error" in result or (
+                "status" in result and "error" in result["status"]
+            ):
+                self.logger.error("%s - %s", endpoint, result)
+                return None
+            return result
+
+    def __get_image(self, obj: dict) -> Optional[str]:
+        """Try to parse image from Qobuz media object."""
+        if obj.get("image"):
+            for key in ["extralarge", "large", "medium", "small"]:
+                if obj["image"].get(key):
+                    if "2a96cbd8b46e442fc41c2b86b821562f" in obj["image"][key]:
+                        continue
+                    return obj["image"][key]
+        if obj.get("images300"):
+            # playlists seem to use this strange format
+            return obj["images300"][0]
+        if obj.get("album"):
+            return self.__get_image(obj["album"])
+        if obj.get("artist"):
+            return self.__get_image(obj["artist"])
+        return None
diff --git a/music_assistant/music_providers/spotify.py b/music_assistant/music_providers/spotify.py
new file mode 100644 (file)
index 0000000..56912c1
--- /dev/null
@@ -0,0 +1,694 @@
+"""Spotify musicprovider support for MusicAssistant."""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import platform
+import time
+from json.decoder import JSONDecodeError
+from tempfile import gettempdir
+from typing import AsyncGenerator, List, Optional
+
+import aiohttp
+from asyncio_throttle import Throttler
+
+from music_assistant.helpers.app_vars import (  # noqa # pylint: disable=no-name-in-module
+    app_var,
+)
+from music_assistant.helpers.cache import use_cache
+from music_assistant.helpers.process import AsyncProcess
+from music_assistant.helpers.util import parse_title_and_version
+from music_assistant.models.enums import ProviderType
+from music_assistant.models.errors import LoginFailed, MediaNotFoundError
+from music_assistant.models.media_items import (
+    Album,
+    AlbumType,
+    Artist,
+    ContentType,
+    ImageType,
+    MediaItemImage,
+    MediaItemProviderId,
+    MediaItemType,
+    MediaQuality,
+    MediaType,
+    Playlist,
+    StreamDetails,
+    Track,
+)
+from music_assistant.models.music_provider import MusicProvider
+
+CACHE_DIR = gettempdir()
+
+
+class SpotifyProvider(MusicProvider):
+    """Implementation of a Spotify MusicProvider."""
+
+    _attr_type = ProviderType.SPOTIFY
+    _attr_name = "Spotify"
+    _attr_supported_mediatypes = [
+        MediaType.ARTIST,
+        MediaType.ALBUM,
+        MediaType.TRACK,
+        MediaType.PLAYLIST
+        # TODO: Return spotify radio
+    ]
+    _auth_token = None
+    _sp_user = None
+    _librespot_bin = None
+    _throttler = Throttler(rate_limit=4, period=1)
+    _cache_dir = CACHE_DIR
+
+    async def setup(self) -> bool:
+        """Handle async initialization of the provider."""
+        if not self.config.enabled:
+            return False
+        if not self.config.username or not self.config.password:
+            raise LoginFailed("Invalid login credentials")
+        # try to get a token, raise if that fails
+        self._cache_dir = os.path.join(CACHE_DIR, self.id)
+        token = await self.get_token()
+        if not token:
+            raise LoginFailed(f"Login failed for user {self.config.username}")
+        return True
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """
+        Perform search on musicprovider.
+
+            :param search_query: Search query.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: Number of items to return in the search (per type).
+        """
+        result = []
+        searchtypes = []
+        if MediaType.ARTIST in media_types:
+            searchtypes.append("artist")
+        if MediaType.ALBUM in media_types:
+            searchtypes.append("album")
+        if MediaType.TRACK in media_types:
+            searchtypes.append("track")
+        if MediaType.PLAYLIST in media_types:
+            searchtypes.append("playlist")
+        searchtype = ",".join(searchtypes)
+        if searchresult := await self._get_data(
+            "search", q=search_query, type=searchtype, limit=limit
+        ):
+            if "artists" in searchresult:
+                result += [
+                    await self._parse_artist(item)
+                    for item in searchresult["artists"]["items"]
+                    if (item and item["id"])
+                ]
+            if "albums" in searchresult:
+                result += [
+                    await self._parse_album(item)
+                    for item in searchresult["albums"]["items"]
+                    if (item and item["id"])
+                ]
+            if "tracks" in searchresult:
+                result += [
+                    await self._parse_track(item)
+                    for item in searchresult["tracks"]["items"]
+                    if (item and item["id"])
+                ]
+            if "playlists" in searchresult:
+                result += [
+                    await self._parse_playlist(item)
+                    for item in searchresult["playlists"]["items"]
+                    if (item and item["id"])
+                ]
+        return result
+
+    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+        """Retrieve library artists from spotify."""
+        endpoint = "me/following"
+        while True:
+            spotify_artists = await self._get_data(
+                endpoint, type="artist", limit=50, skip_cache=True
+            )
+            for item in spotify_artists["artists"]["items"]:
+                if item and item["id"]:
+                    yield await self._parse_artist(item)
+            if spotify_artists["artists"]["next"]:
+                endpoint = spotify_artists["artists"]["next"]
+                endpoint = endpoint.replace("https://api.spotify.com/v1/", "")
+            else:
+                break
+
+    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+        """Retrieve library albums from the provider."""
+        for item in await self._get_all_items("me/albums", skip_cache=True):
+            if item["album"] and item["album"]["id"]:
+                yield await self._parse_album(item["album"])
+
+    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
+        """Retrieve library tracks from the provider."""
+        for item in await self._get_all_items("me/tracks", skip_cache=True):
+            if item and item["track"]["id"]:
+                yield await self._parse_track(item["track"])
+
+    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+        """Retrieve playlists from the provider."""
+        for item in await self._get_all_items("me/playlists", skip_cache=True):
+            if item and item["id"]:
+                yield await self._parse_playlist(item)
+
+    async def get_artist(self, prov_artist_id) -> Artist:
+        """Get full artist details by id."""
+        artist_obj = await self._get_data(f"artists/{prov_artist_id}")
+        return await self._parse_artist(artist_obj) if artist_obj else None
+
+    async def get_album(self, prov_album_id) -> Album:
+        """Get full album details by id."""
+        album_obj = await self._get_data(f"albums/{prov_album_id}")
+        return await self._parse_album(album_obj) if album_obj else None
+
+    async def get_track(self, prov_track_id) -> Track:
+        """Get full track details by id."""
+        track_obj = await self._get_data(f"tracks/{prov_track_id}")
+        return await self._parse_track(track_obj) if track_obj else None
+
+    async def get_playlist(self, prov_playlist_id) -> Playlist:
+        """Get full playlist details by id."""
+        playlist_obj = await self._get_data(f"playlists/{prov_playlist_id}")
+        return await self._parse_playlist(playlist_obj) if playlist_obj else None
+
+    async def get_album_tracks(self, prov_album_id) -> List[Track]:
+        """Get all album tracks for given album id."""
+        return [
+            await self._parse_track(item)
+            for item in await self._get_all_items(f"albums/{prov_album_id}/tracks")
+            if (item and item["id"])
+        ]
+
+    async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]:
+        """Get all playlist tracks for given playlist id."""
+        playlist = await self.get_playlist(prov_playlist_id)
+        return [
+            await self._parse_track(item["track"])
+            for item in await self._get_all_items(
+                f"playlists/{prov_playlist_id}/tracks",
+                cache_checksum=playlist.metadata.checksum,
+            )
+            if (item and item["track"] and item["track"]["id"])
+        ]
+
+    async def get_artist_albums(self, prov_artist_id) -> List[Album]:
+        """Get a list of all albums for the given artist."""
+        return [
+            await self._parse_album(item)
+            for item in await self._get_all_items(
+                f"artists/{prov_artist_id}/albums?include_groups=album,single,compilation"
+            )
+            if (item and item["id"])
+        ]
+
+    async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
+        """Get a list of 10 most popular tracks for the given artist."""
+        artist = await self.get_artist(prov_artist_id)
+        endpoint = f"artists/{prov_artist_id}/top-tracks"
+        items = await self._get_data(endpoint)
+        return [
+            await self._parse_track(item, artist=artist)
+            for item in items["tracks"]
+            if (item and item["id"])
+        ]
+
+    async def library_add(self, prov_item_id, media_type: MediaType):
+        """Add item to library."""
+        result = False
+        if media_type == MediaType.ARTIST:
+            result = await self._put_data(
+                "me/following", {"ids": prov_item_id, "type": "artist"}
+            )
+        elif media_type == MediaType.ALBUM:
+            result = await self._put_data("me/albums", {"ids": prov_item_id})
+        elif media_type == MediaType.TRACK:
+            result = await self._put_data("me/tracks", {"ids": prov_item_id})
+        elif media_type == MediaType.PLAYLIST:
+            result = await self._put_data(
+                f"playlists/{prov_item_id}/followers", data={"public": False}
+            )
+        return result
+
+    async def library_remove(self, prov_item_id, media_type: MediaType):
+        """Remove item from library."""
+        result = False
+        if media_type == MediaType.ARTIST:
+            result = await self._delete_data(
+                "me/following", {"ids": prov_item_id, "type": "artist"}
+            )
+        elif media_type == MediaType.ALBUM:
+            result = await self._delete_data("me/albums", {"ids": prov_item_id})
+        elif media_type == MediaType.TRACK:
+            result = await self._delete_data("me/tracks", {"ids": prov_item_id})
+        elif media_type == MediaType.PLAYLIST:
+            result = await self._delete_data(f"playlists/{prov_item_id}/followers")
+        return result
+
+    async def add_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ):
+        """Add track(s) to playlist."""
+        track_uris = []
+        for track_id in prov_track_ids:
+            track_uris.append(f"spotify:track:{track_id}")
+        data = {"uris": track_uris}
+        return await self._post_data(f"playlists/{prov_playlist_id}/tracks", data=data)
+
+    async def remove_playlist_tracks(
+        self, prov_playlist_id: str, prov_track_ids: List[str]
+    ) -> None:
+        """Remove track(s) from playlist."""
+        track_uris = []
+        for track_id in prov_track_ids:
+            track_uris.append({"uri": f"spotify:track:{track_id}"})
+        data = {"tracks": track_uris}
+        return await self._delete_data(
+            f"playlists/{prov_playlist_id}/tracks", data=data
+        )
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Return the content details for the given track when it will be streamed."""
+        # make sure a valid track is requested.
+        track = await self.get_track(item_id)
+        if not track:
+            raise MediaNotFoundError(f"track {item_id} not found")
+        # make sure that the token is still valid by just requesting it
+        await self.get_token()
+        return StreamDetails(
+            item_id=track.item_id,
+            provider=self.type,
+            content_type=ContentType.OGG,
+            duration=track.duration,
+        )
+
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """Return the audio stream for the provider item."""
+        # make sure that the token is still valid by just requesting it
+        await self.get_token()
+        librespot = await self.get_librespot_binary()
+        args = [
+            librespot,
+            "-c",
+            self._cache_dir,
+            "--pass-through",
+            "-b",
+            "320",
+            "--single-track",
+            f"spotify://track:{streamdetails.item_id}",
+        ]
+        if seek_position:
+            args += ["--start-position", str(int(seek_position))]
+        async with AsyncProcess(args) as librespot_proc:
+            async for chunk in librespot_proc.iterate_chunks():
+                yield chunk
+
+    async def _parse_artist(self, artist_obj):
+        """Parse spotify artist object to generic layout."""
+        artist = Artist(
+            item_id=artist_obj["id"], provider=self.type, name=artist_obj["name"]
+        )
+        artist.add_provider_id(
+            MediaItemProviderId(
+                item_id=artist_obj["id"],
+                prov_type=self.type,
+                prov_id=self.id,
+                url=artist_obj["external_urls"]["spotify"],
+            )
+        )
+        if "genres" in artist_obj:
+            artist.metadata.genres = set(artist_obj["genres"])
+        if artist_obj.get("images"):
+            for img in artist_obj["images"]:
+                img_url = img["url"]
+                if "2a96cbd8b46e442fc41c2b86b821562f" not in img_url:
+                    artist.metadata.images = [MediaItemImage(ImageType.THUMB, img_url)]
+                    break
+        return artist
+
+    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.type, name=name, version=version
+        )
+        for artist_obj in album_obj["artists"]:
+            album.artists.append(await self._parse_artist(artist_obj))
+        if album_obj["album_type"] == "single":
+            album.album_type = AlbumType.SINGLE
+        elif album_obj["album_type"] == "compilation":
+            album.album_type = AlbumType.COMPILATION
+        elif album_obj["album_type"] == "album":
+            album.album_type = AlbumType.ALBUM
+        if "genres" in album_obj:
+            album.metadata.genre = set(album_obj["genres"])
+        if album_obj.get("images"):
+            album.metadata.images = [
+                MediaItemImage(ImageType.THUMB, album_obj["images"][0]["url"])
+            ]
+        if "external_ids" in album_obj and album_obj["external_ids"].get("upc"):
+            album.upc = album_obj["external_ids"]["upc"]
+        if "label" in album_obj:
+            album.metadata.label = album_obj["label"]
+        if album_obj.get("release_date"):
+            album.year = int(album_obj["release_date"].split("-")[0])
+        if album_obj.get("copyrights"):
+            album.metadata.copyright = album_obj["copyrights"][0]["text"]
+        if album_obj.get("explicit"):
+            album.metadata.explicit = album_obj["explicit"]
+        album.add_provider_id(
+            MediaItemProviderId(
+                item_id=album_obj["id"],
+                prov_type=self.type,
+                prov_id=self.id,
+                quality=MediaQuality.LOSSY_OGG,
+                url=album_obj["external_urls"]["spotify"],
+            )
+        )
+        return album
+
+    async def _parse_track(self, track_obj, artist=None):
+        """Parse spotify track object to generic layout."""
+        name, version = parse_title_and_version(track_obj["name"])
+        track = Track(
+            item_id=track_obj["id"],
+            provider=self.type,
+            name=name,
+            version=version,
+            duration=track_obj["duration_ms"] / 1000,
+            disc_number=track_obj["disc_number"],
+            track_number=track_obj["track_number"],
+            position=track_obj.get("position"),
+        )
+        if artist:
+            track.artists.append(artist)
+        for track_artist in track_obj.get("artists", []):
+            artist = await self._parse_artist(track_artist)
+            if artist and artist.item_id not in {x.item_id for x in track.artists}:
+                track.artists.append(artist)
+
+        track.metadata.explicit = track_obj["explicit"]
+        if "preview_url" in track_obj:
+            track.metadata.preview = track_obj["preview_url"]
+        if "external_ids" in track_obj and "isrc" in track_obj["external_ids"]:
+            track.isrc = track_obj["external_ids"]["isrc"]
+        if "album" in track_obj:
+            track.album = await self._parse_album(track_obj["album"])
+            if track_obj["album"].get("images"):
+                track.metadata.images = [
+                    MediaItemImage(
+                        ImageType.THUMB, track_obj["album"]["images"][0]["url"]
+                    )
+                ]
+        if track_obj.get("copyright"):
+            track.metadata.copyright = track_obj["copyright"]
+        if track_obj.get("explicit"):
+            track.metadata.explicit = True
+        if track_obj.get("popularity"):
+            track.metadata.popularity = track_obj["popularity"]
+        track.add_provider_id(
+            MediaItemProviderId(
+                item_id=track_obj["id"],
+                prov_type=self.type,
+                prov_id=self.id,
+                quality=MediaQuality.LOSSY_OGG,
+                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):
+        """Parse spotify playlist object to generic layout."""
+        playlist = Playlist(
+            item_id=playlist_obj["id"],
+            provider=self.type,
+            name=playlist_obj["name"],
+            owner=playlist_obj["owner"]["display_name"],
+        )
+        playlist.add_provider_id(
+            MediaItemProviderId(
+                item_id=playlist_obj["id"],
+                prov_type=self.type,
+                prov_id=self.id,
+                url=playlist_obj["external_urls"]["spotify"],
+            )
+        )
+        playlist.is_editable = (
+            playlist_obj["owner"]["id"] == self._sp_user["id"]
+            or playlist_obj["collaborative"]
+        )
+        if playlist_obj.get("images"):
+            playlist.metadata.images = [
+                MediaItemImage(ImageType.THUMB, playlist_obj["images"][0]["url"])
+            ]
+        playlist.metadata.checksum = str(playlist_obj["snapshot_id"])
+        return playlist
+
+    async def get_token(self):
+        """Get auth token on spotify."""
+        # return existing token if we have one in memory
+        if (
+            self._auth_token
+            and os.path.isdir(self._cache_dir)
+            and (self._auth_token["expiresAt"] > int(time.time()) + 20)
+        ):
+            return self._auth_token
+        tokeninfo = {}
+        if not self.config.username or not self.config.password:
+            return tokeninfo
+        # retrieve token with librespot
+        retries = 0
+        while retries < 4:
+            try:
+                tokeninfo = await asyncio.wait_for(self._get_token(), 5)
+                if tokeninfo:
+                    break
+                retries += 1
+                await asyncio.sleep(2)
+            except TimeoutError:
+                pass
+        if tokeninfo:
+            self._auth_token = tokeninfo
+            self._sp_user = await self._get_data("me")
+            self.mass.metadata.preferred_language = self._sp_user["country"]
+            self.logger.info(
+                "Succesfully logged in to Spotify as %s", self._sp_user["id"]
+            )
+            self._auth_token = tokeninfo
+        else:
+            self.logger.error("Login failed for user %s", self.config.username)
+        return tokeninfo
+
+    async def _get_token(self):
+        """Get spotify auth token with librespot bin."""
+        # authorize with username and password (NOTE: this can also be Spotify Connect)
+        args = [
+            await self.get_librespot_binary(),
+            "-O",
+            "-c",
+            self._cache_dir,
+            "-a",
+            "-u",
+            self.config.username,
+            "-p",
+            self.config.password,
+        ]
+        librespot = await asyncio.create_subprocess_exec(*args)
+        await librespot.wait()
+        # get token with (authorized) librespot
+        scopes = [
+            "user-read-playback-state",
+            "user-read-currently-playing",
+            "user-modify-playback-state",
+            "playlist-read-private",
+            "playlist-read-collaborative",
+            "playlist-modify-public",
+            "playlist-modify-private",
+            "user-follow-modify",
+            "user-follow-read",
+            "user-library-read",
+            "user-library-modify",
+            "user-read-private",
+            "user-read-email",
+            "user-read-birthdate",
+            "user-top-read",
+        ]
+        scope = ",".join(scopes)
+        args = [
+            await self.get_librespot_binary(),
+            "-O",
+            "-t",
+            "--client-id",
+            app_var(2),
+            "--scope",
+            scope,
+            "-c",
+            self._cache_dir,
+        ]
+        librespot = await asyncio.create_subprocess_exec(
+            *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
+        )
+        stdout, _ = await librespot.communicate()
+        try:
+            result = json.loads(stdout)
+        except JSONDecodeError:
+            self.logger.warning(
+                "Error while retrieving Spotify token, details: %s", stdout
+            )
+            return None
+        # transform token info to spotipy compatible format
+        if result and "accessToken" in result:
+            tokeninfo = result
+            tokeninfo["expiresAt"] = tokeninfo["expiresIn"] + int(time.time())
+            return tokeninfo
+        return None
+
+    @use_cache(3600 * 24)
+    async def _get_all_items(self, endpoint, key="items", **kwargs) -> List[dict]:
+        """Get all items from a paged list."""
+        limit = 50
+        offset = 0
+        all_items = []
+        while True:
+            kwargs["limit"] = limit
+            kwargs["offset"] = offset
+            result = await self._get_data(endpoint, skip_cache=True, **kwargs)
+            offset += limit
+            if not result or key not in result or not result[key]:
+                break
+            for item in result[key]:
+                item["position"] = len(all_items) + 1
+                all_items.append(item)
+            if len(result[key]) < limit:
+                break
+        return all_items
+
+    @use_cache(3600 * 2)
+    async def _get_data(self, endpoint, **kwargs):
+        """Get data from api."""
+        url = f"https://api.spotify.com/v1/{endpoint}"
+        kwargs["market"] = "from_token"
+        kwargs["country"] = "from_token"
+        token = await self.get_token()
+        if not token:
+            return None
+        headers = {"Authorization": f'Bearer {token["accessToken"]}'}
+        async with self._throttler:
+            async with self.mass.http_session.get(
+                url, headers=headers, params=kwargs, verify_ssl=False
+            ) as response:
+                try:
+                    result = await response.json()
+                    if "error" in result or (
+                        "status" in result and "error" in result["status"]
+                    ):
+                        self.logger.error("%s - %s", endpoint, result)
+                        return None
+                except (
+                    aiohttp.ContentTypeError,
+                    JSONDecodeError,
+                ) as err:
+                    self.logger.error("%s - %s", endpoint, str(err))
+                    return None
+                return result
+
+    async def _delete_data(self, endpoint, data=None, **kwargs):
+        """Delete data from api."""
+        url = f"https://api.spotify.com/v1/{endpoint}"
+        token = await self.get_token()
+        if not token:
+            return None
+        headers = {"Authorization": f'Bearer {token["accessToken"]}'}
+        async with self.mass.http_session.delete(
+            url, headers=headers, params=kwargs, json=data, verify_ssl=False
+        ) as response:
+            return await response.text()
+
+    async def _put_data(self, endpoint, data=None, **kwargs):
+        """Put data on api."""
+        url = f"https://api.spotify.com/v1/{endpoint}"
+        token = await self.get_token()
+        if not token:
+            return None
+        headers = {"Authorization": f'Bearer {token["accessToken"]}'}
+        async with self.mass.http_session.put(
+            url, headers=headers, params=kwargs, json=data, verify_ssl=False
+        ) as response:
+            return await response.text()
+
+    async def _post_data(self, endpoint, data=None, **kwargs):
+        """Post data on api."""
+        url = f"https://api.spotify.com/v1/{endpoint}"
+        token = await self.get_token()
+        if not token:
+            return None
+        headers = {"Authorization": f'Bearer {token["accessToken"]}'}
+        async with self.mass.http_session.post(
+            url, headers=headers, params=kwargs, json=data, verify_ssl=False
+        ) as response:
+            return await response.text()
+
+    async def get_librespot_binary(self):
+        """Find the correct librespot binary belonging to the platform."""
+        if self._librespot_bin is not None:
+            return self._librespot_bin
+
+        async def check_librespot(librespot_path: str) -> str | None:
+            try:
+                librespot = await asyncio.create_subprocess_exec(
+                    *[librespot_path, "-V"], stdout=asyncio.subprocess.PIPE
+                )
+                stdout, _ = await librespot.communicate()
+                if librespot.returncode == 0 and b"librespot" in stdout:
+                    self._librespot_bin = librespot_path
+                    return librespot_path
+            except OSError:
+                return None
+
+        base_path = os.path.join(os.path.dirname(__file__), "librespot")
+        if platform.system() == "Windows":
+            if librespot := await check_librespot(
+                os.path.join(base_path, "windows", "librespot.exe")
+            ):
+                return librespot
+        if platform.system() == "Darwin":
+            # macos binary is x86_64 intel
+            if librespot := await check_librespot(
+                os.path.join(base_path, "osx", "librespot")
+            ):
+                return librespot
+
+        if platform.system() == "Linux":
+            architecture = platform.machine()
+            if architecture in ["AMD64", "x86_64"]:
+                # generic linux x86_64 binary
+                if librespot := await check_librespot(
+                    os.path.join(
+                        base_path,
+                        "linux",
+                        "librespot-x86_64",
+                    )
+                ):
+                    return librespot
+
+            # arm architecture... try all options one by one...
+            for arch in ["aarch64", "armv7", "armhf", "arm"]:
+                if librespot := await check_librespot(
+                    os.path.join(
+                        base_path,
+                        "linux",
+                        f"librespot-{arch}",
+                    )
+                ):
+                    return librespot
+
+        raise RuntimeError(
+            f"Unable to locate Libespot for platform {platform.system()}"
+        )
diff --git a/music_assistant/music_providers/tunein.py b/music_assistant/music_providers/tunein.py
new file mode 100644 (file)
index 0000000..239896d
--- /dev/null
@@ -0,0 +1,194 @@
+"""Tune-In musicprovider support for MusicAssistant."""
+from __future__ import annotations
+
+from typing import AsyncGenerator, List, Optional
+
+from asyncio_throttle import Throttler
+
+from music_assistant.helpers.audio import get_radio_stream
+from music_assistant.helpers.cache import use_cache
+from music_assistant.helpers.util import create_clean_string
+from music_assistant.models.enums import ProviderType
+from music_assistant.models.errors import LoginFailed, MediaNotFoundError
+from music_assistant.models.media_items import (
+    ContentType,
+    ImageType,
+    MediaItemImage,
+    MediaItemProviderId,
+    MediaItemType,
+    MediaQuality,
+    MediaType,
+    Radio,
+    StreamDetails,
+)
+from music_assistant.models.music_provider import MusicProvider
+
+
+class TuneInProvider(MusicProvider):
+    """Provider implementation for Tune In."""
+
+    _attr_type = ProviderType.TUNEIN
+    _attr_name = "Tune-in Radio"
+    _attr_supported_mediatypes = [MediaType.RADIO]
+    _throttler = Throttler(rate_limit=1, period=1)
+
+    async def setup(self) -> bool:
+        """Handle async initialization of the provider."""
+        if not self.config.enabled:
+            return False
+        if not self.config.username:
+            raise LoginFailed("Username is invalid")
+        return True
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """
+        Perform search on musicprovider.
+
+            :param search_query: Search query.
+            :param media_types: A list of media_types to include. All types if None.
+            :param limit: Number of items to return in the search (per type).
+        """
+        # pylint: disable=no-self-use
+        # TODO: search for radio stations
+        return []
+
+    async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
+        """Retrieve library/subscribed radio stations from the provider."""
+
+        async def parse_items(
+            items: List[dict], folder: str = None
+        ) -> AsyncGenerator[Radio, None]:
+            for item in items:
+                item_type = item.get("type", "")
+                if item_type == "audio":
+                    if "preset_id" not in item:
+                        continue
+                    # each radio station can have multiple streams add each one as different quality
+                    stream_info = await self.__get_data(
+                        "Tune.ashx", id=item["preset_id"]
+                    )
+                    for stream in stream_info["body"]:
+                        yield await self._parse_radio(item, stream, folder)
+                elif item_type == "link":
+                    # stations are in sublevel (new style)
+                    if sublevel := await self.__get_data(item["URL"], render="json"):
+                        async for subitem in parse_items(
+                            sublevel["body"], item["text"]
+                        ):
+                            yield subitem
+                elif item.get("children"):
+                    # stations are in sublevel (old style ?)
+                    async for subitem in parse_items(item["children"], item["text"]):
+                        yield subitem
+
+        data = await self.__get_data("Browse.ashx", c="presets")
+        if data and "body" in data:
+            async for item in parse_items(data["body"]):
+                yield item
+
+    async def get_radio(self, prov_radio_id: str) -> Radio:
+        """Get radio station details."""
+        prov_radio_id, media_type = prov_radio_id.split("--", 1)
+        params = {"c": "composite", "detail": "listing", "id": prov_radio_id}
+        result = await self.__get_data("Describe.ashx", **params)
+        if result and result.get("body") and result["body"][0].get("children"):
+            item = result["body"][0]["children"][0]
+            stream_info = await self.__get_data("Tune.ashx", id=prov_radio_id)
+            for stream in stream_info["body"]:
+                if stream["media_type"] != media_type:
+                    continue
+                return await self._parse_radio(item, stream)
+        return None
+
+    async def _parse_radio(
+        self, details: dict, stream: dict, folder: Optional[str] = None
+    ) -> Radio:
+        """Parse Radio object from json obj returned from api."""
+        if "name" in details:
+            name = details["name"]
+        else:
+            # parse name from text attr
+            name = details["text"]
+            if " | " in name:
+                name = name.split(" | ")[1]
+            name = name.split(" (")[0]
+        item_id = f'{details["preset_id"]}--{stream["media_type"]}'
+        radio = Radio(item_id=item_id, provider=self.type, name=name)
+        if stream["media_type"] == "aac":
+            quality = MediaQuality.LOSSY_AAC
+        elif stream["media_type"] == "ogg":
+            quality = MediaQuality.LOSSY_OGG
+        else:
+            quality = MediaQuality.LOSSY_MP3
+        radio.add_provider_id(
+            MediaItemProviderId(
+                item_id=item_id,
+                prov_type=self.type,
+                prov_id=self.id,
+                quality=quality,
+                details=stream["url"],
+            )
+        )
+        # preset number is used for sorting (not present at stream time)
+        preset_number = details.get("preset_number")
+        if preset_number and folder:
+            radio.sort_name = f'{folder}-{details["preset_number"]}'
+        elif preset_number:
+            radio.sort_name = details["preset_number"]
+        radio.sort_name += create_clean_string(name)
+        if "text" in details:
+            radio.metadata.description = details["text"]
+        # images
+        if img := details.get("image"):
+            radio.metadata.images = [MediaItemImage(ImageType.THUMB, img)]
+        if img := details.get("logo"):
+            radio.metadata.images = [MediaItemImage(ImageType.LOGO, img)]
+        return radio
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails:
+        """Get streamdetails for a radio station."""
+        item_id, media_type = item_id.split("--", 1)
+        stream_info = await self.__get_data("Tune.ashx", id=item_id)
+        for stream in stream_info["body"]:
+            if stream["media_type"] == media_type:
+                return StreamDetails(
+                    provider=self.type,
+                    item_id=item_id,
+                    content_type=ContentType(stream["media_type"]),
+                    media_type=MediaType.RADIO,
+                    data=stream,
+                )
+        raise MediaNotFoundError(f"Unable to retrieve stream details for {item_id}")
+
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """Return the audio stream for the provider item."""
+        async for chunk in get_radio_stream(
+            self.mass, streamdetails.data["url"], streamdetails
+        ):
+            yield chunk
+
+    @use_cache(3600 * 2)
+    async def __get_data(self, endpoint: str, **kwargs):
+        """Get data from api."""
+        if endpoint.startswith("http"):
+            url = endpoint
+        else:
+            url = f"https://opml.radiotime.com/{endpoint}"
+            kwargs["formats"] = "ogg,aac,wma,mp3"
+            kwargs["username"] = self.config.username
+            kwargs["partnerId"] = "1"
+            kwargs["render"] = "json"
+        async with self._throttler:
+            async with self.mass.http_session.get(
+                url, params=kwargs, verify_ssl=False
+            ) as response:
+                result = await response.json()
+                if not result or "error" in result:
+                    self.logger.error(url)
+                    self.logger.error(kwargs)
+                    result = None
+                return result
diff --git a/music_assistant/music_providers/url.py b/music_assistant/music_providers/url.py
new file mode 100644 (file)
index 0000000..1e82483
--- /dev/null
@@ -0,0 +1,74 @@
+"""Basic provider allowing for external URL's to be streamed."""
+from __future__ import annotations
+
+import os
+from typing import AsyncGenerator, List, Optional
+
+from music_assistant.helpers.audio import (
+    get_file_stream,
+    get_http_stream,
+    get_radio_stream,
+)
+from music_assistant.models.config import MusicProviderConfig
+from music_assistant.models.enums import ContentType, MediaType, ProviderType
+from music_assistant.models.media_items import MediaItemType, StreamDetails
+from music_assistant.models.music_provider import MusicProvider
+
+PROVIDER_CONFIG = MusicProviderConfig(ProviderType.URL)
+
+
+class URLProvider(MusicProvider):
+    """Music Provider for manual URL's/files added to the queue."""
+
+    _attr_name: str = "URL"
+    _attr_type: ProviderType = ProviderType.URL
+    _attr_available: bool = True
+    _attr_supported_mediatypes: List[MediaType] = []
+
+    async def setup(self) -> bool:
+        """
+        Handle async initialization of the provider.
+
+        Called when provider is registered.
+        """
+        return True
+
+    async def search(
+        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+    ) -> List[MediaItemType]:
+        """Perform search on musicprovider."""
+        return []
+
+    async def get_stream_details(self, item_id: str) -> StreamDetails | None:
+        """Get streamdetails for a track/radio."""
+        url = item_id
+        return StreamDetails(
+            provider=ProviderType.URL,
+            item_id=item_id,
+            content_type=ContentType.try_parse(url),
+            media_type=MediaType.URL,
+            data=url,
+        )
+
+    async def get_audio_stream(
+        self, streamdetails: StreamDetails, seek_position: int = 0
+    ) -> AsyncGenerator[bytes, None]:
+        """Return the audio stream for the provider item."""
+        if streamdetails.media_type == MediaType.RADIO:
+            # radio stream url
+            async for chunk in get_radio_stream(
+                self.mass, streamdetails.data, streamdetails
+            ):
+                yield chunk
+        elif os.path.isfile(streamdetails.data):
+            # local file
+            async for chunk in get_file_stream(
+                self.mass, streamdetails.data, streamdetails, seek_position
+            ):
+                yield chunk
+        else:
+            # regular stream url (without icy meta and reconnect)
+            async for chunk in get_http_stream(
+                self.mass, streamdetails.data, streamdetails, seek_position
+            ):
+                yield chunk