refactoring - wip
authormarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Thu, 10 Oct 2019 22:24:14 +0000 (00:24 +0200)
committermarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Thu, 10 Oct 2019 22:24:14 +0000 (00:24 +0200)
restrucring of player and queue

18 files changed:
music_assistant/main.py
music_assistant/models.py [deleted file]
music_assistant/models/__init__.py [new file with mode: 0644]
music_assistant/models/media_types.py [new file with mode: 0755]
music_assistant/models/musicprovider.py [new file with mode: 0755]
music_assistant/models/player.py [new file with mode: 0755]
music_assistant/models/player_queue.py [new file with mode: 0755]
music_assistant/models/playerprovider.py [new file with mode: 0755]
music_assistant/modules/http_streamer.py
music_assistant/modules/music.py [deleted file]
music_assistant/modules/music_manager.py [new file with mode: 0755]
music_assistant/modules/player.py [deleted file]
music_assistant/modules/player_manager.py [new file with mode: 0755]
music_assistant/modules/playerproviders/chromecast.py
music_assistant/modules/playerproviders/lms.py
music_assistant/modules/playerproviders/pylms.py
music_assistant/modules/web.py
music_assistant/utils.py

index e9e5530c830ef0e14bb161a93a2df241e2ebdf7a..8b31d17fe5af9edddd1e8d0b2dca7caabcb2263a 100755 (executable)
@@ -20,7 +20,7 @@ from utils import run_periodic, LOGGER
 from modules.metadata import MetaData
 from modules.cache import Cache
 from modules.music import Music
-from modules.player import Player
+from modules.playermanager import PlayerManager
 from modules.http_streamer import HTTPStreamer
 from modules.homeassistant import setup as hass_setup
 from modules.web import setup as web_setup
@@ -48,7 +48,7 @@ class Main():
         self.web = web_setup(self)
         self.hass = hass_setup(self)
         self.music = Music(self)
-        self.player = Player(self)
+        self.player = PlayerManager(self)
         self.http_streamer = HTTPStreamer(self)
 
         # agent = stackimpact.start(
