hass integration
authormarcelveldt <marcelvanderveldt@MacBook-Pro.local>
Mon, 13 May 2019 23:38:46 +0000 (01:38 +0200)
committermarcelveldt <marcelvanderveldt@MacBook-Pro.local>
Mon, 13 May 2019 23:38:46 +0000 (01:38 +0200)
23 files changed:
.gitignore
music_assistant/api.py
music_assistant/database.py
music_assistant/main.py
music_assistant/metadata.py
music_assistant/models.py
music_assistant/modules/homeassistant.py [new file with mode: 0644]
music_assistant/modules/musicproviders/file.py
music_assistant/modules/musicproviders/qobuz.py
music_assistant/modules/musicproviders/spotify.py
music_assistant/modules/playerproviders/chromecast.py
music_assistant/modules/playerproviders/homeassistant.py [deleted file]
music_assistant/modules/playerproviders/lms.py
music_assistant/music.py
music_assistant/player.py
music_assistant/utils.py
music_assistant/web/components/player.vue.js
music_assistant/web/components/playmenu.vue.js
music_assistant/web/index.html
music_assistant/web/pages/artistdetails.vue.js
music_assistant/web/pages/browse.vue.js
music_assistant/web/pages/config.vue.js
requirements.txt

index 1eb6c37588fe88381d8b547299baa49adde6bb3c..452d444da62bd7b24062bf43672d2a7371d0f723 100644 (file)
@@ -3,3 +3,5 @@
 *.db
 *.pyc
 music_assistant/config.json
+*.cert
+*.pem
index 02e84379af071dc4180aa4b81cfc75565cd6a2db..61552888634efb16897b1b19921ee6ec15974b5c 100755 (executable)
@@ -10,14 +10,17 @@ from aiohttp import web
 from models import MediaType, media_type_from_string
 from functools import partial
 json_serializer = partial(json.dumps, default=lambda x: x.__dict__)
-
+import ssl
 
 class Api():
     ''' expose our data through json api '''
     
-    def __init__(self, mass):
+    def __init__(self, mass, ssl_cert, ssl_key):
         self.mass = mass
+        self._ssl_cert = ssl_cert
+        self._ssl_key = ssl_key
         self.http_session = aiohttp.ClientSession()
+        mass.event_loop.create_task(self.setup_web())
 
     def stop(self):
         self.runner.cleanup()
@@ -48,8 +51,12 @@ class Api():
         
         self.runner = web.AppRunner(app)
         await self.runner.setup()
-        site = web.TCPSite(self.runner, '0.0.0.0', 8095)
-        await site.start()
+        ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key)
+        http_site = web.TCPSite(self.runner, '0.0.0.0', 8095)
+        https_site = web.TCPSite(self.runner, '0.0.0.0', 8096, ssl_context=ssl_context)
+        await http_site.start()
+        await https_site.start()
 
     async def get_items(self, request):
         ''' get multiple library items'''
@@ -71,22 +78,25 @@ class Api():
         media_id = request.match_info.get('media_id')
         action = request.match_info.get('action','')
         lazy = request.rel_url.query.get('lazy', '') != 'false'
+        provider = request.rel_url.query.get('provider')
         if action:
-            result = await self.mass.music.item_action(media_id, media_type, action)
+            result = await self.mass.music.item_action(media_id, media_type, provider, action)
         else:
-            result = await self.mass.music.item(media_id, media_type, lazy=lazy)
+            result = await self.mass.music.item(media_id, media_type, provider, lazy=lazy)
         return web.json_response(result, dumps=json_serializer)
 
     async def artist_toptracks(self, request):
         ''' get top tracks for given artist '''
         artist_id = request.match_info.get('artist_id')
-        result = await self.mass.music.artist_toptracks(artist_id)
+        provider = request.rel_url.query.get('provider')
+        result = await self.mass.music.artist_toptracks(artist_id, provider)
         return web.json_response(result, dumps=json_serializer)
 
     async def artist_albums(self, request):
         ''' get (all) albums for given artist '''
         artist_id = request.match_info.get('artist_id')
-        result = await self.mass.music.artist_albums(artist_id)
+        provider = request.rel_url.query.get('provider')
+        result = await self.mass.music.artist_albums(artist_id, provider)
         return web.json_response(result, dumps=json_serializer)
 
     async def playlist_tracks(self, request):
@@ -94,13 +104,15 @@ class Api():
         playlist_id = request.match_info.get('playlist_id')
         limit = int(request.query.get('limit', 50))
         offset = int(request.query.get('offset', 0))
-        result = await self.mass.music.playlist_tracks(playlist_id, offset=offset, limit=limit)
+        provider = request.rel_url.query.get('provider')
+        result = await self.mass.music.playlist_tracks(playlist_id, provider, offset=offset, limit=limit)
         return web.json_response(result, dumps=json_serializer)
 
     async def album_tracks(self, request):
         ''' get album tracks from provider'''
         album_id = request.match_info.get('album_id')
-        result = await self.mass.music.album_tracks(album_id)
+        provider = request.rel_url.query.get('provider')
+        result = await self.mass.music.album_tracks(album_id, provider)
         return web.json_response(result, dumps=json_serializer)
 
     async def search(self, request):
@@ -142,7 +154,8 @@ class Api():
         media_type = media_type_from_string(media_type_str)
         media_id = request.match_info.get('media_id')
         queue_opt = request.match_info.get('queue_opt','')
-        media_item = await self.mass.music.item(media_id, media_type, lazy=True)
+        provider = request.rel_url.query.get('provider')
+        media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True)
         result = await self.mass.player.play_media(player_id, media_item, queue_opt)
         return web.json_response(result, dumps=json_serializer) 
     
@@ -185,16 +198,16 @@ class Api():
                         players = await self.mass.player.players()
                         ws_msg = {'message': 'players', 'message_details': players}
                         await ws.send_json(ws_msg, dumps=json_serializer)
-                    elif msg.data.startswith('players') and '/play_media/' in msg.data:
-                        #'players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}'
-                        msg_data_parts = msg.data.split('/')
-                        player_id = msg_data_parts[1]
-                        media_type = msg_data_parts[3]
-                        media_type = media_type_from_string(media_type)
-                        media_id = msg_data_parts[4]
-                        queue_opt = msg_data_parts[5] if len(msg_data_parts) == 6 else 'replace'
-                        media_item = await self.mass.music.item(media_id, media_type, lazy=True)
-                        await self.mass.player.play_media(player_id, media_item, queue_opt)
+                    elif msg.data.startswith('players') and '/play_media/' in msg.data:
+                        #'players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}'
+                        msg_data_parts = msg.data.split('/')
+                        player_id = msg_data_parts[1]
+                        media_type = msg_data_parts[3]
+                        media_type = media_type_from_string(media_type)
+                        media_id = msg_data_parts[4]
+                        queue_opt = msg_data_parts[5] if len(msg_data_parts) == 6 else 'replace'
+                        media_item = await self.mass.music.item(media_id, media_type, lazy=True)
+                        await self.mass.player.play_media(player_id, media_item, queue_opt)
 
                     elif msg.data.startswith('players') and '/cmd/' in msg.data:
                         # players/{player_id}/cmd/{cmd} or players/{player_id}/cmd/{cmd}/{cmd_args}
@@ -231,10 +244,13 @@ class Api():
         ''' start streaming audio from provider '''
         track_id = request.match_info.get('track_id')
         provider = request.match_info.get('provider')
-        stream_details = await self.mass.music.providers[provider].get_stream_details(track_id)
+        #stream_details = await self.mass.music.providers[provider].get_stream_details(track_id)
+        # resp = web.StreamResponse(status=200,
+        #                         reason='OK',
+        #                         headers={'Content-Type': stream_details['mime_type']})
         resp = web.StreamResponse(status=200,
-                                reason='OK',
-                                headers={'Content-Type': stream_details['mime_type']})
+                                 reason='OK',
+                                 headers={'Content-Type': 'audio/flac'})
         await resp.prepare(request)
         async for chunk in self.mass.music.providers[provider].get_stream(track_id):
             await resp.write(chunk)
index 302137a3d2ba4c17c3406434cffe9fa8214a26bd..7fc884cdbafe03e5f3ee2a801935e05f84274f5c 100755 (executable)
@@ -38,7 +38,7 @@ class Database():
             await db.execute('CREATE TABLE IF NOT EXISTS metadata(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(item_id, media_type, key));')
             await db.execute('CREATE TABLE IF NOT EXISTS external_ids(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(item_id, media_type, key, value));')
             
-            await db.execute('CREATE TABLE IF NOT EXISTS playlists(playlist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner TEXT NOT NULL, UNIQUE(name, owner));')
+            await db.execute('CREATE TABLE IF NOT EXISTS playlists(playlist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner TEXT NOT NULL, is_editable BOOLEAN NOT NULL, UNIQUE(name, owner));')
             await db.execute('CREATE TABLE IF NOT EXISTS playlist_tracks(playlist_id INTEGER NOT NULL, track_id INTEGER NOT NULL, position INTEGER, UNIQUE(playlist_id, track_id));')
             
             await db.commit()
@@ -100,13 +100,66 @@ class Database():
             sql_query = ' WHERE track_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Track
         return await self.tracks(sql_query, limit=limit, offset=offset, orderby=orderby)
     
-    async def library_playlists(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Playlist]:
-        ''' get all library playlists, optionally filtered by provider'''
-        if provider != None:
-            sql_query = ' WHERE playlist_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Playlist)
-        else:
-            sql_query = ' WHERE playlist_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Playlist
-        return await self.playlists(sql_query, limit=limit, offset=offset, orderby=orderby)
+    async def playlists(self, filter_query=None, provider=None, limit=100000, offset=0, orderby='name') -> List[Playlist]:
+        ''' fetch all playlist records from table'''
+        playlists = []
+        sql_query = 'SELECT * FROM playlists'
+        if filter_query:
+            sql_query += filter_query
+        elif provider != None:
+            sql_query += ' WHERE playlist_id in (SELECT item_id FROM provider_mappings WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Playlist)
+        sql_query += ' ORDER BY %s' % orderby
+        if limit:
+            sql_query += ' LIMIT %d OFFSET %d' %(limit, offset)
+        async with aiosqlite.connect(self.dbfile) as db:
+            async with db.execute(sql_query) as cursor:
+                db_rows = await cursor.fetchall()
+            for db_row in db_rows:
+                playlist = Playlist()
+                playlist.item_id = db_row[0]
+                playlist.name = db_row[1]
+                playlist.owner = db_row[2]
+                playlist.is_editable = db_row[3]
+                playlist.metadata = await self.__get_metadata(playlist.item_id, MediaType.Playlist, db)
+                playlist.provider_ids = await self.__get_prov_ids(playlist.item_id, MediaType.Playlist, db)
+                playlist.in_library = await self.__get_library_providers(playlist.item_id, MediaType.Playlist, db)
+                playlists.append(playlist)
+        return playlists
+
+    async def playlist(self, playlist_id:int) -> Playlist:
+        ''' get playlist record by id '''
+        playlist_id = try_parse_int(playlist_id)
+        playlists = await self.playlists(' WHERE playlist_id = %s' % playlist_id)
+        if not playlists:
+            return None
+        return playlists[0]
+
+    async def add_playlist(self, playlist:Playlist):
+        ''' add a new playlist record into table'''
+        assert(playlist.name)
+        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+            async with db.execute('SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;', (playlist.name, playlist.owner)) as cursor:
+                result = await cursor.fetchone()
+                if result:
+                    playlist_id = result[0]
+                    # update existing
+                    sql_query = 'UPDATE playlists SET is_editable=? WHERE playlist_id=?;'
+                    await db.execute(sql_query, (playlist.is_editable, playlist_id))
+                else:
+                    # insert playlist
+                    sql_query = 'INSERT OR REPLACE INTO playlists (name, owner, is_editable) VALUES(?,?,?);'
+                    await db.execute(sql_query, (playlist.name, playlist.owner, playlist.is_editable))
+                    # get id from newly created item (the safe way)
+                    async with db.execute('SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;', (playlist.name,playlist.owner)) as cursor:
+                        playlist_id = await cursor.fetchone()
+                        playlist_id = playlist_id[0]
+                    LOGGER.info('added playlist %s to database: %s' %(playlist.name, playlist_id))
+            # add/update metadata
+            await self.__add_prov_ids(playlist_id, MediaType.Playlist, playlist.provider_ids, db)
+            await self.__add_metadata(playlist_id, MediaType.Playlist, playlist.metadata, db)
+            # save
+            await db.commit()
+        return playlist_id
 
     async def add_to_library(self, item_id:int, media_type:MediaType, provider:str):
         ''' add an item to the library (item must already be present in the db!) '''
@@ -195,7 +248,7 @@ class Database():
             await self.__add_external_ids(artist_id, MediaType.Artist, artist.external_ids, db)
             # save
             await db.commit()
-        LOGGER.debug('added artist %s (%s) to database: %s' %(artist.name, artist.provider_ids, artist_id))
+        LOGGER.info('added artist %s (%s) to database: %s' %(artist.name, artist.provider_ids, artist_id))
         return artist_id
     
     async def albums(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Album]:
@@ -273,7 +326,7 @@ class Database():
             await self.__add_external_ids(album_id, MediaType.Album, album.external_ids, db)
             # save
             await db.commit()
-        LOGGER.debug('added album %s (%s) to database: %s' %(album.name, album.provider_ids, album_id))
+        LOGGER.info('added album %s (%s) to database: %s' %(album.name, album.provider_ids, album_id))
         return album_id
 
     async def tracks(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Track]:
@@ -354,59 +407,9 @@ class Database():
             await self.__add_external_ids(track_id, MediaType.Track, track.external_ids, db)
             # save to db
             await db.commit()
-        LOGGER.debug('added track %s (%s) to database: %s' %(track.name, track.provider_ids, track_id))
+        LOGGER.info('added track %s (%s) to database: %s' %(track.name, track.provider_ids, track_id))
         return track_id
 
-    async def playlists(self, filter_query=None, limit=100000, offset=0, orderby='name') -> List[Playlist]:
-        ''' fetch all playlist records from table'''
-        playlists = []
-        sql_query = 'SELECT * FROM playlists'
-        if filter_query:
-            sql_query += ' ' + filter_query
-        sql_query += ' ORDER BY %s' % orderby
-        if limit:
-            sql_query += ' LIMIT %d OFFSET %d' %(limit, offset)
-        async with aiosqlite.connect(self.dbfile) as db:
-            async with db.execute(sql_query) as cursor:
-                db_rows = await cursor.fetchall()
-            for db_row in db_rows:
-                playlist = Playlist()
-                playlist.item_id = db_row[0]
-                playlist.name = db_row[1]
-                playlist.owner = db_row[2]
-                playlist.metadata = await self.__get_metadata(playlist.item_id, MediaType.Playlist, db)
-                playlist.provider_ids = await self.__get_prov_ids(playlist.item_id, MediaType.Playlist, db)
-                playlist.in_library = await self.__get_library_providers(playlist.item_id, MediaType.Playlist, db)
-                playlists.append(playlist)
-        return playlists
-
-    async def playlist(self, playlist_id:int) -> Playlist:
-        ''' get playlist record by id '''
-        playlist_id = try_parse_int(playlist_id)
-        playlists = await self.playlists('WHERE playlist_id = %s' % playlist_id)
-        if not playlists:
-            return None
-        return playlists[0]
-
-    async def add_playlist(self, playlist:Playlist):
-        ''' add a new playlist record into table'''
-        assert(playlist.name)
-        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
-            # insert playlist
-            sql_query = 'INSERT OR IGNORE INTO playlists (name, owner) VALUES(?,?);'
-            await db.execute(sql_query, (playlist.name, playlist.owner))
-            # get id from newly created item (the safe way)
-            async with db.execute('SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;', (playlist.name,playlist.owner)) as cursor:
-                playlist_id = await cursor.fetchone()
-                playlist_id = playlist_id[0]
-            # add metadata
-            await self.__add_prov_ids(playlist_id, MediaType.Playlist, playlist.provider_ids, db)
-            await self.__add_metadata(playlist_id, MediaType.Playlist, playlist.metadata, db)
-            # save
-            await db.commit()
-        LOGGER.debug('added playlist %s to database: %s' %(playlist.name, playlist_id))
-        return playlist_id
-
     async def artist_tracks(self, artist_id, limit=1000000, offset=0, orderby='name') -> List[Track]:
         ''' get all library tracks for the given artist '''
         artist_id = try_parse_int(artist_id)
index 63ef2e356f137368098bdca0f4e664fb78c64c80..ad3835da199155c029f3535cd3415a0c67ad82f7 100755 (executable)
@@ -6,7 +6,6 @@ import asyncio
 from concurrent.futures import ThreadPoolExecutor
 import re
 import uvloop
-import logging
 import os
 import shutil
 import slugify as unicode_slug
@@ -21,10 +20,11 @@ from utils import run_periodic, LOGGER
 from cache import Cache
 from music import Music
 from player import Player
+from modules.homeassistant import setup as hass_setup
 
 class Main():
 
-    def __init__(self, datapath):
+    def __init__(self, datapath, ssl_cert, ssl_key):
         uvloop.install()
         self._datapath = datapath
         self.parse_config()
@@ -43,20 +43,21 @@ class Main():
             time.sleep(0.5) 
         self.cache = Cache(datapath)
         self.metadata = MetaData(self.event_loop, self.db, self.cache)
+
+        # init modules
+        self.api = Api(self, ssl_cert, ssl_key)
+        self.hass = hass_setup(self)
         self.music = Music(self)
         self.player = Player(self)
 
-        # init web/api
-        self.api = Api(self)
-        asyncio.ensure_future(self.api.setup_web())
-        
         # start the event loop
         self.event_loop.run_forever()
 
     async def event(self, msg, msg_details=None):
         ''' signal event '''
         LOGGER.debug("Event: %s - %s" %(msg, msg_details))
-        for listener in self.event_listeners.values():
+        listeners = list(self.event_listeners.values())
+        for listener in listeners:
             await listener(msg, msg_details)
 
     async def add_event_listener(self, cb):
@@ -81,18 +82,19 @@ class Main():
     def parse_config(self):
         '''get config from config file'''
         config = {
+            "base": {},
             "musicproviders": {},
             "playerproviders": {},
             "player_settings": 
                 {
                     "__desc__":
                     [
+                        ("enabled", False, "Enable player"),
                         ("name", "", "Custom name for this player"),
                         ("group_parent", "<player>", "Group this player with another player"),
                         ("mute_as_power", False, "Use muting as power control"),
                         ("disable_volume", False, "Disable volume controls"),
-                        ("apply_group_volume", False, "Apply group volume to childs (for group players only)"),
-                        ("enabled", False, "Enable player")
+                        ("apply_group_volume", False, "Apply group volume to childs (for group players only)")
                     ]
                 }
             }
@@ -119,5 +121,7 @@ if __name__ == "__main__":
     datapath = sys.argv[1:]
     if not datapath:
         datapath = os.path.dirname(os.path.abspath(__file__))
-    Main(datapath)
+    ssl_cert = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'certificate.cert')
+    ssl_key = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'privkey.pem')
+    Main(datapath, ssl_cert, ssl_key)
     
\ No newline at end of file
index e3e3a35c1b73e2976781366474e21494792dc6da..e3c14d2e0718cefe0eec16fad245b07f957dac91 100755 (executable)
@@ -60,7 +60,7 @@ class MusicBrainz():
     def __init__(self, event_loop, cache):
         self.event_loop = event_loop
         self.cache = cache
-        self.http_session = aiohttp.ClientSession(loop=event_loop)
+        self.http_session = aiohttp.ClientSession(loop=event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
         self.throttler = Throttler(rate_limit=1, period=1)
 
     async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
@@ -76,15 +76,16 @@ class MusicBrainz():
             params = {'query': 'artist:"%s" AND release:"%s"' % (searchartist, searchalbum)}
         result = await self.get_data(endpoint, params)
         if result and result.get('releases'):
-            for strictness in [1, 0.95, 0.9, 0.8]:
+            for strictness in [1, 0.95, 0.9]:
                 for item in result['releases']:
-                    for artist in item['artist-credit']:
-                        artist = artist['artist']
-                        if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
-                            return artist['id']
-                        for item in artist.get('aliases',[]):
-                            if item['name'].lower() == artistname.lower():
+                    if album_upc or Matcher(None, item['title'].lower(), albumname.lower()).ratio() >= strictness:
+                        for artist in item['artist-credit']:
+                            artist = artist['artist']
+                            if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
                                 return artist['id']
+                            for item in artist.get('aliases',[]):
+                                if item['name'].lower() == artistname.lower():
+                                    return artist['id']
         return ''
 
     async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
@@ -101,21 +102,22 @@ class MusicBrainz():
             params = {'query': '"%s" AND artist:"%s"' % (searchtrack, searchartist)}
         result = await self.get_data(endpoint, params)
         if result and result.get('recordings'):
-            for strictness in [1, 0.95, 0.9, 0.8]:
+            for strictness in [1, 0.95]:
                 for item in result['recordings']:
-                    for artist in item['artist-credit']:
-                        artist = artist['artist']
-                        if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
-                            return artist['id']
-                        for item in artist.get('aliases',[]):
-                            if item['name'].lower() == artistname.lower():
+                    if track_isrc or Matcher(None, item['title'].lower(), trackname.lower()).ratio() >= strictness:
+                        for artist in item['artist-credit']:
+                            artist = artist['artist']
+                            if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
                                 return artist['id']
+                            for item in artist.get('aliases',[]):
+                                if item['name'].lower() == artistname.lower():
+                                    return artist['id']
         return ''
 
     @use_cache(30)
     async def get_data(self, endpoint, params={}):
         ''' get data from api'''
-        url = 'https://musicbrainz.org/ws/2/%s' % endpoint
+        url = 'http://musicbrainz.org/ws/2/%s' % endpoint
         headers = {'User-Agent': 'Music Assistant/1.0.0 https://github.com/marcelveldt'}
         params['fmt'] = 'json'
         async with self.throttler:
@@ -134,8 +136,8 @@ class FanartTv():
     def __init__(self, event_loop, cache):
         self.event_loop = event_loop
         self.cache = cache
-        self.http_session = aiohttp.ClientSession(loop=event_loop)
-        self.throttler = Throttler(rate_limit=5, period=1)
+        self.http_session = aiohttp.ClientSession(loop=event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.throttler = Throttler(rate_limit=1, period=1)
 
     async def artist_images(self, mb_artist_id):
         ''' retrieve images by musicbrainz artist id '''
index e40f87e7a061a83079ecce6ef723f29e9fd48c75..421ccbe1b5a9751321677c94905db41b51bab4c0 100755 (executable)
@@ -55,6 +55,7 @@ class Artist(object):
     ''' representation of an artist '''
     def __init__(self):
         self.item_id = None
+        self.provider = 'database'
         self.name = ''
         self.sort_name = ''
         self.metadata = {}
@@ -69,6 +70,7 @@ class Album(object):
     ''' representation of an album '''
     def __init__(self):
         self.item_id = None
+        self.provider = 'database'
         self.name = '' 
         self.metadata = {}
         self.version = ''
@@ -87,6 +89,7 @@ class Track(object):
     ''' representation of a track '''
     def __init__(self):
         self.item_id = None
+        self.provider = 'database'
         self.name = ''
         self.duration = 0
         self.version = ''
@@ -114,13 +117,14 @@ 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 MusicProvider():
     ''' 
@@ -147,8 +151,7 @@ class MusicProvider():
             # artist not yet in local database so fetch details
             artist_details = await self.get_artist(prov_item_id)
             if not artist_details:
-                LOGGER.warning('artist not found: %s' % prov_item_id)
-                return None
+                raise Exception('artist not found: %s' % prov_item_id)
             if lazy:
                 asyncio.create_task(self.add_artist(artist_details))
                 artist_details.is_lazy = True
@@ -156,14 +159,14 @@ class MusicProvider():
             item_id = await self.add_artist(artist_details)
         return await self.mass.db.artist(item_id)
 
-    async def add_artist(self, artist_details, skip_match=False) -> int:
+    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, allow_fallback=not skip_match)
+            musicbrainz_id = await self.get_artist_musicbrainz_id(artist_details)
         if not musicbrainz_id:
             return
         # grab additional metadata
@@ -172,20 +175,21 @@ class MusicProvider():
             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
-        if not skip_match:
-            new_artist = await self.mass.db.artist(item_id)
+        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)
+                    await provider.match_artist(new_artist, new_artist_toptracks)
         return item_id
 
-    async def get_artist_musicbrainz_id(self, artist_details:Artist, allow_fallback=False):
+    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[:10]:
+        for lookup_album in lookup_albums[:5]:
             lookup_album_upc = None
             for item in lookup_album.external_ids:
                 if item.get("upc"):
@@ -196,21 +200,21 @@ class MusicProvider():
             if musicbrainz_id:
                 break
         # fallback to track
-        lookup_tracks = await self.get_artist_toptracks(artist_details.item_id)
-        for lookup_track in lookup_tracks[:10]:
-            lookup_track_isrc = None
-            for item in lookup_track.external_ids:
-                if item.get("isrc"):
-                    lookup_track_isrc = item["isrc"]
+        if not musicbrainz_id:
+            lookup_tracks = await self.get_artist_toptracks(artist_details.item_id)
+            for lookup_track in lookup_tracks:
+                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
-            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)
-            if allow_fallback:
-                musicbrainz_id = artist_details.name
+            musicbrainz_id = artist_details.name
         return musicbrainz_id
 
     async def album(self, prov_item_id, lazy=True) -> Album:
@@ -220,8 +224,7 @@ class MusicProvider():
             # album not yet in local database so fetch details
             album_details = await self.get_album(prov_item_id)
             if not album_details:
-                LOGGER.warning('album not found: %s' % prov_item_id)
-                return 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
@@ -229,19 +232,18 @@ class MusicProvider():
             item_id = await self.add_album(album_details)
         return await self.mass.db.album(item_id)
 
-    async def add_album(self, album_details, skip_match=False) -> int:
+    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
-        if not skip_match:
-            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)
+        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:
@@ -252,60 +254,55 @@ class MusicProvider():
             if not track_details:
                 track_details = await self.get_track(prov_item_id)
             if not track_details:
-                LOGGER.warning('track not found: %s' % prov_item_id)
-                return None
+                raise Exception('track not found: %s' % prov_item_id)
             if lazy:
-                asyncio.ensure_future(self.add_track(track_details))
+                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, skip_match=False) -> int:
+    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 = []
-        assert(track_details)
         # we need to fetch track artists too
         for track_artist in track_details.artists:
-            prov_item_id = track_artist.item_id
-            db_track_artist = await self.artist(prov_item_id, lazy=False)
-            assert(db_track_artist)
-            track_artists.append(db_track_artist)
+            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
-        if not skip_match:
-            new_track = await self.mass.db.track(item_id)
-            item_provider_keys = [item['provider'] for item in new_track.provider_ids]
-            for prov_id, provider in self.mass.music.providers.items():
-                if not prov_id in item_provider_keys:
-                    await provider.match_track(new_track)
+        new_track = await self.mass.db.track(item_id)
+        item_provider_keys = [item['provider'] for item in new_track.provider_ids]
+        for prov_id, provider in self.mass.music.providers.items():
+            if not prov_id in item_provider_keys:
+                await provider.match_track(new_track)
         return item_id
     
-    async def playlist(self, prov_item_id) -> Playlist:
+    async def playlist(self, prov_playlist_id) -> Playlist:
         ''' return playlist details for the given provider playlist id '''
-        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Playlist)
-        if item_id:
-            return await self.mass.db.playlist(item_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_item_id)
-
-    async def add_playlist(self, playlist_details) -> int:
-        ''' add playlist to local db and return the (new) database id'''
-        item_id = await self.mass.db.add_playlist(playlist_details)
-        return item_id
+            return await self.get_playlist(prov_playlist_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):
-            prov_track.album = album
-            track = await self.track(prov_track.item_id, track_details=prov_track)
-            items.append(track)
+            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]:
@@ -320,7 +317,6 @@ class MusicProvider():
                     items.append( await self.mass.db.track(db_id) )
                 else:
                     items.append(prov_track)
-                    asyncio.create_task(self.add_track(prov_track))
         return items
     
     async def artist_toptracks(self, prov_item_id) -> List[Track]:
@@ -332,7 +328,6 @@ class MusicProvider():
                 items.append( await self.mass.db.track(db_id) )
             else:
                 items.append(prov_track)
-                asyncio.create_task(self.add_track(prov_track))
         return items
 
     async def artist_albums(self, prov_item_id) -> List[Track]:
@@ -344,49 +339,42 @@ class MusicProvider():
                 items.append( await self.mass.db.album(db_id) )
             else:
                 items.append(prov_album)
-                asyncio.create_task(self.add_album(prov_album))
         return items
     
-    async def match_artist(self, searchartist:Artist):
+    async def match_artist(self, searchartist:Artist, searchtracks:List[Track]):
         ''' try to match artist in this provider by supplying db artist '''
-        for prov_mapping in searchartist.provider_ids:
-            if prov_mapping["provider"] == self.prov_id:
-                return # we already have a mapping on this provider
-        search_results = await self.search(searchartist.name, [MediaType.Artist], limit=2)
-        for item in search_results["artists"]:
-            if item.name.lower() == searchartist.name.lower():
-                # just lazy load this item in the database, it will be matched automagically ;-)
-                db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Artist)
-                if not db_id:
-                    asyncio.create_task(self.add_artist(item, skip_match=True))
+        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 '''
-        for prov_mapping in searchalbum.provider_ids:
-            if prov_mapping["provider"] == self.prov_id:
-                return # we already have a mapping on this provider
         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 lazy load this item in the database, it will be matched automagically ;-)
-                db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Album)
-                if not db_id:
-                    asyncio.create_task(self.add_album(item, skip_match=True))
+                # 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:Album):
+    async def match_track(self, searchtrack:Track):
         ''' try to match track in this provider by supplying db track '''