diff --git a/music_assistant/models.py b/music_assistant/models.py
deleted file mode 100755 (executable)
index 7492af1..0000000
+++ /dev/null
@@ -1,563 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-from enum import Enum, IntEnum
-from typing import List
-import sys
-sys.path.append("..")
-from utils import run_periodic, LOGGER, parse_track_title
-from difflib import SequenceMatcher as Matcher
-import asyncio
-from modules.cache import use_cache
-
-
-class MediaType(IntEnum):
-    Artist = 1
-    Album = 2
-    Track = 3
-    Playlist = 4
-    Radio = 5
-
-def media_type_from_string(media_type_str):
-    media_type_str = media_type_str.lower()
-    if 'artist' in media_type_str or media_type_str == '1':
-        return MediaType.Artist
-    elif 'album' in media_type_str or media_type_str == '2':
-        return MediaType.Album
-    elif 'track' in media_type_str or media_type_str == '3':
-        return MediaType.Track
-    elif 'playlist' in media_type_str or media_type_str == '4':
-        return MediaType.Playlist
-    elif 'radio' in media_type_str or media_type_str == '5':
-        return MediaType.Radio
-    else:
-        return None
-
-class ContributorRole(IntEnum):
-    Artist = 1
-    Writer = 2
-    Producer = 3
-
-class AlbumType(IntEnum):
-    Album = 1
-    Single = 2
-    Compilation = 3
-
-class TrackQuality(IntEnum):
-    LOSSY_MP3 = 0
-    LOSSY_OGG = 1
-    LOSSY_AAC = 2
-    FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES
-    FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES
-    FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES
-    FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES
-    FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES
-
-
-class Artist(object):
-    ''' representation of an artist '''
-    def __init__(self):
-        self.item_id = None
-        self.provider = 'database'
-        self.name = ''
-        self.sort_name = ''
-        self.metadata = {}
-        self.tags = []
-        self.external_ids = []
-        self.provider_ids = []
-        self.media_type = MediaType.Artist
-        self.in_library = []
-        self.is_lazy = False
-
-class Album(object):
-    ''' representation of an album '''
-    def __init__(self):
-        self.item_id = None
-        self.provider = 'database'
-        self.name = '' 
-        self.metadata = {}
-        self.version = ''
-        self.external_ids = []
-        self.tags = []
-        self.albumtype = AlbumType.Album
-        self.year = 0
-        self.artist = None
-        self.labels = []
-        self.provider_ids = []
-        self.media_type = MediaType.Album
-        self.in_library = []
-        self.is_lazy = False
-
-class Track(object):
-    ''' representation of a track '''
-    def __init__(self):
-        self.item_id = None
-        self.provider = 'database'
-        self.name = ''
-        self.duration = 0
-        self.version = ''
-        self.external_ids = []
-        self.metadata = { }
-        self.tags = []
-        self.artists = []
-        self.provider_ids = []
-        self.album = None
-        self.disc_number = 1
-        self.track_number = 1
-        self.media_type = MediaType.Track
-        self.in_library = []
-        self.is_lazy = False
-        self.uri = ""
-    def __eq__(self, other): 
-        if not isinstance(other, self.__class__):
-            return NotImplemented
-        return (self.name == other.name and 
-                self.version == other.version and
-                self.item_id == other.item_id and
-                self.provider == other.provider)
-    def __ne__(self, other):
-        return not self.__eq__(other)
-
-class Playlist(object):
-    ''' representation of a playlist '''
-    def __init__(self):
-        self.item_id = None
-        self.provider = 'database'
-        self.name = ''
-        self.owner = ''
-        self.provider_ids = []
-        self.metadata = {}
-        self.media_type = MediaType.Playlist
-        self.in_library = []
-        self.is_editable = False
-
-class Radio(Track):
-    ''' representation of a radio station '''
-    def __init__(self):
-        super().__init__()
-        self.item_id = None
-        self.provider = 'database'
-        self.name = ''
-        self.provider_ids = []
-        self.metadata = {}
-        self.media_type = MediaType.Radio
-        self.in_library = []
-        self.is_editable = False
-        self.duration = 0
-
-class MusicProvider():
-    ''' 
-        Model for a Musicprovider
-        Common methods usable for every provider
-        Provider specific get methods shoud be overriden in the provider specific implementation
-        Uses a form of lazy provisioning to local db as cache
-    '''
-
-    name = 'My great Music provider' # display name
-    prov_id = 'my_provider' # used as id
-    icon = ''
-
-    def __init__(self, mass):
-        self.mass = mass
-        self.cache = mass.cache
-
-    ### Common methods and properties ####
-
-    async def artist(self, prov_item_id, lazy=True) -> Artist:
-        ''' return artist details for the given provider artist id '''
-        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Artist)
-        if not item_id:
-            # artist not yet in local database so fetch details
-            artist_details = await self.get_artist(prov_item_id)
-            if not artist_details:
-                raise Exception('artist not found: %s' % prov_item_id)
-            if lazy:
-                asyncio.create_task(self.add_artist(artist_details))
-                artist_details.is_lazy = True
-                return artist_details
-            item_id = await self.add_artist(artist_details)
-        return await self.mass.db.artist(item_id)
-
-    async def add_artist(self, artist_details) -> int:
-        ''' add artist to local db and return the new database id'''
-        musicbrainz_id = None
-        for item in artist_details.external_ids:
-            if item.get("musicbrainz"):
-                musicbrainz_id = item["musicbrainz"]
-        if not musicbrainz_id:
-            musicbrainz_id = await self.get_artist_musicbrainz_id(artist_details)
-        if not musicbrainz_id:
-            return
-        # grab additional metadata
-        if musicbrainz_id:
-            artist_details.external_ids.append({"musicbrainz": musicbrainz_id})
-            artist_details.metadata = await self.mass.metadata.get_artist_metadata(musicbrainz_id, artist_details.metadata)
-        item_id = await self.mass.db.add_artist(artist_details)
-        # also fetch same artist on all providers
-        new_artist = await self.mass.db.artist(item_id)
-        new_artist_toptracks = await self.get_artist_toptracks(artist_details.item_id)
-        if new_artist_toptracks:
-            item_provider_keys = [item['provider'] for item in new_artist.provider_ids]
-            for prov_id, provider in self.mass.music.providers.items():
-                if not prov_id in item_provider_keys:
-                    await provider.match_artist(new_artist, new_artist_toptracks)
-        return item_id
-
-    async def get_artist_musicbrainz_id(self, artist_details:Artist):
-        ''' fetch musicbrainz id by performing search with both the artist and one of it's albums or tracks '''
-        musicbrainz_id = ""
-        # try with album first
-        lookup_albums = await self.get_artist_albums(artist_details.item_id)
-        for lookup_album in lookup_albums[:5]:
-            lookup_album_upc = None
-            if not lookup_album:
-                continue
-            for item in lookup_album.external_ids:
-                if item.get("upc"):
-                    lookup_album_upc = item["upc"]
-                    break
-            musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name, 
-                    albumname=lookup_album.name, album_upc=lookup_album_upc)
-            if musicbrainz_id:
-                break
-        # fallback to track
-        if not musicbrainz_id:
-            lookup_tracks = await self.get_artist_toptracks(artist_details.item_id)
-            for lookup_track in lookup_tracks:
-                if not lookup_track:
-                    continue
-                lookup_track_isrc = None
-                for item in lookup_track.external_ids:
-                    if item.get("isrc"):
-                        lookup_track_isrc = item["isrc"]
-                        break
-                musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name, 
-                            trackname=lookup_track.name, track_isrc=lookup_track_isrc)
-                if musicbrainz_id:
-                    break
-        if not musicbrainz_id:
-            LOGGER.warning("Unable to get musicbrainz ID for artist %s !" % artist_details.name)
-            musicbrainz_id = artist_details.name
-        return musicbrainz_id
-
-    async def album(self, prov_item_id, lazy=True) -> Album:
-        ''' return album details for the given provider album id'''
-        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Album)
-        if not item_id:
-            # album not yet in local database so fetch details
-            album_details = await self.get_album(prov_item_id)
-            if not album_details:
-                raise Exception('album not found: %s' % prov_item_id)
-            if lazy:
-                asyncio.create_task(self.add_album(album_details))
-                album_details.is_lazy = True
-                return album_details
-            item_id = await self.add_album(album_details)
-        return await self.mass.db.album(item_id)
-
-    async def add_album(self, album_details) -> int:
-        ''' add album to local db and return the new database id'''
-        # we need to fetch album artist too
-        db_album_artist = await self.artist(album_details.artist.item_id, lazy=False)
-        album_details.artist = db_album_artist
-        item_id = await self.mass.db.add_album(album_details)
-        # also fetch same album on all providers
-        new_album = await self.mass.db.album(item_id)
-        item_provider_keys = [item['provider'] for item in new_album.provider_ids]
-        for prov_id, provider in self.mass.music.providers.items():
-            if not prov_id in item_provider_keys:
-                await provider.match_album(new_album)
-        return item_id
-
-    async def track(self, prov_item_id, lazy=True, track_details=None) -> Track:
-        ''' return track details for the given provider track id '''
-        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Track)
-        if not item_id:
-            # album not yet in local database so fetch details
-            if not track_details:
-                track_details = await self.get_track(prov_item_id)
-            if not track_details:
-                raise Exception('track not found: %s' % prov_item_id)
-            if lazy:
-                asyncio.create_task(self.add_track(track_details))
-                track_details.is_lazy = True
-                return track_details
-            item_id = await self.add_track(track_details)
-        return await self.mass.db.track(item_id)
-
-    async def add_track(self, track_details, prov_album_id=None) -> int:
-        ''' add track to local db and return the new database id'''
-        track_artists = []
-        # we need to fetch track artists too
-        for track_artist in track_details.artists:
-            db_track_artist = await self.artist(track_artist.item_id, lazy=False)
-            if db_track_artist:
-                track_artists.append(db_track_artist)
-        track_details.artists = track_artists
-        if not prov_album_id:
-            prov_album_id = track_details.album.item_id
-        track_details.album = await self.album(prov_album_id, lazy=False)
-        item_id = await self.mass.db.add_track(track_details)
-        # also fetch same track on all providers (will also get other quality versions)
-        new_track = await self.mass.db.track(item_id)
-        for prov_id, provider in self.mass.music.providers.items():
-            await provider.match_track(new_track)
-        return item_id
-    
-    async def playlist(self, prov_playlist_id) -> Playlist:
-        ''' return playlist details for the given provider playlist id '''
-        db_id = await self.mass.db.get_database_id(self.prov_id, prov_playlist_id, MediaType.Playlist)
-        if db_id:
-            # synced playlist, return database details
-            return await self.mass.db.playlist(db_id)
-        else:
-            return await self.get_playlist(prov_playlist_id)
-
-    async def radio(self, prov_radio_id) -> Radio:
-        ''' return radio details for the given provider playlist id '''
-        db_id = await self.mass.db.get_database_id(self.prov_id, prov_radio_id, MediaType.Radio)
-        if db_id:
-            # synced radio, return database details
-            return await self.mass.db.radio(db_id)
-        else:
-            return await self.get_radio(prov_radio_id)
-
-    async def album_tracks(self, prov_album_id) -> List[Track]:
-        ''' return album tracks for the given provider album id'''
-        items = []
-        album = await self.get_album(prov_album_id)
-        for prov_track in await self.get_album_tracks(prov_album_id):
-            db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track) 
-            if db_id:
-                items.append( await self.mass.db.track(db_id) )
-            else:
-                prov_track.album = album
-                items.append(prov_track)
-        return items
-
-    async def playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]:
-        ''' return playlist tracks for the given provider playlist id'''
-        items = []
-        for prov_track in await self.get_playlist_tracks(prov_playlist_id, limit=limit, offset=offset):
-            for prov_mapping in prov_track.provider_ids:
-                item_prov_id = prov_mapping["provider"]
-                prov_item_id = prov_mapping["item_id"]
-                db_id = await self.mass.db.get_database_id(item_prov_id, prov_item_id, MediaType.Track) 
-                if db_id:
-                    items.append( await self.mass.db.track(db_id) )
-                else:
-                    items.append(prov_track)
-        return items
-    
-    async def artist_toptracks(self, prov_item_id) -> List[Track]:
-        ''' return top tracks for an artist '''
-        items = []
-        for prov_track in await self.get_artist_toptracks(prov_item_id):
-            db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track) 
-            if db_id:
-                items.append( await self.mass.db.track(db_id) )
-            else:
-                items.append(prov_track)
-        return items
-
-    async def artist_albums(self, prov_item_id) -> List[Track]:
-        ''' return (all) albums for an artist '''
-        items = []
-        for prov_album in await self.get_artist_albums(prov_item_id):
-            db_id = await self.mass.db.get_database_id(self.prov_id, prov_album.item_id, MediaType.Album) 
-            if db_id:
-                items.append( await self.mass.db.album(db_id) )
-            else:
-                items.append(prov_album)
-        return items
-    
-    async def match_artist(self, searchartist:Artist, searchtracks:List[Track]):
-        ''' try to match artist in this provider by supplying db artist '''
-        for searchtrack in searchtracks:
-            searchstr = "%s - %s" %(searchartist.name, searchtrack.name)
-            search_results = await self.search(searchstr, [MediaType.Track], limit=5)
-            for item in search_results["tracks"]:
-                if item.name == searchtrack.name and item.version == searchtrack.version and item.album.name == searchtrack.album.name:
-                    # double safety check - artist must match exactly !
-                    for artist in item.artists:
-                        if artist.name == searchartist.name:
-                            # just load this item in the database, it will be matched automagically ;-)
-                            return await self.artist(artist.item_id, lazy=False)
-
-    async def match_album(self, searchalbum:Album):
-        ''' try to match album in this provider by supplying db album '''
-        searchstr = "%s - %s %s" %(searchalbum.artist.name, searchalbum.name, searchalbum.version)
-        search_results = await self.search(searchstr, [MediaType.Album], limit=5)
-        for item in search_results["albums"]:
-            if item.name == searchalbum.name and item.version == searchalbum.version and item.artist.name == searchalbum.artist.name:
-                # just load this item in the database, it will be matched automagically ;-)
-                await self.album(item.item_id, lazy=False)
-
-    async def match_track(self, searchtrack:Track):
-        ''' try to match track in this provider by supplying db track '''
-        searchstr = "%s - %s" %(searchtrack.artists[0].name, searchtrack.name)
-        searchartists = [item.name for item in searchtrack.artists]
-        search_results = await self.search(searchstr, [MediaType.Track], limit=5)
-        for item in search_results["tracks"]:
-            if item.name == searchtrack.name and item.version == searchtrack.version and item.album.name == searchtrack.album.name:
-                # double safety check - artist must match exactly !
-                for artist in item.artists:
-                    if artist.name in searchartists:
-                        # just load this item in the database, it will be matched automagically ;-)
-                        await self.track(item.item_id, lazy=False)
-
-    ### Provider specific implementation #####
-
-    async def search(self, searchstring, media_types=List[MediaType], limit=5):
-        ''' perform search on the provider '''
-        raise NotImplementedError
-    
-    async def get_library_artists(self) -> List[Artist]:
-        ''' retrieve library artists from the provider '''
-        raise NotImplementedError
-    
-    async def get_library_albums(self) -> List[Album]:
-        ''' retrieve library albums from the provider '''
-        raise NotImplementedError
-
-    async def get_library_tracks(self) -> List[Track]:
-        ''' retrieve library tracks from the provider '''
-        raise NotImplementedError
-
-    async def get_playlists(self) -> List[Playlist]:
-        ''' retrieve library/subscribed playlists from the provider '''
-        raise NotImplementedError
-
-    async def get_radios(self) -> List[Radio]:
-        ''' retrieve library/subscribed radio stations from the provider '''
-        raise NotImplementedError
-
-    async def get_artist(self, prov_item_id) -> Artist:
-        ''' get full artist details by id '''
-        raise NotImplementedError
-
-    async def get_artist_albums(self, prov_item_id) -> List[Album]:
-        ''' get a list of albums for the given artist '''
-        raise NotImplementedError
-    
-    async def get_artist_toptracks(self, prov_item_id) -> List[Track]:
-        ''' get a list of most popular tracks for the given artist '''
-        raise NotImplementedError
-
-    async def get_album(self, prov_item_id) -> Album:
-        ''' get full album details by id '''
-        raise NotImplementedError
-
-    async def get_track(self, prov_item_id) -> Track:
-        ''' get full track details by id '''
-        raise NotImplementedError
-
-    async def get_playlist(self, prov_item_id) -> Playlist:
-        ''' get full playlist details by id '''
-        raise NotImplementedError
-
-    async def get_radio(self, prov_item_id) -> Radio:
-        ''' get full radio details by id '''
-        raise NotImplementedError
-
-    async def get_album_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]:
-        ''' get album tracks for given album id '''
-        raise NotImplementedError
-
-    async def get_playlist_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]:
-        ''' get playlist tracks for given playlist id '''
-        raise NotImplementedError
-
-    async def add_library(self, prov_item_id, media_type:MediaType):
-        ''' add item to library '''
-        raise NotImplementedError
-
-    async def remove_library(self, prov_item_id, media_type:MediaType):
-        ''' remove item from library '''
-        raise NotImplementedError
-
-    async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids):
-        ''' add track(s) to playlist '''
-        raise NotImplementedError
-
-    async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids):
-        ''' remove track(s) from playlist '''
-        raise NotImplementedError
-
-    async def get_stream_content_type(self, track_id):
-        ''' return the content type for the given track when it will be streamed'''
-        raise NotImplementedError
-    
-    async def get_stream(self, track_id):
-        ''' get audio stream for a track '''
-        raise NotImplementedError
-    
-    
-class PlayerState(str, Enum):
-    Off = "off"
-    Stopped = "stopped"
-    Paused = "paused"
-    Playing = "playing"
-
-class MusicPlayer():
-    ''' representation of a musicplayer '''
-    def __init__(self):
-        self.player_id = None
-        self.player_provider = None
-        self.name = ''
-        self.state = PlayerState.Stopped
-        self.powered = False
-        self.cur_item = None
-        self.cur_item_time = 0
-        self.volume_level = 0
-        self.shuffle_enabled = True
-        self.repeat_enabled = False
-        self.muted = False
-        self.group_parent = None
-        self.is_group = False
-        self.settings = {}
-        self.enabled = True
-
-class PlayerProvider():
-    ''' 
-        Model for a Playerprovider
-        Common methods usable for every provider
-        Provider specific __get methods shoud be overriden in the provider specific implementation
-    '''
-    name = 'My great Musicplayer provider' # display name
-    prov_id = 'my_provider' # used as id
-    icon = ''
-    supported_musicproviders = ['qobuz', 'file', 'spotify', 'http'] # list of supported music provider uri's this playerprovider supports NATIVELY
-    
-    def __init__(self, mass):
-        self.mass = mass
-
-    ### Common methods and properties ####
-
-
-    async def play_media(self, player_id, media_items:List[Track], queue_opt='play'):
-        ''' 
-            play media on a player
-            params:
-            - player_id: id of the player
-            - media_items: List of Tracks to play, each Track will contain uri attribute (e.g. spotify:track:1234 or http://pathtostream)
-            - queue_opt: 
-                replace: replace whatever is currently playing with this media
-                next: the given media will be played after the currently playing track
-                add: add to the end of the queue
-                play: keep existing queue but play the given item(s) now first
-        '''
-        raise NotImplementedError
-
-
-    ### Provider specific implementation #####
-
-    async def player_command(self, player_id, cmd:str, cmd_args=None):
-        ''' issue command on player (play, pause, next, previous, stop, power, volume) '''
-        raise NotImplementedError
-
-    async def player_queue(self, player_id, offset=0, limit=50):
-        ''' return the items in the player's queue '''
-        raise NotImplementedError
-
-
diff --git a/music_assistant/models/__init__.py b/music_assistant/models/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py
new file mode 100755 (executable)
index 0000000..1c968a6
--- /dev/null
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+from enum import Enum, IntEnum
+
+class MediaType(IntEnum):
+    Artist = 1
+    Album = 2
+    Track = 3
+    Playlist = 4
+    Radio = 5
+
+def media_type_from_string(media_type_str):
+    media_type_str = media_type_str.lower()
+    if 'artist' in media_type_str or media_type_str == '1':
+        return MediaType.Artist
+    elif 'album' in media_type_str or media_type_str == '2':
+        return MediaType.Album
+    elif 'track' in media_type_str or media_type_str == '3':
+        return MediaType.Track
+    elif 'playlist' in media_type_str or media_type_str == '4':
+        return MediaType.Playlist
+    elif 'radio' in media_type_str or media_type_str == '5':
+        return MediaType.Radio
+    else:
+        return None
+
+class ContributorRole(IntEnum):
+    Artist = 1
+    Writer = 2
+    Producer = 3
+
+class AlbumType(IntEnum):
+    Album = 1
+    Single = 2
+    Compilation = 3
+
+class TrackQuality(IntEnum):
+    LOSSY_MP3 = 0
+    LOSSY_OGG = 1
+    LOSSY_AAC = 2
+    FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES
+
+
+class Artist(object):
+    ''' representation of an artist '''
+    def __init__(self):
+        self.item_id = None
+        self.provider = 'database'
+        self.name = ''
+        self.sort_name = ''
+        self.metadata = {}
+        self.tags = []
+        self.external_ids = []
+        self.provider_ids = []
+        self.media_type = MediaType.Artist
+        self.in_library = []
+        self.is_lazy = False
+
+class Album(object):
+    ''' representation of an album '''
+    def __init__(self):
+        self.item_id = None
+        self.provider = 'database'
+        self.name = '' 
+        self.metadata = {}
+        self.version = ''
+        self.external_ids = []
+        self.tags = []
+        self.albumtype = AlbumType.Album
+        self.year = 0
+        self.artist = None
+        self.labels = []
+        self.provider_ids = []
+        self.media_type = MediaType.Album
+        self.in_library = []
+        self.is_lazy = False
+
+class Track(object):
+    ''' representation of a track '''
+    def __init__(self):
+        self.item_id = None
+        self.provider = 'database'
+        self.name = ''
+        self.duration = 0
+        self.version = ''
+        self.external_ids = []
+        self.metadata = { }
+        self.tags = []
+        self.artists = []
+        self.provider_ids = []
+        self.album = None
+        self.disc_number = 1
+        self.track_number = 1
+        self.media_type = MediaType.Track
+        self.in_library = []
+        self.is_lazy = False
+        self.uri = ""
+    def __eq__(self, other): 
+        if not isinstance(other, self.__class__):
+            return NotImplemented
+        return (self.name == other.name and 
+                self.version == other.version and
+                self.item_id == other.item_id and
+                self.provider == other.provider)
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+class Playlist(object):
+    ''' representation of a playlist '''
+    def __init__(self):
+        self.item_id = None
+        self.provider = 'database'
+        self.name = ''
+        self.owner = ''
+        self.provider_ids = []
+        self.metadata = {}
+        self.media_type = MediaType.Playlist
+        self.in_library = []
+        self.is_editable = False
+
+class Radio(Track):
+    ''' representation of a radio station '''
+    def __init__(self):
+        super().__init__()
+        self.item_id = None
+        self.provider = 'database'
+        self.name = ''
+        self.provider_ids = []
+        self.metadata = {}
+        self.media_type = MediaType.Radio
+        self.in_library = []
+        self.is_editable = False
+        self.duration = 0
+
+
diff --git a/music_assistant/models/musicprovider.py b/music_assistant/models/musicprovider.py
new file mode 100755 (executable)
index 0000000..c75e16a
--- /dev/null
@@ -0,0 +1,418 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+from typing import List
+from ..utils import run_periodic, LOGGER, parse_track_title
+import asyncio
+from ..modules.cache import use_cache
+from media_types import *
+
+
+class MusicProvider():
+    ''' 
+        Model for a Musicprovider
+        Common methods usable for every provider
+        Provider specific get methods shoud be overriden in the provider specific implementation
+        Uses a form of lazy provisioning to local db as cache
+    '''
+
+    name = 'My great Music provider' # display name
+    prov_id = 'my_provider' # used as id
+    icon = ''
+
+    def __init__(self, mass):
+        self.mass = mass
+        self.cache = mass.cache
+
+    ### Common methods and properties ####
+
+    async def artist(self, prov_item_id, lazy=True) -> Artist:
+        ''' return artist details for the given provider artist id '''
+        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Artist)
+        if not item_id:
+            # artist not yet in local database so fetch details
+            artist_details = await self.get_artist(prov_item_id)
+            if not artist_details:
+                raise Exception('artist not found: %s' % prov_item_id)
+            if lazy:
+                asyncio.create_task(self.add_artist(artist_details))
+                artist_details.is_lazy = True
+                return artist_details
+            item_id = await self.add_artist(artist_details)
+        return await self.mass.db.artist(item_id)
+
+    async def add_artist(self, artist_details) -> int:
+        ''' add artist to local db and return the new database id'''
+        musicbrainz_id = None
+        for item in artist_details.external_ids:
+            if item.get("musicbrainz"):
+                musicbrainz_id = item["musicbrainz"]
+        if not musicbrainz_id:
+            musicbrainz_id = await self.get_artist_musicbrainz_id(artist_details)
+        if not musicbrainz_id:
+            return
+        # grab additional metadata
+        if musicbrainz_id:
+            artist_details.external_ids.append({"musicbrainz": musicbrainz_id})
+            artist_details.metadata = await self.mass.metadata.get_artist_metadata(musicbrainz_id, artist_details.metadata)
+        item_id = await self.mass.db.add_artist(artist_details)
+        # also fetch same artist on all providers
+        new_artist = await self.mass.db.artist(item_id)
+        new_artist_toptracks = await self.get_artist_toptracks(artist_details.item_id)
+        if new_artist_toptracks:
+            item_provider_keys = [item['provider'] for item in new_artist.provider_ids]
+            for prov_id, provider in self.mass.music.providers.items():
+                if not prov_id in item_provider_keys:
+                    await provider.match_artist(new_artist, new_artist_toptracks)
+        return item_id
+
+    async def get_artist_musicbrainz_id(self, artist_details:Artist):
+        ''' fetch musicbrainz id by performing search with both the artist and one of it's albums or tracks '''
+        musicbrainz_id = ""
+        # try with album first
+        lookup_albums = await self.get_artist_albums(artist_details.item_id)
+        for lookup_album in lookup_albums[:5]:
+            lookup_album_upc = None
+            if not lookup_album:
+                continue
+            for item in lookup_album.external_ids:
+                if item.get("upc"):
+                    lookup_album_upc = item["upc"]
+                    break
+            musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name, 
+                    albumname=lookup_album.name, album_upc=lookup_album_upc)
+            if musicbrainz_id:
+                break
+        # fallback to track
+        if not musicbrainz_id:
+            lookup_tracks = await self.get_artist_toptracks(artist_details.item_id)
+            for lookup_track in lookup_tracks:
+                if not lookup_track:
+                    continue
+                lookup_track_isrc = None
+                for item in lookup_track.external_ids:
+                    if item.get("isrc"):
+                        lookup_track_isrc = item["isrc"]
+                        break
+                musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name, 
+                            trackname=lookup_track.name, track_isrc=lookup_track_isrc)
+                if musicbrainz_id:
+                    break
+        if not musicbrainz_id:
+            LOGGER.warning("Unable to get musicbrainz ID for artist %s !" % artist_details.name)
+            musicbrainz_id = artist_details.name
+        return musicbrainz_id
+
+    async def album(self, prov_item_id, lazy=True) -> Album:
+        ''' return album details for the given provider album id'''
+        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Album)
+        if not item_id:
+            # album not yet in local database so fetch details
+            album_details = await self.get_album(prov_item_id)
+            if not album_details:
+                raise Exception('album not found: %s' % prov_item_id)
+            if lazy:
+                asyncio.create_task(self.add_album(album_details))
+                album_details.is_lazy = True
+                return album_details
+            item_id = await self.add_album(album_details)
+        return await self.mass.db.album(item_id)
+
+    async def add_album(self, album_details) -> int:
+        ''' add album to local db and return the new database id'''
+        # we need to fetch album artist too
+        db_album_artist = await self.artist(album_details.artist.item_id, lazy=False)
+        album_details.artist = db_album_artist
+        item_id = await self.mass.db.add_album(album_details)
+        # also fetch same album on all providers
+        new_album = await self.mass.db.album(item_id)
+        item_provider_keys = [item['provider'] for item in new_album.provider_ids]
+        for prov_id, provider in self.mass.music.providers.items():
+            if not prov_id in item_provider_keys:
+                await provider.match_album(new_album)
+        return item_id
+
+    async def track(self, prov_item_id, lazy=True, track_details=None) -> Track:
+        ''' return track details for the given provider track id '''
+        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Track)
+        if not item_id:
+            # album not yet in local database so fetch details
+            if not track_details:
+                track_details = await self.get_track(prov_item_id)
+            if not track_details:
+                raise Exception('track not found: %s' % prov_item_id)
+            if lazy:
+                asyncio.create_task(self.add_track(track_details))
+                track_details.is_lazy = True
+                return track_details
+            item_id = await self.add_track(track_details)
+        return await self.mass.db.track(item_id)
+
+    async def add_track(self, track_details, prov_album_id=None) -> int:
+        ''' add track to local db and return the new database id'''
+        track_artists = []
+        # we need to fetch track artists too
+        for track_artist in track_details.artists:
+            db_track_artist = await self.artist(track_artist.item_id, lazy=False)
+            if db_track_artist:
+                track_artists.append(db_track_artist)
+        track_details.artists = track_artists
+        if not prov_album_id:
+            prov_album_id = track_details.album.item_id
+        track_details.album = await self.album(prov_album_id, lazy=False)
+        item_id = await self.mass.db.add_track(track_details)
+        # also fetch same track on all providers (will also get other quality versions)
+        new_track = await self.mass.db.track(item_id)
+        for prov_id, provider in self.mass.music.providers.items():
+            await provider.match_track(new_track)
+        return item_id
+    
+    async def playlist(self, prov_playlist_id) -> Playlist:
+        ''' return playlist details for the given provider playlist id '''
+        db_id = await self.mass.db.get_database_id(self.prov_id, prov_playlist_id, MediaType.Playlist)
+        if db_id:
+            # synced playlist, return database details
+            return await self.mass.db.playlist(db_id)
+        else:
+            return await self.get_playlist(prov_playlist_id)
+
+    async def radio(self, prov_radio_id) -> Radio:
+        ''' return radio details for the given provider playlist id '''
+        db_id = await self.mass.db.get_database_id(self.prov_id, prov_radio_id, MediaType.Radio)
+        if db_id:
+            # synced radio, return database details
+            return await self.mass.db.radio(db_id)
+        else:
+            return await self.get_radio(prov_radio_id)
+
+    async def album_tracks(self, prov_album_id) -> List[Track]:
+        ''' return album tracks for the given provider album id'''
+        items = []
+        album = await self.get_album(prov_album_id)
+        for prov_track in await self.get_album_tracks(prov_album_id):
+            db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track) 
+            if db_id:
+                items.append( await self.mass.db.track(db_id) )
+            else:
+                prov_track.album = album
+                items.append(prov_track)
+        return items
+
+    async def playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]:
+        ''' return playlist tracks for the given provider playlist id'''
+        items = []
+        for prov_track in await self.get_playlist_tracks(prov_playlist_id, limit=limit, offset=offset):
+            for prov_mapping in prov_track.provider_ids:
+                item_prov_id = prov_mapping["provider"]
+                prov_item_id = prov_mapping["item_id"]
+                db_id = await self.mass.db.get_database_id(item_prov_id, prov_item_id, MediaType.Track) 
+                if db_id:
+                    items.append( await self.mass.db.track(db_id) )
+                else:
+                    items.append(prov_track)
+        return items
+    
+    async def artist_toptracks(self, prov_item_id) -> List[Track]:
+        ''' return top tracks for an artist '''
+        items = []
+        for prov_track in await self.get_artist_toptracks(prov_item_id):
+            db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track) 
+            if db_id:
+                items.append( await self.mass.db.track(db_id) )
+            else:
+                items.append(prov_track)
+        return items
+
+    async def artist_albums(self, prov_item_id) -> List[Track]:
+        ''' return (all) albums for an artist '''
+        items = []
+        for prov_album in await self.get_artist_albums(prov_item_id):
+            db_id = await self.mass.db.get_database_id(self.prov_id, prov_album.item_id, MediaType.Album) 
+            if db_id:
+                items.append( await self.mass.db.album(db_id) )
+            else:
+                items.append(prov_album)
+        return items
+    
+    async def match_artist(self, searchartist:Artist, searchtracks:List[Track]):
+        ''' try to match artist in this provider by supplying db artist '''
+        for searchtrack in searchtracks:
+            searchstr = "%s - %s" %(searchartist.name, searchtrack.name)
+            search_results = await self.search(searchstr, [MediaType.Track], limit=5)
+            for item in search_results["tracks"]:
+                if item.name == searchtrack.name and item.version == searchtrack.version and item.album.name == searchtrack.album.name:
+                    # double safety check - artist must match exactly !
+                    for artist in item.artists:
+                        if artist.name == searchartist.name:
+                            # just load this item in the database, it will be matched automagically ;-)
+                            return await self.artist(artist.item_id, lazy=False)
+
+    async def match_album(self, searchalbum:Album):
+        ''' try to match album in this provider by supplying db album '''
+        searchstr = "%s - %s %s" %(searchalbum.artist.name, searchalbum.name, searchalbum.version)
+        search_results = await self.search(searchstr, [MediaType.Album], limit=5)
+        for item in search_results["albums"]:
+            if item.name == searchalbum.name and item.version == searchalbum.version and item.artist.name == searchalbum.artist.name:
+                # just load this item in the database, it will be matched automagically ;-)
+                await self.album(item.item_id, lazy=False)
+
+    async def match_track(self, searchtrack:Track):
+        ''' try to match track in this provider by supplying db track '''
+        searchstr = "%s - %s" %(searchtrack.artists[0].name, searchtrack.name)
+        searchartists = [item.name for item in searchtrack.artists]
+        search_results = await self.search(searchstr, [MediaType.Track], limit=5)
+        for item in search_results["tracks"]:
+            if item.name == searchtrack.name and item.version == searchtrack.version and item.album.name == searchtrack.album.name:
+                # double safety check - artist must match exactly !
+                for artist in item.artists:
+                    if artist.name in searchartists:
+                        # just load this item in the database, it will be matched automagically ;-)
+                        await self.track(item.item_id, lazy=False)
+
+    ### Provider specific implementation #####
+
+    async def search(self, searchstring, media_types=List[MediaType], limit=5):
+        ''' perform search on the provider '''
+        raise NotImplementedError
+    
+    async def get_library_artists(self) -> List[Artist]:
+        ''' retrieve library artists from the provider '''
+        raise NotImplementedError
+    
+    async def get_library_albums(self) -> List[Album]:
+        ''' retrieve library albums from the provider '''
+        raise NotImplementedError
+
+    async def get_library_tracks(self) -> List[Track]:
+        ''' retrieve library tracks from the provider '''
+        raise NotImplementedError
+
+    async def get_playlists(self) -> List[Playlist]:
+        ''' retrieve library/subscribed playlists from the provider '''
+        raise NotImplementedError
+
+    async def get_radios(self) -> List[Radio]:
+        ''' retrieve library/subscribed radio stations from the provider '''
+        raise NotImplementedError
+
+    async def get_artist(self, prov_item_id) -> Artist:
+        ''' get full artist details by id '''
+        raise NotImplementedError
+
+    async def get_artist_albums(self, prov_item_id) -> List[Album]:
+        ''' get a list of albums for the given artist '''
+        raise NotImplementedError
+    
+    async def get_artist_toptracks(self, prov_item_id) -> List[Track]:
+        ''' get a list of most popular tracks for the given artist '''
+        raise NotImplementedError
+
+    async def get_album(self, prov_item_id) -> Album:
+        ''' get full album details by id '''
+        raise NotImplementedError
+
+    async def get_track(self, prov_item_id) -> Track:
+        ''' get full track details by id '''
+        raise NotImplementedError
+
+    async def get_playlist(self, prov_item_id) -> Playlist:
+        ''' get full playlist details by id '''
+        raise NotImplementedError
+
+    async def get_radio(self, prov_item_id) -> Radio:
+        ''' get full radio details by id '''
+        raise NotImplementedError
+
+    async def get_album_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]:
+        ''' get album tracks for given album id '''
+        raise NotImplementedError
+
+    async def get_playlist_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]:
+        ''' get playlist tracks for given playlist id '''
+        raise NotImplementedError
+
+    async def add_library(self, prov_item_id, media_type:MediaType):
+        ''' add item to library '''
+        raise NotImplementedError
+
+    async def remove_library(self, prov_item_id, media_type:MediaType):
+        ''' remove item from library '''
+        raise NotImplementedError
+
+    async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids):
+        ''' add track(s) to playlist '''
+        raise NotImplementedError
+
+    async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids):
+        ''' remove track(s) from playlist '''
+        raise NotImplementedError
+
+    async def get_stream_content_type(self, track_id):
+        ''' return the content type for the given track when it will be streamed'''
+        raise NotImplementedError
+    
+    async def get_stream(self, track_id):
+        ''' get audio stream for a track '''
+        raise NotImplementedError
+    
+
+class PlayerProvider():
+    ''' 
+        Model for a Playerprovider
+        Common methods usable for every provider
+        Provider specific __get methods shoud be overriden in the provider specific implementation
+    '''
+    name = 'My great Musicplayer provider' # display name
+    prov_id = 'my_provider' # used as id
+    icon = ''
+
+    def __init__(self, mass):
+        self.mass = mass
+
+    ### Common methods and properties ####
+
+    async def players(self):
+        ''' return all players for this provider '''
+        return self.mass.provider_players(self.prov_id)
+    
+    async def get_player(self, player_id):
+        ''' return player by id '''
+        return self.mass.get_player(player_id)
+
+    async def add_player(self, player_id, name='', is_group=False):
+        ''' register a new player '''
+        return self.mass.player.add_player(player_id, 
+                self.prov_id, name=name, is_group=is_group)
+
+    async def remove_player(self, player_id):
+        ''' remove a player '''
+        return self.mass.player.remove_player(player_id)
+
+    ### Provider specific implementation #####
+
+    async def player_config_entries(self):
+        ''' get the player config entries for this provider (list with key/value pairs)'''
+        return [
+            (CONF_ENABLED, True, CONF_ENABLED)
+            ]
+
+    async def play_media(self, player_id, media_items:List[Track], queue_opt='play'):
+        ''' 
+            play media on a player
+            params:
+            - player_id: id of the player
+            - media_items: List of Tracks to play, each Track will contain uri attribute (e.g. spotify:track:1234 or http://pathtostream)
+            - queue_opt: 
+                replace: replace whatever is currently playing with this media
+                next: the given media will be played after the currently playing track
+                add: add to the end of the queue
+                play: keep existing queue but play the given item(s) now first
+        '''
+        raise NotImplementedError
+
+    async def player_command(self, player_id, cmd:str, cmd_args=None):
+        ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) '''
+        raise NotImplementedError
+
+
+
diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py
new file mode 100755 (executable)
index 0000000..05f7ad5
--- /dev/null
@@ -0,0 +1,525 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+from enum import Enum
+from typing import List
+from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, try_parse_bool, try_parse_float
+from ..constants import CONF_ENABLED
+from ..modules.cache import use_cache
+from media_types import Track, MediaType
+from player_queue import PlayerQueue, QueueItem
+
+
+class PlayerState(str, Enum):
+    Off = "off"
+    Stopped = "stopped"
+    Paused = "paused"
+    Playing = "playing"
+
+class Player():
+    ''' representation of a player '''
+
+    #### Provider specific implementation, should be overridden ####
+
+    async def get_config_entries(self):
+        ''' [MAY OVERRIDE] get the player-specific config entries for this player (list with key/value pairs)'''
+        return []
+
+    async def __stop(self):
+        ''' [MUST OVERRIDE] send stop command to player '''
+        raise NotImplementedError
+
+    async def __play(self):
+        ''' [MUST OVERRIDE] send play (unpause) command to player '''
+        raise NotImplementedError
+
+    async def __pause(self):
+        ''' [MUST OVERRIDE] send pause command to player '''
+        raise NotImplementedError
+    
+    async def __power_on(self):
+        ''' [MUST OVERRIDE] send power ON command to player '''
+        raise NotImplementedError
+
+    async def __power_off(self):
+        ''' [MUST OVERRIDE] send power TOGGLE command to player '''
+        raise NotImplementedError
+
+    async def __volume_set(self, volume_level):
+        ''' [MUST OVERRIDE] send new volume level command to player '''
+        raise NotImplementedError
+
+    async def __volume_mute(self, is_muted=False):
+        ''' [MUST OVERRIDE] send mute command to player '''
+        raise NotImplementedError
+
+    async def __play_queue(self):
+        ''' [MUST OVERRIDE] tell player to start playing the queue '''
+        raise NotImplementedError
+
+    #### Common implementation, should NOT be overrridden #####
+
+    def __init__(self, mass, player_id, prov_id):
+        self.mass = mass
+        self._player_id = player_id
+        self._prov_id = prov_id
+        self._name = ''
+        self._is_group = False
+        self._state = PlayerState.Stopped
+        self._powered = False
+        self._cur_time = 0
+        self._volume_level = 0
+        self._muted = False
+        self._group_parent = None
+        self._queue = PlayerQueue(mass, self)
+
+    @property
+    def player_id(self):
+        ''' [PROTECTED] player_id of this player '''
+        return self._player_id
+
+    @property
+    def player_provider(self):
+        ''' [PROTECTED] provider id of this player '''
+        return self._prov_id
+
+    @property
+    def name(self):
+        ''' [PROTECTED] name of this player '''
+        if self.settings.get('name'):
+            return self.settings['name']
+        else:
+            return self._name
+
+    @name.setter
+    def name(self, name):
+        ''' [PROTECTED] set (real) name of this player '''
+        if name != self._name:
+            self._name = name
+            self.mass.event_loop.create_task(self.update())
+
+    @property
+    def is_group(self):
+        ''' [PROTECTED] is_group property of this player '''
+        return self._is_group
+
+    @is_group.setter
+    def is_group(self, is_group:bool):
+        ''' [PROTECTED] set is_group property of this player '''
+        if is_group != self._is_group:
+            self._is_group = is_group
+            self.mass.event_loop.create_task(self.update())
+
+    @property
+    def state(self):
+        ''' [PROTECTED] state property of this player '''
+        if not self.powered:
+            return PlayerState.Off
+        if self.group_parent:
+            group_player = self.mass.event_loop.run_until_complete(
+                    self.mass.player.get_player(self.group_parent))
+            if group_player:
+                return group_player.state
+        return self._state
+
+    @state.setter
+    def state(self, state:PlayerState):
+        ''' [PROTECTED] set state property of this player '''
+        if state != self.state:
+            self._state = state
+            self.mass.event_loop.create_task(self.update())
+
+    @property
+    def powered(self):
+        ''' [PROTECTED] return power state for this player '''
+        # homeassistant integration
+        if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'):
+            hass_state = self.mass.event_loop.run_until_complete(
+                self.mass.hass.get_state(
+                    self.settings['hass_power_entity'],
+                    attribute='source',
+                    register_listener=self.update()))
+            return hass_state == self.settings['hass_power_entity_source']
+        elif self.settings.get('hass_power_entity'):
+            hass_state = self.mass.event_loop.run_until_complete(
+                self.mass.hass.get_state(
+                    self.settings['hass_power_entity'],
+                    attribute='state',
+                    register_listener=self.update()))
+            return hass_state != 'off'
+        # mute as power
+        elif self.settings.get('mute_as_power'):
+            return self.muted
+        else:
+            return self._powered
+
+    @powered.setter
+    def powered(self, powered):
+        ''' [PROTECTED] set (real) power state for this player '''
+        self._powered = powered
+
+    @property
+    def cur_time(self):
+        ''' [PROTECTED] cur_time (player's elapsed time) property of this player '''
+        # handle group player
+        if self.group_parent:
+            group_player = self.mass.event_loop.run_until_complete(
+                    self.mass.player.get_player(self.group_parent))
+            if group_player:
+                return group_player.cur_time
+        return self._cur_time
+
+    @cur_time.setter
+    def cur_time(self, cur_time:int):
+        ''' [PROTECTED] set cur_time (player's elapsed time) property of this player '''
+        if cur_time != self._cur_time:
+            self._cur_time = cur_time
+            self.mass.event_loop.create_task(self.update())
+
+    @property
+    def volume_level(self):
+        ''' [PROTECTED] volume_level property of this player '''
+        # handle group volume
+        if self.is_group:
+            group_volume = 0
+            active_players = 0
+            for child_player in self.group_childs:
+                if child_player.enabled and child_player.powered:
+                    group_volume += child_player.volume_level
+                    active_players += 1
+            if active_players:
+                group_volume = group_volume / active_players
+            return group_volume
+        # handle hass integration
+        elif self.mass.hass and self.settings.get('hass_volume_entity'):
+            hass_state = self.mass.event_loop.run_until_complete(
+                self.mass.hass.get_state(
+                    self.settings['hass_volume_entity'], 
+                    attribute='volume_level',
+                    register_listener=self.update()))
+            return int(try_parse_float(hass_state)*100)
+        else:
+            return self._volume_level
+
+    @volume_level.setter
+    def volume_level(self, volume_level:int):
+        ''' [PROTECTED] set volume_level property of this player '''
+        volume_level = try_parse_int(volume_level)
+        if volume_level != self._volume_level:
+            self._volume_level = volume_level
+            self.mass.event_loop.create_task(self.update())
+
+    @property
+    def muted(self):
+        ''' [PROTECTED] muted property of this player '''
+        return self._muted
+
+    @muted.setter
+    def muted(self, is_muted:bool):
+        ''' [PROTECTED] set muted property of this player '''
+        is_muted = try_parse_bool(is_muted)
+        if is_muted != self._muted:
+            self._muted = is_muted
+            self.mass.event_loop.create_task(self.update())
+
+    @property
+    def group_parent(self):
+        ''' [PROTECTED] group_parent property of this player '''
+        return self._group_parent
+
+    @group_parent.setter
+    def group_parent(self, group_parent:str):
+        ''' [PROTECTED] set muted property of this player '''
+        if group_parent != self._group_parent:
+            self._group_parent = group_parent
+            self.mass.create_task(self.update())
+
+    @property
+    def group_childs(self):
+        ''' [PROTECTED] return group childs '''
+        if not self.is_group:
+            return []
+        return [item for item in self.mass.player.players if item.group_parent == self.player_id]
+
+    @property
+    def settings(self):
+        ''' [PROTECTED] get the player config settings '''
+        player_settings = self.mass.config['player_settings'].get(self.player_id)
+        if not player_settings:
+            return self.mass.event_loop.run_until_complete(self.__update_player_settings())
+
+    @property
+    def enabled(self):
+        ''' [PROTECTED] player enabled config setting '''
+        return self.settings.get('enabled')
+
+    @property
+    def queue(self):
+        ''' [PROTECTED] player's queue '''
+        # handle group player
+        if self.group_parent:
+            group_player = self.mass.event_loop.run_until_complete(
+                    self.mass.player.get_player(self.group_parent))
+            if group_player:
+                return group_player.queue
+        return self._queue
+
+    async def stop(self):
+        ''' [PROTECTED] send stop command to player '''
+        if self.group_parent:
+            # redirect playback related commands to parent player
+            group_player = await self.mass.player.get(self.group_parent)
+            if group_player:
+                return await group_player.stop()
+        else:
+            return await self.__stop()
+
+    async def play(self):
+        ''' [PROTECTED] send play (unpause) command to player '''
+        if self.group_parent:
+            # redirect playback related commands to parent player
+            group_player = await self.mass.player.get_player(self.group_parent)
+            if group_player:
+                return await group_player.play()
+        elif self.state == PlayerState.Paused:
+            return await self.__play()
+        elif self.state != PlayerState.Playing:
+            return await self.play_queue()
+
+    async def pause(self):
+        ''' [PROTECTED] send pause command to player '''
+        if self.group_parent:
+            # redirect playback related commands to parent player
+            group_player = await self.mass.player.get_player(self.group_parent)
+            if group_player:
+                return await group_player.pause()
+        else:
+            return await self.__pause()
+    
+    async def power_on(self):
+        ''' [PROTECTED] send power ON command to player '''
+        self.__power_on()
+        # handle mute as power
+        if self.settings['mute_as_power']:
+            self.volume_mute(False)
+        # handle hass integration
+        if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'):
+            cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source')
+            if not cur_source:
+                service_data = { 
+                    'entity_id': self.settings['hass_power_entity'], 
+                    'source':self.settings['hass_power_entity_source'] 
+                }
+                await self.mass.hass.call_service('media_player', 'select_source', service_data)
+        elif self.settings.get('hass_power_entity'):
+            domain = self.settings['hass_power_entity'].split('.')[0]
+            service_data = { 'entity_id': self.settings['hass_power_entity']}
+            await self.mass.hass.call_service(domain, 'turn_on', service_data)
+        # handle play on power on
+        if self.settings['play_power_on']:
+            self.play()
+        # handle group power
+        if self.group_parent:
+            # player has a group parent, check if it should be turned on
+            group_player = await self.mass.player.get_player(self.group_parent)
+            if group_player and not group_player.powered:
+                return await group_player.power_on()
+
+    async def power_off(self):
+        ''' [PROTECTED] send power TOGGLE command to player '''
+        self.__power_off()
+        # handle mute as power
+        if self.settings['mute_as_power']:
+            self.volume_mute(True)
+        # handle hass integration
+        if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'):
+            cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source')
+            if cur_source == self.settings['hass_power_entity_source']:
+                service_data = { 'entity_id': self.settings['hass_power_entity'] }
+                await self.mass.hass.call_service('media_player', 'turn_off', service_data)
+        elif self.mass.hass and self.settings.get('hass_power_entity'):
+            domain = self.settings['hass_power_entity'].split('.')[0]
+            service_data = { 'entity_id': self.settings['hass_power_entity']}
+            await self.mass.hass.call_service(domain, 'turn_ff', service_data)
+        # handle group power
+        if self.is_group:
+            # player is group, turn off all childs
+            for item in self.group_childs:
+                if item.powered:
+                    await item.power_off()
+        elif self.group_parent:
+            # player has a group parent, check if it should be turned off
+            group_player = await self.mass.player.get_player(self.group_parent)
+            if group_player.powered:
+                needs_power = False
+                for child_player in group_player.group_childs:
+                    if child_player.player_id != self.player_id and child_player.powered:
+                        needs_power = True
+                        break
+                if not needs_power:
+                    await group_player.power_off()
+
+    async def power_toggle(self):
+        ''' [PROTECTED] send toggle power command to player '''
+        if self.powered:
+            return await self.power_off()
+        else:
+            return await self.power_on()
+
+    async def volume_set(self, volume_level):
+        ''' [PROTECTED] send new volume level command to player '''
+        volume_level = try_parse_int(volume_level)
+        # handle group volume
+        if self.is_group:
+            cur_volume = self.volume_level
+            new_volume = volume_level
+            volume_dif = new_volume - cur_volume
+            if cur_volume == 0:
+                volume_dif_percent = 1+(new_volume/100)
+            else:
+                volume_dif_percent = volume_dif/cur_volume
+            for child_player in self.group_childs:
+                if child_player.enabled and child_player.powered:
+                    cur_child_volume = child_player.volume_level
+                    new_child_volume = cur_child_volume + (cur_child_volume * volume_dif_percent)
+                    await child_player.volume_set(new_child_volume)
+        # handle hass integration
+        elif self.mass.hass and self.settings.get('hass_volume_entity'):
+            service_data = { 
+                'entity_id': self.settings['hass_volume_entity'], 
+                'volume_level': volume_level/100
+            }
+            await self.mass.hass.call_service('media_player', 'volume_set', service_data)
+            await self.__volume_set(100) # just force full volume on actual player if volume is outsourced to hass
+        else:
+            await self.__volume_set(volume_level)
+
+    async def volume_up(self):
+        ''' [MAY OVERRIDE] send volume up command to player '''
+        new_level = self.volume_level + 1
+        return await self.volume_set(new_level)
+
+    async def volume_down(self):
+        ''' [MAY OVERRIDE] send volume down command to player '''
+        new_level = self.volume_level - 1
+        if new_level < 0:
+            new_level = 0
+        return await self.volume_set(new_level)
+
+    async def volume_mute(self, is_muted=False):
+        ''' [MUST OVERRIDE] send mute command to player '''
+        return await self.__volume_mute(is_muted)
+
+    async def play_queue(self):
+        ''' [PROTECTED] send play_queue (start stream) command to player '''
+        if self.group_parent:
+            # redirect playback related commands to parent player
+            group_player = await self.mass.player.get_player(self.group_parent)
+            if group_player:
+                return await group_player.play_queue()
+        elif self.queue.items:
+            return await self.__play_queue()
+
+    async def play_media(self, media_item, queue_opt='play'):
+        ''' 
+            play media item(s) on this player 
+            media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio)
+                        single item or list of items
+            queue_opt: 
+                play -> insert new items in queue and start playing at the inserted position
+                replace -> replace queue contents with these items
+                next -> play item(s) after current playing item
+                add -> append new items at end of the queue
+        '''
+        # a single item or list of items may be provided
+        media_items = media_item if isinstance(media_item, list) else [media_item]
+        queue_tracks = []
+        for media_item in media_items:
+            # collect tracks to play
+            if media_item.media_type == MediaType.Artist:
+                tracks = await self.mass.music.artist_toptracks(media_item.item_id, 
+                        provider=media_item.provider)
+            elif media_item.media_type == MediaType.Album:
+                tracks = await self.mass.music.album_tracks(media_item.item_id, 
+                        provider=media_item.provider)
+            elif media_item.media_type == MediaType.Playlist:
+                tracks = await self.mass.music.playlist_tracks(media_item.item_id, 
+                        provider=media_item.provider, offset=0, limit=0) 
+            else:
+                tracks = [media_item] # single track
+            for track in tracks:
+                queue_item = QueueItem()
+                queue_item.name = track.name
+                queue_item.artists = track.artists
+                queue_item.album = track.album
+                queue_item.duration = track.duration
+                queue_item.version = track.version
+                queue_item.metadata = track.metadata
+                queue_item.media_type = track.media_type
+                queue_item.uri = 'http://%s:%s/stream_queue?player_id=%s'% (
+                        self.local_ip, self.mass.config['base']['web']['http_port'], player_id)
+                # sort by quality and check track availability
+                for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True):
+                    media_provider = prov_media['provider']
+                    media_item_id = prov_media['item_id']
+                    player_supported_provs = player_prov.supported_musicproviders
+                    if media_provider in player_supported_provs and not self.mass.config['player_settings'][player_id]['force_http_streamer']:
+                        # the provider can handle this media_type directly !
+                        track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, is_radio=is_radio)
+                        playable_tracks.append(track)
+                        match_found = True
+                    elif 'http' in player_prov.supported_musicproviders:
+                        # fallback to http streaming if supported
+                        track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True, is_radio=is_radio)
+                        queue_tracks.append(track)
+                        match_found = True
+                    if match_found:
+                        break
+        if queue_tracks:
+            if self._players[player_id].shuffle_enabled:
+                random.shuffle(playable_tracks)
+            if queue_opt in ['next', 'play'] and len(playable_tracks) > 1:
+                queue_opt = 'replace' # always assume playback of multiple items as new queue
+            return await player_prov.play_media(player_id, playable_tracks, queue_opt)
+        else:
+            raise Exception("Musicprovider and/or media not supported by player %s !" % (player_id) )
+    
+    async def update(self):
+        ''' [PROTECTED] signal player updated '''
+        self.__update_player_settings()
+        LOGGER.info("player updated: %s" % self.name)
+        self.mass.signal_event('player changed', self)
+    
+    async def __update_player_settings(self):
+        ''' [PROTECTED] get (or create) player config settings '''
+        config_entries = [ # default config entries for a player
+            ("enabled", False, "player_enabled"),
+            ("name", "", "player_name"),
+            ("mute_as_power", False, "player_mute_power"),
+            ("max_sample_rate", 96000, "max_sample_rate"),
+            ('volume_normalisation', True, 'enable_r128_volume_normalisation'), 
+            ('target_volume', '-23', 'target_volume_lufs'),
+            ('fallback_gain_correct', '-12', 'fallback_gain_correct')
+        ]
+        # append player specific settings
+        config_entries += await self.get_config_entries()
+        if self.is_group or not self.group_parent:
+            config_entries += [ # play on power on setting
+                ("play_power_on", False, "player_power_play"),
+            ]
+        if self.mass.config['base'].get('homeassistant',{}).get("enabled"):
+            # append hass specific config entries
+            config_entries += [("hass_power_entity", "", "hass_player_power"),
+                            ("hass_power_entity_source", "", "hass_player_source"),
+                            ("hass_volume_entity", "", "hass_player_volume")]
+        player_settings = self.mass.config['player_settings'].get(self.player_id,{})
+        for key, def_value, desc in config_entries:
+            if not key in player_settings:
+                if (isinstance(def_value, str) and def_value.startswith('<')):
+                    player_settings[key] = None
+                else:
+                    player_settings[key] = def_value
+        self.mass.config['player_settings'][self.player_id] = player_settings
+        self.mass.config['player_settings'][self.player_id]['__desc__'] = config_entries
+        return player_settings
+    
\ No newline at end of file
diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py
new file mode 100755 (executable)
index 0000000..95d6437
--- /dev/null
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+from ..utils import LOGGER
+from ..constants import CONF_ENABLED
+from typing import List
+from player import PlayerState
+from media_types import Track, TrackQuality
+import operator
+import random
+
+class QueueItem(object):
+    ''' representation of a queue item, simplified version of track '''
+    def __init__(self):
+        self.item_id = None
+        self.provider = None
+        self.name = ''
+        self.duration = 0
+        self.version = ''
+        self.quality = TrackQuality.FLAC_LOSSLESS
+        self.metadata = {}
+        self.artists = []
+        self.album = None
+        self.uri = ""
+        self.is_radio = False
+    def __eq__(self, other): 
+        if not isinstance(other, self.__class__):
+            return NotImplemented
+        return (self.name == other.name and 
+                self.version == other.version and
+                self.item_id == other.item_id and
+                self.provider == other.provider)
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+class PlayerQueue():
+    ''' 
+        basic implementation of a queue for a player 
+        if no player specific queue exists, this will be used
+    '''
+    # TODO: Persistent storage in DB
+    
+    def __init__(self, mass, player):
+        self.mass = mass
+        self._player = player
+        self._items = []
+        self._shuffle_enabled = True
+        self._repeat_enabled = True
+        self._cur_index = None
+
+    @property
+    def shuffle_enabled(self):
+        return self._shuffle_enabled
+
+    @property
+    def repeat_enabled(self):
+        return self._repeat_enabled
+
+    @property
+    def cur_index(self):
+        return self._cur_index
+
+    @property
+    def cur_item(self):
+        if self._cur_index == None:
+            return None
+        return self.mass.event_loop.run_until_complete(self.get_item(self._cur_index))
+
+    @property
+    async def next_index(self):
+        ''' 
+            return the next queue index for this player
+        '''
+        if not self.items:
+            # queue is empty
+            return None
+        if self.cur_index == None:
+            # playback started
+            return 0
+        else:
+            # player already playing (or paused) so return the next item
+            if len(self.items) > (self.cur_index + 1):
+                return self.cur_index + 1
+            elif self._repeat_enabled:
+                # repeat enabled, start queue at beginning
+                return 0
+        return None
+
+    @property
+    async def next_item(self):
+        ''' 
+            return the next item in the queue
+        '''
+        return self.mass.event_loop.run_until_complete(
+                self.get_item(self.next_index))
+    
+    @property
+    async def items(self):
+        ''' 
+            return all queue items for this player 
+        '''
+        return self._items
+
+    async def get_item(self, index):
+        ''' get item by index from queue '''
+        if len(self._items) > index:
+            return self._items[index]
+        return None
+
+    async def shuffle(self, enable_shuffle:bool):
+        ''' enable/disable shuffle '''
+        if not self._shuffle_enabled and enable_shuffle:
+            # shuffle requested
+            self._shuffle_enabled = True
+            self._items = await self.__shuffle_items(self._items)
+            self._cur_index = None
+            await self._player.play_queue()
+            self.mass.event_loop.create_task(self._player.update())
+        elif self._shuffle_enabled and not enable_shuffle:
+            self._shuffle_enabled = False
+            # TODO: Unshuffle the list ?
+            self.mass.event_loop.create_task(self._player.update())
+    
+    async def load(self, queue_items:List[QueueItem]):
+        ''' load (overwrite) queue with new items '''
+        if self._shuffle_enabled:
+            queue_items = await self.__shuffle_items(queue_items)
+        self._items = queue_items
+        self._cur_index = None
+        await self._player.play_queue()
+
+    async def insert(self, queue_items:List[QueueItem], offset=0):
+        ''' 
+            insert new items at offset x from current position
+            keeps remaining items in queue
+            if offset 0 or None, will start playing newly added item(s)
+        '''
+        insert_at_index = self.cur_index + offset
+        if not self.items or insert_at_index >= len(self.items):
+            return await self.load(queue_items)
+        if self.shuffle_enabled:
+            queue_items = await self.__shuffle_items(queue_items)
+        self._items = self._items[:insert_at_index] + queue_items + self._items[insert_at_index:]
+        if not offset:
+            await self._player.stop()
+            await self._player.play_queue()
+
+    async def append(self, queue_items:List[QueueItem]):
+        ''' 
+            append new items at the end of the queue
+        '''
+        if self.shuffle_enabled:
+            queue_items = await self.__shuffle_items(queue_items)
+        self._items = self._items + queue_items
+
+    async def __shuffle_items(self, queue_items):
+        ''' shuffle a list of tracks '''
+        # for now we use default python random function
+        # can be extended with some more magic last last_played and stuff
+        return random.sample(queue_items, len(queue_items))
\ No newline at end of file
diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py
new file mode 100755 (executable)
index 0000000..d2868b0
--- /dev/null
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+from enum import Enum
+from typing import List
+from ..utils import run_periodic, LOGGER, parse_track_title
+from ..constants import CONF_ENABLED
+from ..modules.cache import use_cache
+from player_queue import PlayerQueue
+from media_types import Track
+from player import Player
+
+
+class PlayerProvider():
+    ''' 
+        Model for a Playerprovider
+        Common methods usable for every provider
+        Provider specific methods should be overriden in the provider specific implementation
+    '''
+    
+
+    def __init__(self, mass):
+        self.mass = mass
+        self.name = 'My great Musicplayer provider' # display name
+        self.prov_id = 'my_provider' # used as id
+
+    ### Common methods and properties ####
+
+    @property
+    async def players(self):
+        ''' return all players for this provider '''
+        return self.mass.player.get_provider_players(self.prov_id)
+    
+    async def get_player(self, player_id:str):
+        ''' return player by id '''
+        return self.mass.player.get_player(player_id)
+
+    async def add_player(self, player:Player):
+        ''' register a new player '''
+        return self.mass.player.add_player(player)
+
+    async def remove_player(self, player_id:str):
+        ''' remove a player '''
+        return self.mass.player.remove_player(player_id)
+
+    ### Provider specific implementation #####
+
+
+
+
+
index 19ff243dc20802131e338699e85f19187a98ea96..e4c026129950ed4f495d80845253a5e26100d89c 100755 (executable)
@@ -24,20 +24,6 @@ class HTTPStreamer():
         self.create_config_entries()
         self.local_ip = get_ip()
         self.analyze_jobs = {}
-
-    def create_config_entries(self):
-        ''' sets the config entries for this module (list with key/value pairs)'''
-        config_entries = [
-            ('volume_normalisation', True, 'enable_r128_volume_normalisation'), 
-            ('target_volume', '-23', 'target_volume_lufs'),
-            ('fallback_gain_correct', '-12', 'fallback_gain_correct')
-            ]
-        if not self.mass.config['base'].get('http_streamer'):
-            self.mass.config['base']['http_streamer'] = {}
-        self.mass.config['base']['http_streamer']['__desc__'] = config_entries
-        for key, def_value, desc in config_entries:
-            if not key in self.mass.config['base']['http_streamer']:
-                self.mass.config['base']['http_streamer'][key] = def_value
     
     async def stream_track(self, http_request):
         ''' start streaming track from provider '''
@@ -129,14 +115,12 @@ class HTTPStreamer():
                 raise asyncio.CancelledError()
         return resp
     
-    async def stream_queue(self, http_request):
+    async def stream(self, http_request):
         ''' 
-            stream all tracks in queue from player with http
-            loads large part of audiodata in memory so only recommended for high performance servers
-            use case is enable crossfade/gapless support for chromecast devices 
+            stream queue track(s) for player with http
         '''
-        player_id = http_request.query.get('player_id')
-        startindex = int(http_request.query.get('startindex'))
+        player_id = request.match_info.get('player_id','')
+        #startindex = int(http_request.query.get('startindex'))
         cancelled = threading.Event()
         resp = web.StreamResponse(status=200,
                                  reason='OK',
@@ -157,10 +141,10 @@ class HTTPStreamer():
                         break
                     await resp.write(chunk)
                     queue.task_done()
-                LOGGER.info("stream_queue fininished for %s" % player_id)
+                LOGGER.info("stream fininished for %s" % player_id)
             except asyncio.CancelledError:
                 cancelled.set()
-                LOGGER.info("stream_queue interrupted for %s" % player_id)
+                LOGGER.info("stream interrupted for %s" % player_id)
                 raise asyncio.CancelledError()
         return resp
 
@@ -189,7 +173,7 @@ class HTTPStreamer():
         LOGGER.info("Start Queue Stream for player %s at index %s" %(player.name, queue_index))
         last_fadeout_data = b''
         # report start of queue playback so we can calculate current track/duration etc.
-        self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True))
+        self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True))
         while True:
             # get the (next) track in queue
             try:
diff --git a/music_assistant/modules/music.py b/music_assistant/modules/music.py
deleted file mode 100755 (executable)
index ad4bdc1..0000000
+++ /dev/null
@@ -1,416 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from utils import run_periodic, run_async_background_task, LOGGER, try_parse_int, try_supported
-import aiohttp
-from difflib import SequenceMatcher as Matcher
-from models import MediaType, Track, Artist, Album, Playlist, Radio
-from typing import List
-import toolz
-import operator
-
-
-BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-MODULES_PATH = os.path.join(BASE_DIR, "musicproviders" )
-
-class Music():
-    ''' several helpers around the musicproviders '''
-    
-    def __init__(self, mass):
-        self.sync_running = False
-        self.mass = mass
-        self.providers = {}
-        # dynamically load musicprovider modules
-        self.load_music_providers()
-        # schedule sync task
-        mass.event_loop.create_task(self.sync_music_providers())
-
-    async def item(self, item_id, media_type:MediaType, provider='database', lazy=True):
-        ''' get single music item by id and media type'''
-        if media_type == MediaType.Artist:
-            return await self.artist(item_id, provider, lazy=lazy)
-        elif media_type == MediaType.Album:
-            return await self.album(item_id, provider, lazy=lazy)
-        elif media_type == MediaType.Track:
-            return await self.track(item_id, provider, lazy=lazy)
-        elif media_type == MediaType.Playlist:
-            return await self.playlist(item_id, provider)
-        elif media_type == MediaType.Radio:
-            return await self.radio(item_id, provider)
-        else:
-            return None
-
-    async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Artist]:
-        ''' return all library artists, optionally filtered by provider '''
-        return await self.mass.db.library_artists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def library_albums(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Album]:
-        ''' return all library albums, optionally filtered by provider '''
-        return await self.mass.db.library_albums(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def library_tracks(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Track]:
-        ''' return all library tracks, optionally filtered by provider '''
-        return await self.mass.db.library_tracks(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def playlists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]:
-        ''' return all library playlists, optionally filtered by provider '''
-        return await self.mass.db.playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def radios(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]:
-        ''' return all library radios, optionally filtered by provider '''
-        return await self.mass.db.radios(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def library_items(self, media_type:MediaType, limit=0, offset=0, orderby='name', provider_filter=None) -> List[object]:
-        ''' get multiple music items in library'''
-        if media_type == MediaType.Artist:
-            return await self.library_artists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-        elif media_type == MediaType.Album:
-            return await self.library_albums(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-        elif media_type == MediaType.Track:
-            return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-        elif media_type == MediaType.Playlist:
-            return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-        elif media_type == MediaType.Radio:
-            return await self.radios(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-
-    async def artist(self, item_id, provider='database', lazy=True) -> Artist:
-        ''' get artist by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.artist(item_id)
-        return await self.providers[provider].artist(item_id, lazy=lazy)
-
-    async def album(self, item_id, provider='database', lazy=True) -> Album:
-        ''' get album by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.album(item_id)
-        return await self.providers[provider].album(item_id, lazy=lazy)
-
-    async def track(self, item_id, provider='database', lazy=True) -> Track:
-        ''' get track by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.track(item_id)
-        return await self.providers[provider].track(item_id, lazy=lazy)
-
-    async def playlist(self, item_id, provider='database') -> Playlist:
-        ''' get playlist by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.playlist(item_id)
-        return await self.providers[provider].playlist(item_id)
-
-    async def radio(self, item_id, provider='database') -> Radio:
-        ''' get radio by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.radio(item_id)
-        return await self.providers[provider].radio(item_id)
-
-    async def playlist_by_name(self, name) -> Playlist:
-        ''' get playlist by name '''
-        for playlist in await self.playlists():
-            if playlist.name == name:
-                return playlist
-        return None
-
-    async def radio_by_name(self, name) -> Radio:
-        ''' get radio by name '''
-        for radio in await self.radios():
-            if radio.name == name:
-                return radio
-        return None
-    
-    async def artist_toptracks(self, artist_id, provider='database') -> List[Track]:
-        ''' get top tracks for given artist '''
-        artist = await self.artist(artist_id, provider)
-        # always append database tracks
-        items = await self.mass.db.artist_tracks(artist.item_id)
-        for prov_mapping in artist.provider_ids:
-            prov_id = prov_mapping['provider']
-            prov_item_id = prov_mapping['item_id']
-            prov_obj = self.providers[prov_id]
-            items += await prov_obj.artist_toptracks(prov_item_id)
-        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        items.sort(key=lambda x: x.name, reverse=False)
-        return items
-
-    async def artist_albums(self, artist_id, provider='database') -> List[Album]:
-        ''' get (all) albums for given artist '''
-        artist = await self.artist(artist_id, provider)
-        # always append database tracks
-        items = await self.mass.db.artist_albums(artist.item_id)
-        for prov_mapping in artist.provider_ids:
-            prov_id = prov_mapping['provider']
-            prov_item_id = prov_mapping['item_id']
-            prov_obj = self.providers[prov_id]
-            items += await prov_obj.artist_albums(prov_item_id)
-        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        items.sort(key=lambda x: x.name, reverse=False)
-        return items
-
-    async def album_tracks(self, album_id, provider='database') -> List[Track]:
-        ''' get the album tracks for given album '''
-        items = []
-        album = await self.album(album_id, provider)
-        for prov_mapping in album.provider_ids:
-            prov_id = prov_mapping['provider']
-            prov_item_id = prov_mapping['item_id']
-            prov_obj = self.providers[prov_id]
-            items += await prov_obj.album_tracks(prov_item_id)
-        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        items = sorted(items, key=operator.attrgetter('disc_number'), reverse=False)
-        items = sorted(items, key=operator.attrgetter('track_number'), reverse=False)
-        return items
-
-    async def playlist_tracks(self, playlist_id, provider='database', offset=0, limit=50) -> List[Track]:
-        ''' get the tracks for given playlist '''
-        playlist = None
-        if not provider or provider == 'database':
-            playlist = await self.mass.db.playlist(playlist_id)
-        if playlist and playlist.is_editable:
-            # database synced playlist, return tracks from db...
-            return await self.mass.db.playlist_tracks(
-                    playlist.item_id, offset=offset, limit=limit)
-        else:
-            # return playlist tracks from provider
-            playlist = await self.playlist(playlist_id, provider)
-            prov = playlist.provider_ids[0]
-            return await self.providers[prov['provider']].playlist_tracks(
-                    prov['item_id'], offset=offset, limit=limit)
-
-    async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False) -> dict:
-        ''' search database or providers '''
-        # get results from database
-        result = await self.mass.db.search(searchquery, media_types, limit)
-        if online:
-            # include results from music providers
-            for prov in self.providers.values():
-                prov_results = await prov.search(searchquery, media_types, limit)
-                for item_type, items in prov_results.items():
-                    if not item_type in result:
-                        result[item_type] = items
-                    else:
-                        result[item_type] += items
-            # filter out duplicates
-            for item_type, items in result.items():
-                items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        return result
-
-    async def item_action(self, item_id, media_type, provider, action, action_details=None):
-        ''' perform action on item (such as library add/remove) '''
-        result = None
-        item = await self.item(item_id, media_type, provider)
-        if item and action in ['library_add', 'library_remove']:
-            # remove or add item to the library
-            for prov_mapping in result.provider_ids:
-                prov_id = prov_mapping['provider']
-                prov_item_id = prov_mapping['item_id']
-                for prov in self.providers.values():
-                    if prov.prov_id == prov_id:
-                        if action == 'add':
-                            result = await prov.add_library(prov_item_id, media_type)
-                        elif action == 'remove':
-                            result = await prov.remove_library(prov_item_id, media_type)
-        return result
-    
-    async def add_playlist_tracks(self, playlist_id, tracks:List[Track]):
-        ''' add tracks to playlist - make sure we dont add dupes '''
-        # we can only edit playlists that are in the database (marked as editable)
-        playlist = await self.playlist(playlist_id, 'database')
-        if not playlist or not playlist.is_editable:
-            LOGGER.warning("Playlist %s is not editable - skip addition of tracks" %(playlist.name))
-            return False
-        playlist_prov = playlist.provider_ids[0] # playlist can only have one provider (for now)
-        cur_playlist_tracks = await self.mass.db.playlist_tracks(playlist_id, limit=0)
-        # grab all (database) track ids in the playlist so we can check for duplicates
-        cur_playlist_track_ids = [item.item_id for item in cur_playlist_tracks]
-        track_ids_to_add = []
-        for track in tracks:
-            if not track.provider == 'database':
-                # make sure we have a database track
-                track = await self.track(track.item_id, track.provider, lazy=False)
-            if track.item_id in cur_playlist_track_ids:
-                LOGGER.warning("Track %s already in playlist %s - skip addition" %(track.name, playlist.name))
-                continue
-            # we can only add a track to a provider playlist if the track is available on that provider
-            # exception is the file provider which does accept tracks from all providers in the m3u playlist
-            # this should all be handled in the frontend but these checks are here just to be safe
-            track_playlist_provs = [item['provider'] for item in track.provider_ids]
-            if playlist_prov['provider'] in track_playlist_provs:
-                # a track can contain multiple versions on the same provider
-                # # simply sort by quality and just add the first one (assuming the track is still available)
-                track_versions = sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True)
-                for track_version in track_versions:
-                    if track_version['provider'] == playlist_prov['provider']:
-                        track_ids_to_add.append(track_version['item_id'])
-                        break
-            elif playlist_prov['provider'] == 'file':
-                # the file provider can handle uri's from all providers in the file so simply add the db id
-                track_ids_to_add.append(track.item_id)
-            else:
-                LOGGER.warning("Track %s not available on provider %s - skip addition to playlist %s" %(track.name, playlist_prov['provider'], playlist.name))
-                continue
-        # actually add the tracks to the playlist on the provider
-        await self.providers[playlist_prov['provider']].add_playlist_tracks(playlist_prov['item_id'], track_ids_to_add)
-        # schedule sync
-        self.mass.event_loop.create_task(self.sync_playlist_tracks(playlist.item_id, playlist_prov['provider'], playlist_prov['item_id']))
-
-    @run_periodic(3600)
-    async def sync_music_providers(self):
-        ''' periodic sync of all music providers '''
-        if self.sync_running:
-            return
-        self.sync_running = True
-        for prov_id in self.providers.keys():
-            # sync library artists
-            await try_supported(self.sync_library_artists(prov_id))
-            await try_supported(self.sync_library_albums(prov_id))
-            await try_supported(self.sync_library_tracks(prov_id))
-            await try_supported(self.sync_playlists(prov_id))
-            await try_supported(self.sync_radios(prov_id))
-        self.sync_running = False
-        
-    async def sync_library_artists(self, prov_id):
-        ''' sync library artists for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.library_artists(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_library_artists()
-        cur_db_ids = []
-        for item in cur_items:
-            db_item = await music_provider.artist(item.item_id, lazy=False)
-            cur_db_ids.append(db_item.item_id)
-            if not db_item.item_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_item.item_id, MediaType.Artist, prov_id)
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id)
-        LOGGER.info("Finished syncing Artists for provider %s" % prov_id)
-
-    async def sync_library_albums(self, prov_id):
-        ''' sync library albums for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.library_albums(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_library_albums()
-        cur_db_ids = []
-        for item in cur_items:
-            db_item = await music_provider.album(item.item_id, lazy=False)
-            cur_db_ids.append(db_item.item_id)
-            # precache album tracks...
-            for album_track in await music_provider.get_album_tracks(item.item_id):
-                await music_provider.track(album_track.item_id)
-            if not db_item.item_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_item.item_id, MediaType.Album, prov_id)
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id)
-        LOGGER.info("Finished syncing Albums for provider %s" % prov_id)
-
-    async def sync_library_tracks(self, prov_id):
-        ''' sync library tracks for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.library_tracks(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_library_tracks()
-        cur_db_ids = []
-        for item in cur_items:
-            db_item = await music_provider.track(item.item_id, lazy=False)
-            cur_db_ids.append(db_item.item_id)
-            if not db_item.item_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_item.item_id, MediaType.Track, prov_id)
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id)
-        LOGGER.info("Finished syncing Tracks for provider %s" % prov_id)
-
-    async def sync_playlists(self, prov_id):
-        ''' sync library playlists for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.playlists(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_playlists()
-        cur_db_ids = []
-        for item in cur_items:
-            # always add to db because playlist attributes could have changed
-            db_id = await self.mass.db.add_playlist(item)
-            cur_db_ids.append(db_id)
-            if not db_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id)
-            if item.is_editable:
-                # precache/sync playlist tracks (user owned playlists only)
-                asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) )
-        # process playlist deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Playlist, prov_id)
-        LOGGER.info("Finished syncing Playlists for provider %s" % prov_id)
-
-    async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id):
-        ''' sync library playlists tracks for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.playlist_tracks(db_playlist_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_playlist_tracks(prov_playlist_id, limit=0)
-        cur_db_ids = []
-        pos = 0
-        for item in cur_items:
-            # we need to do this the complicated way because the file provider can return tracks from other providers
-            for prov_mapping in item.provider_ids:
-                item_provider = prov_mapping['provider']
-                prov_item_id = prov_mapping['item_id']
-                db_item = await self.providers[item_provider].track(prov_item_id, lazy=False)
-                cur_db_ids.append(db_item.item_id)
-                if not db_item.item_id in prev_db_ids:
-                    await self.mass.db.add_playlist_track(db_playlist_id, db_item.item_id, pos)
-            pos += 1
-        # process playlist track deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_playlist_track(db_playlist_id, db_id)
-        LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id))
-
-    async def sync_radios(self, prov_id):
-        ''' sync library radios for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.radios(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_radios()
-        cur_db_ids = []
-        for item in cur_items:
-            db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Radio)
-            if not db_id:
-                db_id = await self.mass.db.add_radio(item)
-            cur_db_ids.append(db_id)
-            if not db_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_id, MediaType.Radio, prov_id)
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id)
-        LOGGER.info("Finished syncing Radios for provider %s" % prov_id)
-
-    def load_music_providers(self):
-        ''' dynamically load musicproviders '''
-        for item in os.listdir(MODULES_PATH):
-            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
-                    item.endswith('.py') and not item.startswith('.')):
-                module_name = item.replace(".py","")
-                LOGGER.debug("Loading musicprovider module %s" % module_name)
-                try:
-                    mod = __import__("modules.musicproviders." + module_name, fromlist=[''])
-                    if not self.mass.config['musicproviders'].get(module_name):
-                        self.mass.config['musicproviders'][module_name] = {}
-                    self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries()
-                    for key, def_value, desc in mod.config_entries():
-                        if not key in self.mass.config['musicproviders'][module_name]:
-                            self.mass.config['musicproviders'][module_name][key] = def_value
-                    mod = mod.setup(self.mass)
-                    if mod:
-                        self.providers[mod.prov_id] = mod
-                        cls_name = mod.__class__.__name__
-                        LOGGER.info("Successfully initialized module %s" % cls_name)
-                except Exception as exc:
-                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
diff --git a/music_assistant/modules/music_manager.py b/music_assistant/modules/music_manager.py
new file mode 100755 (executable)
index 0000000..f068991
--- /dev/null
@@ -0,0 +1,414 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+from typing import List
+import toolz
+import operator
+import os
+from ..utils import run_periodic, LOGGER, try_supported
+from ..models.media_types import MediaType, Track, Artist, Album, Playlist, Radio
+
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MODULES_PATH = os.path.join(BASE_DIR, "musicproviders" )
+
+class Music():
+    ''' several helpers around the musicproviders '''
+    
+    def __init__(self, mass):
+        self.sync_running = False
+        self.mass = mass
+        self.providers = {}
+        # dynamically load musicprovider modules
+        self.load_music_providers()
+        # schedule sync task
+        mass.event_loop.create_task(self.sync_music_providers())
+
+    async def item(self, item_id, media_type:MediaType, provider='database', lazy=True):
+        ''' get single music item by id and media type'''
+        if media_type == MediaType.Artist:
+            return await self.artist(item_id, provider, lazy=lazy)
+        elif media_type == MediaType.Album:
+            return await self.album(item_id, provider, lazy=lazy)
+        elif media_type == MediaType.Track:
+            return await self.track(item_id, provider, lazy=lazy)
+        elif media_type == MediaType.Playlist:
+            return await self.playlist(item_id, provider)
+        elif media_type == MediaType.Radio:
+            return await self.radio(item_id, provider)
+        else:
+            return None
+
+    async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Artist]:
+        ''' return all library artists, optionally filtered by provider '''
+        return await self.mass.db.library_artists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_albums(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Album]:
+        ''' return all library albums, optionally filtered by provider '''
+        return await self.mass.db.library_albums(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_tracks(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Track]:
+        ''' return all library tracks, optionally filtered by provider '''
+        return await self.mass.db.library_tracks(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def playlists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]:
+        ''' return all library playlists, optionally filtered by provider '''
+        return await self.mass.db.playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def radios(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]:
+        ''' return all library radios, optionally filtered by provider '''
+        return await self.mass.db.radios(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_items(self, media_type:MediaType, limit=0, offset=0, orderby='name', provider_filter=None) -> List[object]:
+        ''' get multiple music items in library'''
+        if media_type == MediaType.Artist:
+            return await self.library_artists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Album:
+            return await self.library_albums(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Track:
+            return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Playlist:
+            return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Radio:
+            return await self.radios(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+
+    async def artist(self, item_id, provider='database', lazy=True) -> Artist:
+        ''' get artist by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.artist(item_id)
+        return await self.providers[provider].artist(item_id, lazy=lazy)
+
+    async def album(self, item_id, provider='database', lazy=True) -> Album:
+        ''' get album by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.album(item_id)
+        return await self.providers[provider].album(item_id, lazy=lazy)
+
+    async def track(self, item_id, provider='database', lazy=True) -> Track:
+        ''' get track by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.track(item_id)
+        return await self.providers[provider].track(item_id, lazy=lazy)
+
+    async def playlist(self, item_id, provider='database') -> Playlist:
+        ''' get playlist by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.playlist(item_id)
+        return await self.providers[provider].playlist(item_id)
+
+    async def radio(self, item_id, provider='database') -> Radio:
+        ''' get radio by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.radio(item_id)
+        return await self.providers[provider].radio(item_id)
+
+    async def playlist_by_name(self, name) -> Playlist:
+        ''' get playlist by name '''
+        for playlist in await self.playlists():
+            if playlist.name == name:
+                return playlist
+        return None
+
+    async def radio_by_name(self, name) -> Radio:
+        ''' get radio by name '''
+        for radio in await self.radios():
+            if radio.name == name:
+                return radio
+        return None
+    
+    async def artist_toptracks(self, artist_id, provider='database') -> List[Track]:
+        ''' get top tracks for given artist '''
+        artist = await self.artist(artist_id, provider)
+        # always append database tracks
+        items = await self.mass.db.artist_tracks(artist.item_id)
+        for prov_mapping in artist.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.artist_toptracks(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def artist_albums(self, artist_id, provider='database') -> List[Album]:
+        ''' get (all) albums for given artist '''
+        artist = await self.artist(artist_id, provider)
+        # always append database tracks
+        items = await self.mass.db.artist_albums(artist.item_id)
+        for prov_mapping in artist.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.artist_albums(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def album_tracks(self, album_id, provider='database') -> List[Track]:
+        ''' get the album tracks for given album '''
+        items = []
+        album = await self.album(album_id, provider)
+        for prov_mapping in album.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.album_tracks(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        items = sorted(items, key=operator.attrgetter('disc_number'), reverse=False)
+        items = sorted(items, key=operator.attrgetter('track_number'), reverse=False)
+        return items
+
+    async def playlist_tracks(self, playlist_id, provider='database', offset=0, limit=50) -> List[Track]:
+        ''' get the tracks for given playlist '''
+        playlist = None
+        if not provider or provider == 'database':
+            playlist = await self.mass.db.playlist(playlist_id)
+        if playlist and playlist.is_editable:
+            # database synced playlist, return tracks from db...
+            return await self.mass.db.playlist_tracks(
+                    playlist.item_id, offset=offset, limit=limit)
+        else:
+            # return playlist tracks from provider
+            playlist = await self.playlist(playlist_id, provider)
+            prov = playlist.provider_ids[0]
+            return await self.providers[prov['provider']].playlist_tracks(
+                    prov['item_id'], offset=offset, limit=limit)
+
+    async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False) -> dict:
+        ''' search database or providers '''
+        # get results from database
+        result = await self.mass.db.search(searchquery, media_types, limit)
+        if online:
+            # include results from music providers
+            for prov in self.providers.values():
+                prov_results = await prov.search(searchquery, media_types, limit)
+                for item_type, items in prov_results.items():
+                    if not item_type in result:
+                        result[item_type] = items
+                    else:
+                        result[item_type] += items
+            # filter out duplicates
+            for item_type, items in result.items():
+                items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        return result
+
+    async def item_action(self, item_id, media_type, provider, action, action_details=None):
+        ''' perform action on item (such as library add/remove) '''
+        result = None
+        item = await self.item(item_id, media_type, provider)
+        if item and action in ['library_add', 'library_remove']:
+            # remove or add item to the library
+            for prov_mapping in result.provider_ids:
+                prov_id = prov_mapping['provider']
+                prov_item_id = prov_mapping['item_id']
+                for prov in self.providers.values():
+                    if prov.prov_id == prov_id:
+                        if action == 'add':
+                            result = await prov.add_library(prov_item_id, media_type)
+                        elif action == 'remove':
+                            result = await prov.remove_library(prov_item_id, media_type)
+        return result
+    
+    async def add_playlist_tracks(self, playlist_id, tracks:List[Track]):
+        ''' add tracks to playlist - make sure we dont add dupes '''
+        # we can only edit playlists that are in the database (marked as editable)
+        playlist = await self.playlist(playlist_id, 'database')
+        if not playlist or not playlist.is_editable:
+            LOGGER.warning("Playlist %s is not editable - skip addition of tracks" %(playlist.name))
+            return False
+        playlist_prov = playlist.provider_ids[0] # playlist can only have one provider (for now)
+        cur_playlist_tracks = await self.mass.db.playlist_tracks(playlist_id, limit=0)
+        # grab all (database) track ids in the playlist so we can check for duplicates
+        cur_playlist_track_ids = [item.item_id for item in cur_playlist_tracks]
+        track_ids_to_add = []
+        for track in tracks:
+            if not track.provider == 'database':
+                # make sure we have a database track
+                track = await self.track(track.item_id, track.provider, lazy=False)
+            if track.item_id in cur_playlist_track_ids:
+                LOGGER.warning("Track %s already in playlist %s - skip addition" %(track.name, playlist.name))
+                continue
+            # we can only add a track to a provider playlist if the track is available on that provider
+            # exception is the file provider which does accept tracks from all providers in the m3u playlist
+            # this should all be handled in the frontend but these checks are here just to be safe
+            track_playlist_provs = [item['provider'] for item in track.provider_ids]
+            if playlist_prov['provider'] in track_playlist_provs:
+                # a track can contain multiple versions on the same provider
+                # # simply sort by quality and just add the first one (assuming the track is still available)
+                track_versions = sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True)
+                for track_version in track_versions:
+                    if track_version['provider'] == playlist_prov['provider']:
+                        track_ids_to_add.append(track_version['item_id'])
+                        break
+            elif playlist_prov['provider'] == 'file':
+                # the file provider can handle uri's from all providers in the file so simply add the db id
+                track_ids_to_add.append(track.item_id)
+            else:
+                LOGGER.warning("Track %s not available on provider %s - skip addition to playlist %s" %(track.name, playlist_prov['provider'], playlist.name))
+                continue
+        # actually add the tracks to the playlist on the provider
+        await self.providers[playlist_prov['provider']].add_playlist_tracks(playlist_prov['item_id'], track_ids_to_add)
+        # schedule sync
+        self.mass.event_loop.create_task(self.sync_playlist_tracks(playlist.item_id, playlist_prov['provider'], playlist_prov['item_id']))
+
+    @run_periodic(3600)
+    async def sync_music_providers(self):
+        ''' periodic sync of all music providers '''
+        if self.sync_running:
+            return
+        self.sync_running = True
+        for prov_id in self.providers.keys():
+            # sync library artists
+            await try_supported(self.sync_library_artists(prov_id))
+            await try_supported(self.sync_library_albums(prov_id))
+            await try_supported(self.sync_library_tracks(prov_id))
+            await try_supported(self.sync_playlists(prov_id))
+            await try_supported(self.sync_radios(prov_id))
+        self.sync_running = False
+        
+    async def sync_library_artists(self, prov_id):
+        ''' sync library artists for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_artists(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_artists()
+        cur_db_ids = []
+        for item in cur_items:
+            db_item = await music_provider.artist(item.item_id, lazy=False)
+            cur_db_ids.append(db_item.item_id)
+            if not db_item.item_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_item.item_id, MediaType.Artist, prov_id)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id)
+        LOGGER.info("Finished syncing Artists for provider %s" % prov_id)
+
+    async def sync_library_albums(self, prov_id):
+        ''' sync library albums for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_albums(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_albums()
+        cur_db_ids = []
+        for item in cur_items:
+            db_item = await music_provider.album(item.item_id, lazy=False)
+            cur_db_ids.append(db_item.item_id)
+            # precache album tracks...
+            for album_track in await music_provider.get_album_tracks(item.item_id):
+                await music_provider.track(album_track.item_id)
+            if not db_item.item_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_item.item_id, MediaType.Album, prov_id)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id)
+        LOGGER.info("Finished syncing Albums for provider %s" % prov_id)
+
+    async def sync_library_tracks(self, prov_id):
+        ''' sync library tracks for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_tracks(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_tracks()
+        cur_db_ids = []
+        for item in cur_items:
+            db_item = await music_provider.track(item.item_id, lazy=False)
+            cur_db_ids.append(db_item.item_id)
+            if not db_item.item_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_item.item_id, MediaType.Track, prov_id)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id)
+        LOGGER.info("Finished syncing Tracks for provider %s" % prov_id)
+
+    async def sync_playlists(self, prov_id):
+        ''' sync library playlists for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.playlists(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_playlists()
+        cur_db_ids = []
+        for item in cur_items:
+            # always add to db because playlist attributes could have changed
+            db_id = await self.mass.db.add_playlist(item)
+            cur_db_ids.append(db_id)
+            if not db_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id)
+            if item.is_editable:
+                # precache/sync playlist tracks (user owned playlists only)
+                asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) )
+        # process playlist deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Playlist, prov_id)
+        LOGGER.info("Finished syncing Playlists for provider %s" % prov_id)
+
+    async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id):
+        ''' sync library playlists tracks for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.playlist_tracks(db_playlist_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_playlist_tracks(prov_playlist_id, limit=0)
+        cur_db_ids = []
+        pos = 0
+        for item in cur_items:
+            # we need to do this the complicated way because the file provider can return tracks from other providers
+            for prov_mapping in item.provider_ids:
+                item_provider = prov_mapping['provider']
+                prov_item_id = prov_mapping['item_id']
+                db_item = await self.providers[item_provider].track(prov_item_id, lazy=False)
+                cur_db_ids.append(db_item.item_id)
+                if not db_item.item_id in prev_db_ids:
+                    await self.mass.db.add_playlist_track(db_playlist_id, db_item.item_id, pos)
+            pos += 1
+        # process playlist track deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_playlist_track(db_playlist_id, db_id)
+        LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id))
+
+    async def sync_radios(self, prov_id):
+        ''' sync library radios for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.radios(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_radios()
+        cur_db_ids = []
+        for item in cur_items:
+            db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Radio)
+            if not db_id:
+                db_id = await self.mass.db.add_radio(item)
+            cur_db_ids.append(db_id)
+            if not db_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_id, MediaType.Radio, prov_id)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id)
+        LOGGER.info("Finished syncing Radios for provider %s" % prov_id)
+
+    def load_music_providers(self):
+        ''' dynamically load musicproviders '''
+        for item in os.listdir(MODULES_PATH):
+            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
+                    item.endswith('.py') and not item.startswith('.')):
+                module_name = item.replace(".py","")
+                LOGGER.debug("Loading musicprovider module %s" % module_name)
+                try:
+                    mod = __import__("modules.musicproviders." + module_name, fromlist=[''])
+                    if not self.mass.config['musicproviders'].get(module_name):
+                        self.mass.config['musicproviders'][module_name] = {}
+                    self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries()
+                    for key, def_value, desc in mod.config_entries():
+                        if not key in self.mass.config['musicproviders'][module_name]:
+                            self.mass.config['musicproviders'][module_name][key] = def_value
+                    mod = mod.setup(self.mass)
+                    if mod:
+                        self.providers[mod.prov_id] = mod
+                        cls_name = mod.__class__.__name__
+                        LOGGER.info("Successfully initialized module %s" % cls_name)
+                except Exception as exc:
+                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
diff --git a/music_assistant/modules/player.py b/music_assistant/modules/player.py
deleted file mode 100755 (executable)
index 9f4e460..0000000
+++ /dev/null
@@ -1,441 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task
-from models import MediaType, PlayerState, MusicPlayer, TrackQuality
-import operator
-import random
-from copy import deepcopy
-import functools
-import urllib
-
-BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" )
-
-
-class Player():
-    ''' several helpers to handle playback through player providers '''
-    
-    def __init__(self, mass):
-        self.mass = mass
-        self.providers = {}
-        self._players = {}
-        self.local_ip = get_ip()
-        # dynamically load provider modules
-        self.load_providers()
-    
-    async def players(self):
-        ''' return all players '''
-        items = list(self._players.values())
-        items.sort(key=lambda x: x.name, reverse=False)
-        return items
-
-    async def player(self, player_id):
-        ''' return players by id '''
-        return self._players[player_id]
-
-    async def player_command(self, player_id, cmd, cmd_args=None, skip_group_power=False):
-        ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) '''
-        if player_id not in self._players:
-            return
-        player = self._players[player_id]
-        prov_id = player.player_provider
-        prov = self.providers[prov_id]
-        LOGGER.debug('received command %s for player %s' %(cmd, player.name))
-        # handle some common workarounds
-        if (cmd in ['pause', 'play'] and cmd_args == 'toggle') or cmd == 'playpause':
-            cmd = 'pause' if player.state == PlayerState.Playing else 'play'
-        if cmd == 'power' and (cmd_args == 'toggle' or not cmd_args):
-            cmd_args = 'off' if player.powered else 'on'
-        if cmd == 'volume' and cmd_args == 'up':
-            cmd_args = player.volume_level + 1
-        elif cmd == 'volume' and cmd_args == 'down':
-            cmd_args = player.volume_level - 1
-        elif cmd == 'volume' and '+' in str(cmd_args):
-            cmd_args = player.volume_level + try_parse_int(cmd_args.replace('+',''))
-        elif cmd == 'volume' and '-' in str(cmd_args):
-            cmd_args = player.volume_level - try_parse_int(cmd_args.replace('-',''))
-        if cmd == 'mute' and (cmd_args == 'toggle' or not cmd_args):
-            cmd_args = 'off' if player.muted else 'on'
-        if cmd == 'volume' and cmd_args:
-            if try_parse_int(cmd_args) > 100:
-                cmd_args = 100
-            elif try_parse_int(cmd_args) < 0:
-                cmd_args = 0
-        if cmd == 'volume' and player.is_group and player.settings['apply_group_volume']:
-            # group volume
-            return await self.__player_command_group_volume(player, cmd_args)
-        
-        # redirect playlist related commands to parent player
-        if player.group_parent and cmd not in ['power', 'volume', 'mute']:
-            return await self.player_command(player.group_parent, cmd, cmd_args)
-        # handle hass integration
-        await self.__player_command_hass_integration(player, cmd, cmd_args)
-        # handle group power for group players
-        if not skip_group_power:
-            asyncio.create_task(self.__player_command_group_power(player, cmd, cmd_args))
-        # handle play on power on
-        if cmd == 'power' and cmd_args == 'on' and player.settings['play_power_on']:
-            cmd = 'play'
-            cmd_args = None
-        # handle mute as power
-        if cmd == 'power' and player.settings['mute_as_power']:
-            cmd = 'mute'
-            cmd_args = 'on' if cmd_args == 'off' else 'off' # invert logic (power ON is mute OFF)
-        # normal execution of command on player
-        await prov.player_command(player_id, cmd, cmd_args)
-        
-    async def __player_command_hass_integration(self, player, cmd, cmd_args):
-        ''' handle hass integration in player command '''
-        if not self.mass.hass:
-            return
-        if cmd == 'power' and player.settings.get('hass_power_entity') and player.settings.get('hass_power_entity_source'):
-            cur_source = await self.mass.hass.get_state(player.settings['hass_power_entity'], attribute='source')
-            if cmd_args == 'on' and not cur_source:
-                service_data = { 'entity_id': player.settings['hass_power_entity'], 'source':player.settings['hass_power_entity_source'] }
-                await self.mass.hass.call_service('media_player', 'select_source', service_data)
-            elif cmd_args == 'off' and cur_source == player.settings['hass_power_entity_source']:
-                service_data = { 'entity_id': player.settings['hass_power_entity'] }
-                await self.mass.hass.call_service('media_player', 'turn_off', service_data)
-            else:
-                LOGGER.debug('Ignoring power command as required source is not active')
-        elif cmd == 'power' and player.settings.get('hass_power_entity'):
-            domain = player.settings['hass_power_entity'].split('.')[0]
-            service_data = { 'entity_id': player.settings['hass_power_entity']}
-            await self.mass.hass.call_service(domain, 'turn_%s' % cmd_args, service_data)
-        if cmd == 'volume' and player.settings.get('hass_volume_entity'):
-            service_data = { 'entity_id': player.settings['hass_power_entity'], 'volume_level': int(cmd_args)/100}
-            await self.mass.hass.call_service('media_player', 'volume_set', service_data)
-            cmd_args = 100 # just force full volume on actual player if volume is outsourced to hass
-            
-    async def __player_command_group_volume(self, player, new_volume):
-        ''' handle group volume '''
-        cur_volume = player.volume_level
-        new_volume = try_parse_int(new_volume)
-        volume_dif = new_volume - cur_volume
-        if cur_volume == 0:
-            volume_dif_percent = 1+(new_volume/100)
-        else:
-            volume_dif_percent = volume_dif/cur_volume
-        player_childs = [item for item in self._players.values() if item.group_parent == player.player_id]
-        for child_player in player_childs:
-            if child_player.enabled and child_player.powered:
-                cur_child_volume = child_player.volume_level
-                new_child_volume = cur_child_volume + (cur_child_volume * volume_dif_percent)
-                child_player.volume_level = new_child_volume
-                await self.player_command(child_player.player_id, 'volume', new_child_volume)
-        player.volume_level = new_volume
-
-    async def __player_command_group_power(self, player, cmd, cmd_args):
-        ''' handle group power command '''
-        if player.is_group and player.settings['apply_group_power'] and cmd == 'power' and cmd_args == 'off':
-            # turn off all child players
-            player_childs = [item for item in self._players.values() if item.group_parent == player.player_id]
-            for item in player_childs:
-                if item.powered:
-                    await self.player_command(item.player_id, cmd, cmd_args, True)
-        elif player.group_parent and player.group_parent in self._players:
-            group_player = self._players[player.group_parent]
-            if group_player.settings['apply_group_power']:
-                if cmd == 'power' and cmd_args == 'on':
-                    if not group_player.powered:
-                        return await self.player_command(group_player.player_id, 'power', 'on', True)
-                player_childs = [item for item in self._players.values() if item.group_parent == group_player.player_id]
-                if group_player.powered and cmd == 'power' and cmd_args == 'off':
-                    # check if the group player should (still) be turned on
-                    needs_power = False
-                    for child_player in player_childs:
-                        if child_player.player_id != player.player_id and child_player.powered:
-                            needs_power = True
-                            break
-                    if not needs_power:
-                        await self.player_command(group_player.player_id, 'power', 'off', True)
-    
-    async def remove_player(self, player_id):
-        ''' handle a player remove '''
-        self._players.pop(player_id, None)
-        self.mass.signal_event('player removed', player_id)
-
-    async def trigger_update(self, player_id):
-        ''' manually trigger update for a player '''
-        if player_id in self._players:
-            await self.update_player(self._players[player_id])
-    
-    async def update_player(self, player_details):
-        ''' update (or add) player '''
-        player_details = deepcopy(player_details)
-        player_id = player_details.player_id
-        player_changed = False
-        if not player_id in self._players:
-            # first message from player
-            self._players[player_id] = MusicPlayer()
-            player = self._players[player_id]
-            player.player_id = player_id
-            player.player_provider  = player_details.player_provider
-            player_changed = True
-        else:
-            player = self._players[player_id]
-        player.settings = await self.__get_player_settings(player_details)
-        # handle basic player settings
-        player_details.enabled = player.settings['enabled']
-        player_details.name = player.settings['name'] if player.settings['name'] else player_details.name
-        # handle hass integration
-        await self.__update_player_hass_settings(player_details, player.settings)
-        # handle mute as power setting
-        if player.settings['mute_as_power']:
-            player_details.powered = not player_details.muted
-        # combine state of group parent
-        if player_details.group_parent and player_details.group_parent in self._players:
-            parent_player = self._players[player_details.group_parent]
-            player_details.cur_item_time = parent_player.cur_item_time
-            player_details.cur_item = parent_player.cur_item
-            player_details.state = parent_player.state
-        
-        # handle group volume/power setting
-        if player_details.is_group:
-            player_childs = [item for item in self._players.values() if item.group_parent == player_id]
-            if player.settings['apply_group_volume']:
-                player_details.volume_level = await self.__get_group_volume(player_childs)
-        # detect current track changes
-        if player.cur_item and player_details.cur_item and player.cur_item.name != player_details.cur_item.name:
-            # track changed
-            player_changed = True
-            if not player.group_parent:
-                LOGGER.info("%s -- STOP PLAYING %s -- SECONDS PLAYED: %s" %(player.name, player.cur_item.name, player.cur_item_time))
-                LOGGER.info("%s -- START PLAYING %s" %(player.name, player_details.cur_item.name))
-            player.cur_item = player_details.cur_item
-        elif not player.cur_item and player_details.cur_item:
-            # player started playing
-            player_changed = True
-            if not player.group_parent:
-                LOGGER.info("%s -- START PLAYING %s" %(player.name, player_details.cur_item.name))
-            player.cur_item = player_details.cur_item
-        elif player.cur_item and not player_details.cur_item:
-            # player queue cleared
-            player_changed = True
-            if not player.group_parent:
-                LOGGER.info("%s -- STOP PLAYING %s -- SECONDS PLAYED: %s" %(player.name, player.cur_item.name, player.cur_item_time))
-            player.cur_item = player_details.cur_item
-        # compare values to detect changes
-        for key, cur_value in player.__dict__.items():
-            if key != 'settings':
-                new_value = getattr(player_details, key)
-                if new_value != cur_value:
-                    player_changed = True
-                    setattr(player, key, new_value)
-                    LOGGER.debug('key changed: %s for player %s - new value: %s' % (key, player.name, new_value))
-        if player_changed:
-            # player is added or updated!
-            self.mass.signal_event('player updated', player)
-            if player_details.is_group:
-                # is groupplayer, trigger update of its childs
-                player_childs = [item for item in self._players.values() if item.group_parent == player_id]
-                for child in player_childs:
-                    asyncio.create_task(self.trigger_update(child.player_id))
-            elif player.group_parent:
-                # if child player in a group, trigger update of parent
-                asyncio.create_task(self.trigger_update(player.group_parent))
-
-    async def __update_player_hass_settings(self, player_details, player_settings):
-        ''' handle home assistant integration on a player '''
-        if not self.mass.hass:
-            return
-        player_id = player_details.player_id
-        player_settings = self.mass.config['player_settings'][player_id]
-        if player_settings.get('hass_power_entity') and player_settings.get('hass_power_entity_source'):
-            hass_state = await self.mass.hass.get_state(
-                    player_settings['hass_power_entity'],
-                    attribute='source',
-                    register_listener=functools.partial(self.trigger_update, player_id))
-            player_details.powered = hass_state == player_settings['hass_power_entity_source']
-        elif player_settings.get('hass_power_entity'):
-            hass_state = await self.mass.hass.get_state(
-                    player_settings['hass_power_entity'],
-                    attribute='state',
-                    register_listener=functools.partial(self.trigger_update, player_id))
-            player_details.powered = hass_state != 'off'
-        if player_settings.get('hass_volume_entity'):
-            hass_state = await self.mass.hass.get_state(
-                    player_settings['hass_volume_entity'], 
-                    attribute='volume_level',
-                    register_listener=functools.partial(self.trigger_update, player_id))
-            player_details.volume_level = int(try_parse_float(hass_state)*100)
-    
-    async def __get_group_volume(self, player_childs):
-        ''' handle group volume '''
-        group_volume = 0
-        active_players = 0
-        for child_player in player_childs:
-            if child_player.enabled and child_player.powered:
-                group_volume += child_player.volume_level
-                active_players += 1
-        group_volume = group_volume / active_players if active_players else 0
-        return group_volume
-
-    async def __get_group_power(self, player_childs):
-        ''' handle group volume '''
-        group_power = False
-        for child_player in player_childs:
-            print(child_player.name)
-            print(child_player.powered)
-            if child_player.enabled and child_player.powered:
-                group_power = True
-                break
-        return group_power
-    
-    async def __get_player_settings(self, player_details):
-        ''' get (or create) player config '''
-        player_id = player_details.player_id
-        config_entries = [ # default config entries for a player
-            ("enabled", False, "player_enabled"),
-            ("name", "", "player_name"),
-            ("mute_as_power", False, "player_mute_power"),
-            ("disable_volume", False, "player_disable_vol"),
-            ("sox_effects", '', "http_streamer_sox_effects"),
-            ("max_sample_rate", 96000, "max_sample_rate"),
-            ("force_http_streamer", False, "force_http_streamer")
-        ]
-        # append provider specific player settings
-        config_entries += await self.mass.player.providers[player_details.player_provider].player_config_entries()
-        if player_details.is_group:
-            config_entries += [ # group player settings
-                ("apply_group_volume", False, "player_group_vol"),
-                ("apply_group_power", False, "player_group_pow")
-            ]
-        if player_details.is_group or not player_details.group_parent:
-            config_entries += [ # play on power on setting
-                ("play_power_on", False, "player_power_play"),
-            ]
-        if self.mass.config['base'].get('homeassistant',{}).get("enabled"):
-            # append hass specific config entries
-            config_entries += [("hass_power_entity", "", "hass_player_power"),
-                            ("hass_power_entity_source", "", "hass_player_source"),
-                            ("hass_volume_entity", "", "hass_player_volume")]
-        player_settings = self.mass.config['player_settings'].get(player_id,{})
-        for key, def_value, desc in config_entries:
-            if not key in player_settings:
-                if (isinstance(def_value, str) and def_value.startswith('<')):
-                    player_settings[key] = None
-                else:
-                    player_settings[key] = def_value
-        self.mass.config['player_settings'][player_id] = player_settings
-        self.mass.config['player_settings'][player_id]['__desc__'] = config_entries
-        return player_settings
-
-    async def play_media(self, player_id, media_item, queue_opt='play'):
-        ''' 
-            play media on a player 
-            player_id: id of the player
-            media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio)
-            queue_opt: play, replace, next or add
-        '''
-        if not player_id in self._players:
-            LOGGER.warning('Player %s not found' % player_id)
-            return False
-        player_prov = self.providers[self._players[player_id].player_provider]
-        # a single item or list of items may be provided
-        media_items = media_item if isinstance(media_item, list) else [media_item]
-        playable_tracks = []
-        for media_item in media_items:
-            # collect tracks to play
-            if media_item.media_type == MediaType.Artist:
-                tracks = await self.mass.music.artist_toptracks(media_item.item_id, provider=media_item.provider)
-            elif media_item.media_type == MediaType.Album:
-                tracks = await self.mass.music.album_tracks(media_item.item_id, provider=media_item.provider)
-            elif media_item.media_type == MediaType.Playlist:
-                tracks = await self.mass.music.playlist_tracks(media_item.item_id, provider=media_item.provider, offset=0, limit=0) 
-            else:
-                tracks = [media_item] # single track
-            # check supported music providers by this player and work out how to handle playback...
-            for track in tracks:
-                # sort by quality
-                match_found = False
-                is_radio = track.media_type == MediaType.Radio
-                for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True):
-                    media_provider = prov_media['provider']
-                    media_item_id = prov_media['item_id']
-                    player_supported_provs = player_prov.supported_musicproviders
-                    if media_provider in player_supported_provs and not self.mass.config['player_settings'][player_id]['force_http_streamer']:
-                        # the provider can handle this media_type directly !
-                        track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, is_radio=is_radio)
-                        playable_tracks.append(track)
-                        match_found = True
-                    elif 'http' in player_prov.supported_musicproviders:
-                        # fallback to http streaming if supported
-                        track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True, is_radio=is_radio)
-                        playable_tracks.append(track)
-                        match_found = True
-                    if match_found:
-                        break
-        if playable_tracks:
-            if self._players[player_id].shuffle_enabled:
-                random.shuffle(playable_tracks)
-            if queue_opt in ['next', 'play'] and len(playable_tracks) > 1:
-                queue_opt = 'replace' # always assume playback of multiple items as new queue
-            return await player_prov.play_media(player_id, playable_tracks, queue_opt)
-        else:
-            raise Exception("Musicprovider and/or media not supported by player %s !" % (player_id) )
-    
-    async def get_track_uri(self, item_id, provider, player_id, http_stream=False, is_radio=False):
-        ''' generate the URL/URI for a media item '''
-        uri = ""
-        if http_stream:
-            if is_radio:
-                params = {"provider": provider, "radio_id": str(item_id), "player_id": str(player_id)}
-                params_str = urllib.parse.urlencode(params)
-                uri = 'http://%s:%s/stream_radio?%s'% (self.local_ip, self.mass.config['base']['web']['http_port'], params_str)
-            else:
-                params = {"provider": provider, "track_id": str(item_id), "player_id": str(player_id)}
-                params_str = urllib.parse.urlencode(params)
-                uri = 'http://%s:%s/stream_track?%s'% (self.local_ip, self.mass.config['base']['web']['http_port'], params_str)
-        elif provider == "spotify":
-            uri = 'spotify://spotify:track:%s' % item_id
-        elif provider == "qobuz":
-            uri = 'qobuz://%s.flac' % item_id
-        elif provider == "file":
-            uri = item_id
-        else:
-            uri = "%s://%s" %(provider, item_id)
-        return uri
-
-    async def player_queue(self, player_id, offset=0, limit=50):
-        ''' return the items in the player's queue '''
-        player = self._players[player_id]
-        player_prov = self.providers[player.player_provider]
-        return await player_prov.player_queue(player_id, offset=offset, limit=limit)
-
-    async def player_queue_index(self, player_id):
-        ''' get current index of the player's queue '''
-        player = self._players[player_id]
-        player_prov = self.providers[player.player_provider]
-        return await player_prov.player_queue_index(player_id)
-
-    async def player_queue_stream_update(self, player_id, cur_index, is_start=False):
-        ''' called by our queue streamer when it started playing the queue from position x '''
-        player = self._players[player_id]
-        return await self.providers[player.player_provider].player_queue_stream_update(player_id, cur_index, is_start)
-
-    def load_providers(self):
-        ''' dynamically load providers '''
-        for item in os.listdir(MODULES_PATH):
-            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
-                    item.endswith('.py') and not item.startswith('.')):
-                module_name = item.replace(".py","")
-                LOGGER.debug("Loading playerprovider module %s" % module_name)
-                try:
-                    mod = __import__("modules.playerproviders." + module_name, fromlist=[''])
-                    if not self.mass.config['playerproviders'].get(module_name):
-                        self.mass.config['playerproviders'][module_name] = {}
-                    self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries()
-                    for key, def_value, desc in mod.config_entries():
-                        if not key in self.mass.config['playerproviders'][module_name]:
-                            self.mass.config['playerproviders'][module_name][key] = def_value
-                    mod = mod.setup(self.mass)
-                    if mod:
-                        self.providers[mod.prov_id] = mod
-                        cls_name = mod.__class__.__name__
-                        LOGGER.info("Successfully initialized module %s" % cls_name)
-                except Exception as exc:
-                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
diff --git a/music_assistant/modules/player_manager.py b/music_assistant/modules/player_manager.py
new file mode 100755 (executable)
index 0000000..438ce7b
--- /dev/null
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from enum import Enum
+from ..utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task
+from ..models.media_types import MediaType, TrackQuality
+from ..models.player_queue import QueueItem
+from ..models.player import PlayerState
+import operator
+import random
+import functools
+import urllib
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" )
+
+
+
+class PlayerManager():
+    ''' several helpers to handle playback through player providers '''
+    
+    def __init__(self, mass):
+        self.mass = mass
+        self.providers = {}
+        self._players = {}
+        self.local_ip = get_ip()
+        # dynamically load provider modules
+        self.load_providers()
+
+    @property
+    def players(self):
+        ''' all players as property '''
+        return self.mass.event_loop.run_until_complete(self.get_players())
+    
+    async def get_players(self):
+        ''' return all players as a list '''
+        items = list(self._players.values())
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def get_player(self, player_id):
+        ''' return player by id '''
+        return self._players.get(player_id, None)
+
+    async def get_provider_players(self, player_provider):
+        ''' return all players for given provider_id '''
+        return [item for item in self._players.values() if item.player_provider == player_provider] 
+
+    async def add_player(self, player):
+        ''' register a new player '''
+        self._players[player.player_id] = player
+        self.mass.signal_event('player added', player)
+        # TODO: turn on player if it was previously turned on ?
+        return player
+
+    async def remove_player(self, player_id):
+        ''' handle a player remove '''
+        self._players.pop(player_id, None)
+        self.mass.signal_event('player removed', player_id)
+
+    async def trigger_update(self, player_id):
+        ''' manually trigger update for a player '''
+        if player_id in self._players:
+            await self._players[player_id].update()
+    
+    def load_providers(self):
+        ''' dynamically load providers '''
+        for item in os.listdir(MODULES_PATH):
+            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
+                    item.endswith('.py') and not item.startswith('.')):
+                module_name = item.replace(".py","")
+                LOGGER.debug("Loading playerprovider module %s" % module_name)
+                try:
+                    mod = __import__("modules.playerproviders." + module_name, fromlist=[''])
+                    if not self.mass.config['playerproviders'].get(module_name):
+                        self.mass.config['playerproviders'][module_name] = {}
+                    self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries()
+                    for key, def_value, desc in mod.config_entries():
+                        if not key in self.mass.config['playerproviders'][module_name]:
+                            self.mass.config['playerproviders'][module_name][key] = def_value
+                    mod = mod.setup(self.mass)
+                    if mod:
+                        self.providers[mod.prov_id] = mod
+                        cls_name = mod.__class__.__name__
+                        LOGGER.info("Successfully initialized module %s" % cls_name)
+                except Exception as exc:
+                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
index cc33f6399c53c314f3a484e9021e50a1e464ec3c..736528d4794285633b05d461ca85cebcf90647b5 100644 (file)
@@ -2,25 +2,26 @@
 # -*- coding:utf-8 -*-
 
 import asyncio