-        for prov_mapping in searchtrack.provider_ids:
-            if prov_mapping["provider"] == self.prov_id:
-                return # we already have a mapping on this provider
         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:
-                # just lazy load this item in the database, it will be matched automagically ;-)
-                db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Track)
-                if not db_id:
-                    asyncio.create_task(self.add_track(item, skip_match=True))
+                # 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 #####
 
@@ -406,7 +394,7 @@ class MusicProvider():
         ''' retrieve library tracks from the provider '''
         raise NotImplementedError
 
-    async def get_library_playlists(self) -> List[Playlist]:
+    async def get_playlists(self) -> List[Playlist]:
         ''' retrieve library/subscribed playlists from the provider '''
         raise NotImplementedError
 
@@ -462,7 +450,7 @@ class MusicPlayer():
         self.player_id = None
         self.player_provider = None
         self.name = ''
-        self.state = PlayerState.Off
+        self.state = PlayerState.Stopped
         self.powered = False
         self.cur_item = Track()
         self.cur_item_time = 0
@@ -486,13 +474,7 @@ class PlayerProvider():
     name = 'My great Musicplayer provider' # display name
     prov_id = 'my_provider' # used as id
     icon = ''
-    supports_queue = True # whether this provider has native support for a queue
-    supports_http_stream = True # whether we can fallback to http streaming
-    supported_musicproviders = [ # list with tuples of supported provider_id and media_types this playerprovider supports NATIVELY, order by preference/quality
-            ('qobuz', [MediaType.Track]), 
-            ('file', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]),
-            ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist])
-        ]
+    supported_musicproviders = ['qobuz', 'file', 'spotify', 'http'] # list of supported music provider uri's this playerprovider supports NATIVELY
     
     def __init__(self, mass):
         self.mass = mass
@@ -500,17 +482,17 @@ class PlayerProvider():
     ### Common methods and properties ####
 
 