-import os
-from typing import List
-import random
-import sys
-from utils import run_periodic, run_background_task, LOGGER, parse_track_title, try_parse_int
-from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
-from constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
-import json
+# import os
+# from typing import List
+# import random
+# import sys
+# import json
 import aiohttp
-import time
-import datetime
-import hashlib
+import time
+import datetime
+import hashlib
 import pychromecast
 from pychromecast.controllers.multizone import MultizoneController
 from pychromecast.controllers import BaseController
 from pychromecast.controllers.media import MediaController
 import types
-import urllib
-import select
+# import urllib
+# import select
+from ...utils import run_periodic, LOGGER, try_parse_int
+from ...models.playerprovider import PlayerProvider
+from ...models.player import Player, PlayerState
+from ...constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
 
 def setup(mass):
     ''' setup the provider'''
@@ -36,166 +37,52 @@ def config_entries():
         (CONF_ENABLED, True, CONF_ENABLED),
         ]
 
+class ChromecastPlayer(Player):
+    ''' Chromecast player object '''
+    cc = None
+
+    async def __stop(self):
+        ''' send stop command to player '''
+        self.cc.media_controller.stop()
+
+    async def __play(self):
+        ''' send play command to player '''
+        self.cc.media_controller.play()
+
+    async def __pause(self):
+        ''' send pause command to player '''
+        self.cc.media_controller.pause()
+
+    async def __power_on(self):
+        ''' send power ON command to player '''
+        self.powered = True
+
+    async def __power_off(self):
+        ''' send power OFF command to player '''
+        self.powered = False
+        # power is not supported so send quit_app instead
+        if not self.group_parent:
+            self.cc.quit_app()
+
+    async def __volume_set(self, volume_level):
+        ''' send new volume level command to player '''
+        self.cc.set_volume(volume_level/100)
+        self.volume_level = volume_level
+
+    async def __volume_mute(self, is_muted=False):
+        ''' send mute command to player '''
+        self.cc.set_volume_muted(is_muted)
+
+
 class ChromecastProvider(PlayerProvider):
     ''' support for ChromeCast Audio '''