-    async def play_media(self, player_id, uri, queue_opt='play'):
+    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
-            - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream)
+            - 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 now
+                play: keep existing queue but play the given item(s) now first
         '''
         raise NotImplementedError
 
diff --git a/music_assistant/modules/homeassistant.py b/music_assistant/modules/homeassistant.py
new file mode 100644 (file)
index 0000000..ad55393
--- /dev/null
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import random
+import sys
+sys.path.append("..")
+from utils import run_periodic, 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 aiohttp
+import time
+import datetime
+import hashlib
+from asyncio_throttle import Throttler
+from aiocometd import Client, ConnectionType, Extension
+from cache import use_cache
+import copy
+import slugify as slug
+
+'''
+    Homeassistant integration
+    allows publishing of our players to hass
+    allows using hass entities (like switches, media_players or gui inputs) to be triggered
+'''
+
+def setup(mass):
+    ''' setup the module and read/apply config'''
+    if not mass.config['base'].get('homeassistant'):
+        mass.config['base']['homeassistant'] = {}
+    conf = mass.config['base']['homeassistant']
+    conf['__desc__'] = config_entries()
+    for key, def_value, desc in config_entries():
+        if not key in conf:
+            conf[key] = def_value
+    enabled = conf.get(CONF_ENABLED)
+    token = conf.get('token')
+    url = conf.get('url')
+    if enabled and url and token:
+        # append hass player config settings
+        hass_player_conf = [("hass_power_entity", "", "Attach player power to homeassistant entity"),
+                        ("hass_power_entity_source", "", "Source on the homeassistant entity (optional)"),
+                        ("hass_volume_entity", "", "Attach player volume to homeassistant entity")]
+        for key, default, desc in hass_player_conf:
+            entry_found = False
+            for value in mass.config['player_settings']['__desc__']:
+                if value[0] == key:
+                    entry_found = True
+                    break
+            if not entry_found:
+                mass.config['player_settings']['__desc__'].append((key, default, desc))
+        return HomeAssistant(mass, url, token)
+    return None
+
+def config_entries():
+    ''' get the config entries for this module (list with key/value pairs)'''
+    return [
+        (CONF_ENABLED, False, CONF_ENABLED),
+        ('url', 'localhost', 'URL to homeassistant (e.g. https://homeassistant:8123)'), 
+        ('token', '<password>', 'Long Lived Access Token'),
+        ('publish_players', True, 'Publish players to Home Assistant')
+        ]
+
+class HomeAssistant():
+    ''' HomeAssistant integration '''
+
+    def __init__(self, mass, url, token):
+        self.mass = mass
+        self._published_players = {}
+        self._tracked_states = {}
+        self._state_listeners = []
+        self._token = token
+        if url.startswith('https://'):
+            self._use_ssl = True
+            self._host = url.replace('https://','').split('/')[0]
+        else:
+            self._use_ssl = False
+            self._host = url.replace('http://','').split('/')[0]
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.__send_ws = None
+        self.__last_id = 10
+        LOGGER.info('Homeassistant integration is enabled')
+        mass.event_loop.create_task(self.__hass_websocket())
+        mass.event_loop.create_task(self.mass.add_event_listener(self.mass_event))
+
+    async def get_state(self, entity_id, attribute='state', register_listener=None):
+        ''' get state of a hass entity'''
+        if entity_id in self._tracked_states:
+            state_obj = self._tracked_states[entity_id]
+        else:
+            # first request
+            state_obj = await self.__get_data('states/%s' % entity_id)
+            if register_listener:
+                # register state listener
+                self._state_listeners.append( (entity_id, register_listener) )
+            self._tracked_states[entity_id] = state_obj
+        if attribute == 'state':
+            return state_obj['state']
+        elif not attribute:
+            return state_obj
+        else:
+            return state_obj['attributes'].get(attribute)
+    
+    async def mass_event(self, msg, msg_details):
+        ''' received event from mass '''
+        if msg == "player updated":
+            await self.publish_player(msg_details)
+
+    async def hass_event(self, event_type, event_data):
+        ''' received event from hass '''
+        if event_type == 'state_changed':
+            if event_data['entity_id'] in self._tracked_states:
+                self._tracked_states[event_data['entity_id']] = event_data['new_state']
+                for entity_id, handler in self._state_listeners:
+                    if entity_id == event_data['entity_id']:
+                        asyncio.create_task(handler())
+        elif event_type == 'call_service' and event_data['domain'] == 'media_player':
+            await self.__handle_player_command(event_data['service'], event_data['service_data'])
+
+    async def __handle_player_command(self, service, service_data):
+        ''' handle forwarded service call for one of our players '''
+        if isinstance(service_data['entity_id'], list):
+            # can be a list of entity ids if action fired on multiple items
+            entity_ids = service_data['entity_id']
+        else:
+            entity_ids = [service_data['entity_id']]
+        for entity_id in entity_ids:
+            if entity_id in self._published_players:
+                # call is for one of our players so handle it
+                player_id = self._published_players[entity_id]
+                if service == 'turn_on':
+                    await self.mass.player.player_command(player_id, 'power', 'on')
+                elif service == 'turn_off':
+                    await self.mass.player.player_command(player_id, 'power', 'off')
+                elif service == 'volume_mute':
+                    await self.mass.player.player_command(player_id, 'mute', service_data['is_volume_muted'])
+                elif service == 'volume_set':
+                    volume_level = service_data['volume_level']*100
+                    await self.mass.player.player_command(player_id, 'volume', volume_level)
+                elif service == 'media_play':
+                    await self.mass.player.player_command(player_id, 'play')
+                elif service == 'media_pause':
+                    await self.mass.player.player_command(player_id, 'pause')
+                elif service == 'media_stop':
+                    await self.mass.player.player_command(player_id, 'stop')
+                elif service == 'media_next_track':
+                    await self.mass.player.player_command(player_id, 'next')
+                elif service == 'media_play_pause':
+                    await self.mass.player.player_command(player_id, 'pause', 'toggle')
+                # TODO: handle media play !
+
+    async def publish_player(self, player):
+        ''' publish player details to hass'''
+        if not self.mass.config['base']['homeassistant']['publish_players']:
+            return False
+        player_id = player.player_id
+        entity_id = 'media_player.mass_' + slug.slugify(player.name, separator='_').lower()
+        state = player.state if player.powered else 'off'
+        state_attributes = {
+                "supported_features": 58303, 
+                "friendly_name": player.name,
+                "volume_level": player.volume_level/100,
+                "is_volume_muted": player.muted,
+                "media_duration": player.cur_item.duration if player.cur_item else 0,
+                "media_position": player.cur_item_time,
+                "media_title": player.cur_item.name if player.cur_item else "",
+                "media_artist": player.cur_item.artists[0].name if player.cur_item and player.cur_item.artists else "",
+                "media_album_name": player.cur_item.album.name if player.cur_item and player.cur_item.album else "",
+                "entity_picture": player.cur_item.album.metadata.get('image') if player.cur_item and player.cur_item.album else ""
+                }
+        self._published_players[entity_id] = player_id
+        await self.__set_state(entity_id, state, state_attributes)
+
+    async def call_service(self, domain, service, service_data=None):
+        ''' call service on hass '''
+        if not self.__send_ws:
+            return False
+        msg = {
+            "type": "call_service",
+            "domain": domain,
+            "service": service,
+            }
+        if service_data:
+            msg['service_data'] = service_data
+        return await self.__send_ws(msg)
+
+    async def __set_state(self, entity_id, new_state, state_attributes={}):
+        ''' set state to hass entity '''
+        data = {
+            "state": new_state,
+            "entity_id": entity_id,
+            "attributes": state_attributes
+            }
+        return await self.__post_data('states/%s' % entity_id, data)
+    
+    async def __hass_websocket(self):
+        ''' Receive events from Hass through websockets '''
+        while True:
+            try:
+                protocol = 'wss' if self._use_ssl else 'ws'
+                async with self.http_session.ws_connect('%s://%s/api/websocket' % (protocol, self._host)) as ws:
+                    
+                    async def send_msg(msg):
+                        ''' callback to send message to the websockets client'''
+                        self.__last_id += 1
+                        msg['id'] = self.__last_id
+                        await ws.send_json(msg)
+
+                    async for msg in ws:
+                        if msg.type == aiohttp.WSMsgType.TEXT:
+                            if msg.data == 'close cmd':
+                                await ws.close()
+                                break
+                            else:
+                                data = msg.json()
+                                if data['type'] == 'auth_required':
+                                    # send auth token
+                                    auth_msg = {"type": "auth", "access_token": self._token}
+                                    await ws.send_json(auth_msg)
+                                elif data['type'] == 'auth_invalid':
+                                    raise Exception(data)
+                                elif data['type'] == 'auth_ok':
+                                    # register callback
+                                    self.__send_ws = send_msg
+                                    # subscribe to events
+                                    subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"}
+                                    await send_msg(subscribe_msg)
+                                    subscribe_msg = {"type": "subscribe_events", "event_type": "call_service"}
+                                    await send_msg(subscribe_msg)
+                                elif data['type'] == 'event':
+                                    asyncio.create_task(self.hass_event(data['event']['event_type'], data['event']['data']))
+                                elif data['type'] == 'result' and data.get('result'):
+                                    # reply to our get_states request
+                                    asyncio.create_task(self.hass_event('all_states', data['result']))
+                                else:
+                                    LOGGER.info(data)
+                        elif msg.type == aiohttp.WSMsgType.ERROR:
+                            break
+            except Exception as exc:
+                LOGGER.exception(exc)
+                asyncio.sleep(10)
+
+    async def __get_data(self, endpoint):
+        ''' get data from hass rest api'''
+        url = "http://%s/api/%s" % (self._host, endpoint)
+        if self._use_ssl:
+            url = "https://%s/api/%s" % (self._host, endpoint)
+        headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"}
+        async with self.http_session.get(url, headers=headers) as response:
+            return await response.json()
+
+    async def __post_data(self, endpoint, data):
+        ''' post data to hass rest api'''
+        url = "http://%s/api/%s" % (self._host, endpoint)
+        if self._use_ssl:
+            url = "https://%s/api/%s" % (self._host, endpoint)
+        headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"}
+        async with self.http_session.post(url, headers=headers, json=data) as response:
+            return await response.json()
\ No newline at end of file
index 149ff1771997741d412492a350ec0da6677acbe9..5342ee605c2c0c2601a244a34fbd079502ff656b 100644 (file)
@@ -90,7 +90,7 @@ class FileProvider(MusicProvider):
             result += await self.get_album_tracks(album.item_id)
         return result
     
-    async def get_library_playlists(self) -> List[Playlist]:
+    async def get_playlists(self) -> List[Playlist]:
         ''' retrieve playlists from disk '''
         if not self._playlists_dir:
             return []
@@ -113,7 +113,8 @@ class FileProvider(MusicProvider):
         else:
             name = prov_item_id.split("/")[-1]
         artist = Artist()
-        artist.item_id = prov_item_id # temporary id
+        artist.item_id = prov_item_id
+        artist.provider = self.prov_id
         artist.name = name
         artist.provider_ids.append({
             "provider": self.prov_id,
@@ -133,7 +134,8 @@ class FileProvider(MusicProvider):
             name = prov_item_id.split("/")[-1]
             artistpath = prov_item_id.rsplit("/", 1)[0]
         album = Album()
-        album.item_id = prov_item_id # temporary id
+        album.item_id = prov_item_id
+        album.provider = self.prov_id
         album.name, album.version = parse_track_title(name)
         album.artist = await self.get_artist(artistpath)
         if not album.artist:
@@ -158,8 +160,10 @@ class FileProvider(MusicProvider):
             return None
         filepath = prov_item_id
         playlist = Playlist()
-        playlist.item_id = filepath # temporary id
+        playlist.item_id = filepath
+        playlist.provider = self.prov_id
         playlist.name = filepath.split('\\')[-1].split('/')[-1].replace('.m3u', '')
+        playlist.is_editable = True
         playlist.provider_ids.append({
             "provider": self.prov_id,
             "item_id": filepath
@@ -257,7 +261,8 @@ class FileProvider(MusicProvider):
         except:
             return None # not a media file ?
         track.duration = song.length
-        track.item_id = filename # temporary id
+        track.item_id = filename
+        track.provider = self.prov_id
         name = song.tags['TITLE'][0]
         track.name, track.version = parse_track_title(name)
         if "\\" in filename:
index da5dd2ef8ec64a2b016218ef85210f0e26989573..84c613d6884e22eb4aa0a51c9cc059679bb87d9b 100644 (file)
@@ -45,14 +45,14 @@ class QobuzProvider(MusicProvider):
         self._cur_user = None
         self.mass = mass
         self.cache = mass.cache
-        self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
         self.__username = username
         self.__password = password
         self.__user_auth_token = None
         self.__app_id = "285473059"
         self.__app_secret = "47249d0eaefa6bf43a959c09aacdbce8"
         self.__logged_in = False
-        self.throttler = Throttler(rate_limit=1, period=0.5)
+        self.throttler = Throttler(rate_limit=1, period=1)
 
     async def search(self, searchstring, media_types=List[MediaType], limit=5):
         ''' perform search on the provider '''
@@ -125,7 +125,7 @@ class QobuzProvider(MusicProvider):
                 result.append(track)
         return result 
 
-    async def get_library_playlists(self) -> List[Playlist]:
+    async def get_playlists(self) -> List[Playlist]:
         ''' retrieve playlists from the provider '''
         result = []
         for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists'):
@@ -188,7 +188,7 @@ class QobuzProvider(MusicProvider):
         result = await self.__get_data('artist/get', params)
         albums = []
         for item in result['albums']['items']:
-            if item["streamable"] and item['artist']['id'] == int(prov_artist_id):
+            if str(item['artist']['id']) == str(prov_artist_id):
                 album = await self.__parse_album(item)
                 if album:
                     albums.append(album)
@@ -196,8 +196,16 @@ class QobuzProvider(MusicProvider):
 
     async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
         ''' get a list of most popular tracks for the given artist '''
-        # artist toptracks not supported on Qobuz
-        return []
+        # artist toptracks not supported on Qobuz, so use search instead
+        items = []
+        artist = await self.get_artist(prov_artist_id)
+        params = {"query": artist.name, "limit": 10, "type": "tracks" }
+        searchresult = await self.__get_data("catalog/search", params)
+        for item in searchresult["tracks"]["items"]:
+            if "performer" in item and str(item["performer"]["id"]) == str(prov_artist_id):
+                track = await self.__parse_track(item)
+                items.append(track)
+        return items
     
     async def add_library(self, prov_item_id, media_type:MediaType):
         ''' add item to library '''
@@ -234,22 +242,27 @@ class QobuzProvider(MusicProvider):
     
     async def get_stream(self, track_id):
         ''' get audio stream for a track '''
+        sox_effects='vol -12 dB'
         track_details = await self.get_stream_details(track_id)
         url = track_details['url']
-        async with self.http_session.get(url) as response:
-            while True:
-                chunk = await response.content.read(262144)
-                if not chunk:
-                    LOGGER.debug('end of stream')
-                    break
+        env = os.environ.copy()
+        env["SOX_OPTS"] = "−−multi−threaded âˆ’−replay−gain track"
+        cmd = 'curl -s -X GET "%s" | sox -t flac - -t flac - %s' % (url, sox_effects)
+        process = await asyncio.create_subprocess_shell(cmd, stdout=asyncio.subprocess.PIPE, env=env)
+        while not process.stdout.at_eof():
+            chunk = await process.stdout.readline()
+            if chunk:
                 yield chunk
+            else:
+                break
     
     async def __parse_artist(self, artist_obj):
         ''' parse spotify artist object to generic layout '''
         artist = Artist()
         if not artist_obj.get('id'):
             return None
-        artist.item_id = artist_obj['id'] # temporary id
+        artist.item_id = artist_obj['id']
+        artist.provider = self.prov_id
         artist.provider_ids.append({
             "provider": self.prov_id,
             "item_id": artist_obj['id']
@@ -272,9 +285,10 @@ class QobuzProvider(MusicProvider):
         album = Album()
         if not album_obj.get('id') or not album_obj["streamable"] or not album_obj["displayable"]:
             # some safety checks
-            LOGGER.warning("invalid/unavailable album found: %s" % album_obj.get('id'))
+            LOGGER.debug("invalid/unavailable album found: %s" % album_obj.get('id'))
             return None
-        album.item_id = album_obj['id'] # temporary id
+        album.item_id = album_obj['id']
+        album.provider = self.prov_id
         album.provider_ids.append({
             "provider": self.prov_id,
             "item_id": album_obj['id'],
@@ -317,12 +331,16 @@ class QobuzProvider(MusicProvider):
         track = Track()
         if not track_obj.get('id') or not track_obj["streamable"] or not track_obj["displayable"]:
             # some safety checks
-            LOGGER.warning("invalid/unavailable track found: %s" % track_obj.get('id'))
+            LOGGER.debug("invalid/unavailable track found: %s" % track_obj.get('id'))
             return None
-        track.item_id = track_obj['id'] # temporary id
+        track.item_id = track_obj['id']
+        track.provider = self.prov_id
         if track_obj.get('performer') and not 'Various ' in track_obj['performer']:
             artist = await self.__parse_artist(track_obj['performer'])
-            track.artists.append(artist)
+            if not artist:
+                artist = self.get_artist(track_obj['performer']['id'])
+            if artist:
+                track.artists.append(artist)
         if not track.artists:
             # try to grab artist from album
             if track_obj.get('album') and track_obj['album'].get('artist') and not 'Various ' in track_obj['album']['artist']:
@@ -388,13 +406,15 @@ class QobuzProvider(MusicProvider):
         playlist = Playlist()
         if not playlist_obj.get('id'):
             return None
-        playlist.item_id = playlist_obj['id'] # temporary id
+        playlist.item_id = playlist_obj['id']
+        playlist.provider = self.prov_id
         playlist.provider_ids.append({
             "provider": self.prov_id,
             "item_id": playlist_obj['id']
         })
         playlist.name = playlist_obj['name']
         playlist.owner = playlist_obj['owner']['name']
+        playlist.is_editable = playlist_obj['owner']['id'] == self._cur_user["id"] or playlist_obj['is_collaborative']
         if playlist_obj.get('images300'):
             playlist.metadata["image"] = playlist_obj['images300'][0]
         if playlist_obj.get('url'):
@@ -407,8 +427,9 @@ class QobuzProvider(MusicProvider):
             return self.__user_auth_token
         params = { "username": self.__username, "password": self.__password}
         details = await self.__get_data("user/login", params, ignore_cache=True)
+        self._cur_user = details["user"]
         self.__user_auth_token = details["user_auth_token"]
-        LOGGER.info("Succesfully logged in to Qobuz as %s" % (details["user"]["display_name"]))
+        LOGGER.info("Succesfully logged in to Qobuz as %s" % (self._cur_user["display_name"]))
         return details["user_auth_token"]
 
     async def __get_all_items(self, endpoint, params={}, key="playlists", limit=0, offset=0, cache_checksum=None):
@@ -464,14 +485,14 @@ class QobuzProvider(MusicProvider):
             params["request_sig"] = request_sig
             params["app_id"] = self.__app_id
             params["user_auth_token"] = self.__user_auth_token
-
-        async with self.http_session.get(url, headers=headers, params=params) as response:
-            result = await response.json()
-            if 'error' in result:
-                LOGGER.error(url)
-                LOGGER.error(params)
-                LOGGER.error(result)
-                result = None
-            result = await response.json()
-            return result
+        async with self.throttler:
+            async with self.http_session.get(url, headers=headers, params=params) as response:
+                result = await response.json()
+                if 'error' in result:
+                    LOGGER.error(url)
+                    LOGGER.error(params)
+                    LOGGER.error(result)
+                    result = None
+                result = await response.json()
+                return result
 
index 679da482d20d07eccac7f2721bbf785ce206f8ed..cae6aae1fa7646a70c82528f4959a80da8aab6f9 100644 (file)
@@ -10,6 +10,7 @@ sys.path.append("..")
 from utils import run_periodic, LOGGER, parse_track_title
 from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
 from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
+from asyncio_throttle import Throttler
 import json
 import aiohttp
 from cache import use_cache
@@ -42,7 +43,8 @@ class SpotifyProvider(MusicProvider):
         self._cur_user = None
         self.mass = mass
         self.cache = mass.cache
-        self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.throttler = Throttler(rate_limit=1, period=1)
         self._username = username
         self._password = password
         self.__auth_token = {}
@@ -119,7 +121,7 @@ class SpotifyProvider(MusicProvider):
                 result.append(track)
         return result 
 
-    async def get_library_playlists(self) -> List[Playlist]:
+    async def get_playlists(self) -> List[Playlist]:
         ''' retrieve playlists from the provider '''
         result = []
         for item in await self.__get_all_items("me/playlists"):
@@ -183,11 +185,13 @@ class SpotifyProvider(MusicProvider):
 
     async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
         ''' get a list of 10 most popular tracks for the given artist '''
+        artist = await self.get_artist(prov_artist_id)
         items = await self.__get_data('artists/%s/top-tracks' % prov_artist_id)
         tracks = []
         for item in items['tracks']:
             track = await self.__parse_track(item)
             if track:
+                track.artists = [artist]
                 tracks.append(track)
         return tracks
 
@@ -245,29 +249,46 @@ class SpotifyProvider(MusicProvider):
         import socket
         host = socket.gethostbyname(socket.gethostname())
         return {
-            'mime_type': 'audio/ogg',
+            'mime_type': 'audio/flac',
             'duration': track.duration,
             'sampling_rate': 44100,
             'bit_depth': 16,
             'url': 'http://%s/stream/spotify/%s' % (host, track_id)
         }
-    
     async def get_stream(self, track_id):
+        ''' get audio stream for a track '''
+        sox_effects='vol -12 dB'
+        import subprocess
+        spotty = self.get_spotty_binary()
+        env = os.environ.copy()
+        env["SOX_OPTS"] = "−−multi−threaded âˆ’−replay−gain track"
+        cmd = spotty +  ' -n temp -u ' + self._username + ' -p ' + self._password + ' --pass-through --single-track ' + track_id
+        cmd += ' | sox -t ogg - -t flac - %s' % sox_effects
+        process = await asyncio.create_subprocess_shell(cmd, stdout=asyncio.subprocess.PIPE, env=env)
+        while not process.stdout.at_eof():
+            chunk = await process.stdout.readline()
+            if chunk:
+                yield chunk
+        await process.wait()
+
+    async def get_stream__old(self, track_id, sox_effects='vol -12 dB'):
         ''' get audio stream for a track '''
         import subprocess
         spotty = self.get_spotty_binary()
-        cmd = [spotty, '-n', 'temp', '-u', self._username, '-p', self._password, '--pass-through', '--single-track', track_id]
-        process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE)
+        os.environ["SOX_OPTS"] = "−−multi−threaded âˆ’−replay−gain track"
+        cmd = spotty +  ' -n temp -u ' + self._username + ' -p ' + self._password + ' --pass-through --single-track ' + track_id + ' | sox -t ogg -- - -t flac - %s' % sox_effects
+        process = await asyncio.create_subprocess_shell(cmd, stdout=asyncio.subprocess.PIPE)
         while not process.stdout.at_eof():
-            line = await process.stdout.readline()
-            if line:
-                yield line
+            chunk = await process.stdout.readline()
+            if chunk:
+                yield chunk
         await process.wait()
     
     async def __parse_artist(self, artist_obj):
         ''' parse spotify artist object to generic layout '''
         artist = Artist()
-        artist.item_id = artist_obj['id'] # temporary id
+        artist.item_id = artist_obj['id']
+        artist.provider = self.prov_id
         artist.provider_ids.append({
             "provider": self.prov_id,
             "item_id": artist_obj['id']
@@ -292,7 +313,8 @@ class SpotifyProvider(MusicProvider):
         if not album_obj['id'] or album_obj.get('is_playable') == False:
             return None
         album = Album()
-        album.item_id = album_obj['id'] # temporary id
+        album.item_id = album_obj['id']
+        album.provider = self.prov_id
         album.name, album.version = parse_track_title(album_obj['name'])
         for artist in album_obj['artists']:
             album.artist = await self.__parse_artist(artist)
@@ -336,7 +358,8 @@ class SpotifyProvider(MusicProvider):
         if track_obj['is_local'] or not track_obj['id'] or not track_obj['is_playable']:
             return None
         track = Track()
-        track.item_id = track_obj['id'] # temporary id
+        track.item_id = track_obj['id']
+        track.provider = self.prov_id
         for track_artist in track_obj['artists']:
             artist = await self.__parse_artist(track_artist)
             if artist:
@@ -369,13 +392,15 @@ class SpotifyProvider(MusicProvider):
         playlist = Playlist()
         if not playlist_obj.get('id'):
             return None
-        playlist.item_id = playlist_obj['id'] # temporary id
+        playlist.item_id = playlist_obj['id']
+        playlist.provider = self.prov_id
         playlist.provider_ids.append({
             "provider": self.prov_id,
             "item_id": playlist_obj['id']
         })
         playlist.name = playlist_obj['name']
         playlist.owner = playlist_obj['owner']['display_name']
+        playlist.is_editable = playlist_obj['owner']['id'] == self.sp_user["id"] or playlist_obj['collaborative']
         if playlist_obj.get('images'):
             playlist.metadata["image"] = playlist_obj['images'][0]['url']
         if playlist_obj.get('external_urls'):
@@ -472,13 +497,14 @@ class SpotifyProvider(MusicProvider):
         params['country'] = 'from_token'
         token = await self.get_token()
         headers = {'Authorization': 'Bearer %s' % token["accessToken"]}
-        async with self.http_session.get(url, headers=headers, params=params) as response:
-            result = await response.json()
-            if 'error' in result:
-                LOGGER.error(url)
-                LOGGER.error(params)
-                raise Exception(result['error'])
-            return result
+        async with self.throttler:
+            async with self.http_session.get(url, headers=headers, params=params) as response:
+                result = await response.json()
+                if 'error' in result:
+                    LOGGER.error(url)
+                    LOGGER.error(params)
+                    return None
+                return result
 
     async def __delete_data(self, endpoint, params={}):
         ''' get data from api'''
index 569a68f1c9e21de87f5281070c1dbc9c49cda607..29ff1dc56b2b46699753f6e4b39378769b667907 100644 (file)
@@ -23,8 +23,8 @@ import pychromecast
 from pychromecast.controllers.multizone import MultizoneController
 from pychromecast.controllers import BaseController
 from pychromecast.controllers.spotify import SpotifyController
-import logging
-logging.getLogger("pychromecast").setLevel(logging.WARNING)
+from pychromecast.controllers.media import MediaController
+import types
 
 def setup(mass):
     ''' setup the provider'''
@@ -49,12 +49,8 @@ class ChromecastProvider(PlayerProvider):
         self.icon = ''
         self.mass = mass
         self._players = {}
-        self.supports_queue = False
-        self.supports_http_stream = True
-        self.supported_musicproviders = [
-            ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]), 
-            ('http', [MediaType.Track])
-        ]
+        self._chromecasts = {}
+        self.supported_musicproviders = ['http']
         self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
         asyncio.ensure_future(self.__discover_chromecasts())
         
@@ -64,63 +60,152 @@ class ChromecastProvider(PlayerProvider):
     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 cmd == 'play':
-            self._players[player_id].cast.media_controller.play()
+            self._chromecasts[player_id].media_controller.play()
         elif cmd == 'pause':
-            self._players[player_id].cast.media_controller.pause()
+            self._chromecasts[player_id].media_controller.pause()
         elif cmd == 'stop':
-            self._players[player_id].cast.media_controller.stop()
+            self._chromecasts[player_id].media_controller.stop()
         elif cmd == 'next':
-            self._players[player_id].cast.media_controller.queue_next()
+            self._chromecasts[player_id].media_controller.queue_next()
         elif cmd == 'previous':
-            self._players[player_id].cast.media_controller.queue_previous()
-        elif cmd == 'power' and cmd_args in ['on', '1', 1]:
-            # power is not supported
-            self._players[player_id].state = PlayerState.Stopped
-            self._players[player_id].cast.media_controller.play()
-        elif cmd == 'power' and cmd_args in ['off', '0', 0]:
-            # power is not supported
-            self._players[player_id].state = PlayerState.Off
-            self._players[player_id].cast.media_controller.stop()
+            self._chromecasts[player_id].media_controller.queue_previous()
+        elif cmd == 'power' and cmd_args == 'off':
+            self._players[player_id].powered = False # power is not supported
+            await self.mass.player.update_player(self._players[player_id])
+        elif cmd == 'power':
+            self._players[player_id].powered = True # power is not supported
         elif cmd == 'volume':
-            self._players[player_id].cast.set_volume(try_parse_int(cmd_args)/100)
-        elif cmd == 'mute' and cmd_args in ['on', '1', 1]:
-            self._players[player_id].cast.set_volume_muted(True)
-        elif cmd == 'mute' and cmd_args in ['off', '0', 0]:
-            self._players[player_id].cast.set_volume_muted(False)
+            self._chromecasts[player_id].set_volume(try_parse_int(cmd_args)/100)
+        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)
+        elif cmd == 'power':
+            pass # power is not supported on chromecast
 
-    async def play_media(self, player_id, uri, queue_opt='play'):
+    async def player_queue(self, player_id, offset=0, limit=50):
+        ''' return the items in the player's queue '''
+        items = []
+        for item in self._chromecasts[player_id].queue[offset:limit]:
+            track = await self.__track_from_uri(item['media']['contentId'])
+            if track:
+                items.append(track)
+        return items
+    
+    async def create_queue_item(self, track):
+        '''create queue item from track info '''
+        return {
+            'autoplay' : True,
+            'preloadTime' : 10,
+            'playbackDuration': int(track.duration),
+            'startTime' : 0,
+            'activeTrackIds' : [],
+            'media': {
+                'contentId':  track.uri,
+                'customData': {'provider': track.provider},
+                'contentType': "audio/flac",
+                'streamType': 'BUFFERED',
+                'metadata': {
+                    'title': track.name,
+                    'artist': track.artists[0].name,
+                },
+                'duration': int(track.duration)
+            }
+        }
+    
+    async def play_media(self, player_id, media_items, queue_opt='play'):
         ''' 
             play media on a player
-            params:
-            - player_id: id of the player
-            - uri: the uri for/to the media item (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 now
         '''
-        if uri.startswith('spotify:'):
-            # native spotify playback
-            uri = uri.replace('spotify://', '')
-            from pychromecast.controllers.spotify import SpotifyController
-            spotify =  self.mass.music.providers['spotify']
-            token = await spotify.get_token()
-            sp = SpController(token['accessToken'], token['expiresIn'])
-            self._players[player_id].cast.register_handler(sp)
-            sp.launch_app()
-            spotify_player_id = sp.device
-            if spotify_player_id:
-                return await spotify.play_media(spotify_player_id, uri)
-            else:
-                LOGGER.error('player not found in spotify! %s' % player_id)
-        elif uri.startswith('http'):
-            self._players[player_id].cast.media_controller.play_media(uri, 'audio/flac')
-        else:
-            raise Exception("Not supported media_type or uri")
+        player = self._chromecasts[player_id]
+        media_controller = player.media_controller
+        receiver_ctrl = media_controller._socket_client.receiver_controller
+        cur_queue_index = 0
+        if media_controller.queue_cur_id != None:
+            for item in media_controller.queue_items:
+                # status queue may contain at max 3 tracks (previous, current and next)
+                if item['itemId'] == media_controller.queue_cur_id:
+                    cur_queue_item = item
+                    # find out the current index
+                    for counter, value in enumerate(player.queue):
+                        if value['media']['contentId'] == cur_queue_item['media']['contentId']:
+                            cur_queue_index = counter
+                            break
+                    break
+        if (not media_controller.queue_cur_id or not media_controller.status.media_session_id or not player.queue):
+            queue_opt = 'replace'
+
+        new_queue_items = []
+        for track in media_items:
+            queue_item = await self.create_queue_item(track)
+            new_queue_items.append(queue_item)
 
+        if (queue_opt in ['replace', 'play'] or not media_controller.queue_cur_id or 
+                not media_controller.status.media_session_id or not player.queue):
+            # load new Chromecast queue with items
+            if queue_opt == 'add':
+                # append items to queue
+                player.queue = player.queue + new_queue_items
+                startindex = cur_queue_index
+            elif queue_opt == 'play':
+                # keep current queue but append new items at begin and start playing first item
+                player.queue = new_queue_items + player.queue[cur_queue_index:] + player.queue[:cur_queue_index]
+                startindex = 0
+            elif queue_opt == 'next':
+                # play the new items after the current playing item (insert before current next item)
+                player.queue = new_queue_items + player.queue[cur_queue_index:] + player.queue[:cur_queue_index]
+                startindex = cur_queue_index
+            else:
+                # overwrite the whole queue with new item(s)
+                player.queue = new_queue_items
+                startindex = 0
+            # load first 10 items as soon as possible
+            queuedata = { 
+                    "type": 'QUEUE_LOAD',
+                    "repeatMode":  "REPEAT_ALL" if player.repeat_enabled else "REPEAT_OFF",
+                    "shuffle": player.shuffle_enabled,
+                    "queueType": "PLAYLIST",
+                    "startIndex":    startindex,    # Item index to play after this request or keep same item if undefined
+                    "items": player.queue[:10]
+            }
+            await self.__send_player_queue(receiver_ctrl, media_controller, queuedata)
+            # append the rest of the items in the queue in chunks
+            for chunk in chunks(player.queue[10:], 100):
+                await asyncio.sleep(1)
+                queuedata = { "type": 'QUEUE_INSERT', "items": chunk }
+                await self.__send_player_queue(receiver_ctrl, media_controller, queuedata)
+        elif queue_opt == 'add':
+            # existing queue is playing: simply append items to the end of the queue (in small chunks)
+            player.queue = player.queue + new_queue_items
+            insertbefore = None
+            for chunk in chunks(new_queue_items, 100):
+                queuedata = { "type": 'QUEUE_INSERT', "items": chunk }
+                await self.__send_player_queue(receiver_ctrl, media_controller, queuedata)
+                await asyncio.sleep(1)
+        elif queue_opt == 'next':
+            # play the new items after the current playing item (insert before current next item)
+            player.queue = player.queue[:cur_queue_index] + new_queue_items + player.queue[cur_queue_index:]
+            queuedata = { 
+                        "type": 'QUEUE_INSERT',
+                        "insertBefore":     media_controller.queue_cur_id+1,
+                        "items":            new_queue_items[:200] # limit of the queue message
+                }
+            await self.__send_player_queue(receiver_ctrl, media_controller, queuedata)
+            
     ### Provider specific (helper) methods #####