-
+    
     def __init__(self, mass):
         self.prov_id = 'chromecast'
         self.name = 'Chromecast'
-        self.icon = ''
         self.mass = mass
-        self._players = {}
-        self._chromecasts = {}
-        self._player_queue = {}
-        self._player_queue_index = {}
-        self._player_queue_stream_startindex = {}
         self._discovery_running = False
-        self.supported_musicproviders = ['http']
         self.mass.event_loop.create_task(self.__periodic_chromecast_discovery())
-        
-    ### Provider specific implementation #####
-
-    async def player_config_entries(self):
-        ''' 
-            get the player config entries for this provider 
-            (list with key/value pairs)
-        '''
-        return [
-            ("crossfade_duration", 0, "crossfade_duration"),
-            ]
-
-    async def player_command(self, player_id, cmd:str, cmd_args=None):
-        ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) '''
-        if (not player_id in self._chromecasts or 
-                not self._chromecasts[player_id].socket_client or 
-                not self._chromecasts[player_id].socket_client.is_connected):
-            LOGGER.warning("command %s failed - %s is disconnected, rescan triggered" %(cmd, self._players[player_id].name))
-            self.mass.event_loop.create_task(self.__chromecast_discovery())
-            return
-        if cmd == 'play':
-            self._players[player_id].powered = True
-            if self._chromecasts[player_id].media_controller.status.player_is_playing:
-                pass
-            elif self._chromecasts[player_id].media_controller.status.player_is_paused:
-                self._chromecasts[player_id].media_controller.play()
-            else:
-                await self.__resume_queue(player_id)
-            await self.mass.player.update_player(self._players[player_id])
-        elif cmd == 'pause':
-            self._chromecasts[player_id].media_controller.pause()
-        elif cmd == 'stop':
-            self._chromecasts[player_id].media_controller.stop()
-        elif cmd == 'next':
-            enable_crossfade = self.mass.config['player_settings'][player_id]["crossfade_duration"] > 0
-            if enable_crossfade:
-                await self.__play_stream_queue(player_id, self._player_queue_index[player_id]+1)
-            else:
-                self._chromecasts[player_id].media_controller.queue_next()
-        elif cmd == 'previous':
-            enable_crossfade = self.mass.config['player_settings'][player_id]["crossfade_duration"] > 0
-            if enable_crossfade:
-                await self.__play_stream_queue(player_id, self._player_queue_index[player_id]-1)
-            else:
-                self._chromecasts[player_id].media_controller.queue_prev()
-        elif cmd == 'power' and cmd_args == 'off':
-            self._players[player_id].powered = False
-            if not self._players[player_id].group_parent:
-                self._chromecasts[player_id].quit_app() # power is not supported so send quit_app instead
-            await self.mass.player.update_player(self._players[player_id])
-        elif cmd == 'power':
-            self._players[player_id].powered = True
-            await self.mass.player.update_player(self._players[player_id])
-        elif cmd == 'volume':
-            new_volume = try_parse_int(cmd_args)
-            self._chromecasts[player_id].set_volume(new_volume/100)
-            self._players[player_id].volume_level = new_volume
-            await self.mass.player.update_player(self._players[player_id])
-        elif cmd == 'mute' and cmd_args == 'off':
-            self._chromecasts[player_id].set_volume_muted(False)
-        elif cmd == 'mute':
-            self._chromecasts[player_id].set_volume_muted(True)
-
-    async def player_queue(self, player_id, offset=0, limit=50):
-        ''' return the current items in the player's queue '''
-        return self._player_queue[player_id][offset:limit]
-    
-    async def player_queue_index(self, player_id):
-        ''' get current index of the player's queue '''
-        return self._player_queue_index[player_id]
-    
-    async def play_media(self, player_id, media_items, queue_opt='play'):
-        ''' 
-            play media on a player
-        '''
-        if (not player_id in self._chromecasts or 
-                not self._chromecasts[player_id].socket_client or 
-                not self._chromecasts[player_id].socket_client.is_connected):
-            LOGGER.warning("play_media failed - %s is disconnected, rescan triggered" %(self._players[player_id].name))
-            self.mass.event_loop.create_task(self.__chromecast_discovery())
-            return
-
-        castplayer = self._chromecasts[player_id]
-        cur_queue_index = self._player_queue_index.get(player_id, 0)
-        enable_crossfade = self.mass.config['player_settings'][player_id]["crossfade_duration"] > 0
-        is_radio = media_items and media_items[0].media_type == MediaType.Radio
-
-        if queue_opt == 'replace' or not self._player_queue[player_id]:
-            # overwrite queue with new items
-            self._player_queue[player_id] = media_items
-            if enable_crossfade and not is_radio:
-                await self.__play_stream_queue(player_id, 0)
-            else:
-                await self.__queue_load(player_id, self._player_queue[player_id], 0)
-        elif queue_opt == 'play':
-            # replace current item with new item(s)
-            self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index] + media_items + self._player_queue[player_id][cur_queue_index+1:]
-            if enable_crossfade and not is_radio:
-                await self.__play_stream_queue(player_id, cur_queue_index)
-            else:
-                await self.__queue_load(player_id, self._player_queue[player_id], cur_queue_index)
-        elif queue_opt == 'next':
-            # insert new items at current index +1
-            if len(self._player_queue[player_id]) > cur_queue_index+1:
-                old_next_uri = self._player_queue[player_id][cur_queue_index+1].uri
-            else:
-                old_next_uri = None
-            self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index+1] + media_items + self._player_queue[player_id][cur_queue_index+1:]
-            if not enable_crossfade or is_radio:
-                # find out the itemID of the next item in CC queue
-                insert_at_item_id = None
-                if old_next_uri:
-                    for item in castplayer.media_controller.queue_items:
-                        if item['media']['contentId'] == old_next_uri:
-                            insert_at_item_id = item['itemId']
-                await self.__queue_insert(player_id, media_items, insert_at_item_id)
-        elif queue_opt == 'add':
-            # add new items at end of queue
-            self._player_queue[player_id] = self._player_queue[player_id] + media_items
-            if not enable_crossfade or is_radio:
-                await self.__queue_insert(player_id, media_items)
-
-    async def player_queue_stream_update(self, player_id, cur_index, is_start=False):
-        ''' called by our queue streamer when it started playing a track in the queue at index X '''
-        if is_start:
-            self._player_queue_stream_startindex[player_id] = cur_index
-            self._player_queue_index[player_id] = cur_index
-        # schedule update a few times as we don't know how much time is prebuffered
-        for i in range(0, 20):
-            castplayer = self._chromecasts[player_id]
-            status = castplayer.media_controller.status
-            await self.__handle_player_state(castplayer, mediastatus=status)
-            await asyncio.sleep(2)
-    
-    ### Provider specific (helper) methods #####
-
-    async def __get_cur_queue_index(self, player_id, current_uri):
-        ''' retrieve index of current item in the player queue '''
-        cur_index = 0
-        for index, track in enumerate(self._player_queue[player_id]):
-            if track.uri == current_uri:
-                cur_index = index
-                break
-        return cur_index
 
     async def __queue_load(self, player_id, new_tracks, startindex=None):
         ''' load queue on player with given queue items '''