-    
+
+    async def __send_player_queue(self, receiver_ctrl, media_controller, queuedata):
+        '''send new data to the CC queue'''
+        def app_launched_callback():
+                LOGGER.info("app_launched_callback")
+                """Plays media after chromecast has switched to requested app."""
+                queuedata['mediaSessionId'] = media_controller.status.media_session_id
+                LOGGER.info('')
+                LOGGER.info('')
+                media_controller.send_message(queuedata, inc_session_id=False)
+        receiver_ctrl.launch_app(media_controller.app_id,
+                                callback_function=app_launched_callback)
+
     async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None):
         ''' handle a player state message from the socket '''
         player_id = str(chromecast.uuid)
@@ -144,10 +229,8 @@ class ChromecastProvider(PlayerProvider):
 
     async def __parse_track(self, mediastatus):
         ''' parse track in CC to our internal format '''
-        if mediastatus.content_type == 'application/x-spotify.track':
-            track_id = mediastatus.content_id.replace('spotify:track:','')
-            track = await self.mass.music.providers['spotify'].track(track_id)
-        else:
+        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
@@ -159,6 +242,23 @@ class ChromecastProvider(PlayerProvider):
                 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' in uri:
+            try:
+                item_id = uri.split('/')[-1]
+                provider = uri.split('/')[-2]
+                track = await self.mass.music.providers[provider].track(item_id)
+            except: pass
+        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:
@@ -184,7 +284,13 @@ class ChromecastProvider(PlayerProvider):
                 player = MusicPlayer()
                 player.player_id = player_id
                 player.name = chromecast.name
+                player.player_provider = self.prov_id
                 chromecast.start()
+                # patch the receive message method for handling queue status updates
+                chromecast.queue = []
+                chromecast.media_controller.queue_items = []
+                chromecast.media_controller.queue_cur_id = None
+                chromecast.media_controller.receive_message = types.MethodType(receive_message, chromecast.media_controller)
                 listenerCast = StatusListener(chromecast, self.__handle_player_state, self.mass.event_loop)
                 chromecast.register_status_listener(listenerCast)
                 listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop)
@@ -196,11 +302,14 @@ class ChromecastProvider(PlayerProvider):
                     chromecast.register_handler(mz)
                     chromecast.register_connection_listener(MZConnListener(mz))
                     chromecast.wait()
-                player.cast = chromecast
-                player.player_provider = self.prov_id
+                self._chromecasts[player_id] = chromecast
                 self._players[player_id] = player
         LOGGER.info('Chromecast discovery done...')
 
+def chunks(l, n):
+    """Yield successive n-sized chunks from l."""
+    for i in range(0, len(l), n):
+        yield l[i:i + n]
 
 class StatusListener:
     def __init__(self, chromecast, callback, loop):
@@ -211,7 +320,6 @@ class StatusListener:
     def new_cast_status(self, status):
         asyncio.run_coroutine_threadsafe(self.__handle_player_state(self.chromecast, caststatus=status), self.loop)
 
-
 class StatusMediaListener:
     def __init__(self, chromecast, callback, loop):
         self.chromecast= chromecast
@@ -221,7 +329,6 @@ class StatusMediaListener:
     def new_media_status(self, status):
         asyncio.run_coroutine_threadsafe(self.__handle_player_state(self.chromecast, mediastatus=status), self.loop)
 
-
 class MZConnListener:
     def __init__(self, mz):
         self._mz=mz
@@ -248,7 +355,6 @@ class MZListener:
         asyncio.run_coroutine_threadsafe(
                 self.__handle_group_members_update(self._mz), self._loop)
 
-
 class SpController(SpotifyController):
     """ Controller to interact with Spotify namespace. """
     def receive_message(self, message, data):
@@ -261,3 +367,19 @@ class SpController(SpotifyController):
             self.device = data['payload']['deviceID']
             self.is_launched = True
         return True
+
+def receive_message(self, message, data):
+    """ Called when a media message is received. """
+    #LOGGER.info('message: %s - data: %s'%(message, data))
+    if data['type'] == 'MEDIA_STATUS':
+        try:
+            self.queue_items = data['status'][0]['items']
+        except:
+            pass
+        try:
+            self.queue_cur_id = data['status'][0]['currentItemId']
+        except:
+            pass
+        self._process_media_status(data)
+        return True
+    return False
\ No newline at end of file
diff --git a/music_assistant/modules/playerproviders/homeassistant.py b/music_assistant/modules/playerproviders/homeassistant.py
deleted file mode 100644 (file)
index a3f2dbf..0000000
+++ /dev/null
@@ -1,224 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from typing import List
-import random
-import sys
-sys.path.append("..")
-from utils import run_periodic, 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 aiohttp
-import time
-import datetime
-import hashlib
-from asyncio_throttle import Throttler
-from aiocometd import Client, ConnectionType, Extension
-from cache import use_cache
-import copy
-
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["playerproviders"]['homeassistant'].get(CONF_ENABLED)
-    token = mass.config["playerproviders"]['homeassistant'].get('token')
-    hostname = mass.config["playerproviders"]['homeassistant'].get(CONF_HOSTNAME)
-    if enabled and hostname and token:
-        provider = HassProvider(mass, hostname, token)
-        return provider
-    return False
-
-def config_entries():
-    ''' get the config entries for this provider (list with key/value pairs)'''
-    return [
-        (CONF_ENABLED, False, CONF_ENABLED),
-        (CONF_HOSTNAME, 'localhost', CONF_HOSTNAME), 
-        ('token', '<password>', 'Long Lived Access Token')
-        ]
-
-class HassProvider(PlayerProvider):
-    ''' support for Home Assistant '''
-
-    def __init__(self, mass, hostname, token):
-        self.prov_id = 'homeassistant'
-        self.name = 'Home Assistant'
-        self.icon = ''
-        self.mass = mass
-        self._players = {}
-        self._token = token
-        self._host = hostname
-        self.supports_queue = False
-        self.supports_http_stream = True # whether we can fallback to http streaming
-        self.supported_musicproviders = [] # we have no idea about the mediaplayers attached to hass so assume we can only do http playback
-        self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
-        self.__send_ws = None
-        self.__last_id = 10
-        asyncio.ensure_future(self.__hass_connect())
-        
-
-    ### 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, mute) '''
-        service_data = {"entity_id": player_id}
-        service = None
-        if cmd == 'play':
-            service = 'media_play'
-        elif cmd == 'pause':
-            service = 'media_pause'
-        elif cmd == 'stop':
-            service = 'media_stop'
-        elif cmd == 'next':
-            service = 'media_next_track'
-        elif cmd == 'previous':
-            service = 'media_previous_track'
-        elif cmd == 'power' and cmd_args in ['on', '1', 1]:
-            service = 'turn_on'
-        elif cmd == 'power' and cmd_args in ['off', '0', 0]:
-            service = 'turn_off'
-        elif cmd == 'volume' and cmd_args == 'up':
-            service = 'volume_up'
-        elif cmd == 'volume' and cmd_args == 'down':
-            service = 'volume_down'
-        elif cmd == 'volume':
-            service = 'volume_set'
-            service_data['volume_level'] = try_parse_int(cmd_args) / 100
-            self._players[player_id].volume_level = try_parse_int(cmd_args)
-        elif cmd == 'mute' and cmd_args in ['on', '1', 1]:
-            service = 'volume_mute'
-            service_data['is_volume_muted'] = True
-        elif cmd == 'mute' and cmd_args in ['off', '0', 0]:
-            service = 'volume_mute'
-            service_data['is_volume_muted'] = False
-        return await self.__call_service(service, service_data)
-
-    async def play_media(self, player_id, uri, queue_opt='play'):
-        ''' 
-            play media on a player
-            params:
-            - player_id: id of the player
-            - uri: the uri for/to the media item (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 now
-        '''
-        service = "play_media"
-        service_data = {
-            "entity_id": player_id,
-            "media_content_id": uri,
-            "media_content_type": "music"
-            }
-        return await self.__call_service(service, service_data)
-
-    async def __call_service(self, service, service_data=None, domain='media_player'):
-        ''' call service on hass '''
-        if not self.__send_ws:
-            return False
-        msg = {
-            "type": "call_service",
-            "domain": domain,
-            "service": service,
-            }
-        if service_data:
-            msg['service_data'] = service_data
-        return await self.__send_ws(msg)
-    
-    ### Provider specific (helper) methods #####
-    
-    async def __handle_player_state(self, data):
-        ''' handle a player state message from the websockets '''
-        player_id = data['entity_id']
-        if not player_id in self._players:
-            # new player
-            self._players[player_id] = MusicPlayer()
-            player = self._players[player_id]
-            player.player_id = player_id
-            player.player_provider = self.prov_id
-        else: 
-            # existing player
-            player = self._players[player_id]
-        # always update player details that may change
-        player.name = data['attributes']['friendly_name']
-        player.powered = not data['state'] == 'off'
-        if data['state'] == 'playing':
-            player.state == PlayerState.Playing
-        elif data['state'] == 'paused':
-            player.state == PlayerState.Paused
-        else:
-            player.state = PlayerState.Stopped
-        if 'is_volume_muted' in data['attributes']:
-            player.muted = data['attributes']['is_volume_muted']
-        if 'volume_level' in data['attributes']:
-            player.volume_level = float(data['attributes']['volume_level']) * 100
-        if 'media_position' in data['attributes']:
-            player.cur_item_time = try_parse_int(data['attributes']['media_position'])
-        player.cur_item = await self.__parse_track(data)
-        await self.mass.player.update_player(player)
-
-    async def __parse_track(self, data):
-        ''' parse track in hass to our internal format '''
-        track = Track()
-        # TODO: match this info in the DB!
-        if 'media_content_id' in data['attributes']:
-            artist = data['attributes'].get('media_artist')
-            album = data['attributes'].get('media_album')
-            title = data['attributes'].get('media_title')
-            track.name = "%s - %s" %(artist, title)
-            if 'entity_picture' in data['attributes']:
-                img = "https://%s%s" %(self._host, data['attributes']['entity_picture'])
-                track.metadata['image'] = img
-            track.duration = try_parse_int(data['attributes'].get('media_duration',0))
-        return track
-
-    async def __hass_connect(self):
-        ''' Receive events from Hass through websockets '''
-        while True:
-            try:
-                async with self.http_session.ws_connect('wss://%s/api/websocket' % self._host) as ws:
-                    
-                    async def send_msg(msg):
-                        ''' callback to send message to the websockets client'''
-                        self.__last_id += 1
-                        msg['id'] = self.__last_id
-                        await ws.send_json(msg)
-
-                    async for msg in ws:
-                        if msg.type == aiohttp.WSMsgType.TEXT:
-                            if msg.data == 'close cmd':
-                                await ws.close()
-                                break
-                            else:
-                                data = msg.json()
-                                if data['type'] == 'auth_required':
-                                    # send auth token
-                                    auth_msg = {"type": "auth", "access_token": self._token}
-                                    await ws.send_json(auth_msg)
-                                elif data['type'] == 'auth_invalid':
-                                    raise Exception(data)
-                                elif data['type'] == 'auth_ok':
-                                    # register callback
-                                    self.__send_ws = send_msg
-                                    # subscribe to events
-                                    subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"}
-                                    await send_msg(subscribe_msg)
-                                    subscribe_msg = {"type": "get_states"}
-                                    await send_msg(subscribe_msg)
-                                elif data['type'] == 'event' and data['event']['event_type'] == 'state_changed':
-                                    if data['event']['data']['entity_id'].startswith('media_player'):
-                                        asyncio.ensure_future(self.__handle_player_state(data['event']['data']['new_state']))
-                                elif data['type'] == 'result' and data.get('result'):
-                                    # reply to our get_states request
-                                    for item in data['result']:
-                                        if item['entity_id'].startswith('media_player'):
-                                            asyncio.ensure_future(self.__handle_player_state(item))
-                                else:
-                                    LOGGER.info(data)
-                        elif msg.type == aiohttp.WSMsgType.ERROR:
-                            break
-            except Exception as exc:
-                LOGGER.exception(exc)
-                asyncio.sleep(10)
\ No newline at end of file
index 16c977f30b3d7fb3f247927b841f24adde3967ba..ab086a72d4ed29a5a41758db47de825ca696c517 100644 (file)
@@ -51,14 +51,7 @@ class LMSProvider(PlayerProvider):
         self._port = port
         self._players = {}
         self.last_msg_received = 0
-        self.supports_queue = True # whether this provider has native support for a queue
-        self.supports_http_stream = True # whether we can fallback to http streaming
-        self.supported_musicproviders = [
-            ('qobuz', [MediaType.Track]), 
-            ('file', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]),
-            ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]), 
-            ('http', [MediaType.Track])
-        ]
+        self.supported_musicproviders = ['qobuz', 'file', 'spotify', 'http']
         self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
         # we use a combi of active polling and subscriptions because the cometd implementation of LMS is somewhat unreliable
         asyncio.ensure_future(self.__lms_events())
@@ -81,49 +74,45 @@ class LMSProvider(PlayerProvider):
             lms_commands = ['playlist', 'index', '-1']
         elif cmd == 'stop':
             lms_commands = ['playlist', 'stop']
-        elif cmd == 'power' and cmd_args in ['on', '1', 1]:
-            lms_commands = ['power', '1']
-        elif cmd == 'power' and cmd_args in ['off', '0', 0]:
+        elif cmd == 'power' and cmd_args == 'off':
             lms_commands = ['power', '0']
-        elif cmd == 'volume' and cmd_args == 'up':
-            lms_commands = ['mixer', 'volume', '+2']
-        elif cmd == 'volume' and cmd_args == 'down':
-            lms_commands = ['mixer', 'volume', '-2']
+        elif cmd == 'power':
+            lms_commands = ['power', '1']
         elif cmd == 'volume':
             lms_commands = ['mixer', 'volume', cmd_args]