@@ -310,7 +197,7 @@ class ChromecastProvider(PlayerProvider):
     async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None):
         ''' handle a player state message from the socket '''
         player_id = str(chromecast.uuid)
-        player = self._players[player_id]
+        player = self.get_player(player_id)
         # always update player details that may change
         player.name = chromecast.name
         if caststatus:
@@ -351,56 +238,26 @@ class ChromecastProvider(PlayerProvider):
                 player.cur_item = queue_track
                 player.cur_item_time = track_time
                 self._player_queue_index[player_id] = queue_index
-        await self.mass.player.update_player(player)
-
-    async def __parse_track(self, mediastatus):
-        ''' parse track in CC to our internal format '''
-        track = await self.__track_from_uri(mediastatus.content_id)
-        if not track:
-            # TODO: match this info manually in the DB!!
-            track = Track()
-            artist = mediastatus.artist
-            album = mediastatus.album_name
-            title = mediastatus.title
-            track.name = "%s - %s" %(artist, title)
-            track.duration = try_parse_int(mediastatus.duration)
-            if mediastatus.media_metadata and mediastatus.media_metadata.get('images'):
-                track.metadata.image = mediastatus.media_metadata['images'][-1]['url']
-        return track
-
-    async def __track_from_uri(self, uri):
-        ''' try to parse uri loaded in CC to a track we understand '''
-        track = None
-        if uri.startswith('spotify://track:') and 'spotify' in self.mass.music.providers:
-            track_id = uri.replace('spotify:track:','')
-            track = await self.mass.music.providers['spotify'].track(track_id)
-        elif uri.startswith('qobuz://') and 'qobuz' in self.mass.music.providers:
-            track_id = uri.replace('qobuz://','').replace('.flac','')
-            track = await self.mass.music.providers['qobuz'].track(track_id)
-        elif uri.startswith('http') and '/stream_track' in uri:
-            params = urllib.parse.parse_qs(uri.split('?')[1])
-            track_id = params['track_id'][0]
-            provider = params['provider'][0]
-            track = await self.mass.music.providers[provider].track(track_id)
-        return track
 
     async def __handle_group_members_update(self, mz, added_player=None, removed_player=None):
         ''' callback when cast group members update '''
         if added_player:
-            if added_player in self._players:
-                self._players[added_player].group_parent = str(mz._uuid)
-                LOGGER.debug("player %s added to group %s" %(self._players[added_player].name, self._players[str(mz._uuid)].name))
-                self.mass.event_loop.create_task(self.mass.player.update_player(self._players[added_player]))
+            player = self.get_player(added_player)
+            group_player = self.get_player(str(mz._uuid))
+            if player and group_player:
+                player.group_parent = str(mz._uuid)
+                LOGGER.debug("player %s added to group %s" %(player.name, group_player.name))
         elif removed_player:
-            if removed_player in self._players:
-                self._players[removed_player].group_parent = None
-                LOGGER.debug("player %s removed from group %s" %(self._players[removed_player].name, self._players[str(mz._uuid)].name))
-                self.mass.event_loop.create_task(self.mass.player.update_player(self._players[removed_player]))
+            player = self.get_player(added_player)
+            group_player = self.get_player(str(mz._uuid))
+            if player and group_player:
+                player.group_parent = None
+                LOGGER.debug("player %s removed from group %s" %(player.name, group_player.name))
         else:
             for member in mz.members:
-                if member in self._players:
-                    self._players[member].group_parent = str(mz._uuid)
-                    self.mass.event_loop.create_task(self.mass.player.update_player(self._players[member]))
+                player = self.get_player(member)
+                if player:
+                    player.group_parent = str(mz._uuid)
     
     @run_periodic(1800)
     async def __periodic_chromecast_discovery(self):
@@ -415,17 +272,15 @@ class ChromecastProvider(PlayerProvider):
         LOGGER.info("Chromecast discovery started...")
         # remove any disconnected players...
         removed_players = []
-        for player_id, cast in self._chromecasts.items():
-            if not cast.socket_client or not cast.socket_client.is_connected:
-                LOGGER.info("%s is disconnected" % cast.name)
-                removed_players.append(player_id)
+        for player in self.players:
+            if not player.cc.socket_client or not player.cc.socket_client.is_connected:
+                LOGGER.info("%s is disconnected" % player.name)
+                # cleanup cast object
+                del player.cc
+                removed_players.append(player.player_id)
+        # signal removed players
         for player_id in removed_players:
-            try:
-                self._chromecasts[player_id].disconnect()
-            except Exception:
-                pass
-            del self._chromecasts[player_id]
-            await self.mass.player.remove_player(player_id)
+            await self.remove_player(player_id)
         # search for available chromecasts
         from pychromecast.discovery import start_discovery, stop_discovery
         def discovered_callback(name):
@@ -433,7 +288,7 @@ class ChromecastProvider(PlayerProvider):
             discovery_info = listener.services[name]
             ip_address, port, uuid, model_name, friendly_name = discovery_info
             player_id = str(uuid)
-            if not player_id in self._chromecasts:
+            if not self.get_player(player_id):
                 LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port))
                 asyncio.run_coroutine_threadsafe(
                         self.__chromecast_discovered(player_id, discovery_info), self.mass.event_loop)
@@ -451,12 +306,6 @@ class ChromecastProvider(PlayerProvider):
         except ChromecastConnectionError:
             LOGGER.warning("Could not connect to device %s" % player_id)
             return
-        if not player_id in self._players:
-            player = MusicPlayer()
-            player.player_id = player_id
-            player.name = chromecast.name
-            player.player_provider = self.prov_id
-            self._players[player_id] = player
         # patch the receive message method for handling queue status updates
         chromecast.media_controller.queue_items = []
         chromecast.media_controller.queue_cur_id = None
@@ -465,29 +314,24 @@ class ChromecastProvider(PlayerProvider):
         chromecast.register_status_listener(listenerCast)
         listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop)
         chromecast.media_controller.register_status_listener(listenerMedia)
+        player = ChromecastPlayer(self.mass, player_id, self.prov_id)
         if chromecast.cast_type == 'group':
-            self._players[player_id].is_group = True
+            player.is_group = True
             mz = MultizoneController(chromecast.uuid)
             mz.register_listener(MZListener(mz, self.__handle_group_members_update, self.mass.event_loop))
             chromecast.register_handler(mz)
             chromecast.register_connection_listener(MZConnListener(mz))
             chromecast.mz = mz
-        chromecast.wait()
-        self._chromecasts[player_id] = chromecast
-        if not player_id in self._player_queue:
-            # TODO: persistant storage of player queue ?
-            self._player_queue[player_id] = []
-            self._player_queue_index[player_id] = 0
+        player.cc = chromecast
+        player.cc.wait()
+        self.add_player(player)
         self.update_all_group_members()
-        # turn on player if it was previously turned on
-        if self._players[player_id].powered:
-            self.mass.event_loop.create_task(self.mass.player.player_command(player_id, "power", "on"))
 
     def update_all_group_members(self):
         ''' force member update of all cast groups '''
-        for cast in list(self._chromecasts.values()):
-            if cast.cast_type == 'group':
-                cast.mz.update_members()
+        for player in self.players:
+            if player.cc.cast_type == 'group':
+                player.cc.mz.update_members()
 
 def chunks(l, n):
     """Yield successive n-sized chunks from l."""
index 5eb166adc6f21fba7872919a78f710f97b26b8b2..13db3f782a3aaa99892f224ffd550e632400fe8c 100644 (file)
@@ -126,10 +126,6 @@ class LMSProvider(PlayerProvider):
                 items.append(track)
         return items
 
-    async def player_queue_index(self, player_id):
-        ''' get current index of the player's queue '''
-        raise NotImplementedError()
-
     ### Provider specific (helper) methods #####
     
     async def __get_players(self):
index 15c06088d523ec22a5e493bb790875434163300d..8c67b9b9c65bc8ffbf966fabb7ee7fa0ff8e651a 100644 (file)
@@ -37,12 +37,8 @@ class PyLMSServer(PlayerProvider):
     def __init__(self, mass):
         self.prov_id = 'pylms'
         self.name = 'Logitech Media Server Emulation'
-        self.icon = ''
         self.mass = mass
-        self._players = {}
         self._lmsplayers = {}
-        self._player_queue = {}
-        self._player_queue_index = {}
         self.buffer = b''
         self.last_msg_received = 0
         self.supported_musicproviders = ['http']
@@ -96,59 +92,52 @@ class PyLMSServer(PlayerProvider):
             self._lmsplayers[player_id].unmute()
         elif cmd == 'mute':
             self._lmsplayers[player_id].mute()
-
-    async def player_queue(self, player_id, offset=0, limit=50):
-        ''' return the current items in the player's queue '''
-        return self._player_queue[player_id][offset:limit]
-    
-    async def player_queue_index(self, player_id):
-        ''' get current index of the player's queue '''
-        return self._player_queue_index.get(player_id, 0)
     
     async def play_media(self, player_id, media_items, queue_opt='play'):
         ''' 
             play media on a player
         '''
-        cur_queue_index = self._player_queue_index.get(player_id, 0)
+        player = self.get_player(player_id)
+        cur_index = player.cur_queue_index
 
-        if queue_opt == 'replace' or not self._player_queue[player_id]:
+        if queue_opt == 'replace' or not player.queue:
             # overwrite queue with new items
-            self._player_queue[player_id] = media_items
+            player.queue = media_items
             await self.__queue_play(player_id, 0, send_flush=True)
         elif queue_opt == 'play':
             # replace current item with new item(s)
-            self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index] + media_items + self._player_queue[player_id][cur_queue_index+1:]
-            await self.__queue_play(player_id, cur_queue_index, send_flush=True)
+            player.queue = player.queue[player_id][:cur_index] + media_items + player.queue[player_id][cur_index+1:]
+            await self.__queue_play(player_id, cur_index, send_flush=True)
         elif queue_opt == 'next':
             # insert new items at current index +1
-            self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index+1] + media_items + self._player_queue[player_id][cur_queue_index+1:]
+            player.queue[player_id] = player.queue[player_id][:cur_index+1] + media_items + player.queue[player_id][cur_index+1:]
         elif queue_opt == 'add':
             # add new items at end of queue
-            self._player_queue[player_id] = self._player_queue[player_id] + media_items
+            player.queue[player_id] = player.queue[player_id] + media_items
 
     ### Provider specific (helper) methods #####
 
     async def __queue_play(self, player_id, index, send_flush=False):
         ''' send play command to player '''
-        if not player_id in self._player_queue or not player_id in self._player_queue_index:
+        if not player_id in player.queue or not player_id in player.queue_index:
             return
-        if not self._player_queue[player_id]:
+        if not player.queue[player_id]:
             return
         if index == None:
-            index = self._player_queue_index[player_id]
-        if len(self._player_queue[player_id]) >= index:
-            track = self._player_queue[player_id][index]
+            index = player.queue_index[player_id]
+        if len(player.queue[player_id]) >= index:
+            track = player.queue[player_id][index]
             if send_flush:
                 self._lmsplayers[player_id].flush()
             self._lmsplayers[player_id].play(track.uri)
-            self._player_queue_index[player_id] = index
+            player.queue_index[player_id] = index
 
     async def __queue_next(self, player_id):
         ''' request next track from queue '''
-        if not player_id in self._player_queue or not player_id in self._player_queue:
+        if not player_id in player.queue or not player_id in player.queue:
             return
-        cur_queue_index = self._player_queue_index[player_id]
-        if len(self._player_queue[player_id]) > cur_queue_index:
+        cur_queue_index = player.queue_index[player_id]
+        if len(player.queue[player_id]) > cur_queue_index:
             new_queue_index = cur_queue_index + 1
         elif self._players[player_id].repeat_enabled:
             new_queue_index = 0
@@ -159,16 +148,16 @@ class PyLMSServer(PlayerProvider):
 
     async def __queue_previous(self, player_id):
         ''' request previous track from queue '''
-        if not player_id in self._player_queue:
+        if not player_id in player.queue:
             return
-        cur_queue_index = self._player_queue_index[player_id]
-        if cur_queue_index == 0 and len(self._player_queue[player_id]) > 1:
-            new_queue_index = len(self._player_queue[player_id]) -1
+        cur_queue_index = player.queue_index[player_id]
+        if cur_queue_index == 0 and len(player.queue[player_id]) > 1:
+            new_queue_index = len(player.queue[player_id]) -1
         elif cur_queue_index == 0:
             new_queue_index = cur_queue_index
         else:
             new_queue_index -= 1
-            self._player_queue_index[player_id] = new_queue_index
+            player.queue_index[player_id] = new_queue_index
         return await self.__queue_play(player_id, new_queue_index)
 
     async def __handle_player_event(self, player_id, event, event_data=None):
@@ -179,15 +168,16 @@ class PyLMSServer(PlayerProvider):
         lms_player = self._lmsplayers[player_id]
         if event == "next_track":
             return await self.__queue_next(player_id)
+        player 
         if not player_id in self._players:
             player = MusicPlayer()
             player.player_id = player_id
             player.player_provider = self.prov_id
             self._players[player_id] = player
-            if not player_id in self._player_queue:
-                self._player_queue[player_id] = []
-            if not player_id in self._player_queue_index:
-                self._player_queue_index[player_id] = 0
+            if not player_id in player.queue:
+                player.queue[player_id] = []
+            if not player_id in player.queue_index:
+                player.queue_index[player_id] = 0
         else:
             player = self._players[player_id]
         # update player properties
@@ -200,9 +190,9 @@ class PyLMSServer(PlayerProvider):
             player.powered = event_data
         elif event == "state":
             player.state = event_data
-        if self._player_queue[player_id]:
-            cur_queue_index = self._player_queue_index[player_id]
-            player.cur_item = self._player_queue[player_id][cur_queue_index]
+        if player.queue[player_id]:
+            cur_queue_index = player.queue_index[player_id]
+            player.cur_item = player.queue[player_id][cur_queue_index]
         # update player details
         await self.mass.player.update_player(player)
 
index 7701d7f1c5cdd4b224ae958aeacd6155d8e81b48..6761d6cd0b2fe6f5803e12b8074dbd67aeb19b81 100755 (executable)
@@ -69,9 +69,9 @@ class Web():
         app.add_routes([web.get('/jsonrpc.js', self.json_rpc)])
         app.add_routes([web.post('/jsonrpc.js', self.json_rpc)])
         app.add_routes([web.get('/ws', self.websocket_handler)])
-        app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)])
-        app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)])
-        app.add_routes([web.get('/stream_queue', self.mass.http_streamer.stream_queue)])
+        app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)])
+        app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)])
+        app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream_queue)])
         app.add_routes([web.get('/api/search', self.search)])
         app.add_routes([web.get('/api/config', self.get_config)])
         app.add_routes([web.post('/api/config', self.save_config)])
@@ -186,10 +186,21 @@ class Web():
 
     async def player_command(self, request):
         ''' issue player command'''
+        result = False
         player_id = request.match_info.get('player_id')
-        cmd = request.match_info.get('cmd')
-        cmd_args = request.match_info.get('cmd_args')
-        result = await self.mass.player.player_command(player_id, cmd, cmd_args)
+        player = await self.mass.player.get_player(player_id)
+        if player:
+            cmd = request.match_info.get('cmd')
+            cmd_args = request.match_info.get('cmd_args')
+            player_cmd = getattr(player, cmd, None)
+            if player_cmd and cmd_args:
+                result = await player_cmd(player_id, cmd, cmd_args)
+            elif player_cmd and cmd_args:
+                result = await player_cmd(player_id, cmd, cmd_args)
+            else:
+                LOGGER.error("Received non-existing command %s for player %s" %(cmd, player.name))
+        else:
+            LOGGER.error("Received command dor non-existing player %s" %(player_id))
         return web.json_response(result, dumps=json_serializer) 
     
     async def play_media(self, request):
index 971037fa848b0e86e2c8ab0f5a121aeadf414108..e40de73d545f5fc4f22eaeeddaa5419a43c4f694 100755 (executable)
@@ -68,7 +68,13 @@ def try_parse_float(possible_float):
     try:
         return float(possible_float)
     except:
-        return 0
+        return 0.0
+
+def try_parse_bool(possible_bool):
+    if isinstance(possible_bool, bool):
+        return possible_bool
+    else:
+        return possible_bool in ['true', 'True', '1', 'on', 'ON', 1]
 
 def parse_track_title(track_title):
     ''' try to parse clean track title and version from the title '''