-        elif cmd == 'mute' and cmd_args in ['on', '1', 1]:
-            lms_commands = ['mixer', 'muting', '1']
-        elif cmd == 'mute' and cmd_args in ['off', '0', 0]:
+        elif cmd == 'mute' and cmd_args == 'off':
             lms_commands = ['mixer', 'muting', '0']
+        elif cmd == 'mute':
+            lms_commands = ['mixer', 'muting', '1']
         return await self.__get_data(lms_commands, player_id=player_id)
 
-    async def play_media(self, player_id, uri, queue_opt='play'):
+    async def play_media(self, player_id, media_items, queue_opt='play'):
         ''' 
             play media on a player
-            params:
-            - player_id: id of the player
-            - uri: the uri for/to the media item (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 now
         '''
         if queue_opt == 'play':
-            cmd = ['playlist', 'insert', uri]
+            cmd = ['playlist', 'insert', media_items[0].uri]
             await self.__get_data(cmd, player_id=player_id)
-            cmd2 = ['playlist', 'index', '+1']
-            return await self.__get_data(cmd2, player_id=player_id)
+            cmd = ['playlist', 'index', '+1']
+            await self.__get_data(cmd, player_id=player_id)
+            for track in media_items[1:]:
+                cmd = ['playlist', 'insert', track.uri]
+                await self.__get_data(cmd, player_id=player_id)
         elif queue_opt == 'replace':
-            cmd = ['playlist', 'play', uri]
-            return await self.__get_data(cmd, player_id=player_id)
+            cmd = ['playlist', 'play', media_items[0].uri]
+            await self.__get_data(cmd, player_id=player_id)
+            for track in media_items[1:]:
+                cmd = ['playlist', 'add', track.uri]
+                await self.__get_data(cmd, player_id=player_id)
         elif queue_opt == 'next':
-            cmd = ['playlist', 'insert', uri]
-            return await self.__get_data(cmd, player_id=player_id)
+            for track in media_items:
+                cmd = ['playlist', 'insert', track.uri]
+                await self.__get_data(cmd, player_id=player_id)
         else:
-            cmd = ['playlist', 'add', uri]
-            return await self.__get_data(cmd, player_id=player_id)
-        
+            for track in media_items:
+                cmd = ['playlist', 'add', track.uri]
+                await self.__get_data(cmd, player_id=player_id)
+    
     async def player_queue(self, player_id, offset=0, limit=50):
         ''' return the items in the player's queue '''
         items = []
index 73a980c621cdceba21203aec670b4d136fbec30a..4d3753edfd1b8556f462198f7eb05121670852a0 100755 (executable)
@@ -3,7 +3,7 @@
 
 import asyncio
 import os
-from utils import run_periodic, run_async_background_task, LOGGER
+from utils import run_periodic, run_async_background_task, LOGGER, try_parse_int
 import aiohttp
 from difflib import SequenceMatcher as Matcher
 from models import MediaType
@@ -27,16 +27,16 @@ class Music():
         # schedule sync task
         mass.event_loop.create_task(self.sync_music_providers())
 
-    async def item(self, item_id, media_type:MediaType, lazy=True):
+    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, lazy=lazy)
+            return await self.artist(item_id, provider, lazy=lazy)
         elif media_type == MediaType.Album:
-            return await self.album(item_id, lazy=lazy)
+            return await self.album(item_id, provider, lazy=lazy)
         elif media_type == MediaType.Track:
-            return await self.track(item_id, lazy=lazy)
+            return await self.track(item_id, provider, lazy=lazy)
         elif media_type == MediaType.Playlist:
-            return await self.playlist(item_id)
+            return await self.playlist(item_id, provider)
         else:
             return None
 
@@ -52,9 +52,9 @@ class Music():
         ''' 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 library_playlists(self, limit=0, offset=0, orderby='name', provider_filter=None):
+    async def playlists(self, limit=0, offset=0, orderby='name', provider_filter=None):
         ''' return all library playlists, optionally filtered by provider '''
-        return await self.mass.db.library_playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+        return await self.mass.db.playlists(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):
         ''' get multiple music items in library'''
@@ -65,62 +65,37 @@ class Music():
         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.library_playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+            return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
 
-    async def artist(self, item_id, lazy=True):
+    async def artist(self, item_id, provider='database', lazy=True):
         ''' get artist by id '''
-        artist = await self.mass.db.artist(item_id)
-        if artist:
-            return artist
-        # not a database id, probably a provider id
-        for provider in self.providers.values():
-            artist = await provider.artist(item_id, lazy=lazy)
-            if artist:
-                return artist
-        raise Exception("Artist %s is not found" % item_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, lazy=True):
+    async def album(self, item_id, provider='database', lazy=True):
         ''' get album by id '''
-        album = await self.mass.db.album(item_id)
-        if album:
-            return album
-        # not a database id, probably a provider id
-        for provider in self.providers.values():
-            album = await provider.album(item_id, lazy=lazy)
-            if album:
-                return album
-        raise Exception("Album %s is not found" % item_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, lazy=True):
+    async def track(self, item_id, provider='database', lazy=True):
         ''' get track by id '''
-        track = await self.mass.db.track(item_id)
-        if track:
-            return track
-        # not a database id, probably a provider id
-        for provider in self.providers.values():
-            track = await provider.track(item_id, lazy=lazy)
-            if track:
-                return track
-        raise Exception("Track %s is not found" % item_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):
+    async def playlist(self, item_id, provider='database'):
         ''' get playlist by id '''
-        playlist = await self.mass.db.playlist(item_id)
-        if playlist:
-            return playlist
-        # not a database id, probably a provider id
-        for provider in self.providers.values():
-            playlist = await provider.playlist(item_id)
-            if playlist:
-                return playlist
-        raise Exception("Playlist %s is not found" % item_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 artist_toptracks(self, artist_id):
+    async def artist_toptracks(self, artist_id, provider='database'):
         ''' get top tracks for given artist '''
-        items = []
-        artist = await self.artist(artist_id)
+        artist = await self.artist(artist_id, provider)
         # always append database tracks
-        items += await self.mass.db.artist_tracks(artist.item_id)
+        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']
@@ -130,12 +105,11 @@ class Music():
         items.sort(key=lambda x: x.name, reverse=False)
         return items
 
-    async def artist_albums(self, artist_id):
+    async def artist_albums(self, artist_id, provider='database'):
         ''' get (all) albums for given artist '''
-        items = []
-        artist = await self.artist(artist_id)
+        artist = await self.artist(artist_id, provider)
         # always append database tracks
-        items += await self.mass.db.artist_albums(artist.item_id)
+        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']
@@ -145,33 +119,39 @@ class Music():
         items.sort(key=lambda x: x.name, reverse=False)
         return items
 
-    async def album_tracks(self, album_id):
+    async def album_tracks(self, album_id, provider='database'):
         ''' get the album tracks for given album '''
         items = []
-        album = await self.album(album_id)
+        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)
-            if items:
-                break # no need to pull in dups
         items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
         return items
 
-    async def playlist_tracks(self, playlist_id, offset=0, limit=50):
+    async def playlist_tracks(self, playlist_id, provider='database', offset=0, limit=50):
         ''' get the tracks for given playlist '''
-        items = []
-        playlist = await self.playlist(playlist_id)
-        for prov_mapping in playlist.provider_ids:
-            prov_id = prov_mapping['provider']
-            prov_item_id = prov_mapping['item_id']
-            prov_obj = self.providers[prov_id]
-            items += await prov_obj.playlist_tracks(prov_item_id, offset=offset, limit=limit)
-            if items:
-                break
-        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        return items
+        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
+            items = []
+            playlist = await self.playlist(playlist_id)
+            for prov_mapping in playlist.provider_ids:
+                prov_id = prov_mapping['provider']
+                prov_item_id = prov_mapping['item_id']
+                prov_obj = self.providers[prov_id]
+                items += await prov_obj.playlist_tracks(prov_item_id, offset=offset, limit=limit)
+                if items:
+                    break
+            items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+            return items
 
     async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False):
         ''' search database or providers '''
@@ -188,10 +168,10 @@ class Music():
                 items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
         return result
 
-    async def item_action(self, item_id, media_type, action=None):
+    async def item_action(self, item_id, media_type, provider='database', action=None):
         ''' perform action on item (such as library add/remove) '''
         result = None
-        item = await self.item(item_id, media_type)
+        item = await self.item(item_id, media_type, provider)
         if item and action in ['add', 'remove']:
             # remove or add item to the library
             for prov_mapping in result.provider_ids:
@@ -205,17 +185,6 @@ class Music():
                             result = await prov.remove_library(prov_item_id, media_type)
         return result
     
-    def get_music_provider(self, item_id):
-        ''' get musicprovider object by id '''
-        prov_obj = None
-        if isinstance(item_id,int) or not '_' in item_id:
-            prov_obj = self.mass.db
-        else:
-            prov_id = item_id.split('_')[0]
-            item_id = item_id.split('_')[1]
-            prov_obj = self.providers[prov_id]
-        return item_id, prov_obj
-
     @run_periodic(3600)
     async def sync_music_providers(self):
         ''' periodic sync of all music providers '''
@@ -227,7 +196,7 @@ class Music():
             await self.sync_library_artists(prov_id)
             await self.sync_library_albums(prov_id)
             await self.sync_library_tracks(prov_id)
-            await self.sync_library_playlists(prov_id)
+            await self.sync_playlists(prov_id)
         self.sync_running = False
         
     async def sync_library_artists(self, prov_id):
@@ -238,12 +207,10 @@ class Music():
         cur_items = await music_provider.get_library_artists()
         cur_db_ids = []
         for item in cur_items:
-            db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Artist)
-            if db_id == None:
-                db_id = await music_provider.add_artist(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.Artist, prov_id)
+            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:
@@ -258,12 +225,13 @@ class Music():
         cur_items = await music_provider.get_library_albums()
         cur_db_ids = []
         for item in cur_items:
-            db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Album)
-            if db_id == None:
-                db_id = await music_provider.add_album(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.Album, prov_id)
+            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:
@@ -278,34 +246,32 @@ class Music():
         cur_items = await music_provider.get_library_tracks()
         cur_db_ids = []
         for item in cur_items:
-            db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Track)
-            if db_id == None:
-                db_id = await music_provider.add_track(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.Track, prov_id)
+            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_library_playlists(self, 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.library_playlists(provider_filter=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_library_playlists()
+        cur_items = await music_provider.get_playlists()
         cur_db_ids = []
         for item in cur_items:
-            db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Playlist)
-            if db_id == None:
-                db_id = await music_provider.add_playlist(item)
+            # 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)
-            # playlist tracks
-            #asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_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:
@@ -323,14 +289,12 @@ class Music():
         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_prov_id = prov_mapping['provider']
+                item_provider = 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 == None:
-                    db_id = await self.providers[item_prov_id].add_track(item)
-                if not db_id in cur_db_ids:
-                    cur_db_ids.append(db_id)
-                    await self.mass.db.add_playlist_track(db_playlist_id, db_id, pos)
+                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:
index 829a3981d9f83a4e81bac6db571f760908e52646..444ba1b62557d004b9c5c01aebc11adbdbeb4bc2 100755 (executable)
@@ -3,14 +3,17 @@
 
 import asyncio
 import os
-from utils import run_periodic, LOGGER, try_parse_int
+from utils import run_periodic, LOGGER, try_parse_int, try_parse_float
 import aiohttp
 from difflib import SequenceMatcher as Matcher
 from models import MediaType, PlayerState, MusicPlayer
 from typing import List
 import toolz
 import operator
-
+import socket
+import random
+from copy import deepcopy
+import functools
 
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 MODULES_PATH = os.path.join(BASE_DIR, "modules", "playerproviders" )
@@ -37,33 +40,46 @@ class Player():
 
     async def player_command(self, player_id, cmd, cmd_args=None):
         ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) '''
-        if not player_id in self._players:
-            LOGGER.warning('Player %s not found' % player_id)
-            return False
         player = self._players[player_id]
+        player_settings = await self.get_player_config(player)
+        # handle some common workarounds
+        if cmd in ['pause', 'play'] and cmd_args == 'toggle':
+            cmd = 'pause' if player.state == PlayerState.Playing else 'play'
+        if cmd == 'volume' and cmd_args == 'up':
+            cmd_args = try_parse_int(cmd_args) + 2
+        elif cmd == 'volume' and cmd_args == 'down':
+            cmd_args = try_parse_int(cmd_args) - 2
         if player.group_parent and cmd not in ['power', 'volume', 'mute']:
             # redirect playlist related commands to parent player
             return await self.player_command(player.group_parent, cmd, cmd_args)
+        # handle hass integration
+        if self.mass.hass:
+            if cmd == 'power' and cmd_args == 'on' and player_settings.get('hass_power_entity') and player_settings.get('hass_power_entity_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 == '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
         if cmd == 'power' and player.mute_as_power:
             cmd = 'mute'
             cmd_args = 'on' if cmd_args == 'off' else 'off' # invert logic (power ON is mute OFF)
-        if cmd == 'volume' and player.apply_group_volume:
+        player_childs = [item for item in self._players.values() if item.group_parent == player_id]
+        is_group = len(player_childs) > 0
+        if is_group and cmd == 'volume' and player.apply_group_volume:
             # group volume, apply to childs (if any)
             cur_volume = player.volume_level
-            if cmd_args == 'up':
-                new_volume = cur_volume + 2
-            elif cmd_args == 'down':
-                new_volume = cur_volume - 2
-            else:
-                new_volume = try_parse_int(cmd_args)
+            new_volume = try_parse_int(cmd_args)
             if new_volume < cur_volume:
                 volume_dif = new_volume - cur_volume
             else:
                 volume_dif = cur_volume - new_volume
-            for child_player in await self.players():
-                if child_player.group_parent == player_id:
-                    LOGGER.debug("%s - %s - %s" % (child_player.name, child_player.state, child_player.muted))
-                if child_player.group_parent == player_id and child_player.state != PlayerState.Off:
+            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 + volume_dif
                     LOGGER.debug('apply group volume %s to child %s' %(new_child_volume, child_player.name))
@@ -80,13 +96,19 @@ class Player():
         self._players.pop(player_id, None)
         asyncio.ensure_future(self.mass.event('player removed', player_id))
 
+    async def trigger_update(self, player_id):
+        ''' manually trigger update for a player '''
+        await self.update_player(self._players[player_id])
+    
     async def update_player(self, player_details):
         ''' update (or add) player '''
+        player_details = deepcopy(player_details)
         LOGGER.debug('Incoming msg from %s' % player_details.name)
         player_id = player_details.player_id
         player_settings = await self.get_player_config(player_details)
         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
@@ -101,6 +123,27 @@ class Player():
         player_details.disable_volume = player_settings['disable_volume']
         player_details.mute_as_power = player_settings['mute_as_power']
         player_details.apply_group_volume = player_settings['apply_group_volume']
+
+        # handle hass integration
+        if self.mass.hass:
+            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)
         
         # handle mute as power setting
         if player_details.mute_as_power:
@@ -110,15 +153,17 @@ class Player():
             player_details.group_parent = player_settings['group_parent']
         if player_details.group_parent and player_details.group_parent in self._players:
             parent_player = self._players[player_details.group_parent]
-            if player_details.powered and player_details.state != PlayerState.Playing:
-                player_details.cur_item_time = parent_player.cur_item_time
-                player_details.cur_item = parent_player.cur_item
+            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 setting
+        player_childs = [item for item in self._players.values() if item.group_parent == player_id]
+        player_details.is_group = len(player_childs) > 0
         if player_details.is_group and player_details.apply_group_volume:
             group_volume = 0
             active_players = 0
-            for child_player in self._players.values():
-                if child_player.group_parent == player_id and child_player.enabled and child_player.powered:
+            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
@@ -133,6 +178,8 @@ class Player():
         if player_changed:
             # player is added or updated!
             asyncio.ensure_future(self.mass.event('player updated', player))
+            for child in player_childs:
+                asyncio.create_task(self.trigger_update(child.player_id))
 
     async def get_player_config(self, player_details):
         ''' get or create player config '''
@@ -155,81 +202,71 @@ class Player():
             play media on a player 
             player_id: id of the player
             media_item: media item that should be played (Track, Album, Artist, Playlist)
-            queue_opt: replace, next or add
+            queue_opt: play, replace, next or add
         '''
         if not player_id in self._players:
             LOGGER.warning('Player %s not found' % player_id)
             return False
-        prov_id = self._players[player_id].player_provider
-        prov = self.providers[prov_id]
-        # check supported music providers by this player and work out how to handle playback...
-        musicprovider = None
-        item_id = None
-        for prov_id, supported_types in prov.supported_musicproviders:
-            if media_item.provider_ids.get(prov_id):
-                musicprovider = prov_id
-                prov_item_id = media_item.provider_ids[prov_id]
-                if media_item.media_type in supported_types:
-                    # the provider can handle this media_type directly !
-                    uri = await self.get_item_uri(media_item.media_type, prov_item_id, prov_id)
-                    return await prov.play_media(player_id, uri, queue_opt)
-                else:
-                    # manually enqueue the tracks of this listing
-                    return await self.queue_items(player_id, media_item, queue_opt)
-            elif prov_id == 'http':
-                # fallback to http streaming
-                if media_item.media_type == MediaType.Track:
-                    for media_prov_id, media_prov_item_id in media_item.provider_ids.items():
-                        stream_details = await self.mass.music.providers[media_prov_id].get_stream_details(media_prov_item_id)
-                        return await prov.play_media(player_id, stream_details['url'], queue_opt)
-                else:
-                    return await self.queue_items(player_id, media_item, queue_opt)
-        raise Exception("Musicprovider %s and/or mediatype %s not supported by player %s !" % ("/".join(media_item.provider_ids.keys()), media_item.media_type, player_id) )
-
-    async def queue_items(self, player_id, media_item, queue_opt):
-        ''' extract a list of items and manually enqueue the tracks '''
-        tracks = []
-        #TODO: respect shuffle
+        player_prov = self.providers[self._players[player_id].player_provider]
+        # collect tracks to play
         if media_item.media_type == MediaType.Artist:
-            tracks = await self.mass.music.artist_toptracks(media_item.item_id)
+            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)
+            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, offset=0, limit=0)
-        if queue_opt == 'replace':
-            await self.play_media(player_id, tracks[0], 'replace')
-            tracks = tracks[1:]
-            queue_opt = 'add'
+            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...
+        playable_tracks = []
         for track in tracks:
-            await self.play_media(player_id, track, queue_opt)
-
-    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]
-        if player_prov.supports_queue:
-            return await player_prov.player_queue(player_id, offset=offset, limit=limit)
+            # sort by quality
+            match_found = False
+            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:
+                    # the provider can handle this media_type directly !
+                    track.uri = await self.get_track_uri(media_item_id, media_provider)
+                    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, True)
+                    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:
-            # TODO: Implement 'fake' queue
-            raise NotImplementedError
+            raise Exception("Musicprovider %s and/or mediatype %s not supported by player %s !" % ("/".join(media_item.provider_ids), media_item.media_type, player_id) )
     
-    async def get_item_uri(self, media_type, item_id, provider):
+    async def get_track_uri(self, item_id, provider, http_stream=False):
         ''' generate the URL/URI for a media item '''
         uri = ""
-        if provider == "spotify" and media_type == MediaType.Track:
+        if http_stream:
+            host = socket.gethostbyname(socket.gethostname())
+            uri = 'http://%s:8095/stream/%s/%s'% (host, provider, item_id)
+        elif provider == "spotify":
             uri = 'spotify://spotify:track:%s' % item_id
-        elif provider == "spotify" and media_type == MediaType.Album:
-            uri = 'spotify://spotify:album:%s' % item_id
-        elif provider == "spotify" and media_type == MediaType.Artist:
-            uri = 'spotify://spotify:artist:%s' % item_id
-        elif provider == "spotify" and media_type == MediaType.Playlist:
-            uri = 'spotify://spotify:playlist:%s' % item_id
-        elif provider == "qobuz" and media_type == MediaType.Track:
+        elif provider == "qobuz":
             uri = 'qobuz://%s.flac' % item_id
         elif provider == "file":
             uri = 'file://%s' % 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)
+
     def load_providers(self):
         ''' dynamically load providers '''
         for item in os.listdir(MODULES_PATH):
index 408775182c02799826388d095baf2d676da30837..d6f2c1b3c59f80339354bb248f44ab94e44437c8 100755 (executable)
@@ -4,13 +4,14 @@
 import asyncio
 import logging
 from concurrent.futures import ThreadPoolExecutor
-
-logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(module)s -- %(message)s')
-LOGGER = logging.getLogger("music_assistant")
+logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s')
 consolehandler = logging.StreamHandler()
 consolehandler.setFormatter(logformat)
+LOGGER = logging.getLogger(__package__)
+LOGGER.setLevel(logging.INFO)
 LOGGER.addHandler(consolehandler)
-LOGGER.setLevel(logging.DEBUG)
+
+
 
 def run_periodic(period):
     def scheduler(fcn):
@@ -51,6 +52,12 @@ def try_parse_int(possible_int):
     except:
         return 0
 
+def try_parse_float(possible_float):
+    try:
+        return float(possible_float)
+    except:
+        return 0
+
 def parse_track_title(track_title):
     ''' try to parse clean track title and version from the title '''
     track_title = track_title.lower()
@@ -73,8 +80,15 @@ def parse_track_title(track_title):
                         version = title_part
                         title = title.split(splitter+version)[0]
     title = title.strip().title()
-    # version substitues
-
+    # version substitutes
+    if "radio" in version:
+        version = "radio version"
+    elif "album" in version:
+        version = "album version"
+    elif "single" in version:
+        version = "single version"
+    elif "remaster" in version:
+        version = "remaster"
     version = version.strip().title()
     return title, version
 
index 7b87ac312dc09ee2072e111187e58fac31fa4733..9bbfc1d71d641fe68aa99eff53a2525aafb5e754 100755 (executable)
@@ -191,9 +191,19 @@ Vue.component("player", {
     },
     playItem(item, queueopt) {
       console.log('playItem: ' + item);
-      var cmd = 'players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt;
-      console.log(cmd);
-      this.ws.send(cmd);
+      var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt;
+      axios
+      .get(api_url, {
+        params: {
+          provider: item.provider
+        }
+      })
+      .then(result => {
+        console.log(result.data);
+      })
+      .catch(error => {
+        console.log("error", error);
+      });
     },
     switchPlayer (new_player_id) {
       this.active_player_id = new_player_id;
index 54fae32fb54535a0b779d0412c2a6c37bb3b31c8..6b3bacd9fdab4c5cc942a526ed550eea58f641bf 100644 (file)
@@ -16,6 +16,16 @@ Vue.component("playmenu", {
                        </v-list-tile>\r
                        <v-divider></v-divider>\r
 \r
+                       <v-list-tile avatar @click="$emit('playItem', $globals.playmenuitem, 'replace')">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>play_circle_outline</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>Replace</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider></v-divider>\r
+\r
                        <v-list-tile avatar @click="$emit('playItem', $globals.playmenuitem, 'next')">\r
                                <v-list-tile-avatar>\r
                                        <v-icon>queue_play_next</v-icon>\r
index 2b1b5a45f272842c8640584b435fa5553be1312b..3157ab836764229f53961700947519b42f2cfb7c 100755 (executable)
@@ -79,7 +79,7 @@
                     endpoint = "/playlists/"
                 item_id = item.item_id.toString();
                 var url = endpoint + item_id;
-                router.push({ path: url});
+                router.push({ path: url, params: {provider: item.provider}});
             }
 
             String.prototype.formatDuration = function () {
index 785e94b387d6545e2d75a89796be15c33ac6a30c..961430c6754d70031bc71be02d1de84fc3994930 100755 (executable)
@@ -75,7 +75,7 @@ var ArtistDetails = Vue.component('ArtistDetails', {
       this.$globals.loading = true;
       const api_url = '/api/artists/' + this.media_id
       axios
-        .get(api_url, { params: { lazy: lazy }})
+        .get(api_url, { params: { lazy: lazy, provider: this.provider }})
         .then(result => {
           data = result.data;
           this.info = data;
index fae6b2998777b080acb23afe697b1db7f46c2cd8..d23a86c778a7a1ea0667b519a788baae8a4c90a8 100755 (executable)
@@ -23,7 +23,7 @@ var Browse = Vue.component('Browse', {
       </v-list>
     </section>
   `,
-  props: ['mediatype'],
+  props: ['mediatype', 'provider'],
   data() {
     return {
       selected: [2],
@@ -43,7 +43,7 @@ var Browse = Vue.component('Browse', {
       this.$globals.loading = true
       const api_url = '/api/' + this.mediatype;
       axios
-        .get(api_url, { params: { offset: this.offset, limit: 50 }})
+        .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider }})
         .then(result => {
           data = result.data;
           this.items.push(...data);
index e9b721113707d541d63312f0cce41b4fedf92726..ff2e8c36b604b9ae3bb86eb8101800e9a613d370 100755 (executable)
@@ -11,6 +11,38 @@ var Config = Vue.component('Config', {
 
       <v-list two-line>
 
+        <!-- base/generic config -->
+        <v-list-group prepend-icon="settings" no-action>
+            <template v-slot:activator>
+              <v-list-tile>
+                <v-list-tile-content>
+                  <v-list-tile-title>Generic settings</v-list-tile-title>
+                </v-list-tile-content>
+              </v-list-tile>
+            </template>
+            <template v-for="(conf_value, conf_key) in conf.base">
+                <v-list-tile>
+                  <v-list-tile-avatar>
+                      <img :src="'images/icons/' + conf_key + '.png'"/>
+                  </v-list-tile-avatar>
+                  <v-list-tile-content>
+                    <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+                
+                <div v-for="conf_item_key in conf.base[conf_key].__desc__">
+                  <v-list-tile>
+                        <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="conf_item_key[2]"></v-switch>
+                        <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-text-field>
+                        <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-select>
+                        <v-text-field v-else v-model="conf.base[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box></v-text-field>
+                  </v-list-tile>
+              </div>
+              <v-divider></v-divider>
+            </template>
+          </v-list-group>
+
+
           <!-- music providers -->
           <v-list-group prepend-icon="library_music" no-action>
               <template v-slot:activator>
index 05009d90bcabe722b9e2bd324b465202472436de..ba7b397fb80e83578e7fd47f40203fb5781f78cb 100644 (file)
@@ -7,4 +7,5 @@ slugify
 asyncio_throttle
 aiocometd
 aiosqlite
-pytaglib
\ No newline at end of file
+pytaglib
+python-slugify
\ No newline at end of file