first alpha version
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 9 May 2019 06:32:46 +0000 (08:32 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 9 May 2019 06:32:46 +0000 (08:32 +0200)
still needs a lot of work

71 files changed:
.gitignore [new file with mode: 0644]
Dockerfile [new file with mode: 0755]
config.json [new file with mode: 0755]
music_assistant/.vscode/launch.json [new file with mode: 0644]
music_assistant/__init__.py [new file with mode: 0644]
music_assistant/api.py [new file with mode: 0755]
music_assistant/cache.py [new file with mode: 0644]
music_assistant/constants.py [new file with mode: 0755]
music_assistant/database.py [new file with mode: 0755]
music_assistant/main.py [new file with mode: 0755]
music_assistant/metadata.py [new file with mode: 0755]
music_assistant/models.py [new file with mode: 0755]
music_assistant/modules/__init__.py [new file with mode: 0644]
music_assistant/modules/musicproviders/__init__.py [new file with mode: 0644]
music_assistant/modules/musicproviders/file.py [new file with mode: 0644]
music_assistant/modules/musicproviders/qobuz.py [new file with mode: 0644]
music_assistant/modules/musicproviders/spotify.py [new file with mode: 0644]
music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf [new file with mode: 0755]
music_assistant/modules/musicproviders/spotty/darwin/spotty [new file with mode: 0755]
music_assistant/modules/musicproviders/spotty/windows/spotty.exe [new file with mode: 0755]
music_assistant/modules/musicproviders/spotty/x86-linux/spotty [new file with mode: 0755]
music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 [new file with mode: 0755]
music_assistant/modules/playerproviders/__init__.py [new file with mode: 0644]
music_assistant/modules/playerproviders/chromecast.py [new file with mode: 0644]
music_assistant/modules/playerproviders/homeassistant.py [new file with mode: 0644]
music_assistant/modules/playerproviders/lms.py [new file with mode: 0644]
music_assistant/music.py [new file with mode: 0755]
music_assistant/player.py [new file with mode: 0755]
music_assistant/utils.py [new file with mode: 0755]
music_assistant/web/components/headermenu.vue.js [new file with mode: 0755]
music_assistant/web/components/infoheader.vue.js [new file with mode: 0644]
music_assistant/web/components/listviewItem.vue.js [new file with mode: 0755]
music_assistant/web/components/player.vue.js [new file with mode: 0755]
music_assistant/web/components/playmenu.vue.js [new file with mode: 0644]
music_assistant/web/components/qualityicon.vue.js [new file with mode: 0644]
music_assistant/web/components/readmore.vue.js [new file with mode: 0644]
music_assistant/web/components/searchbar.vue.js [new file with mode: 0644]
music_assistant/web/components/volumecontrol.vue.js [new file with mode: 0644]
music_assistant/web/css/nprogress.css [new file with mode: 0644]
music_assistant/web/css/site.css [new file with mode: 0755]
music_assistant/web/images/default_artist.png [new file with mode: 0644]
music_assistant/web/images/icons/aac.png [new file with mode: 0644]
music_assistant/web/images/icons/file.png [new file with mode: 0644]
music_assistant/web/images/icons/flac.png [new file with mode: 0644]
music_assistant/web/images/icons/hires.png [new file with mode: 0644]
music_assistant/web/images/icons/icon-128x128.png [new file with mode: 0755]
music_assistant/web/images/icons/icon-144x144.png [new file with mode: 0755]
music_assistant/web/images/icons/icon-152x152.png [new file with mode: 0755]
music_assistant/web/images/icons/icon-192x192.png [new file with mode: 0755]
music_assistant/web/images/icons/icon-384x384.png [new file with mode: 0755]
music_assistant/web/images/icons/icon-512x512.png [new file with mode: 0755]
music_assistant/web/images/icons/icon-72x72.png [new file with mode: 0755]
music_assistant/web/images/icons/icon-96x96.png [new file with mode: 0755]
music_assistant/web/images/icons/info_gradient.jpg [new file with mode: 0644]
music_assistant/web/images/icons/mp3.png [new file with mode: 0644]
music_assistant/web/images/icons/qobuz.png [new file with mode: 0644]
music_assistant/web/images/icons/spotify.png [new file with mode: 0644]
music_assistant/web/images/icons/vorbis.png [new file with mode: 0644]
music_assistant/web/images/info_gradient.jpg [new file with mode: 0644]
music_assistant/web/index.html [new file with mode: 0755]
music_assistant/web/manifest.json [new file with mode: 0755]
music_assistant/web/pages/albumdetails.vue.js [new file with mode: 0755]
music_assistant/web/pages/artistdetails.vue.js [new file with mode: 0755]
music_assistant/web/pages/browse.vue.js [new file with mode: 0755]
music_assistant/web/pages/config.vue.js [new file with mode: 0755]
music_assistant/web/pages/home.vue.js [new file with mode: 0755]
music_assistant/web/pages/playlistdetails.vue.js [new file with mode: 0755]
music_assistant/web/pages/queue.vue.js [new file with mode: 0755]
music_assistant/web/pages/search.vue.js [new file with mode: 0755]
music_assistant/web/pages/trackdetails.vue.js [new file with mode: 0755]
requirements.txt [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..1eb6c37
--- /dev/null
@@ -0,0 +1,5 @@
+
+.DS_Store
+*.db
+*.pyc
+music_assistant/config.json
diff --git a/Dockerfile b/Dockerfile
new file mode 100755 (executable)
index 0000000..596a04c
--- /dev/null
@@ -0,0 +1,15 @@
+FROM python:3.7.3-alpine
+
+# install deps
+RUN pip install --upgrade requirements.txt
+
+# copy files
+RUN mkdir -p /usr/src/app
+WORKDIR /usr/src/app
+COPY music_assistant /usr/src/app
+RUN chmod a+x /usr/src/app/main.py
+RUN pip install --upgrade requirements.txt
+
+VOLUME ["/data"]
+
+CMD ["python3.7", "/usr/src/app/main.py", "/data"]
\ No newline at end of file
diff --git a/config.json b/config.json
new file mode 100755 (executable)
index 0000000..ef4edda
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "name": "Music Assistant",
+  "version": "0.0.1",
+  "description": "Media library manager for (streaming) media",
+  "slug": "music_assistant",
+  "startup": "application",
+  "boot": "auto",
+  "map": [],
+  "host_network": true,
+  "options": {
+  },
+  "schema": {
+  }
+}
diff --git a/music_assistant/.vscode/launch.json b/music_assistant/.vscode/launch.json
new file mode 100644 (file)
index 0000000..7a6c16f
--- /dev/null
@@ -0,0 +1,14 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "type": "node",
+            "request": "launch",
+            "name": "Launch Program",
+            "program": "${workspaceFolder}/web/components/vue-read-more/index.js"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/music_assistant/api.py b/music_assistant/api.py
new file mode 100755 (executable)
index 0000000..02e8437
--- /dev/null
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from utils import run_periodic, LOGGER
+import json
+import aiohttp
+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__)
+
+
+class Api():
+    ''' expose our data through json api '''
+    
+    def __init__(self, mass):
+        self.mass = mass
+        self.http_session = aiohttp.ClientSession()
+
+    def stop(self):
+        self.runner.cleanup()
+        self.http_session.close()
+
+    async def setup_web(self):
+        app = web.Application()
+        app.add_routes([web.get('/ws', self.websocket_handler)])
+        app.add_routes([web.get('/stream/{provider}/{track_id}', self.stream)])
+        app.add_routes([web.get('/api/search', self.search)])
+        app.add_routes([web.get('/api/config', self.get_config)])
+        app.add_routes([web.post('/api/config', self.save_config)])
+        app.add_routes([web.get('/api/players', self.players)])
+        app.add_routes([web.get('/api/players/{player_id}/queue', self.player_queue)])
+        app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}', self.player_command)])
+        app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}/{cmd_args}', self.player_command)])
+        app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}', self.play_media)])
+        app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}', self.play_media)])
+        app.add_routes([web.get('/api/playlists/{playlist_id}/tracks', self.playlist_tracks)])
+        app.add_routes([web.get('/api/artists/{artist_id}/toptracks', self.artist_toptracks)])
+        app.add_routes([web.get('/api/artists/{artist_id}/albums', self.artist_albums)])
+        app.add_routes([web.get('/api/albums/{album_id}/tracks', self.album_tracks)])
+        app.add_routes([web.get('/api/{media_type}', self.get_items)])
+        app.add_routes([web.get('/api/{media_type}/{media_id}/{action}', self.get_item)])
+        app.add_routes([web.get('/api/{media_type}/{media_id}', self.get_item)])
+        app.add_routes([web.get('/', self.index)])
+        app.router.add_static("/", "./web")  
+        
+        self.runner = web.AppRunner(app)
+        await self.runner.setup()
+        site = web.TCPSite(self.runner, '0.0.0.0', 8095)
+        await site.start()
+
+    async def get_items(self, request):
+        ''' get multiple library items'''
+        media_type_str = request.match_info.get('media_type')
+        media_type = media_type_from_string(media_type_str)
+        limit = int(request.query.get('limit', 50))
+        offset = int(request.query.get('offset', 0))
+        orderby = request.query.get('orderby', 'name')
+        provider_filter = request.rel_url.query.get('provider')
+        result = await self.mass.music.library_items(media_type, 
+                    limit=limit, offset=offset, 
+                    orderby=orderby, provider_filter=provider_filter)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def get_item(self, request):
+        ''' get item full details'''
+        media_type_str = request.match_info.get('media_type')
+        media_type = media_type_from_string(media_type_str)
+        media_id = request.match_info.get('media_id')
+        action = request.match_info.get('action','')
+        lazy = request.rel_url.query.get('lazy', '') != 'false'
+        if action:
+            result = await self.mass.music.item_action(media_id, media_type, action)
+        else:
+            result = await self.mass.music.item(media_id, media_type, 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)
+        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)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def playlist_tracks(self, request):
+        ''' get playlist tracks from provider'''
+        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)
+        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)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def search(self, request):
+        ''' search database or providers '''
+        searchquery = request.rel_url.query.get('query')
+        media_types_query = request.rel_url.query.get('media_types')
+        limit = request.rel_url.query.get('media_id', 5)
+        online = request.rel_url.query.get('online', False)
+        media_types = []
+        if not media_types_query or "artists" in media_types_query:
+            media_types.append(MediaType.Artist)
+        if not media_types_query or "albums" in media_types_query:
+            media_types.append(MediaType.Album)
+        if not media_types_query or "tracks" in media_types_query:
+            media_types.append(MediaType.Track)
+        if not media_types_query or "playlists" in media_types_query:
+            media_types.append(MediaType.Playlist)
+        # get results from database
+        result = await self.mass.music.search(searchquery, media_types, limit=limit, online=online)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def players(self, request):
+        ''' get all players '''
+        players = await self.mass.player.players()
+        return web.json_response(players, dumps=json_serializer)
+
+    async def player_command(self, request):
+        ''' issue player command'''
+        player_id = request.match_info.get('player_id')
+        cmd = request.match_info.get('cmd')
+        cmd_args = request.match_info.get('cmd_args')
+        result = await self.mass.player.player_command(player_id, cmd, cmd_args)
+        return web.json_response(result, dumps=json_serializer) 
+    
+    async def play_media(self, request):
+        ''' issue player play_media command'''
+        player_id = request.match_info.get('player_id')
+        media_type_str = request.match_info.get('media_type')
+        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)
+        result = await self.mass.player.play_media(player_id, media_item, queue_opt)
+        return web.json_response(result, dumps=json_serializer) 
+    
+    async def player_queue(self, request):
+        ''' return the items in the player's queue '''
+        player_id = request.match_info.get('player_id')
+        limit = int(request.query.get('limit', 50))
+        offset = int(request.query.get('offset', 0))
+        result = await self.mass.player.player_queue(player_id, offset, limit)
+        return web.json_response(result, dumps=json_serializer) 
+    
+    async def index(self, request):  
+        return web.FileResponse("./web/index.html")
+
+    async def websocket_handler(self, request):
+        ''' websockets handler '''
+        ws = web.WebSocketResponse()
+        await ws.prepare(request)
+        # register callback for internal events
+        async def send_event(msg, msg_details):
+            ws_msg = {"message": msg, "message_details": msg_details }
+            try:
+                await ws.send_json(ws_msg, dumps=json_serializer)
+            except Exception as exc:
+                if 'the handler is closed' in str(exc):
+                    await self.mass.remove_event_listener(cb_id)
+                else:
+                    LOGGER.exception(exc)
+
+        cb_id = await self.mass.add_event_listener(send_event)
+        # process incoming messages
+        async for msg in ws:
+            if msg.type == aiohttp.WSMsgType.TEXT:
+                if msg.data == 'close':
+                    await self.mass.remove_event_listener(cb_id)
+                    await ws.close()
+                else:
+                    # for now we only use WS for player commands
+                    if msg.data == 'players':
+                        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 '/cmd/' in msg.data:
+                        # players/{player_id}/cmd/{cmd} or players/{player_id}/cmd/{cmd}/{cmd_args}
+                        msg_data_parts = msg.data.split('/')
+                        player_id = msg_data_parts[1]
+                        cmd = msg_data_parts[3]
+                        cmd_args = msg_data_parts[4] if len(msg_data_parts) == 5 else None
+                        await self.mass.player.player_command(player_id, cmd, cmd_args)
+            elif msg.type == aiohttp.WSMsgType.ERROR:
+                LOGGER.error('ws connection closed with exception %s' %
+                    ws.exception())
+        LOGGER.info('websocket connection closed')
+        return ws
+
+    async def get_config(self, request):
+        ''' get the config '''
+        return web.json_response(self.mass.config)
+
+    async def save_config(self, request):
+        ''' save the config '''
+        LOGGER.debug('save config called from api')
+        new_config = await request.json()
+        for key, value in self.mass.config.items():
+            if isinstance(value, dict):
+                for subkey, subvalue in value.items():
+                    if subkey in new_config[key]:
+                        self.mass.config[key][subkey] = new_config[key][subkey]
+            elif key in new_config:
+                self.mass.config[key] = new_config[key]
+        self.mass.save_config()
+        return web.Response(text='success')
+
+    async def stream(self, request):
+        ''' 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)
+        resp = web.StreamResponse(status=200,
+                                reason='OK',
+                                headers={'Content-Type': stream_details['mime_type']})
+        await resp.prepare(request)
+        async for chunk in self.mass.music.providers[provider].get_stream(track_id):
+            await resp.write(chunk)
+        return resp
\ No newline at end of file
diff --git a/music_assistant/cache.py b/music_assistant/cache.py
new file mode 100644 (file)
index 0000000..16cdc39
--- /dev/null
@@ -0,0 +1,236 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+
+'''provides a simple stateless caching system'''
+
+import datetime
+import time
+import sqlite3
+from functools import reduce
+import os
+from utils import run_periodic, LOGGER, parse_track_title
+import functools
+import asyncio
+
+
+class Cache(object):
+    '''basic stateless caching system '''
+    _exit = False
+    _mem_cache = {}
+    _busy_tasks = []
+    _database = None
+
+    def __init__(self):
+        '''Initialize our caching class'''
+        asyncio.ensure_future(self._do_cleanup())
+        LOGGER.debug("Initialized")
+
+    async def get(self, endpoint, checksum=""):
+        '''
+            get object from cache and return the results
+            endpoint: the (unique) name of the cache object as reference
+            checkum: optional argument to check if the checksum in the cacheobject matches the checkum provided
+        '''
+        checksum = self._get_checksum(checksum)
+        cur_time = self._get_timestamp(datetime.datetime.now())
+        result = None
+        # 1: try memory cache first
+        result = await self._get_mem_cache(endpoint, checksum, cur_time)
+        # 2: fallback to _database cache
+        if result is None:
+            result = await self._get_db_cache(endpoint, checksum, cur_time)
+        return result
+
+    async def set(self, endpoint, data, checksum="", expiration=datetime.timedelta(days=14)):
+        '''
+            set data in cache
+        '''
+        task_name = "set.%s" % endpoint
+        self._busy_tasks.append(task_name)
+        checksum = self._get_checksum(checksum)
+        expires = self._get_timestamp(datetime.datetime.now() + expiration)
+
+        # memory cache
+        await self._set_mem_cache(endpoint, checksum, expires, data)
+
+        # db cache
+        if not self._exit:
+            await self._set_db_cache(endpoint, checksum, expires, data)
+
+        # remove this task from list
+        self._busy_tasks.remove(task_name)
+
+    async def _get_mem_cache(self, endpoint, checksum, cur_time):
+        '''
+            get cache data from memory cache
+        '''
+        result = None
+        cachedata = self._mem_cache.get(endpoint)
+        if cachedata:
+            cachedata = cachedata
+            if cachedata[0] > cur_time:
+                if checksum == None or checksum == cachedata[2]:
+                    result = cachedata[1]
+        return result
+
+    async def _set_mem_cache(self, endpoint, checksum, expires, data):
+        '''
+            put data in memory cache
+        '''
+        cachedata = (expires, data, checksum)
+        self._mem_cache[endpoint] = cachedata
+
+    async def _get_db_cache(self, endpoint, checksum, cur_time):
+        '''get cache data from sqllite database'''
+        result = None
+        query = "SELECT expires, data, checksum FROM simplecache WHERE id = ?"
+        cache_data = self._execute_sql(query, (endpoint,))
+        if cache_data:
+            cache_data = cache_data.fetchone()
+            if cache_data and cache_data[0] > cur_time:
+                if checksum == None or cache_data[2] == checksum:
+                    result = eval(cache_data[1])
+                    # also set result in memory cache for further access
+                    await self._set_mem_cache(endpoint, checksum, cache_data[0], result)
+        return result
+
+    async def _set_db_cache(self, endpoint, checksum, expires, data):
+        ''' store cache data in _database '''
+        query = "INSERT OR REPLACE INTO simplecache( id, expires, data, checksum) VALUES (?, ?, ?, ?)"
+        data = repr(data)
+        self._execute_sql(query, (endpoint, expires, data, checksum))
+
+    @run_periodic(3600)
+    async def _do_cleanup(self):
+        '''perform cleanup task'''
+        if self._exit:
+            return
+        self._busy_tasks.append(__name__)
+        cur_time = datetime.datetime.now()
+        cur_timestamp = self._get_timestamp(cur_time)
+        LOGGER.debug("Running cleanup...")
+        query = "SELECT id, expires FROM simplecache"
+        for cache_data in self._execute_sql(query).fetchall():
+            cache_id = cache_data[0]
+            cache_expires = cache_data[1]
+            if self._exit:
+                return
+            # always cleanup all memory objects on each interval
+            self._mem_cache.pop(cache_id, None)
+            # clean up db cache object only if expired
+            if cache_expires < cur_timestamp:
+                query = 'DELETE FROM simplecache WHERE id = ?'
+                self._execute_sql(query, (cache_id,))
+                LOGGER.debug("delete from db %s" % cache_id)
+
+        # compact db
+        self._execute_sql("VACUUM")
+
+        # remove task from list
+        self._busy_tasks.remove(__name__)
+        LOGGER.debug("Auto cleanup done")
+
+    def _get_database(self):
+        '''get reference to our sqllite _database - performs basic integrity check'''
+        dbfile = "/tmp/simplecache.db"
+        try:
+            connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None)
+            connection.execute('SELECT * FROM simplecache LIMIT 1')
+            return connection
+        except Exception as error:
+            # our _database is corrupt or doesn't exist yet, we simply try to recreate it
+            if os.path.isfile(dbfile):
+                os.remove(dbfile)
+            try:
+                connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None)
+                connection.execute(
+                    """CREATE TABLE IF NOT EXISTS simplecache(
+                    id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""")
+                return connection
+            except Exception as error:
+                LOGGER.warning("Exception while initializing _database: %s" % str(error))
+                return None
+
+    def _execute_sql(self, query, data=None):
+        '''little wrapper around execute and executemany to just retry a db command if db is locked'''
+        retries = 0
+        result = None
+        error = None
+        # always use new db object because we need to be sure that data is available for other simplecache instances
+        with self._get_database() as _database:
+            while not retries == 10:
+                if self._exit:
+                    return None
+                try:
+                    if isinstance(data, list):
+                        result = _database.executemany(query, data)
+                    elif data:
+                        result = _database.execute(query, data)
+                    else:
+                        result = _database.execute(query)
+                    return result
+                except sqlite3.OperationalError as error:
+                    if "_database is locked" in error:
+                        LOGGER.debug("retrying DB commit...")
+                        retries += 1
+                        time.sleep(0.5)
+                    else:
+                        break
+                except Exception as error:
+                    LOGGER.error("_database ERROR ! -- %s" % str(error))
+                    break
+        return None
+
+    @staticmethod
+    def _get_timestamp(date_time):
+        '''Converts a datetime object to unix timestamp'''
+        return int(time.mktime(date_time.timetuple()))
+
+    @staticmethod
+    def _get_checksum(stringinput):
+        '''get int checksum from string'''
+        if not stringinput:
+            return 0
+        else:
+            stringinput = str(stringinput)
+        return reduce(lambda x, y: x + y, map(ord, stringinput))
+
+def use_cache(cache_days=14, cache_hours=8):
+    def wrapper(func):
+        @functools.wraps(func)
+        async def wrapped(*args, **kwargs):
+            if kwargs.get("ignore_cache"):
+                return await func(*args, **kwargs)
+            cache_checksum = kwargs.get("cache_checksum")
+            method_class = args[0]
+            method_class_name = method_class.__class__.__name__
+            cache_str = "%s.%s" % (method_class_name, func.__name__)
+            # append args to cache identifier
+            for item in args[1:]:
+                if isinstance(item, dict):
+                    for subkey in sorted(list(item.keys())):
+                        subvalue = item[subkey]
+                        cache_str += ".%s%s" %(subkey,subvalue)
+                else:
+                    cache_str += ".%s" % item
+            # append kwargs to cache identifier
+            for key in sorted(list(kwargs.keys())):
+                if key in ["ignore_cache", "cache_checksum"]:
+                    continue
+                value = kwargs[key]
+                if isinstance(value, dict):
+                    for subkey in sorted(list(value.keys())):
+                        subvalue = value[subkey]
+                        cache_str += ".%s%s" %(subkey,subvalue)
+                else:
+                    cache_str += ".%s%s" %(key,value)
+            cache_str = cache_str.lower()
+            cachedata = await method_class.cache.get(cache_str, checksum=cache_checksum)
+            if cachedata is not None:
+                return cachedata
+            else:
+                result = await func(*args, **kwargs)
+                await method_class.cache.set(cache_str, result, checksum=cache_checksum, expiration=datetime.timedelta(days=cache_days, hours=cache_hours))
+                return result
+        return wrapped
+    return wrapper
diff --git a/music_assistant/constants.py b/music_assistant/constants.py
new file mode 100755 (executable)
index 0000000..93f1e06
--- /dev/null
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+CONF_USERNAME = "username"
+CONF_PASSWORD = "password"
+CONF_ENABLED = "enabled"
+CONF_HOSTNAME = "hostname"
+CONF_PORT = "port"
\ No newline at end of file
diff --git a/music_assistant/database.py b/music_assistant/database.py
new file mode 100755 (executable)
index 0000000..7e9e92b
--- /dev/null
@@ -0,0 +1,595 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from utils import run_periodic, LOGGER, get_sort_name, try_parse_int
+from models import MediaType, Artist, Album, Track, Playlist
+from typing import List
+import aiosqlite
+
+DBFILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),"database.db")
+
+class Database():
+
+    def __init__(self, event_loop, dbfile=DBFILE):
+        self.event_loop = event_loop
+        self.dbfile = dbfile
+        self.db_ready = False
+        event_loop.run_until_complete(self.__init_database())
+
+    async def __init_database(self):
+        ''' init database tables'''
+        async with aiosqlite.connect(self.dbfile) as db:
+
+            await db.execute('CREATE TABLE IF NOT EXISTS library_items(item_id INTEGER NOT NULL, provider TEXT NOT NULL, media_type INTEGER NOT NULL, UNIQUE(item_id, provider, media_type));')
+
+            await db.execute('CREATE TABLE IF NOT EXISTS artists(artist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sort_name TEXT, musicbrainz_id TEXT NOT NULL UNIQUE);')            
+            await db.execute('CREATE TABLE IF NOT EXISTS albums(album_id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL, name TEXT NOT NULL, albumtype TEXT, year INTEGER, version TEXT, UNIQUE(artist_id, name, version, albumtype));')
+            await db.execute('CREATE TABLE IF NOT EXISTS labels(label_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);')
+            await db.execute('CREATE TABLE IF NOT EXISTS album_labels(album_id INTEGER, label_id INTEGER, UNIQUE(album_id, label_id));')
+
+            await db.execute('CREATE TABLE IF NOT EXISTS tracks(track_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, album_id INTEGER, duration INTEGER, version TEXT, disc_number INT, track_number INT, UNIQUE(name, album_id, version));')
+            await db.execute('CREATE TABLE IF NOT EXISTS track_artists(track_id INTEGER, artist_id INTEGER, UNIQUE(track_id, artist_id));')
+            
+            await db.execute('CREATE TABLE IF NOT EXISTS tags(tag_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);')
+            await db.execute('CREATE TABLE IF NOT EXISTS media_tags(item_id INTEGER, media_type INTEGER, tag_id, UNIQUE(item_id, media_type, tag_id));')
+            
+            await db.execute('CREATE TABLE IF NOT EXISTS provider_mappings(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, prov_item_id TEXT NOT NULL, provider TEXT NOT NULL, quality INTEGER NOT NULL, details TEXT NULL, UNIQUE(item_id, media_type, prov_item_id, provider, quality));')
+            
+            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 playlist_tracks(playlist_id INTEGER NOT NULL, track_id INTEGER NOT NULL, position INTEGER, UNIQUE(playlist_id, track_id));')
+            
+            await db.commit()
+            await db.execute('VACUUM;')
+            self.db_ready = True
+
+    async def get_database_id(self, provider:str, prov_item_id:str, media_type:MediaType):
+        ''' get the database id for the given prov_id '''
+        async with aiosqlite.connect(self.dbfile) as db:
+            sql_query = 'SELECT item_id FROM provider_mappings WHERE prov_item_id = ? AND provider = ? AND media_type = ?;'
+            cursor = await db.execute(sql_query, (prov_item_id, provider, media_type))
+            item_id = await cursor.fetchone()
+            if item_id:
+                item_id = item_id[0]
+            await cursor.close()
+            return item_id
+    
+    async def search(self, searchquery, media_types:List[MediaType], limit=10):
+        ''' search library for the given searchphrase '''
+        result = {
+            "artists": [],
+            "albums": [],
+            "tracks": [],
+            "playlists": []
+        }
+        searchquery = "%" + searchquery + "%"
+        sql_query = ' WHERE name LIKE "%s"' % searchquery
+        if MediaType.Artist in media_types:
+            result["artists"] = await self.artists(sql_query, limit=limit)
+        if MediaType.Album in media_types:
+            result["albums"] = await self.albums(sql_query, limit=limit)
+        if MediaType.Track in media_types:
+            result["tracks"] = await self.tracks(sql_query, limit=limit)
+        if MediaType.Playlist in media_types:
+            result["playlists"] = await self.playlists(sql_query, limit=limit)
+        return result
+    
+    async def library_artists(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Artist]:
+        ''' get all library artists, optionally filtered by provider'''
+        if provider != None:
+            sql_query = ' WHERE artist_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Artist)
+        else:
+            sql_query = ' WHERE artist_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Artist
+        return await self.artists(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_albums(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Album]:
+        ''' get all library albums, optionally filtered by provider'''
+        if provider != None:
+            sql_query = ' WHERE album_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Album)
+        else:
+            sql_query = ' WHERE album_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Album
+        return await self.albums(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_tracks(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Track]:
+        ''' get all library tracks, optionally filtered by provider'''
+        if provider != None:
+            sql_query = ' WHERE track_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Track)
+        else:
+            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 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!) '''
+        item_id = try_parse_int(item_id)
+        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+            sql_query = 'INSERT or REPLACE INTO library_items (item_id, provider, media_type) VALUES(?,?,?);'
+            await db.execute(sql_query, (item_id, provider, media_type))
+            await db.commit()
+
+    async def remove_from_library(self, item_id:int, media_type:MediaType, provider:str):
+        ''' remove item from the library '''
+        item_id = try_parse_int(item_id)
+        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+            sql_query = 'DELETE FROM library_items WHERE item_id=? AND provider=? AND media_type=?;'
+            await db.execute(sql_query, (item_id, provider, media_type))
+            if media_type == MediaType.Playlist:
+                sql_query = 'DELETE FROM playlist_tracks WHERE playlist_id=?;'
+                await db.execute(sql_query, (item_id,))
+            await db.commit()
+    
+    async def artists(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Artist]:
+        ''' fetch artist records from table'''
+        artists = []
+        sql_query = 'SELECT * FROM artists'
+        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:
+                artist = Artist()
+                artist.item_id = db_row[0]
+                artist.name = db_row[1]
+                artist.sort_name = db_row[2]
+                artist.provider_ids = await self.__get_prov_ids(artist.item_id, MediaType.Artist, db)
+                artist.in_library = await self.__get_library_providers(artist.item_id, MediaType.Artist, db)
+                artist.external_ids = await self.__get_external_ids(artist.item_id, MediaType.Artist, db)
+                if fulldata:
+                    artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db)
+                    artist.tags = await self.__get_tags(artist.item_id, MediaType.Artist, db)
+                else:
+                    artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db, filter_key='image')
+                artists.append(artist)
+        return artists
+
+    async def artist(self, artist_id:int, fulldata=True) -> Artist:
+        ''' get artist record by id '''
+        artist_id = try_parse_int(artist_id)
+        artists = await self.artists('WHERE artist_id = %s' % artist_id, fulldata=fulldata)
+        if not artists:
+            return None
+        return artists[0]
+
+    async def add_artist(self, artist:Artist):
+        ''' add a new artist record into table'''
+        artist_id = None
+        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+            # always prefer to grab existing artist with external_id (=musicbrainz_id)
+            artist_id = await self.__get_item_by_external_id(artist, db)
+            if not artist_id:
+                # insert artist
+                musicbrainz_id = None
+                for item in artist.external_ids:
+                    if item.get('musicbrainz'):
+                        musicbrainz_id = item['musicbrainz']
+                        break
+                assert(musicbrainz_id) # musicbrainz id is required
+                if not artist.sort_name:
+                    artist.sort_name = get_sort_name(artist.name)
+                sql_query = 'INSERT OR IGNORE INTO artists (name, sort_name, musicbrainz_id) VALUES(?,?,?);'
+                await db.execute(sql_query, (artist.name, artist.sort_name, musicbrainz_id))
+                await db.commit()
+                # get id from (newly created) item (the safe way)
+                artist_id = await self.__get_item_by_external_id(artist, db)
+                if not artist_id:
+                    async with db.execute('SELECT (artist_id) FROM artists WHERE musicbrainz_id=?;', (musicbrainz_id,)) as cursor:
+                        artist_id = await cursor.fetchone()
+                        artist_id = artist_id[0]
+            # add metadata and tags etc.
+            await self.__add_prov_ids(artist_id, MediaType.Artist, artist.provider_ids, db)
+            await self.__add_metadata(artist_id, MediaType.Artist, artist.metadata, db)
+            await self.__add_tags(artist_id, MediaType.Artist, artist.tags, db)
+            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))
+        return artist_id
+    
+    async def albums(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Album]:
+        ''' fetch all album records from table'''
+        albums = []
+        sql_query = 'SELECT * FROM albums'
+        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:
+                album = Album()
+                album.item_id = db_row[0]
+                album.artist = await self.artist(db_row[1], fulldata=fulldata)
+                album.name = db_row[2]
+                album.albumtype = db_row[3]
+                album.year = db_row[4]
+                album.version = db_row[5]
+                album.provider_ids = await self.__get_prov_ids(album.item_id, MediaType.Album, db)
+                album.in_library = await self.__get_library_providers(album.item_id, MediaType.Album, db)
+                album.external_ids = await self.__get_external_ids(album.item_id, MediaType.Album, db)
+                if fulldata:
+                    album.metadata = await self.__get_metadata(album.item_id, MediaType.Album, db)
+                    album.tags = await self.__get_tags(album.item_id, MediaType.Album, db)
+                    album.labels = await self.__get_album_labels(album.item_id, db)
+                else:
+                    album.metadata = await self.__get_metadata(album.item_id, MediaType.Album, db, filter_key='image')
+                albums.append(album)
+        return albums
+
+    async def album(self, album_id:int, fulldata=True) -> Album:
+        ''' get album record by id '''
+        album_id = try_parse_int(album_id)
+        albums = await self.albums('WHERE album_id = %s' % album_id, fulldata=fulldata)
+        if not albums:
+            return None
+        return albums[0]
+
+    async def add_album(self, album:Album):
+        ''' add a new album record into table'''
+        album_id = None
+        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+            # always try to grab existing album with external_id
+            album_id = await self.__get_item_by_external_id(album, db)
+            # fallback to matching on artist_id, name and version
+            if not album_id:
+                async with db.execute('SELECT (album_id) FROM albums WHERE artist_id=? AND name=? AND version=?;', (album.artist.item_id, album.name, album.version)) as cursor:
+                    result = await cursor.fetchone()
+                    if result:
+                        album_id = result[0]
+            if not album_id and album.year:
+                async with db.execute('SELECT (album_id) FROM albums WHERE year=? AND name=? AND version=?;', (album.year, album.name, album.version)) as cursor:
+                    result = await cursor.fetchone()
+                    if result:
+                        album_id = result[0]
+            if not album_id:
+                # insert album
+                sql_query = 'INSERT OR IGNORE INTO albums (artist_id, name, albumtype, year, version) VALUES(?,?,?,?,?);'
+                await db.execute(sql_query, (album.artist.item_id, album.name, album.albumtype, album.year, album.version))
+                await db.commit()
+                # get id from newly created item
+                async with db.execute('SELECT (album_id) FROM albums WHERE artist_id=? AND name=? AND version=?;', (album.artist.item_id, album.name, album.version)) as cursor:
+                    album_id = await cursor.fetchone()
+                    assert(album_id)
+                    album_id = album_id[0]
+            # add metadata, artists and tags etc.
+            await self.__add_prov_ids(album_id, MediaType.Album, album.provider_ids, db)
+            await self.__add_metadata(album_id, MediaType.Album, album.metadata, db)
+            await self.__add_tags(album_id, MediaType.Album, album.tags, db)
+            await self.__add_album_labels(album_id, album.labels, db)
+            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))
+        return album_id
+
+    async def tracks(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Track]:
+        ''' fetch all track records from table'''
+        tracks = []
+        sql_query = 'SELECT * FROM tracks'
+        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:
+                track = Track()
+                track.item_id = db_row[0]
+                track.name = db_row[1]
+                track.album = await self.album(db_row[2], fulldata=fulldata)
+                track.duration = db_row[3]
+                track.version = db_row[4]
+                track.disc_number = db_row[5]
+                track.track_number = db_row[6]
+                track.metadata = await self.__get_metadata(track.item_id, MediaType.Track, db)
+                track.tags = await self.__get_tags(track.item_id, MediaType.Track, db)
+                track.provider_ids = await self.__get_prov_ids(track.item_id, MediaType.Track, db)
+                track.in_library = await self.__get_library_providers(track.item_id, MediaType.Track, db)
+                track.artists = await self.__get_track_artists(track.item_id, db, fulldata=fulldata)
+                track.external_ids = await self.__get_external_ids(track.item_id, MediaType.Track, db)
+                tracks.append(track)
+        return tracks
+
+    async def track(self, track_id:int, fulldata=True) -> Track:
+        ''' get track record by id '''
+        track_id = try_parse_int(track_id)
+        tracks = await self.tracks('WHERE track_id = %s' % track_id, fulldata=fulldata)
+        if not tracks:
+            return None
+        return tracks[0]
+
+    async def add_track(self, track:Track):
+        ''' add a new track record into table'''
+        assert(track.name and track.album)
+        track_id = None
+        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+            # always try to grab existing track with external_id
+            track_id = await self.__get_item_by_external_id(track, db)
+            # fallback to matching on album_id, name and version or track number
+            if not track_id and track.track_number:
+                async with db.execute('SELECT (track_id) FROM tracks WHERE album_id=? AND track_number=?;', (track.album.item_id, track.track_number)) as cursor:
+                    result = await cursor.fetchone()
+                    if result:
+                        track_id = result[0]
+            if not track_id:
+                async with db.execute('SELECT (track_id) FROM tracks WHERE album_id=? AND name=? AND version=?;', (track.album.item_id, track.name, track.version)) as cursor:
+                    result = await cursor.fetchone()
+                    if result:
+                        track_id = result[0]
+            if not track_id:
+                # insert track
+                assert(track.name and track.album.item_id)
+                sql_query = 'INSERT OR IGNORE INTO tracks (name, album_id, duration, version, disc_number, track_number) VALUES(?,?,?,?,?,?);'
+                await db.execute(sql_query, (track.name, track.album.item_id, track.duration, track.version, track.disc_number, track.track_number))
+                await db.commit()
+                # get id from newly created item (the safe way)
+                async with db.execute('SELECT (track_id) FROM tracks WHERE name=? AND album_id=? AND version=?;', (track.name, track.album.item_id, track.version)) as cursor:
+                    track_id = await cursor.fetchone()
+                    assert(track_id)
+                    track_id = track_id[0]
+            # add track artists
+            for artist in track.artists:
+                sql_query = 'INSERT or IGNORE INTO track_artists (track_id, artist_id) VALUES(?,?);'
+                await db.execute(sql_query, (track_id, artist.item_id))
+            # add metadata, tags and artists etc.
+            await self.__add_prov_ids(track_id, MediaType.Track, track.provider_ids, db)
+            await self.__add_metadata(track_id, MediaType.Track, track.metadata, db)
+            await self.__add_tags(track_id, MediaType.Track, track.tags, db)
+            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))
+        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)
+        sql_query = ' WHERE track_id in (SELECT track_id FROM track_artists WHERE artist_id = %d)' % artist_id
+        return await self.tracks(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+    async def artist_albums(self, artist_id, limit=1000000, offset=0, orderby='name') -> List[Album]:
+        ''' get all library albums for the given artist '''
+        sql_query = ' WHERE artist_id = %d' % artist_id
+        return await self.albums(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+    async def playlist_tracks(self, playlist_id:int, limit=100000, offset=0, orderby='position', fulldata=False) -> List[Track]:
+        ''' get playlist tracks for the given playlist_id '''
+        playlist_id = try_parse_int(playlist_id)
+        playlist_tracks = []
+        sql_query = 'SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? 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, (playlist_id,)) as cursor:
+                db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            playlist_track = await self.track(db_row[0], fulldata=fulldata)
+            playlist_track.position = db_row[1]
+            playlist_tracks.append(playlist_track)
+        return playlist_tracks
+
+    async def add_playlist_track(self, playlist_id:int, track_id, position):
+        ''' add playlist track to playlist '''
+        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+            sql_query = 'INSERT or IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES(?,?,?);'
+            await db.execute(sql_query, (playlist_id, track_id, position))
+            await db.commit()
+
+    async def remove_playlist_track(self, playlist_id:int, track_id):
+        ''' remove playlist track from playlist '''
+        async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+            sql_query = 'DELETE FROM playlist_tracks WHERE playlist_id=? AND track_id=?;'
+            await db.execute(sql_query, (playlist_id, track_id))
+            await db.commit()
+            
+    async def __add_metadata(self, item_id, media_type, metadata, db):
+        ''' add or update metadata'''
+        for key, value in metadata.items():
+            if value:
+                sql_query = 'INSERT or REPLACE INTO metadata (item_id, media_type, key, value) VALUES(?,?,?,?);'
+                await db.execute(sql_query, (item_id, media_type, key, value))
+
+    async def __get_metadata(self, item_id, media_type, db, filter_key=None):
+        ''' get metadata for media item '''
+        metadata = {}
+        sql_query = 'SELECT key, value FROM metadata WHERE item_id = ? AND media_type = ?'
+        if filter_key:
+            sql_query += ' AND key = "%s"' % filter_key
+        async with db.execute(sql_query, (item_id, media_type)) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            key = db_row[0]
+            value = db_row[1]
+            metadata[key] = value
+        return metadata
+
+    async def __add_tags(self, item_id, media_type, tags, db):
+        ''' add tags to db '''
+        for tag in tags:
+            sql_query = 'INSERT or IGNORE INTO tags (name) VALUES(?);'
+            async with db.execute(sql_query, (tag,)) as cursor:
+                tag_id = cursor.lastrowid
+            sql_query = 'INSERT or IGNORE INTO media_tags (item_id, media_type, tag_id) VALUES(?,?,?);'
+            await db.execute(sql_query, (item_id, media_type, tag_id))
+
+    async def __get_tags(self, item_id, media_type, db):
+        ''' get tags for media item '''
+        tags = []
+        sql_query = 'SELECT name FROM tags INNER JOIN media_tags on tags.tag_id = media_tags.tag_id WHERE item_id = ? AND media_type = ?'
+        async with db.execute(sql_query, (item_id, media_type)) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            tags.append(db_row[0])
+        return tags
+    
+    async def __add_album_labels(self, album_id, labels, db):
+        ''' add labels to album in db '''
+        for label in labels:
+            sql_query = 'INSERT or IGNORE INTO labels (name) VALUES(?);'
+            async with db.execute(sql_query, (label,)) as cursor:
+                label_id = cursor.lastrowid
+            sql_query = 'INSERT or IGNORE INTO album_labels (album_id, label_id) VALUES(?,?);'
+            await db.execute(sql_query, (album_id, label_id))
+
+    async def __get_album_labels(self, album_id, db):
+        ''' get labels for album item '''
+        labels = []
+        sql_query = 'SELECT name FROM labels INNER JOIN album_labels on labels.label_id = album_labels.label_id WHERE album_id = ?'
+        async with db.execute(sql_query, (album_id,)) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            labels.append(db_row[0])
+        return labels
+    
+    async def __get_track_artists(self, track_id, db, fulldata=False) -> List[Artist]:
+        ''' get artists for track '''
+        artists = []
+        sql_query = 'SELECT artist_id FROM track_artists WHERE track_id = ?'
+        async with db.execute(sql_query, (track_id,)) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            artist = await self.artist(db_row[0], fulldata=fulldata)
+            artists.append(artist)
+        return artists
+    
+    async def __add_external_ids(self, item_id, media_type, external_ids, db):
+        ''' add or update external_ids'''
+        for external_id in external_ids:
+            for key, value in external_id.items():
+                sql_query = 'INSERT or REPLACE INTO external_ids (item_id, media_type, key, value) VALUES(?,?,?,?);'
+                await db.execute(sql_query, (item_id, media_type, key, value))
+
+    async def __get_external_ids(self, item_id, media_type, db):
+        ''' get external_ids for media item '''
+        external_ids = []
+        sql_query = 'SELECT key, value FROM external_ids WHERE item_id = ? AND media_type = ?'
+        async with db.execute(sql_query, (item_id, media_type)) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            external_id = {
+                db_row[0]: db_row[1]
+            }
+            external_ids.append(external_id)
+        return external_ids
+
+    async def __add_prov_ids(self, item_id, media_type, provider_ids, db):
+        ''' add provider ids for media item to db '''
+        for prov_mapping in provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            quality = prov_mapping.get('quality',0)
+            details = prov_mapping.get('details','')
+            sql_query = 'INSERT OR REPLACE INTO provider_mappings (item_id, media_type, prov_item_id, provider, quality, details) VALUES(?,?,?,?,?,?);'
+            await db.execute(sql_query, (item_id, media_type, prov_item_id, prov_id, quality, details))
+
+    async def __get_prov_ids(self, item_id, media_type:MediaType, db):
+        ''' get all provider_ids for media item '''
+        provider_ids = []
+        sql_query = 'SELECT prov_item_id, provider, quality, details \
+            FROM provider_mappings \
+            WHERE item_id = ? AND media_type = ?'
+        async with db.execute(sql_query, (item_id, media_type)) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            prov_mapping = {
+                "provider": db_row[1],
+                "item_id": db_row[0],
+                "quality": db_row[2],
+                "details": db_row[3]
+            }
+            provider_ids.append(prov_mapping)
+        return provider_ids
+
+    async def __get_library_providers(self, item_id, media_type:MediaType, db):
+        ''' get the providers that have this media_item added to the library '''
+        providers = []
+        sql_query = 'SELECT provider FROM library_items WHERE item_id = ? AND media_type = ?'
+        async with db.execute(sql_query, (item_id, media_type)) as cursor:
+            db_rows = await cursor.fetchall()
+        for db_row in db_rows:
+            providers.append( db_row[0] )
+        return providers
+
+    async def __get_item_by_external_id(self, media_item, db):
+        ''' try to get existing item in db by matching the new item's external id's '''
+        item_id = None
+        for external_id in media_item.external_ids:
+            if item_id:
+                break
+            for key, value in external_id.items():
+                async with db.execute('SELECT (item_id) FROM external_ids WHERE media_type=? AND key=? AND value=?;', (media_item.media_type, key, value)) as cursor:
+                    result = await cursor.fetchone()
+                    if result:
+                        item_id = result[0]
+                        break
+                if item_id:
+                    break
+        return item_id
\ No newline at end of file
diff --git a/music_assistant/main.py b/music_assistant/main.py
new file mode 100755 (executable)
index 0000000..70bcda0
--- /dev/null
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import sys
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+import re
+import uvloop
+import logging
+import os
+import shutil
+import slugify as unicode_slug
+import uuid
+import json
+import pkgutil
+import time
+
+from database import Database
+from metadata import MetaData
+from api import Api
+from utils import run_periodic, LOGGER
+from cache import Cache
+from music import Music
+from player import Player
+
+class Main():
+
+    def __init__(self, datapath):
+        uvloop.install()
+        self._datapath = datapath
+        self.parse_config()
+        self.event_loop = asyncio.get_event_loop()
+        self.bg_executor = ThreadPoolExecutor(max_workers=5)
+        self.event_listeners = {}
+
+        import signal
+        signal.signal(signal.SIGINT, self.stop)
+        signal.signal(signal.SIGTERM, self.stop)
+
+        # init database and metadata modules
+        self.db = Database(self.event_loop)
+        # allow some time for the database to initialize
+        while not self.db.db_ready:
+            time.sleep(0.5) 
+        self.cache = Cache()
+        self.metadata = MetaData(self.event_loop, self.db, self.cache)
+        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():
+            await listener(msg, msg_details)
+
+    async def add_event_listener(self, cb):
+        ''' add callback to our event listeners '''
+        cb_id = str(uuid.uuid4())
+        self.event_listeners[cb_id] = cb
+
+    async def remove_event_listener(self, cb_id):
+        ''' add callback to our event listeners '''
+        self.event_listeners.pop(cb_id, None)
+
+    def save_config(self):
+        ''' save config to file '''
+        # backup existing file
+        conf_file = os.path.join(self._datapath, 'config.json')
+        conf_file_backup = os.path.join(self._datapath, 'config.json')
+        if os.path.isfile(conf_file):
+            shutil.move(conf_file, conf_file_backup)
+        with open(conf_file, 'w') as f:
+            f.write(json.dumps(self.config, indent=4))
+        
+    def parse_config(self):
+        '''get config from config file'''
+        config = {
+            "musicproviders": {},
+            "playerproviders": {},
+            "player_settings": 
+                {
+                    "__desc__":
+                    [
+                        ("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")
+                    ]
+                }
+            }
+        conf_file = os.path.join(self._datapath, 'config.json')
+        if os.path.isfile(conf_file):
+            with open(conf_file) as f:
+                data = f.read()
+                stored_config = json.loads(data)
+                for key in config.keys():
+                    if stored_config.get(key):
+                        config[key].update(stored_config[key])
+        self.config = config
+
+    def stop(self, signum=None, frame=None):
+        ''' properly close all connections'''
+        print('stop requested!')
+        self.save_config()
+        self.api.stop()
+        print('stopping event loop...')
+        self.event_loop.stop()
+        self.event_loop.close()
+
+if __name__ == "__main__":
+    datapath = sys.argv[1:]
+    if not datapath:
+        datapath = os.path.dirname(os.path.abspath(__file__))
+    Main(datapath)
+    
\ No newline at end of file
diff --git a/music_assistant/metadata.py b/music_assistant/metadata.py
new file mode 100755 (executable)
index 0000000..e3e3a35
--- /dev/null
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from utils import run_periodic, LOGGER
+import json
+import aiohttp
+from asyncio_throttle import Throttler
+from difflib import SequenceMatcher as Matcher
+from cache import use_cache
+from yarl import URL
+import re
+
+LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
+
+class MetaData():
+    ''' several helpers to search and store mediadata for mediaitems '''
+    
+    def __init__(self, event_loop, db, cache):
+        self.event_loop = event_loop
+        self.db = db
+        self.cache = cache
+        self.musicbrainz = MusicBrainz(event_loop, cache)
+        self.fanarttv = FanartTv(event_loop, cache)
+
+    async def get_artist_metadata(self, mb_artist_id, cur_metadata):
+        ''' get/update rich metadata for an artist by providing the musicbrainz artist id '''
+        metadata = cur_metadata
+        if not ('fanart' in metadata or 'thumb' in metadata):
+            res = await self.fanarttv.artist_images(mb_artist_id)
+            self.merge_metadata(cur_metadata, res)
+        return metadata
+
+    async def get_mb_artist_id(self, artistname, albumname=None, album_upc=None, trackname=None, track_isrc=None):
+        ''' retrieve musicbrainz artist id for the given details '''
+        LOGGER.debug('searching musicbrainz for %s (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)' %(artistname, albumname, album_upc, trackname, track_isrc))
+        mb_artist_id = None
+        if album_upc:
+            mb_artist_id = await self.musicbrainz.search_artist_by_album(artistname, None, album_upc)
+        if not mb_artist_id and track_isrc:
+            mb_artist_id = await self.musicbrainz.search_artist_by_track(artistname, None, track_isrc)
+        if not mb_artist_id and albumname:
+            mb_artist_id = await self.musicbrainz.search_artist_by_album(artistname, albumname)
+        if not mb_artist_id and trackname:
+            mb_artist_id = await self.musicbrainz.search_artist_by_track(artistname, trackname)
+        LOGGER.debug('Got musicbrainz artist id for artist %s --> %s' %(artistname, mb_artist_id))
+        return mb_artist_id
+
+    @staticmethod
+    def merge_metadata(cur_metadata, new_values):
+        ''' merge new info into the metadata dict without overwiteing existing values '''
+        for key, value in new_values.items():
+            if not cur_metadata.get(key):
+                cur_metadata[key] = value
+        return cur_metadata
+
+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.throttler = Throttler(rate_limit=1, period=1)
+
+    async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
+        ''' retrieve musicbrainz artist id by providing the artist name and albumname or upc '''
+        if album_upc:
+            endpoint = 'release'
+            params = {'query': 'barcode:%s' % album_upc}
+        else:
+            searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname)
+            searchartist = searchartist.replace('/','').replace('\\','')
+            searchalbum = re.sub(LUCENE_SPECIAL, r'\\\1', albumname)
+            endpoint = 'release'
+            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 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():
+                                return artist['id']
+        return ''
+
+    async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
+        ''' retrieve artist id by providing the artist name and trackname or track isrc '''
+        endpoint = 'recording'
+        searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname)
+        searchartist = searchartist.replace('/','').replace('\\','')
+        if track_isrc:
+            endpoint = 'isrc/%s' % track_isrc
+            params = {'inc': 'artist-credits'}
+        else:
+            searchtrack = re.sub(LUCENE_SPECIAL, r'\\\1', trackname)
+            endpoint = 'recording'
+            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 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():
+                                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
+        headers = {'User-Agent': 'Music Assistant/1.0.0 https://github.com/marcelveldt'}
+        params['fmt'] = 'json'
+        async with self.throttler:
+            async with self.http_session.get(url, headers=headers, params=params) as response:
+                try:
+                    result = await response.json()
+                except Exception as exc:
+                    msg = await response.text()
+                    LOGGER.exception("%s - %s" % (str(exc), msg))
+                    result = None
+                return result
+
+
+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)
+
+    async def artist_images(self, mb_artist_id):
+        ''' retrieve images by musicbrainz artist id '''
+        metadata = {}
+        data = await self.get_data("music/%s" % mb_artist_id)
+        if data:
+            if data.get('hdmusiclogo'):
+                metadata['logo'] = data['hdmusiclogo'][0]["url"]
+            elif data.get('musiclogo'):
+                metadata['logo'] = data['musiclogo'][0]["url"]
+            if data.get('artistbackground'):
+                count = 0
+                for item in data['artistbackground']:
+                    key = "fanart" if count == 0 else "fanart.%s" % count
+                    metadata[key] = item["url"]
+            if data.get('artistthumb'):
+                url = data['artistthumb'][0]["url"]
+                if not '2a96cbd8b46e442fc41c2b86b821562f' in url:
+                    metadata['image'] = url
+            if data.get('musicbanner'):
+                metadata['banner'] = data['musicbanner'][0]["url"]
+        return metadata
+
+    @use_cache(30)
+    async def get_data(self, endpoint, params={}):
+        ''' get data from api'''
+        url = 'http://webservice.fanart.tv/v3/%s' % endpoint
+        params['api_key'] = '639191cb0774661597f28a47e7e2bad5'
+        async with self.throttler:
+            async with self.http_session.get(url, params=params) as response:
+                result = await response.json()
+                if 'error' in result and 'limit' in result['error']:
+                    raise Exception(result['error'])
+                return result
diff --git a/music_assistant/models.py b/music_assistant/models.py
new file mode 100755 (executable)
index 0000000..e40f87e
--- /dev/null
@@ -0,0 +1,528 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+from enum import Enum, IntEnum
+from typing import List
+import sys
+sys.path.append("..")
+from utils import run_periodic, LOGGER, parse_track_title
+from difflib import SequenceMatcher as Matcher
+import asyncio
+from cache import use_cache
+
+
+class MediaType(IntEnum):
+    Artist = 1
+    Album = 2
+    Track = 3
+    Playlist = 4
+
+def media_type_from_string(media_type_str):
+    media_type_str = media_type_str.lower()
+    if 'artist' in media_type_str or media_type_str == '1':
+        return MediaType.Artist
+    elif 'album' in media_type_str or media_type_str == '2':
+        return MediaType.Album
+    elif 'track' in media_type_str or media_type_str == '3':
+        return MediaType.Track
+    elif 'playlist' in media_type_str or media_type_str == '4':
+        return MediaType.Playlist
+    else:
+        return None
+
+class ContributorRole(IntEnum):
+    Artist = 1
+    Writer = 2
+    Producer = 3
+
+class AlbumType(IntEnum):
+    Album = 1
+    Single = 2
+    Compilation = 3
+
+class TrackQuality(IntEnum):
+    LOSSY_MP3 = 0
+    LOSSY_OGG = 1
+    LOSSY_AAC = 2
+    FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES
+    FLAC_LOSSLES_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES
+
+
+class Artist(object):
+    ''' representation of an artist '''
+    def __init__(self):
+        self.item_id = None
+        self.name = ''
+        self.sort_name = ''
+        self.metadata = {}
+        self.tags = []
+        self.external_ids = []
+        self.provider_ids = []
+        self.media_type = MediaType.Artist
+        self.in_library = []
+        self.is_lazy = False
+
+class Album(object):
+    ''' representation of an album '''
+    def __init__(self):
+        self.item_id = None
+        self.name = '' 
+        self.metadata = {}
+        self.version = ''
+        self.external_ids = []
+        self.tags = []
+        self.albumtype = AlbumType.Album
+        self.year = 0
+        self.artist = None
+        self.labels = []
+        self.provider_ids = []
+        self.media_type = MediaType.Album
+        self.in_library = []
+        self.is_lazy = False
+
+class Track(object):
+    ''' representation of a track '''
+    def __init__(self):
+        self.item_id = None
+        self.name = ''
+        self.duration = 0
+        self.version = ''
+        self.external_ids = []
+        self.metadata = { }
+        self.tags = []
+        self.artists = []
+        self.provider_ids = []
+        self.album = None
+        self.disc_number = 0
+        self.track_number = 0
+        self.media_type = MediaType.Track
+        self.in_library = []
+        self.is_lazy = False
+    def __eq__(self, other): 
+        if not isinstance(other, self.__class__):
+            return NotImplemented
+        return (self.name == other.name and 
+                self.version == other.version and
+                self.item_id == other.item_id)
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+class Playlist(object):
+    ''' representation of a playlist '''
+    def __init__(self):
+        self.item_id = None
+        self.name = ''
+        self.owner = ''
+        self.provider_ids = []
+        self.metadata = {}
+        self.media_type = MediaType.Playlist
+        self.in_library = []
+
+
+class MusicProvider():
+    ''' 
+        Model for a Musicprovider
+        Common methods usable for every provider
+        Provider specific get methods shoud be overriden in the provider specific implementation
+        Uses a form of lazy provisioning to local db as cache
+    '''
+
+    name = 'My great Music provider' # display name
+    prov_id = 'my_provider' # used as id
+    icon = ''
+
+    def __init__(self, mass):
+        self.mass = mass
+        self.cache = mass.cache
+
+    ### Common methods and properties ####
+
+    async def artist(self, prov_item_id, lazy=True) -> Artist:
+        ''' return artist details for the given provider artist id '''
+        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Artist)
+        if not item_id:
+            # artist not yet in local database so fetch details
+            artist_details = await self.get_artist(prov_item_id)
+            if not artist_details:
+                LOGGER.warning('artist not found: %s' % prov_item_id)
+                return None
+            if lazy:
+                asyncio.create_task(self.add_artist(artist_details))
+                artist_details.is_lazy = True
+                return artist_details
+            item_id = await self.add_artist(artist_details)
+        return await self.mass.db.artist(item_id)
+
+    async def add_artist(self, artist_details, skip_match=False) -> 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)
+        if not musicbrainz_id:
+            return
+        # grab additional metadata
+        if musicbrainz_id:
+            artist_details.external_ids.append({"musicbrainz": musicbrainz_id})
+            artist_details.metadata = await self.mass.metadata.get_artist_metadata(musicbrainz_id, artist_details.metadata)
+        item_id = await self.mass.db.add_artist(artist_details)
+        # also fetch same artist on all providers
+        if not skip_match:
+            new_artist = await self.mass.db.artist(item_id)
+            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)
+        return item_id
+
+    async def get_artist_musicbrainz_id(self, artist_details:Artist, allow_fallback=False):
+        ''' 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]:
+            lookup_album_upc = None
+            for item in lookup_album.external_ids:
+                if item.get("upc"):
+                    lookup_album_upc = item["upc"]
+                    break
+            musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name, 
+                    albumname=lookup_album.name, album_upc=lookup_album_upc)
+            if musicbrainz_id:
+                break
+        # fallback to track
+        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"]
+                    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
+        return musicbrainz_id
+
+    async def album(self, prov_item_id, lazy=True) -> Album:
+        ''' return album details for the given provider album id'''
+        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Album)
+        if not item_id:
+            # album not yet in local database so fetch details
+            album_details = await self.get_album(prov_item_id)
+            if not album_details:
+                LOGGER.warning('album not found: %s' % prov_item_id)
+                return album_details
+            if lazy:
+                asyncio.create_task(self.add_album(album_details))
+                album_details.is_lazy = True
+                return album_details
+            item_id = await self.add_album(album_details)
+        return await self.mass.db.album(item_id)
+
+    async def add_album(self, album_details, skip_match=False) -> 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)
+        return item_id
+
+    async def track(self, prov_item_id, lazy=True, track_details=None) -> Track:
+        ''' return track details for the given provider track id '''
+        item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Track)
+        if not item_id:
+            # album not yet in local database so fetch details
+            if not track_details:
+                track_details = await self.get_track(prov_item_id)
+            if not track_details:
+                LOGGER.warning('track not found: %s' % prov_item_id)
+                return None
+            if lazy:
+                asyncio.ensure_future(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:
+        ''' 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)
+        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)
+        return item_id
+    
+    async def playlist(self, prov_item_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)
+        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
+
+    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)
+        return items
+
+    async def playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]:
+        ''' return playlist tracks for the given provider playlist id'''
+        items = []
+        for prov_track in await self.get_playlist_tracks(prov_playlist_id, limit=limit, offset=offset):
+            for prov_mapping in prov_track.provider_ids:
+                item_prov_id = prov_mapping["provider"]
+                prov_item_id = prov_mapping["item_id"]
+                db_id = await self.mass.db.get_database_id(item_prov_id, prov_item_id, MediaType.Track) 
+                if db_id:
+                    items.append( await self.mass.db.track(db_id) )
+                else:
+                    items.append(prov_track)
+                    asyncio.create_task(self.add_track(prov_track))
+        return items
+    
+    async def artist_toptracks(self, prov_item_id) -> List[Track]:
+        ''' return top tracks for an artist '''
+        items = []
+        for prov_track in await self.get_artist_toptracks(prov_item_id):
+            db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track) 
+            if db_id:
+                items.append( await self.mass.db.track(db_id) )
+            else:
+                items.append(prov_track)
+                asyncio.create_task(self.add_track(prov_track))
+        return items
+
+    async def artist_albums(self, prov_item_id) -> List[Track]:
+        ''' return (all) albums for an artist '''
+        items = []
+        for prov_album in await self.get_artist_albums(prov_item_id):
+            db_id = await self.mass.db.get_database_id(self.prov_id, prov_album.item_id, MediaType.Album) 
+            if db_id:
+                items.append( await self.mass.db.album(db_id) )
+            else:
+                items.append(prov_album)
+                asyncio.create_task(self.add_album(prov_album))
+        return items
+    
+    async def match_artist(self, searchartist:Artist):
+        ''' 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))
+
+    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))
+
+    async def match_track(self, searchtrack:Album):
+        ''' 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)
+        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))
+
+    ### Provider specific implementation #####
+
+    async def search(self, searchstring, media_types=List[MediaType], limit=5):
+        ''' perform search on the provider '''
+        raise NotImplementedError
+    
+    async def get_library_artists(self) -> List[Artist]:
+        ''' retrieve library artists from the provider '''
+        raise NotImplementedError
+    
+    async def get_library_albums(self) -> List[Album]:
+        ''' retrieve library albums from the provider '''
+        raise NotImplementedError
+
+    async def get_library_tracks(self) -> List[Track]:
+        ''' retrieve library tracks from the provider '''
+        raise NotImplementedError
+
+    async def get_library_playlists(self) -> List[Playlist]:
+        ''' retrieve library/subscribed playlists from the provider '''
+        raise NotImplementedError
+
+    async def get_artist(self, prov_item_id) -> Artist:
+        ''' get full artist details by id '''
+        raise NotImplementedError
+
+    async def get_artist_albums(self, prov_item_id) -> List[Album]:
+        ''' get a list of albums for the given artist '''
+        raise NotImplementedError
+    
+    async def get_artist_toptracks(self, prov_item_id) -> List[Track]:
+        ''' get a list of most popular tracks for the given artist '''
+        raise NotImplementedError
+
+    async def get_album(self, prov_item_id) -> Album:
+        ''' get full album details by id '''
+        raise NotImplementedError
+
+    async def get_track(self, prov_item_id) -> Track:
+        ''' get full track details by id '''
+        raise NotImplementedError
+
+    async def get_playlist(self, prov_item_id) -> Playlist:
+        ''' get full playlist details by id '''
+        raise NotImplementedError
+
+    async def get_album_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]:
+        ''' get album tracks for given album id '''
+        raise NotImplementedError
+
+    async def get_playlist_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]:
+        ''' get playlist tracks for given playlist id '''
+        raise NotImplementedError
+
+    async def add_library(self, prov_item_id, media_type:MediaType):
+        ''' add item to library '''
+        raise NotImplementedError
+
+    async def remove_library(self, prov_item_id, media_type:MediaType):
+        ''' remove item from library '''
+        raise NotImplementedError
+    
+class PlayerState(str, Enum):
+    Off = "off"
+    Stopped = "stopped"
+    Paused = "paused"
+    Playing = "playing"
+
+class MusicPlayer():
+    ''' representation of a musicplayer '''
+    def __init__(self):
+        self.player_id = None
+        self.player_provider = None
+        self.name = ''
+        self.state = PlayerState.Off
+        self.powered = False
+        self.cur_item = Track()
+        self.cur_item_time = 0
+        self.volume_level = 0
+        self.shuffle_enabled = False
+        self.repeat_enabled = False
+        self.muted = False
+        self.group_parent = None # set to id of REAL group/parent player
+        self.is_group = False # is this player a group player ?
+        self.disable_volume = False
+        self.mute_as_power = False
+        self.apply_group_volume = False
+        self.enabled = False
+
+class PlayerProvider():
+    ''' 
+        Model for a Playerprovider
+        Common methods usable for every provider
+        Provider specific __get methods shoud be overriden in the provider specific implementation
+    '''
+    name = 'My great Musicplayer provider' # display name
+    prov_id = 'my_provider' # used as id
+    icon = ''
+    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])
+        ]
+    
+    def __init__(self, mass):
+        self.mass = mass
+
+    ### Common methods and properties ####
+
+
+    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
+        '''
+        raise NotImplementedError
+
+
+    ### Provider specific implementation #####
+
+    async def player_command(self, player_id, cmd:str, cmd_args=None):
+        ''' issue command on player (play, pause, next, previous, stop, power, volume) '''
+        raise NotImplementedError
+
+    async def player_queue(self, player_id, offset=0, limit=50):
+        ''' return the items in the player's queue '''
+        raise NotImplementedError
+
+
diff --git a/music_assistant/modules/__init__.py b/music_assistant/modules/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/music_assistant/modules/musicproviders/__init__.py b/music_assistant/modules/musicproviders/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/music_assistant/modules/musicproviders/file.py b/music_assistant/modules/musicproviders/file.py
new file mode 100644 (file)
index 0000000..149ff17
--- /dev/null
@@ -0,0 +1,328 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import sys
+import time
+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_ENABLED
+import taglib
+from cache import use_cache
+
+
+def setup(mass):
+    ''' setup the provider'''
+    enabled = mass.config["musicproviders"]['file'].get(CONF_ENABLED)
+    music_dir = mass.config["musicproviders"]['file'].get('music_dir')
+    playlists_dir = mass.config["musicproviders"]['file'].get('playlists_dir')
+    if enabled and (music_dir or playlists_dir):
+        file_provider = FileProvider(mass, music_dir, playlists_dir)
+        return file_provider
+    return False
+
+def config_entries():
+    ''' get the config entries for this provider (list with key/value pairs)'''
+    return [
+        (CONF_ENABLED, False, CONF_ENABLED),
+        ("music_dir", "", "Path to music files"), 
+        ("playlists_dir", "", "Path to playlists")
+        ]
+
+class FileProvider(MusicProvider):
+    ''' 
+        Very basic implementation of a musicprovider for local files
+        Assumes files are stored on disk in format <artist>/<album>/<track.ext>
+        Reads ID3 tags from file and falls back to parsing filename
+        Supports m3u files only for playlists
+        Supports having URI's from streaming providers within m3u playlist
+        Should be compatible with LMS
+    '''
+    
+
+    def __init__(self, mass, music_dir, playlists_dir):
+        self.name = 'Local files and playlists'
+        self.prov_id = 'file'
+        self.mass = mass
+        self.cache = mass.cache
+        self._music_dir = music_dir
+        self._playlists_dir = playlists_dir
+
+    async def search(self, searchstring, media_types=List[MediaType], limit=5):
+        ''' perform search on the provider '''
+        result = {
+            "artists": [],
+            "albums": [],
+            "tracks": [],
+            "playlists": []
+        }
+        return result
+    
+    async def get_library_artists(self) -> List[Artist]:
+        ''' get artist folders in music directory '''
+        if not os.path.isdir(self._music_dir):
+            LOGGER.error("music path does not exist: %s" % self._music_dir)
+            return []
+        result = []
+        for dirname in os.listdir(self._music_dir):
+            dirpath = os.path.join(self._music_dir, dirname)
+            if os.path.isdir(dirpath) and not dirpath.startswith('.'):
+                artist = await self.get_artist(dirpath)
+                if artist:
+                    result.append(artist)
+        return result
+    
+    async def get_library_albums(self) -> List[Album]:
+        ''' get album folders recursively '''
+        result = []
+        for artist in await self.get_library_artists():
+            result += await self.get_artist_albums(artist.item_id)
+        return result
+
+    async def get_library_tracks(self) -> List[Track]:
+        ''' get all tracks recursively '''
+        #TODO: support disk subfolders
+        result = []
+        for album in await self.get_library_albums():
+            result += await self.get_album_tracks(album.item_id)
+        return result
+    
+    async def get_library_playlists(self) -> List[Playlist]:
+        ''' retrieve playlists from disk '''
+        if not self._playlists_dir:
+            return []
+        result = []
+        for filename in os.listdir(self._playlists_dir):
+            filepath = os.path.join(self._playlists_dir, filename)
+            if os.path.isfile(filepath) and not filename.startswith('.') and filename.lower().endswith('.m3u'):
+                playlist = await self.get_playlist(filepath)
+                if playlist:
+                    result.append(playlist)
+        return result 
+
+    async def get_artist(self, prov_item_id) -> Artist:
+        ''' get full artist details by id '''
+        if not os.path.isdir(prov_item_id):
+            LOGGER.error("artist path does not exist: %s" % prov_item_id)
+            return None
+        if "\\" in prov_item_id:
+            name = prov_item_id.split("\\")[-1]
+        else:
+            name = prov_item_id.split("/")[-1]
+        artist = Artist()
+        artist.item_id = prov_item_id # temporary id
+        artist.name = name
+        artist.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": prov_item_id
+        })
+        return artist
+        
+    async def get_album(self, prov_item_id) -> Album:
+        ''' get full album details by id '''
+        if not os.path.isdir(prov_item_id):
+            LOGGER.error("album path does not exist: %s" % prov_item_id)
+            return None
+        if "\\" in prov_item_id:
+            name = prov_item_id.split("\\")[-1]
+            artistpath = prov_item_id.rsplit("\\", 1)[0]
+        else:
+            name = prov_item_id.split("/")[-1]
+            artistpath = prov_item_id.rsplit("/", 1)[0]
+        album = Album()
+        album.item_id = prov_item_id # temporary id
+        album.name, album.version = parse_track_title(name)
+        album.artist = await self.get_artist(artistpath)
+        if not album.artist:
+            raise Exception("No album artist ! %s" % artistpath)
+        album.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": prov_item_id
+        })
+        return album
+
+    async def get_track(self, prov_item_id) -> Track:
+        ''' get full track details by id '''
+        if not os.path.isfile(prov_item_id):
+            LOGGER.error("track path does not exist: %s" % prov_item_id)
+            return None
+        return await self.__parse_track(prov_item_id)
+
+    async def get_playlist(self, prov_item_id) -> Playlist:
+        ''' get full playlist details by id '''
+        if not os.path.isfile(prov_item_id):
+            LOGGER.error("playlist path does not exist: %s" % prov_item_id)
+            return None
+        filepath = prov_item_id
+        playlist = Playlist()
+        playlist.item_id = filepath # temporary id
+        playlist.name = filepath.split('\\')[-1].split('/')[-1].replace('.m3u', '')
+        playlist.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": filepath
+        })
+        playlist.owner = 'disk'
+        return playlist
+    
+    async def get_album_tracks(self, prov_album_id) -> List[Track]:
+        ''' get album tracks for given album id '''
+        result = []
+        albumpath = prov_album_id
+        if not os.path.isdir(albumpath):
+            LOGGER.error("album path does not exist: %s" % albumpath)
+            return []
+        album = await self.get_album(albumpath)
+        for filename in os.listdir(albumpath):
+            filepath = os.path.join(albumpath, filename)
+            if os.path.isfile(filepath) and not filepath.startswith('.'):
+                track = await self.__parse_track(filepath)
+                if track:
+                    track.album = album
+                    result.append(track)
+        return result
+
+    async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]:
+        ''' get playlist tracks for given playlist id '''
+        tracks = []
+        if not os.path.isfile(prov_playlist_id):
+            LOGGER.error("playlist path does not exist: %s" % prov_playlist_id)
+            return []
+        counter = 0
+        with open(prov_playlist_id) as f:
+            for line in f.readlines():
+                line = line.strip()
+                if line and not line.startswith('#'):
+                    counter += 1
+                    if counter > offset:
+                        track = await self.__parse_track_from_uri(line)
+                        if track:
+                            tracks.append(track)
+                    if len(tracks) == limit:
+                        break
+        return tracks
+
+    async def get_artist_albums(self, prov_artist_id) -> List[Album]:
+        ''' get a list of albums for the given artist '''
+        result = []
+        artistpath = prov_artist_id
+        if not os.path.isdir(artistpath):
+            LOGGER.error("artist path does not exist: %s" % artistpath)
+            return []
+        for dirname in os.listdir(artistpath):
+            dirpath = os.path.join(artistpath, dirname)
+            if os.path.isdir(dirpath) and not dirpath.startswith('.'):
+                album = await self.get_album(dirpath)
+                if album:
+                    result.append(album)
+        return result
+
+    async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
+        ''' get a list of 10 random tracks as we have no clue about preference '''
+        tracks = []
+        for album in await self.get_artist_albums(prov_artist_id):
+            tracks += await self.get_album_tracks(album.item_id)
+        return tracks[:10]
+
+    async def get_stream_details(self, track_id):
+        ''' returns the stream details for the given track '''
+        track = await self.track(track_id)
+        import socket
+        host = socket.gethostbyname(socket.gethostname())
+        return {
+            'mime_type': 'audio/flac',
+            'duration': track.duration,
+            'sampling_rate': 44100,
+            'bit_depth': 16,
+            'url': 'http://%s/stream/file/%s' % (host, track_id)
+        }
+    
+    async def get_stream(self, track_id):
+        ''' get audio stream for a track '''
+        with open(track_id) as f:
+            while True:
+                line = f.readline()
+                if line:
+                    yield line
+                else:
+                    break
+    
+    async def __parse_track(self, filename):
+        ''' try to parse a track from a filename with taglib '''
+        track = Track()
+        try:
+            song = taglib.File(filename)
+        except:
+            return None # not a media file ?
+        track.duration = song.length
+        track.item_id = filename # temporary id
+        name = song.tags['TITLE'][0]
+        track.name, track.version = parse_track_title(name)
+        if "\\" in filename:
+            albumpath = filename.rsplit("\\",1)[0]
+        else:
+            albumpath = filename.rsplit("/",1)[0]
+        track.album = await self.get_album(albumpath)
+        artists = []
+        for artist_str in song.tags['ARTIST']:
+            local_artist_path = os.path.join(self._music_dir, artist_str)
+            if os.path.isfile(local_artist_path):
+                artist = await self.get_artist(local_artist_path)
+            else:
+                artist = Artist()
+                artist.name = artist_str
+                fake_artistpath = os.path.join(self._music_dir, artist_str)
+                artist.item_id = fake_artistpath # temporary id
+                artist.provider_ids.append({
+                        "provider": self.prov_id,
+                        "item_id": fake_artistpath
+                    })
+            artists.append(artist)
+        track.artists = artists
+        if 'GENRE' in song.tags:
+            track.tags = song.tags['GENRE']
+        if 'ISRC' in song.tags:
+            track.external_ids.append( {"isrc": song.tags['ISRC'][0]} ) 
+        if 'DISCNUMBER' in song.tags:
+            track.disc_number = int(song.tags['DISCNUMBER'][0])
+        if 'TRACKNUMBER' in song.tags:
+            track.track_number = int(song.tags['TRACKNUMBER'][0])
+        if filename.endswith('.flac'):
+            # TODO: try to get more quality info
+            quality = TrackQuality.FLAC_LOSSLESS
+        elif filename.endswith('.ogg'):
+            quality = TrackQuality.LOSSY_OGG
+        elif filename.endswith('.m4a'):
+            quality = TrackQuality.LOSSY_AAC
+        else:
+            quality = TrackQuality.LOSSY_MP3
+        track.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": filename,
+            "quality": quality
+        })
+        return track
+                
+    async def __parse_track_from_uri(self, uri):
+        ''' try to parse a track from an uri found in playlist '''
+        if "://" in uri:
+            # track is uri from external provider?
+            prov_id = uri.split('://')[0]
+            prov_item_id = uri.split('/')[-1].split('.')[0].split(':')[-1]
+            try:
+                return await self.mass.music.providers[prov_id].track(prov_item_id, lazy=False)
+            except Exception as exc:
+                LOGGER.warning("Could not parse uri %s to track: %s" %(uri, str(exc)))
+                return None
+        # try to treat uri as filename
+        # TODO: filename could be related to musicdir or full path
+        track = await self.get_track(uri)
+        if track:
+            return track
+        track = await self.get_track(os.path.join(self._music_dir, uri))
+        if track:
+            return track
+        return None
+
diff --git a/music_assistant/modules/musicproviders/qobuz.py b/music_assistant/modules/musicproviders/qobuz.py
new file mode 100644 (file)
index 0000000..da5dd2e
--- /dev/null
@@ -0,0 +1,477 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import sys
+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
+import json
+import aiohttp
+import time
+import datetime
+import hashlib
+from asyncio_throttle import Throttler
+from cache import use_cache
+
+
+def setup(mass):
+    ''' setup the provider'''
+    enabled = mass.config["musicproviders"]['qobuz'].get(CONF_ENABLED)
+    username = mass.config["musicproviders"]['qobuz'].get(CONF_USERNAME)
+    password = mass.config["musicproviders"]['qobuz'].get(CONF_PASSWORD)
+    if enabled and username and password:
+        spotify_provider = QobuzProvider(mass, username, password)
+        return spotify_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_USERNAME, "", CONF_USERNAME), 
+        (CONF_PASSWORD, "<password>", CONF_PASSWORD)
+        ]
+
+class QobuzProvider(MusicProvider):
+    
+
+    def __init__(self, mass, username, password):
+        self.name = 'Qobuz'
+        self.prov_id = 'qobuz'
+        self._cur_user = None
+        self.mass = mass
+        self.cache = mass.cache
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+        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)
+
+    async def search(self, searchstring, media_types=List[MediaType], limit=5):
+        ''' perform search on the provider '''
+        result = {
+            "artists": [],
+            "albums": [],
+            "tracks": [],
+            "playlists": []
+        }
+        params = {"query": searchstring, "limit": limit }
+        if len(media_types) == 1:
+            # qobuz does not support multiple searchtypes, falls back to all if no type given
+            if media_types[0] == MediaType.Artist:
+                params["type"] = "artists"
+            if media_types[0] == MediaType.Album:
+                params["type"] = "albums"
+            if media_types[0] == MediaType.Track:
+                params["type"] = "tracks"
+            if media_types[0] == MediaType.Playlist:
+                params["type"] = "playlists"
+        searchresult = await self.__get_data("catalog/search", params)
+        if searchresult:
+            if "artists" in searchresult:
+                for item in searchresult["artists"]["items"]:
+                    artist = await self.__parse_artist(item)
+                    if artist:
+                        result["artists"].append(artist)
+            if "albums" in searchresult:
+                for item in searchresult["albums"]["items"]:
+                    album = await self.__parse_album(item)
+                    if album:
+                        result["albums"].append(album)
+            if "tracks" in searchresult:
+                for item in searchresult["tracks"]["items"]:
+                    track = await self.__parse_track(item)
+                    if track:
+                        result["tracks"].append(track)
+            if "playlists" in searchresult:
+                for item in searchresult["playlists"]["items"]:
+                    result["playlists"].append(await self.__parse_playlist(item))
+        return result
+    
+    async def get_library_artists(self) -> List[Artist]:
+        ''' retrieve library artists from qobuz '''
+        result = []
+        params = {'type': 'artists'}
+        for item in await self.__get_all_items("favorite/getUserFavorites", params, key='artists'):
+            artist = await self.__parse_artist(item)
+            if artist:
+                result.append(artist)
+        return result
+    
+    async def get_library_albums(self) -> List[Album]:
+        ''' retrieve library albums from qobuz '''
+        result = []
+        params = {'type': 'albums'}
+        for item in await self.__get_all_items("favorite/getUserFavorites", params, key='albums'):
+            album = await self.__parse_album(item)
+            if album:
+                result.append(album)
+        return result
+
+    async def get_library_tracks(self) -> List[Track]:
+        ''' retrieve library tracks from qobuz '''
+        result = []
+        params = {'type': 'tracks'}
+        for item in await self.__get_all_items("favorite/getUserFavorites", params, key='tracks'):
+            track = await self.__parse_track(item)
+            if track:
+                result.append(track)
+        return result 
+
+    async def get_library_playlists(self) -> List[Playlist]:
+        ''' retrieve playlists from the provider '''
+        result = []
+        for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists'):
+            playlist = await self.__parse_playlist(item)
+            if playlist:
+                result.append(playlist)
+        return result 
+
+    async def get_artist(self, prov_artist_id) -> Artist:
+        ''' get full artist details by id '''
+        params = {'artist_id': prov_artist_id}
+        artist_obj = await self.__get_data("artist/get", params)
+        return await self.__parse_artist(artist_obj)
+
+    async def get_album(self, prov_album_id) -> Album:
+        ''' get full album details by id '''
+        params = {'album_id': prov_album_id}
+        album_obj = await self.__get_data("album/get", params)
+        return await self.__parse_album(album_obj)
+
+    async def get_track(self, prov_track_id) -> Track:
+        ''' get full track details by id '''
+        params = {'track_id': prov_track_id}
+        track_obj = await self.__get_data("track/get", params)
+        return await self.__parse_track(track_obj)
+
+    async def get_playlist(self, prov_playlist_id) -> Playlist:
+        ''' get full playlist details by id '''
+        params = {'playlist_id': prov_playlist_id}
+        playlist_obj = await self.__get_data("playlist/get", params)
+        return await self.__parse_playlist(playlist_obj)
+
+    async def get_album_tracks(self, prov_album_id) -> List[Track]:
+        ''' get album tracks for given album id '''
+        params = {'album_id': prov_album_id}
+        track_objs = await self.__get_all_items("album/get", params, key='tracks')
+        tracks = []
+        for track_obj in track_objs:
+            track = await self.__parse_track(track_obj)
+            if track:
+                tracks.append(track)
+        return tracks
+
+    async def get_playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]:
+        ''' get playlist tracks for given playlist id '''
+        playlist_obj = await self.__get_data("playlist/get?playlist_id=%s" % prov_playlist_id, ignore_cache=True)
+        cache_checksum = playlist_obj["updated_at"]
+        params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'}
+        track_objs = await self.__get_all_items("playlist/get", params, key='tracks', limit=limit, offset=offset, cache_checksum=cache_checksum)
+        tracks = []
+        for track_obj in track_objs:
+            playlist_track = await self.__parse_track(track_obj)
+            if playlist_track:
+                tracks.append(playlist_track)
+        return tracks
+
+    async def get_artist_albums(self, prov_artist_id, limit=100, offset=0) -> List[Album]:
+        ''' get a list of albums for the given artist '''
+        params = {'artist_id': prov_artist_id, 'extra': 'albums', 'limit': limit, 'offset': offset}
+        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):
+                album = await self.__parse_album(item)
+                if album:
+                    albums.append(album)
+        return albums
+
+    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 []
+    
+    async def add_library(self, prov_item_id, media_type:MediaType):
+        ''' add item to library '''
+        if media_type == MediaType.Artist:
+            result = await self.__get_data('favorite/create', {'artist_ids': prov_item_id})
+            item = await self.artist(prov_item_id)
+        elif media_type == MediaType.Album:
+            result = await self.__get_data('favorite/create', {'album_ids': prov_item_id})
+            item = await self.album(prov_item_id)
+        elif media_type == MediaType.Track:
+            result = await self.__get_data('favorite/create', {'track_ids': prov_item_id})
+            item = await self.track(prov_item_id)
+        await self.mass.db.add_to_library(item.item_id, media_type, self.prov_id)
+        LOGGER.debug("added item %s to %s - %s" %(prov_item_id, self.prov_id, result))
+
+    async def remove_library(self, prov_item_id, media_type:MediaType):
+        ''' remove item from library '''
+        if media_type == MediaType.Artist:
+            result = await self.__get_data('favorite/delete', {'artist_ids': prov_item_id})
+            item = await self.artist(prov_item_id)
+        elif media_type == MediaType.Album:
+            result = await self.__get_data('favorite/delete', {'album_ids': prov_item_id})
+            item = await self.album(prov_item_id)
+        elif media_type == MediaType.Track:
+            result = await self.__get_data('favorite/delete', {'track_ids': prov_item_id})
+            item = await self.track(prov_item_id)
+        await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id)
+        LOGGER.debug("deleted item %s from %s - %s" %(prov_item_id, self.prov_id, result))
+    
+    async def get_stream_details(self, track_id):
+        ''' returns the stream details for the provider '''
+        params = {'format_id': 27, 'track_id': track_id, 'intent': 'stream'}
+        return await self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True)
+    
+    async def get_stream(self, track_id):
+        ''' get audio stream for a track '''
+        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
+                yield chunk
+    
+    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.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": artist_obj['id']
+        })
+        artist.name = artist_obj['name']
+        if artist_obj.get('image'):
+            for key in ['extralarge', 'large', 'medium', 'small']:
+                if artist_obj['image'].get(key):
+                    if not '2a96cbd8b46e442fc41c2b86b821562f' in artist_obj['image'][key]:
+                        artist.metadata["image"] = artist_obj['image'][key]
+                        break
+        if artist_obj.get('biography'):
+            artist.metadata["biography"] = artist_obj['biography'].get('content','')
+        if artist_obj.get('url'):
+            artist.metadata["qobuz_url"] = artist_obj['url']
+        return artist
+
+    async def __parse_album(self, album_obj):
+        ''' parse spotify album object to generic layout '''
+        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'))
+            return None
+        album.item_id = album_obj['id'] # temporary id
+        album.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": album_obj['id'],
+            "details": "%skHz %sbit" %(album_obj['maximum_sampling_rate'], album_obj['maximum_bit_depth'])
+        })
+        album.name, album.version = parse_track_title(album_obj['title'])
+        album.artist = await self.__parse_artist(album_obj['artist'])
+        if not album.artist:
+            raise Exception("No album artist ! %s" % album_obj)
+        if album_obj.get('product_type','') == 'single':
+            album.albumtype = AlbumType.Single
+        elif album_obj.get('product_type','') == 'compilation' or 'Various' in album_obj['artist']['name']:
+            album.albumtype = AlbumType.Compilation
+        else:
+            album.albumtype = AlbumType.Album
+        if 'genre' in album_obj:
+            album.tags = [album_obj['genre']['name']]
+        if album_obj.get('image'):
+            for key in ['extralarge', 'large', 'medium', 'small']:
+                if album_obj['image'].get(key):
+                    album.metadata["image"] = album_obj['image'][key]
+                    break
+        album.external_ids.append({ "upc": album_obj['upc'] })
+        if 'label' in album_obj:
+            album.labels = album_obj['label']['name'].split('/')
+        if album_obj.get('released_at'):
+            album.year = datetime.datetime.fromtimestamp(album_obj['released_at']).year
+        if album_obj.get('copyright'):
+            album.metadata["copyright"] = album_obj['copyright']
+        if album_obj.get('hires'):
+            album.metadata["hires"] = "true"
+        if album_obj.get('url'):
+            album.metadata["qobuz_url"] = album_obj['url']
+        if album_obj.get('description'):
+            album.metadata["description"] = album_obj['description']
+        return album
+
+    async def __parse_track(self, track_obj):
+        ''' parse spotify track object to generic layout '''
+        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'))
+            return None
+        track.item_id = track_obj['id'] # temporary 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 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']:
+                artist = await self.__parse_artist(track_obj['album']['artist'])
+                if artist:
+                    track.artists.append(artist)
+        if not track.artists:
+            # last resort: parse from performers string
+            for performer_str in track_obj['performers'].split(' - '):
+                role = performer_str.split(', ')[1]
+                name = performer_str.split(', ')[0]
+                if 'artist' in role.lower():
+                    artist = Artist()
+                    artist.name = name
+                    artist.item_id = name
+                track.artists.append(artist)
+        # TODO: fix grabbing composer from details
+        track.name, track.version = parse_track_title(track_obj['title'])
+        if not track.version and track_obj['version']:
+            track.version = track_obj['version']
+        track.duration = track_obj['duration']
+        if 'album' in track_obj:
+            album = await self.__parse_album(track_obj['album'])
+            if album:
+                track.album = album
+        track.disc_number = track_obj['media_number']
+        track.track_number = track_obj['track_number']
+        if track_obj.get('hires'):
+            track.metadata["hires"] = "true"
+        if track_obj.get('url'):
+            track.metadata["qobuz_url"] = track_obj['url']
+        if track_obj.get('isrc'):
+            track.external_ids.append({
+                "isrc": track_obj['isrc']
+            })
+        if track_obj.get('performers'):
+            track.metadata["performers"] = track_obj['performers']
+        if track_obj.get('copyright'):
+            track.metadata["copyright"] = track_obj['copyright']
+        # get track quality
+        if track_obj['maximum_sampling_rate'] > 192:
+            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
+        elif track_obj['maximum_sampling_rate'] > 96:
+            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
+        elif track_obj['maximum_sampling_rate'] > 48:
+            quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
+        elif track_obj['maximum_bit_depth'] > 16:
+            quality = TrackQuality.FLAC_LOSSLES_HI_RES_1
+        elif track_obj.get('format_id',0) == 5:
+            quality = TrackQuality.LOSSY_AAC
+        else:
+            quality = TrackQuality.FLAC_LOSSLESS
+        track.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": track_obj['id'],
+            "quality": quality,
+            "details": "%skHz %sbit" %(track_obj['maximum_sampling_rate'], track_obj['maximum_bit_depth'])
+        })
+        return track
+
+    async def __parse_playlist(self, playlist_obj):
+        ''' parse spotify playlist object to generic layout '''
+        playlist = Playlist()
+        if not playlist_obj.get('id'):
+            return None
+        playlist.item_id = playlist_obj['id'] # temporary 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']
+        if playlist_obj.get('images300'):
+            playlist.metadata["image"] = playlist_obj['images300'][0]
+        if playlist_obj.get('url'):
+            playlist.metadata["qobuz_url"] = playlist_obj['url']
+        return playlist
+
+    async def __auth_token(self):
+        ''' login to qobuz and store the token'''
+        if self.__user_auth_token:
+            return self.__user_auth_token
+        params = { "username": self.__username, "password": self.__password}
+        details = await self.__get_data("user/login", params, ignore_cache=True)
+        self.__user_auth_token = details["user_auth_token"]
+        LOGGER.info("Succesfully logged in to Qobuz as %s" % (details["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):
+        ''' get all items from a paged list '''
+        if not cache_checksum:
+            params["limit"] = 1
+            params["offset"] = 0
+            cache_checksum = await self.__get_data(endpoint, params, ignore_cache=True)
+            cache_checksum = cache_checksum[key]["total"]
+        if limit:
+            # partial listing
+            params["limit"] = limit
+            params["offset"] = offset
+            result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum)
+            return result[key]["items"]
+        else:
+            # full listing
+            offset = 0
+            total_items = 1
+            count = 0
+            items = []
+            while count < total_items:
+                params["limit"] = 200
+                params["offset"] = offset
+                result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum)
+                if result and key in result:
+                    total_items = result[key]["total"]
+                    offset += 200
+                    count += len(result[key]["items"])
+                    items += result[key]["items"]
+                else:
+                    LOGGER.error("failed to retrieve items for %s (%s) --> %s" %(endpoint, params, result))
+                    break
+            return items
+
+    @use_cache(7)
+    async def __get_data(self, endpoint, params={}, sign_request=False, ignore_cache=False, cache_checksum=None):
+        ''' get data from api'''
+        url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint
+        headers = {"X-App-Id": self.__app_id}
+        if endpoint != 'user/login':
+            headers["X-User-Auth-Token"] = await self.__auth_token()
+        if sign_request:
+            signing_data = "".join(endpoint.split('/'))
+            keys = list(params.keys())
+            keys.sort()
+            for key in keys:
+                signing_data += "%s%s" %(key, params[key])
+            request_ts = str(time.time())
+            request_sig = signing_data + request_ts + self.__app_secret
+            request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
+            params["request_ts"] = request_ts
+            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
+
diff --git a/music_assistant/modules/musicproviders/spotify.py b/music_assistant/modules/musicproviders/spotify.py
new file mode 100644 (file)
index 0000000..679da48
--- /dev/null
@@ -0,0 +1,519 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import sys
+import time
+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
+import json
+import aiohttp
+from cache import use_cache
+
+
+def setup(mass):
+    ''' setup the provider'''
+    enabled = mass.config["musicproviders"]['spotify'].get(CONF_ENABLED)
+    username = mass.config["musicproviders"]['spotify'].get(CONF_USERNAME)
+    password = mass.config["musicproviders"]['spotify'].get(CONF_PASSWORD)
+    if enabled and username and password:
+        spotify_provider = SpotifyProvider(mass, username, password)
+        return spotify_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_USERNAME, "", CONF_USERNAME), 
+        (CONF_PASSWORD, "<password>", CONF_PASSWORD)
+        ]
+
+class SpotifyProvider(MusicProvider):
+    
+
+    def __init__(self, mass, username, password):
+        self.name = 'Spotify'
+        self.prov_id = 'spotify'
+        self._cur_user = None
+        self.mass = mass
+        self.cache = mass.cache
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+        self._username = username
+        self._password = password
+        self.__auth_token = {}
+
+    async def search(self, searchstring, media_types=List[MediaType], limit=5):
+        ''' perform search on the provider '''
+        result = {
+            "artists": [],
+            "albums": [],
+            "tracks": [],
+            "playlists": []
+        }
+        searchtypes = []
+        if MediaType.Artist in media_types:
+            searchtypes.append("artist")
+        if MediaType.Album in media_types:
+            searchtypes.append("album")
+        if MediaType.Track in media_types:
+            searchtypes.append("track")
+        if MediaType.Playlist in media_types:
+            searchtypes.append("playlist")
+        searchtype = ",".join(searchtypes)
+        params = {"q": searchstring, "type": searchtype, "limit": limit }
+        searchresult = await self.__get_data("search", params=params, cache_checksum="bla")
+        if searchresult:
+            if "artists" in searchresult:
+                for item in searchresult["artists"]["items"]:
+                    artist = await self.__parse_artist(item)
+                    if artist:
+                        result["artists"].append(artist)
+            if "albums" in searchresult:
+                for item in searchresult["albums"]["items"]:
+                    album = await self.__parse_album(item)
+                    if album:
+                        result["albums"].append(album)
+            if "tracks" in searchresult:
+                for item in searchresult["tracks"]["items"]:
+                    track = await self.__parse_track(item)
+                    if track:
+                        result["tracks"].append(track)
+            if "playlists" in searchresult:
+                for item in searchresult["playlists"]["items"]:
+                    playlist = await self.__parse_playlist(item)
+                    if playlist:
+                        result["playlists"].append(playlist)
+        return result
+    
+    async def get_library_artists(self) -> List[Artist]:
+        ''' retrieve library artists from spotify '''
+        items = []
+        spotify_artists = await self.__get_data("me/following?type=artist&limit=50")
+        if spotify_artists:
+            # TODO: use cursor method to retrieve more than 50 artists
+            for artist_obj in spotify_artists['artists']['items']:
+                prov_artist = await self.__parse_artist(artist_obj)
+                items.append(prov_artist)
+        return items
+    
+    async def get_library_albums(self) -> List[Album]:
+        ''' retrieve library albums from the provider '''
+        result = []
+        for item in await self.__get_all_items("me/albums"):
+            album = await self.__parse_album(item)
+            if album:
+                result.append(album)
+        return result
+
+    async def get_library_tracks(self) -> List[Track]:
+        ''' retrieve library tracks from the provider '''
+        result = []
+        for item in await self.__get_all_items("me/tracks"):
+            track = await self.__parse_track(item)
+            if track:
+                result.append(track)
+        return result 
+
+    async def get_library_playlists(self) -> List[Playlist]:
+        ''' retrieve playlists from the provider '''
+        result = []
+        for item in await self.__get_all_items("me/playlists"):
+            playlist = await self.__parse_playlist(item)
+            if playlist:
+                result.append(playlist)
+        return result 
+
+    async def get_artist(self, prov_artist_id) -> Artist:
+        ''' get full artist details by id '''
+        artist_obj = await self.__get_data("artists/%s" % prov_artist_id)
+        return await self.__parse_artist(artist_obj)
+
+    async def get_album(self, prov_album_id) -> Album:
+        ''' get full album details by id '''
+        album_obj = await self.__get_data("albums/%s" % prov_album_id)
+        return await self.__parse_album(album_obj)
+
+    async def get_track(self, prov_track_id) -> Track:
+        ''' get full track details by id '''
+        track_obj = await self.__get_data("tracks/%s" % prov_track_id)
+        return await self.__parse_track(track_obj)
+
+    async def get_playlist(self, prov_playlist_id) -> Playlist:
+        ''' get full playlist details by id '''
+        playlist_obj = await self.__get_data("playlists/%s" % prov_playlist_id, ignore_cache=True)
+        return await self.__parse_playlist(playlist_obj)
+
+    async def get_album_tracks(self, prov_album_id) -> List[Track]:
+        ''' get album tracks for given album id '''
+        track_objs = await self.__get_all_items("albums/%s/tracks" % prov_album_id)
+        tracks = []
+        for track_obj in track_objs:
+            track = await self.__parse_track(track_obj)
+            if track:
+                tracks.append(track)
+        return tracks
+
+    async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]:
+        ''' get playlist tracks for given playlist id '''
+        playlist_obj = await self.__get_data("playlists/%s?fields=snapshot_id" % prov_playlist_id, ignore_cache=True)
+        cache_checksum = playlist_obj["snapshot_id"]
+        track_objs = await self.__get_all_items("playlists/%s/tracks" % prov_playlist_id, limit=limit, offset=offset, cache_checksum=cache_checksum)
+        tracks = []
+        for track_obj in track_objs:
+            playlist_track = await self.__parse_track(track_obj)
+            if playlist_track:
+                tracks.append(playlist_track)
+        return tracks
+
+    async def get_artist_albums(self, prov_artist_id) -> List[Album]:
+        ''' get a list of albums for the given artist '''
+        params = {'include_groups': 'album,single,compilation'}
+        items = await self.__get_all_items('artists/%s/albums' % prov_artist_id, params)
+        albums = []
+        for item in items:
+            album = await self.__parse_album(item)
+            if album:
+                albums.append(album)
+        return albums
+
+    async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
+        ''' get a list of 10 most popular tracks for the given artist '''
+        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:
+                tracks.append(track)
+        return tracks
+
+    async def add_library(self, prov_item_id, media_type:MediaType):
+        ''' add item to library '''
+        if media_type == MediaType.Artist:
+            result = await self.__put_data('me/following', {'ids': prov_item_id, 'type': 'artist'})
+            item = await self.artist(prov_item_id)
+        elif media_type == MediaType.Album:
+            result = await self.__put_data('me/albums', {'ids': prov_item_id})
+            item = await self.album(prov_item_id)
+        elif media_type == MediaType.Track:
+            result = await self.__put_data('me/tracks', {'ids': prov_item_id})
+            item = await self.track(prov_item_id)
+        await self.mass.db.add_to_library(item.item_id, media_type, self.prov_id)
+        LOGGER.debug("added item %s to %s - %s" %(prov_item_id, self.prov_id, result))
+
+    async def remove_library(self, prov_item_id, media_type:MediaType):
+        ''' remove item from library '''
+        if media_type == MediaType.Artist:
+            result = await self.__delete_data('me/following', {'ids': prov_item_id, 'type': 'artist'})
+            item = await self.artist(prov_item_id)
+        elif media_type == MediaType.Album:
+            result = await self.__delete_data('me/albums', {'ids': prov_item_id})
+            item = await self.album(prov_item_id)
+        elif media_type == MediaType.Track:
+            result = await self.__delete_data('me/tracks', {'ids': prov_item_id})
+            item = await self.track(prov_item_id)
+        await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id)
+        LOGGER.debug("deleted item %s from %s - %s" %(prov_item_id, self.prov_id, result))
+
+    async def devices(self):
+        ''' list all available devices '''
+        items = await self.__get_data('me/player/devices')
+        return items['devices']
+
+    async def play_media(self, device_id, uri, offset_pos=None, offset_uri=None):
+        ''' play uri on spotify device'''
+        opts = {}
+        if isinstance(uri, list):
+            opts['uris'] = uri
+        elif uri.startswith('spotify:track'):
+            opts['uris'] = [uri]
+        else:
+            opts['context_uri'] = uri
+        if offset_pos != None: # only for playlists/albums!
+            opts["offset"] = {"position": offset_pos }
+        elif offset_uri != None: # only for playlists/albums!
+            opts["offset"] = {"uri": offset_uri }
+        return await self.__put_data('me/player/play', {"device_id": device_id}, opts)
+
+    async def get_stream_details(self, track_id):
+        ''' returns the stream details for the provider '''
+        track = await self.track(track_id)
+        import socket
+        host = socket.gethostbyname(socket.gethostname())
+        return {
+            'mime_type': 'audio/ogg',
+            '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 '''
+        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)
+        while not process.stdout.at_eof():
+            line = await process.stdout.readline()
+            if line:
+                yield line
+        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.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": artist_obj['id']
+        })
+        artist.name = artist_obj['name']
+        if 'genres' in artist_obj:
+            artist.tags = artist_obj['genres']
+        if artist_obj.get('images'):
+            for img in artist_obj['images']:
+                img_url = img['url']
+                if not '2a96cbd8b46e442fc41c2b86b821562f' in img_url:
+                    artist.metadata["image"] = img_url
+                    break
+        if artist_obj.get('external_urls'):
+            artist.metadata["spotify_url"] = artist_obj['external_urls']['spotify']
+        return artist
+
+    async def __parse_album(self, album_obj):
+        ''' parse spotify album object to generic layout '''
+        if 'album' in album_obj:
+            album_obj = album_obj['album']
+        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.name, album.version = parse_track_title(album_obj['name'])
+        for artist in album_obj['artists']:
+            album.artist = await self.__parse_artist(artist)
+            if album.artist:
+                break
+        if not album.artist:
+            raise Exception("No album artist ! %s" % album_obj)
+        if album_obj['album_type'] == 'single':
+            album.albumtype = AlbumType.Single
+        elif album_obj['album_type'] == 'compilation':
+            album.albumtype = AlbumType.Compilation
+        else:
+            album.albumtype = AlbumType.Album
+        if 'genres' in album_obj:
+            album.tags = album_obj['genres']
+        if album_obj.get('images'):
+            album.metadata["image"] = album_obj['images'][0]['url']
+        if 'external_ids' in album_obj:
+            for key, value in album_obj['external_ids'].items():
+                album.external_ids.append( { key: value } )
+        if 'label' in album_obj:
+            album.labels = album_obj['label'].split('/')
+        if album_obj.get('release_date'):
+            album.year = int(album_obj['release_date'].split('-')[0])
+        if album_obj.get('copyrights'):
+            album.metadata["copyright"] = album_obj['copyrights'][0]['text']
+        if album_obj.get('external_urls'):
+            album.metadata["spotify_url"] = album_obj['external_urls']['spotify']
+        if album_obj.get('explicit'):
+            album.metadata['explicit'] = str(album_obj['explicit']).lower()
+        album.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": album_obj['id']
+        })
+        return album
+
+    async def __parse_track(self, track_obj):
+        ''' parse spotify track object to generic layout '''
+        if 'track' in track_obj:
+            track_obj = track_obj['track']
+        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
+        for track_artist in track_obj['artists']:
+            artist = await self.__parse_artist(track_artist)
+            if artist:
+                track.artists.append(artist)
+        track.name, track.version = parse_track_title(track_obj['name'])
+        track.duration = track_obj['duration_ms'] / 1000
+        track.metadata['explicit'] = str(track_obj['explicit']).lower()
+        if not track.version and track_obj['explicit']:
+            track.version = 'Explicit'
+        if 'external_ids' in track_obj:
+            for key, value in track_obj['external_ids'].items():
+                track.external_ids.append( { key: value } )
+        if 'album' in track_obj:
+            track.album = await self.__parse_album(track_obj['album'])
+        if track_obj.get('copyright'):
+            track.metadata["copyright"] = track_obj['copyright']
+        track.disc_number = track_obj['disc_number']
+        track.track_number = track_obj['track_number']
+        if track_obj.get('external_urls'):
+            track.metadata["spotify_url"] = track_obj['external_urls']['spotify']
+        track.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": track_obj['id'],
+            "quality": TrackQuality.LOSSY_OGG
+        })
+        return track
+
+    async def __parse_playlist(self, playlist_obj):
+        ''' parse spotify playlist object to generic layout '''
+        playlist = Playlist()
+        if not playlist_obj.get('id'):
+            return None
+        playlist.item_id = playlist_obj['id'] # temporary 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']
+        if playlist_obj.get('images'):
+            playlist.metadata["image"] = playlist_obj['images'][0]['url']
+        if playlist_obj.get('external_urls'):
+            playlist.metadata["spotify_url"] = playlist_obj['external_urls']['spotify']
+        return playlist
+
+    async def get_token(self):
+        ''' get auth token on spotify '''
+        # return existing token if we have one in memory
+        if self.__auth_token and (self.__auth_token['expiresAt'] > int(time.time()) + 20):
+            return self.__auth_token
+        tokeninfo = {}
+        if not self._username or not self._password:
+            return tokeninfo
+        # try with spotipy-token module first, fallback to spotty
+        try:
+            import spotify_token as st
+            data = st.start_session(self._username, self._password)
+            if data and len(data) == 2:
+                tokeninfo = {"accessToken": data[0], "expiresIn": data[1] - int(time.time()), "expiresAt":data[1] }
+        except Exception as exc:
+            LOGGER.exception(exc)
+        if not tokeninfo:
+            # fallback to spotty approach
+            import subprocess
+            scopes = [
+                "user-read-playback-state",
+                "user-read-currently-playing",
+                "user-modify-playback-state",
+                "playlist-read-private",
+                "playlist-read-collaborative",
+                "playlist-modify-public",
+                "playlist-modify-private",
+                "user-follow-modify",
+                "user-follow-read",
+                "user-library-read",
+                "user-library-modify",
+                "user-read-private",
+                "user-read-email",
+                "user-read-birthdate",
+                "user-top-read"]
+            scope = ",".join(scopes)
+            clientid = '2eb96f9b37494be1824999d58028a305'
+            args = [self.get_spotty_binary(), "-t", "--client-id", clientid, "--scope", scope, "-n", "temp-spotty", "-u", self._username, "-p", self._password, "--disable-discovery"]
+            spotty = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+            stdout, stderr = spotty.communicate()
+            result = json.loads(stdout)
+            # transform token info to spotipy compatible format
+            if result and "accessToken" in result:
+                tokeninfo = result
+                tokeninfo['expiresAt'] = tokeninfo['expiresIn'] + int(time.time())
+        if tokeninfo:
+            self.__auth_token = tokeninfo
+            self.sp_user = await self.__get_data("me")
+            LOGGER.info("Succesfully logged in to Spotify as %s" % self.sp_user["id"])
+            self.__auth_token = tokeninfo
+        else:
+            raise Exception("Can't get Spotify token for user %s" % self._username)
+        return tokeninfo
+
+    async def __get_all_items(self, endpoint, params={}, limit=0, offset=0, cache_checksum=None):
+        ''' get all items from a paged list '''
+        if not cache_checksum:
+            params["limit"] = 1
+            params["offset"] = 0
+            cache_checksum = await self.__get_data(endpoint, params, ignore_cache=True)
+            cache_checksum = cache_checksum["total"]
+        if limit:
+            # partial listing
+            params["limit"] = limit
+            params["offset"] = offset
+            result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum)
+            return result["items"]
+        else:
+            # full listing
+            total_items = 1
+            count = 0
+            items = []
+            while count < total_items:
+                params["limit"] = 50
+                params["offset"] = offset
+                result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum)
+                total_items = result["total"]
+                offset += 50
+                count += len(result["items"])
+                items += result["items"]
+            return items
+
+    @use_cache(7)
+    async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None):
+        ''' get data from api'''
+        url = 'https://api.spotify.com/v1/%s' % endpoint
+        params['market'] = 'from_token'
+        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 def __delete_data(self, endpoint, params={}):
+        ''' get data from api'''
+        url = 'https://api.spotify.com/v1/%s' % endpoint
+        token = await self.get_token()
+        headers = {'Authorization': 'Bearer %s' % token["accessToken"]}
+        async with self.http_session.delete(url, headers=headers, params=params) as response:
+            return await response.text()
+
+    async def __put_data(self, endpoint, params={}, data=None):
+        ''' put data on api'''
+        url = 'https://api.spotify.com/v1/%s' % endpoint
+        token = await self.get_token()
+        headers = {'Authorization': 'Bearer %s' % token["accessToken"]}
+        async with self.http_session.put(url, headers=headers, params=params, json=data) as response:
+            return await response.text()
+
+    @staticmethod
+    def get_spotty_binary():
+        '''find the correct spotty binary belonging to the platform'''
+        import platform
+        sp_binary = None
+        if platform.system() == "Windows":
+            sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "windows", "spotty.exe")
+        elif platform.system() == "Darwin":
+            # macos binary is x86_64 intel
+            sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "darwin", "spotty")
+        elif platform.system() == "Linux":
+            # try to find out the correct architecture by trial and error
+            architecture = platform.machine()
+            if architecture.startswith('AMD64') or architecture.startswith('x86_64'):
+                # generic linux x86_64 binary
+                sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "x86-linux", "spotty-x86_64")
+            else:
+                sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "arm-linux", "spotty-muslhf")
+        return sp_binary
+
+
diff --git a/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf b/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf
new file mode 100755 (executable)
index 0000000..c928d8a
Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf differ
diff --git a/music_assistant/modules/musicproviders/spotty/darwin/spotty b/music_assistant/modules/musicproviders/spotty/darwin/spotty
new file mode 100755 (executable)
index 0000000..44c6b60
Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/darwin/spotty differ
diff --git a/music_assistant/modules/musicproviders/spotty/windows/spotty.exe b/music_assistant/modules/musicproviders/spotty/windows/spotty.exe
new file mode 100755 (executable)
index 0000000..6ce9b19
Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/windows/spotty.exe differ
diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty
new file mode 100755 (executable)
index 0000000..b2c3f34
Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty differ
diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64
new file mode 100755 (executable)
index 0000000..58911cf
Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 differ
diff --git a/music_assistant/modules/playerproviders/__init__.py b/music_assistant/modules/playerproviders/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/modules/playerproviders/chromecast.py
new file mode 100644 (file)
index 0000000..569a68f
--- /dev/null
@@ -0,0 +1,263 @@
+#!/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, run_background_task, LOGGER, parse_track_title, try_parse_int
+from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
+from constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
+import json
+import 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 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)
+
+def setup(mass):
+    ''' setup the provider'''
+    enabled = mass.config["playerproviders"]['chromecast'].get(CONF_ENABLED)
+    if enabled:
+        provider = ChromecastProvider(mass)
+        return provider
+    return False
+
+def config_entries():
+    ''' get the config entries for this provider (list with key/value pairs)'''
+    return [
+        (CONF_ENABLED, True, CONF_ENABLED),
+        ]
+
+class ChromecastProvider(PlayerProvider):
+    ''' support for Home Assistant '''
+
+    def __init__(self, mass):
+        self.prov_id = 'chromecast'
+        self.name = 'Chromecast'
+        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.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+        asyncio.ensure_future(self.__discover_chromecasts())
+        
+
+    ### 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) '''
+        if cmd == 'play':
+            self._players[player_id].cast.media_controller.play()
+        elif cmd == 'pause':
+            self._players[player_id].cast.media_controller.pause()
+        elif cmd == 'stop':
+            self._players[player_id].cast.media_controller.stop()
+        elif cmd == 'next':
+            self._players[player_id].cast.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()
+        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)
+
+    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
+        '''
+        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")
+
+    ### Provider specific (helper) methods #####
+    
+    async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None):
+        ''' handle a player state message from the socket '''
+        player_id = str(chromecast.uuid)
+        player = self._players[player_id]
+        # always update player details that may change
+        player.name = chromecast.name
+        if caststatus:
+            player.muted = caststatus.volume_muted
+            player.volume_level = caststatus.volume_level * 100
+            player.powered = not caststatus.is_stand_by
+        if mediastatus:
+            if mediastatus.player_state in ['PLAYING', 'BUFFERING']:
+                player.state = PlayerState.Playing
+            elif mediastatus.player_state == 'PAUSED':
+                player.state = PlayerState.Paused
+            else:
+                player.state = PlayerState.Stopped
+            player.cur_item = await self.__parse_track(mediastatus)
+            player.cur_item_time = try_parse_int(mediastatus.current_time)
+        await self.mass.player.update_player(player)
+
+    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:
+            # TODO: match this info manually in the DB!!
+            track = Track()
+            artist = mediastatus.artist
+            album = mediastatus.album_name
+            title = mediastatus.title
+            track.name = "%s - %s" %(artist, title)
+            track.duration = try_parse_int(mediastatus.duration)
+            if mediastatus.media_metadata and mediastatus.media_metadata.get('images'):
+                track.metadata.image = mediastatus.media_metadata['images'][-1]['url']
+        return track
+
+    async def __handle_group_members_update(self, mz, added_player=None, removed_player=None):
+        ''' callback when cast group members update '''
+        if added_player:
+            if added_player in self._players:
+                self._players[added_player].group_parent = str(mz._uuid)
+        elif removed_player:
+            if removed_player in self._players:
+                self._players[removed_player].group_parent = None
+        else:
+            for member in mz.members:
+                if member in self._players:
+                    self._players[member].group_parent = str(mz._uuid)
+
+    @run_periodic(600)
+    async def __discover_chromecasts(self):
+        ''' discover chromecasts on the network '''
+        LOGGER.info('Starting Chromecast discovery...')
+        bg_task = run_background_task(self.mass.bg_executor, pychromecast.get_chromecasts)
+        chromecasts = await asyncio.gather(bg_task)
+        for chromecast in chromecasts[0]:
+            player_id = str(chromecast.uuid)
+            if not player_id in self._players:
+                player = MusicPlayer()
+                player.player_id = player_id
+                player.name = chromecast.name
+                chromecast.start()
+                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)
+                chromecast.media_controller.register_status_listener(listenerMedia)
+                if chromecast.cast_type == 'group':
+                    player.is_group = True
+                    mz = MultizoneController(chromecast.uuid)
+                    mz.register_listener(MZListener(mz, self.__handle_group_members_update, self.mass.event_loop))
+                    chromecast.register_handler(mz)
+                    chromecast.register_connection_listener(MZConnListener(mz))
+                    chromecast.wait()
+                player.cast = chromecast
+                player.player_provider = self.prov_id
+                self._players[player_id] = player
+        LOGGER.info('Chromecast discovery done...')
+
+
+class StatusListener:
+    def __init__(self, chromecast, callback, loop):
+        self.chromecast = chromecast
+        self.__handle_player_state = callback
+        self.loop = loop
+
+    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
+        self.__handle_player_state = callback
+        self.loop = loop
+
+    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
+    def new_connection_status(self, connection_status):
+        """Handle reception of a new ConnectionStatus."""
+        if connection_status.status == 'CONNECTED':
+            self._mz.update_members()
+
+class MZListener:
+    def __init__(self, mz, callback, loop):
+        self._mz = mz
+        self._loop = loop
+        self.__handle_group_members_update = callback
+
+    def multizone_member_added(self, uuid):
+        asyncio.run_coroutine_threadsafe(
+                self.__handle_group_members_update(self._mz, added_player=str(uuid)), self._loop)
+
+    def multizone_member_removed(self, uuid):
+        asyncio.run_coroutine_threadsafe(
+                self.__handle_group_members_update(self._mz, removed_player=str(uuid)), self._loop)
+
+    def multizone_status_received(self):
+        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):
+        """ handle the auth flow and active player selection """
+        if data['type'] == 'setCredentialsResponse':
+            self.send_message({'type': 'getInfo', 'payload': {}})
+        if data['type'] == 'setCredentialsError':
+            self.device = None
+        if data['type'] == 'getInfoResponse':
+            self.device = data['payload']['deviceID']
+            self.is_launched = True
+        return True
diff --git a/music_assistant/modules/playerproviders/homeassistant.py b/music_assistant/modules/playerproviders/homeassistant.py
new file mode 100644 (file)
index 0000000..a3f2dbf
--- /dev/null
@@ -0,0 +1,224 @@
+#!/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
diff --git a/music_assistant/modules/playerproviders/lms.py b/music_assistant/modules/playerproviders/lms.py
new file mode 100644 (file)
index 0000000..6df0fc0
--- /dev/null
@@ -0,0 +1,320 @@
+#!/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
+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"]['lms'].get(CONF_ENABLED)
+    hostname = mass.config["playerproviders"]['lms'].get(CONF_HOSTNAME)
+    port = mass.config["playerproviders"]['lms'].get(CONF_PORT)
+    if enabled and hostname and port:
+        provider = LMSProvider(mass, hostname, port)
+        return provider
+    return False
+
+def config_entries():
+    ''' get the config entries for this provider (list with key/value pairs)'''
+    return [
+        (CONF_ENABLED, True, CONF_ENABLED),
+        (CONF_HOSTNAME, 'localhost', CONF_HOSTNAME), 
+        (CONF_PORT, 9000, CONF_PORT)
+        ]
+
+class LMSProvider(PlayerProvider):
+    ''' support for Logitech Media Server '''
+
+    def __init__(self, mass, hostname, port):
+        self.prov_id = 'lms'
+        self.name = 'Logitech Media Server'
+        self.icon = ''
+        self.mass = mass
+        self._players = {}
+        self._host = hostname
+        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.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())
+        asyncio.ensure_future(self.__get_players())            
+
+    ### 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) '''
+        lms_commands = []
+        if cmd == 'play':
+            lms_commands = ['play']
+        elif cmd == 'pause':
+            lms_commands = ['pause', '1']
+        elif cmd == 'stop':
+            lms_commands = ['stop']
+        elif cmd == 'next':
+            lms_commands = ['playlist', 'index', '+1']
+        elif cmd == 'previous':
+            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]:
+            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 == '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]:
+            lms_commands = ['mixer', 'muting', '0']
+        return await self.__get_data(lms_commands, player_id=player_id)
+
+    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
+        '''
+        if queue_opt == 'play':
+            cmd = ['playlist', 'insert', uri]
+            await self.__get_data(cmd, player_id=player_id)
+            cmd2 = ['playlist', 'index', '+1']
+            return await self.__get_data(cmd2, player_id=player_id)
+        elif queue_opt == 'replace':
+            cmd = ['playlist', 'play', uri]
+            return 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)
+        else:
+            cmd = ['playlist', 'add', uri]
+            return 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 = []
+        cur_index = await self.__get_data(["playlist", "index", "?"], player_id=player_id)
+        cur_index = int(cur_index['_index'])
+        offset += cur_index # we do not care about already played tracks
+        player_details = await self.__get_data(["status", offset, limit, "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id)
+        for item in player_details['playlist_loop']:
+            track = await self.__parse_track(item)
+            items.append(track)
+        return items
+
+    ### Provider specific (helper) methods #####
+    
+    async def __get_players(self):
+        ''' update all players, used as fallback if cometd is failing and to detect removed players'''
+        server_info = await self.__get_data(['players', 0, 1000])
+        player_ids = await self.__process_serverstatus(server_info)
+        for player_id in player_ids:
+            player_details = await self.__get_data(["status", "-","1", "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id)
+            await self.__process_player_details(player_id, player_details)
+
+    async def __process_player_details(self, player_id, player_details):
+        ''' get state of a given player '''
+        if player_id not in self._players:
+            return
+        player = self._players[player_id]
+        volume = player_details.get('mixer volume',0)
+        player.muted = volume < 0
+        if volume >= 0:
+            player.volume_level = player_details.get('mixer volume',0)
+        player.shuffle_enabled = player_details.get('playlist shuffle',0) != 0
+        player.repeat_enabled = player_details.get('playlist repeat',0) != 0
+        # player state
+        player.powered = player_details['power'] == 1
+        if player_details['mode'] == 'play':
+            player.state = PlayerState.Playing
+        elif player_details['mode'] == 'pause':
+            player.state = PlayerState.Paused
+        else:
+            player.state = PlayerState.Stopped
+        # current track
+        if player_details.get('playlist_loop'):
+            player.cur_item = await self.__parse_track(player_details['playlist_loop'][0])
+            player.cur_item_time = player_details.get('time',0)
+        else:
+            player.cur_item = None
+            player.cur_item_time = 0
+        await self.mass.player.update_player(player)
+
+    async def __process_serverstatus(self, server_status):
+        ''' process players from server state msg (players_loop) '''
+        cur_player_ids = []
+        for lms_player in server_status['players_loop']:
+            if lms_player['isplayer'] != 1:
+                continue
+            player_id = lms_player['playerid']
+            cur_player_ids.append(player_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 = lms_player['name']
+            if lms_player['model'] == "group":
+                player.is_group = True
+                # player is a groupplayer, retrieve childs
+                group_player_child_ids = await self.__get_group_childs(player_id)
+                for child_player_id in group_player_child_ids:
+                    self._players[child_player_id].group_parent = player_id
+            elif player.group_parent:
+                # check if player parent is still correct
+                group_player_child_ids = await self.__get_group_childs(player.group_parent)
+                if not player_id in group_player_child_ids:
+                    player.group_parent = None
+            # process update
+            await self.mass.player.update_player(player)
+        # process removed players...
+        for player_id, player in self._players.items():
+            if player_id not in cur_player_ids:
+                await self.mass.player.remove_player(player_id)
+        return cur_player_ids
+
+    async def __parse_track(self, track_details):
+        ''' parse track in LMS to our internal format '''
+        track_url = track_details.get('url','')
+        if track_url.startswith('qobuz://') and 'qobuz' in self.mass.music.providers:
+            # qobuz track!
+            try:
+                track_id = track_url.replace('qobuz://','').replace('.flac','')
+                return await self.mass.music.providers['qobuz'].track(track_id)
+            except Exception as exc:
+                LOGGER.error(exc)
+        elif track_url.startswith('spotify://track:') and 'spotify' in self.mass.music.providers:
+            # spotify track!
+            try:
+                track_id = track_url.replace('spotify://track:','')
+                return await self.mass.music.providers['spotify'].track(track_id)
+            except Exception as exc:
+                LOGGER.error(exc)
+        # fallback to a generic track
+        track = Track()
+        track.name = track_details['title']
+        track.duration = int(track_details['duration'])
+        image = "http://%s:%s%s" % (self._host, self._port, track_details['artwork_url'])
+        track.metadata['image'] = image
+        return track
+
+    async def __get_group_childs(self, group_player_id):
+        ''' get child players for groupplayer '''
+        group_childs = []
+        result = await self.__get_data('playergroup', player_id=group_player_id)
+        if result and 'players_loop' in result:
+            group_childs = [item['id'] for item in result['players_loop']]
+        return group_childs
+    
+    async def __lms_events(self):
+        # Receive events from LMS through CometD socket
+        while True:
+            try:
+                last_msg_received = 0
+                async with Client("http://%s:%s/cometd" % (self._host, self._port), 
+                            connection_types=ConnectionType.LONG_POLLING, 
+                            extensions=[LMSExtension()]) as client:
+                    # subscribe
+                    watched_players = []
+                    await client.subscribe("/slim/subscribe/serverstatus")
+                    
+                    # listen for incoming messages
+                    async for message in client:
+                        last_msg_received = int(time.time())
+                        if 'playerstatus' in message['channel']:
+                            # player state
+                            player_id = message['channel'].split('playerstatus/')[1]
+                            asyncio.ensure_future(self.__process_player_details(player_id, message['data']))           
+                        elif '/slim/serverstatus' in message['channel']:
+                            # server state with all players
+                            player_ids = await self.__process_serverstatus(message['data'])
+                            for player_id in player_ids:
+                                if player_id not in watched_players:
+                                    # subscribe to player change events
+                                    watched_players.append(player_id)
+                                    await client.subscribe("/slim/subscribe/playerstatus/%s" % player_id)
+            except Exception as exc:
+                LOGGER.exception(exc)
+      
+    async def __get_data(self, cmds:List, player_id=''):
+        ''' get data from api'''
+        if not isinstance(cmds, list):
+            cmds = [cmds]
+        cmd = [player_id, cmds]
+        url = "http://%s:%s/jsonrpc.js" % (self._host, self._port)
+        params = {"id": 1, "method": "slim.request", "params": cmd}
+        try:
+            async with self.http_session.post(url, json=params) as response:
+                result = await response.json()
+                return result['result']
+        except Exception as exc:
+            LOGGER.exception('Error executing LMS command %s' % params)
+            return None
+
+
+class LMSExtension(Extension):
+    ''' Extension for the custom cometd implementation of LMS'''
+
+    async def incoming(self, payload, headers=None):
+        pass
+
+    async def outgoing(self, payload, headers):
+        ''' override outgoing messages to fit LMS custom implementation'''
+
+        # LMS does not need/want id for the connect and handshake message    
+        if payload[0]['channel'] == '/meta/handshake' or payload[0]['channel'] == '/meta/connect':
+            del payload[0]['id']
+        
+        # handle subscriptions
+        if 'subscribe' in payload[0]['channel']:
+            client_id = payload[0]['clientId']
+            if payload[0]['subscription'] == '/slim/subscribe/serverstatus':
+                # append additional request data to the request
+                payload[0]['data'] = {'response':'/%s/slim/serverstatus' % client_id, 
+                            'request':['', ['serverstatus', 0, 100, 'subscribe:60']]}
+                payload[0]['channel'] = '/slim/subscribe'
+            if payload[0]['subscription'].startswith('/slim/subscribe/playerstatus'):
+                # append additional request data to the request
+                player_id = payload[0]['subscription'].split('/')[-1]
+                payload[0]['data'] = {'response':'/%s/slim/playerstatus/%s' % (client_id, player_id), 
+                            'request':[player_id, ["status", "-", 1, "tags:aAcCdegGijJKlostuxyRwk", "subscribe:60"]]}
+                payload[0]['channel'] = '/slim/subscribe'
\ No newline at end of file
diff --git a/music_assistant/music.py b/music_assistant/music.py
new file mode 100755 (executable)
index 0000000..e3bb4a3
--- /dev/null
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from utils import run_periodic, run_async_background_task, LOGGER
+import aiohttp
+from difflib import SequenceMatcher as Matcher
+from models import MediaType
+from typing import List
+import toolz
+import operator
+
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MODULES_PATH = os.path.join(BASE_DIR, "modules", "musicproviders" )
+
+class Music():
+    ''' several helpers around the musicproviders '''
+    
+    def __init__(self, mass):
+        self.sync_running = False
+        self.mass = mass
+        self.providers = {}
+        # dynamically load musicprovider modules
+        self.load_music_providers()
+        # schedule sync task
+        mass.event_loop.create_task(self.sync_music_providers())
+
+    async def item(self, item_id, media_type:MediaType, lazy=True):
+        ''' get single music item by id and media type'''
+        if media_type == MediaType.Artist:
+            return await self.artist(item_id, lazy=lazy)
+        elif media_type == MediaType.Album:
+            return await self.album(item_id, lazy=lazy)
+        elif media_type == MediaType.Track:
+            return await self.track(item_id, lazy=lazy)
+        elif media_type == MediaType.Playlist:
+            return await self.playlist(item_id)
+        else:
+            return None
+
+    async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None):
+        ''' return all library artists, optionally filtered by provider '''
+        return await self.mass.db.library_artists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_albums(self, limit=0, offset=0, orderby='name', provider_filter=None):
+        ''' return all library albums, optionally filtered by provider '''
+        return await self.mass.db.library_albums(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_tracks(self, limit=0, offset=0, orderby='name', provider_filter=None):
+        ''' 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):
+        ''' return all library playlists, optionally filtered by provider '''
+        return await self.mass.db.library_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'''
+        if media_type == MediaType.Artist:
+            return await self.library_artists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Album:
+            return await self.library_albums(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Track:
+            return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Playlist:
+            return await self.library_playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+
+    async def artist(self, item_id, 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)
+
+    async def album(self, item_id, 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)
+
+    async def track(self, item_id, 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)
+
+    async def playlist(self, item_id):
+        ''' 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)
+
+    async def artist_toptracks(self, artist_id):
+        ''' get top tracks for given artist '''
+        items = []
+        artist = await self.artist(artist_id)
+        # always append database tracks
+        items += await self.mass.db.artist_tracks(artist.item_id)
+        for prov_mapping in artist.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.artist_toptracks(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def artist_albums(self, artist_id):
+        ''' get (all) albums for given artist '''
+        items = []
+        artist = await self.artist(artist_id)
+        # always append database tracks
+        items += await self.mass.db.artist_albums(artist.item_id)
+        for prov_mapping in artist.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.artist_albums(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def album_tracks(self, album_id):
+        ''' get the album tracks for given album '''
+        items = []
+        album = await self.album(album_id)
+        for prov_mapping in album.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.album_tracks(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        return items
+
+    async def playlist_tracks(self, playlist_id, 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)
+        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 '''
+        # get results from database
+        result = await self.mass.db.search(searchquery, media_types, limit)
+        if online:
+            # include results from music providers
+            for prov in self.providers.values():
+                prov_results = await prov.search(searchquery, media_types, limit)
+                for item_type, items in prov_results.items():
+                    result[item_type] += items
+            # filter out duplicates
+            for item_type, items in result.items():
+                items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        return result
+
+    async def item_action(self, item_id, media_type, action=None):
+        ''' perform action on item (such as library add/remove) '''
+        result = None
+        item = await self.item(item_id, media_type)
+        if item and action in ['add', 'remove']:
+            # remove or add item to the library
+            for prov_mapping in result.provider_ids:
+                prov_id = prov_mapping['provider']
+                prov_item_id = prov_mapping['item_id']
+                for prov in self.providers.values():
+                    if prov.prov_id == prov_id:
+                        if action == 'add':
+                            result = await prov.add_library(prov_item_id, media_type)
+                        elif action == 'remove':
+                            result = await prov.remove_library(prov_item_id, media_type)
+        return result
+    
+    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 '''
+        if self.sync_running:
+            return
+        self.sync_running = True
+        for prov_id in self.providers.keys():
+            # sync library artists
+            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)
+        self.sync_running = False
+        
+    async def sync_library_artists(self, prov_id):
+        ''' sync library artists for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_artists(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_artists()
+        cur_db_ids = []
+        for item in cur_items:
+            db_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)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id)
+        LOGGER.info("Finished syncing Artists for provider %s" % prov_id)
+
+    async def sync_library_albums(self, prov_id):
+        ''' sync library albums for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_albums(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_albums()
+        cur_db_ids = []
+        for item in cur_items:
+            db_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)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id)
+        LOGGER.info("Finished syncing Albums for provider %s" % prov_id)
+
+    async def sync_library_tracks(self, prov_id):
+        ''' sync library tracks for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_tracks(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_tracks()
+        cur_db_ids = []
+        for item in cur_items:
+            db_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)
+        # 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):
+        ''' sync library playlists for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_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_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)
+            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) )
+        # process playlist deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Playlist, prov_id)
+        LOGGER.info("Finished syncing Playlists for provider %s" % prov_id)
+
+    async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id):
+        ''' sync library playlists tracks for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.playlist_tracks(db_playlist_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_playlist_tracks(prov_playlist_id, limit=0)
+        cur_db_ids = []
+        pos = 0
+        for item in cur_items:
+            # we need to do this the complicated way because the file provider can return tracks from other providers
+            for prov_mapping in item.provider_ids:
+                item_prov_id = prov_mapping['provider']
+                prov_item_id = prov_mapping['item_id']
+                db_id = await self.mass.db.get_database_id(item_prov_id, prov_item_id, MediaType.Track)
+                if db_id == 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)
+            pos += 1
+        # process playlist track deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_playlist_track(db_playlist_id, db_id)
+        LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id))
+
+    def load_music_providers(self):
+        ''' dynamically load musicproviders '''
+        for item in os.listdir(MODULES_PATH):
+            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
+                    item.endswith('.py') and not item.startswith('.')):
+                module_name = item.replace(".py","")
+                LOGGER.debug("Loading musicprovider module %s" % module_name)
+                try:
+                    mod = __import__("modules.musicproviders." + module_name, fromlist=[''])
+                    if not self.mass.config['musicproviders'].get(module_name):
+                        self.mass.config['musicproviders'][module_name] = {}
+                    self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries()
+                    for key, def_value, desc in mod.config_entries():
+                        if not key in self.mass.config['musicproviders'][module_name]:
+                            self.mass.config['musicproviders'][module_name][key] = def_value
+                    mod = mod.setup(self.mass)
+                    if mod:
+                        self.providers[mod.prov_id] = mod
+                        cls_name = mod.__class__.__name__
+                        LOGGER.info("Successfully initialized module %s" % cls_name)
+                except Exception as exc:
+                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
diff --git a/music_assistant/player.py b/music_assistant/player.py
new file mode 100755 (executable)
index 0000000..829a398
--- /dev/null
@@ -0,0 +1,254 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from utils import run_periodic, LOGGER, try_parse_int
+import aiohttp
+from difflib import SequenceMatcher as Matcher
+from models import MediaType, PlayerState, MusicPlayer
+from typing import List
+import toolz
+import operator
+
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MODULES_PATH = os.path.join(BASE_DIR, "modules", "playerproviders" )
+
+class Player():
+    ''' several helpers to handle playback through player providers '''
+    
+    def __init__(self, mass):
+        self.mass = mass
+        self.providers = {}
+        self._players = {}
+        # dynamically load provider modules
+        self.load_providers()
+
+    async def players(self):
+        ''' return all players '''
+        items = list(self._players.values())
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def player(self, player_id):
+        ''' return players by id '''
+        return self._players[player_id]
+
+    async def player_command(self, player_id, cmd, cmd_args=None):
+        ''' 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]
+        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)
+        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:
+            # 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)
+            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:
+                    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))
+                    await self.player_command(child_player.player_id, 'volume', new_child_volume)
+            player.volume_level = new_volume
+            return True
+        else:
+            prov_id = self._players[player_id].player_provider
+            prov = self.providers[prov_id]
+            return await prov.player_command(player_id, cmd, cmd_args)
+            
+    async def remove_player(self, player_id):
+        ''' handle a player remove '''
+        self._players.pop(player_id, None)
+        asyncio.ensure_future(self.mass.event('player removed', player_id))
+
+    async def update_player(self, player_details):
+        ''' update (or add) player '''
+        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:
+            self._players[player_id] = MusicPlayer()
+            player = self._players[player_id]
+            player.player_id = player_id
+            player.player_provider  = player_details.player_provider
+            player_changed = True
+        else:
+            player = self._players[player_id]
+        
+        # handle basic player settings
+        player_details.enabled = player_settings['enabled']
+        player_details.name = player_settings['name']
+        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 mute as power setting
+        if player_details.mute_as_power:
+            player_details.powered = not player_details.muted
+        # combine state of group parent
+        if player_settings['group_parent']:
+            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
+        # handle group volume setting
+        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:
+                    group_volume += child_player.volume_level
+                    active_players += 1
+            group_volume = group_volume / active_players if active_players else 0
+            player_details.volume_level = group_volume
+        # compare values to detect changes
+        for key, cur_value in player.__dict__.items():
+            new_value = getattr(player_details, key)
+            if new_value != cur_value:
+                player_changed = True
+                setattr(player, key, new_value)
+                LOGGER.debug('key changed: %s for player %s - new value: %s' % (key, player.name, new_value))
+        if player_changed:
+            # player is added or updated!
+            asyncio.ensure_future(self.mass.event('player updated', player))
+
+    async def get_player_config(self, player_details):
+        ''' get or create player config '''
+        player_id = player_details.player_id
+        if player_id in self.mass.config['player_settings']:
+            return self.mass.config['player_settings'][player_id]
+        new_config = {
+                "name": player_details.name,
+                "group_parent": player_details.group_parent,
+                "mute_as_power": False,
+                "disable_volume": False,
+                "apply_group_volume": False,
+                "enabled": False
+            }
+        self.mass.config['player_settings'][player_id] = new_config
+        return new_config
+
+    async def play_media(self, player_id, media_item, queue_opt='replace'):
+        ''' 
+            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
+        '''
+        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
+        if media_item.media_type == MediaType.Artist:
+            tracks = await self.mass.music.artist_toptracks(media_item.item_id)
+        elif media_item.media_type == MediaType.Album:
+            tracks = await self.mass.music.album_tracks(media_item.item_id)
+        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'
+        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)
+        else:
+            # TODO: Implement 'fake' queue
+            raise NotImplementedError
+    
+    async def get_item_uri(self, media_type, item_id, provider):
+        ''' generate the URL/URI for a media item '''
+        uri = ""
+        if provider == "spotify" and media_type == MediaType.Track:
+            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:
+            uri = 'qobuz://%s.flac' % item_id
+        elif provider == "file":
+            uri = 'file://%s' % item_id
+        return uri
+
+    def load_providers(self):
+        ''' dynamically load providers '''
+        for item in os.listdir(MODULES_PATH):
+            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
+                    item.endswith('.py') and not item.startswith('.')):
+                module_name = item.replace(".py","")
+                LOGGER.debug("Loading playerprovider module %s" % module_name)
+                try:
+                    mod = __import__("modules.playerproviders." + module_name, fromlist=[''])
+                    if not self.mass.config['playerproviders'].get(module_name):
+                        self.mass.config['playerproviders'][module_name] = {}
+                    self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries()
+                    for key, def_value, desc in mod.config_entries():
+                        if not key in self.mass.config['playerproviders'][module_name]:
+                            self.mass.config['playerproviders'][module_name][key] = def_value
+                    mod = mod.setup(self.mass)
+                    if mod:
+                        self.providers[mod.prov_id] = mod
+                        cls_name = mod.__class__.__name__
+                        LOGGER.info("Successfully initialized module %s" % cls_name)
+                except Exception as exc:
+                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
diff --git a/music_assistant/utils.py b/music_assistant/utils.py
new file mode 100755 (executable)
index 0000000..4087751
--- /dev/null
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+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")
+consolehandler = logging.StreamHandler()
+consolehandler.setFormatter(logformat)
+LOGGER.addHandler(consolehandler)
+LOGGER.setLevel(logging.DEBUG)
+
+def run_periodic(period):
+    def scheduler(fcn):
+        async def wrapper(*args, **kwargs):
+            while True:
+                asyncio.create_task(fcn(*args, **kwargs))
+                await asyncio.sleep(period)
+        return wrapper
+    return scheduler
+
+def run_background_task(executor, corofn, *args):
+    ''' run non-async task in background '''
+    return asyncio.get_event_loop().run_in_executor(executor, corofn, *args)
+
+def run_async_background_task(executor, corofn, *args):
+    ''' run async task in background '''
+    def run_task(corofn, *args):
+        loop = asyncio.new_event_loop()
+        try:
+            coro = corofn(*args)
+            asyncio.set_event_loop(loop)
+            return loop.run_until_complete(coro)
+        finally:
+            loop.close()
+    return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args)
+
+def get_sort_name(name):
+    ''' create a sort name for an artist/title '''
+    sort_name = name
+    for item in ["The ", "De ", "de ", "Les "]:
+        if name.startswith(item):
+            sort_name = "".join(name.split(item)[1:])
+    return sort_name
+
+def try_parse_int(possible_int):
+    try:
+        return int(possible_int)
+    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()
+    title = track_title
+    version = ''
+    for splitter in [" (", " [", " - ", " (", " [", "-"]:
+        if splitter in title:
+            title_parts = title.split(splitter)
+            for title_part in title_parts:
+                # look for the end splitter
+                for end_splitter in [")", "]"]:
+                    if end_splitter in title_part:
+                        title_part = title_part.split(end_splitter)[0]
+                for ignore_str in ["feat.", "featuring", "ft.", "with ", " & "]:
+                    if ignore_str in title_part:
+                        title = title.split(splitter+title_part)[0]
+                for version_str in ["version", "live", "edit", "remix", "mix", 
+                            "acoustic", " instrumental", "karaoke", "remaster", "versie", "explicit", "radio", "unplugged", "disco"]:
+                    if version_str in title_part:
+                        version = title_part
+                        title = title.split(splitter+version)[0]
+    title = title.strip().title()
+    # version substitues
+
+    version = version.strip().title()
+    return title, version
+
diff --git a/music_assistant/web/components/headermenu.vue.js b/music_assistant/web/components/headermenu.vue.js
new file mode 100755 (executable)
index 0000000..d5505c7
--- /dev/null
@@ -0,0 +1,53 @@
+Vue.component("headermenu", {
+  template: `<div>
+    <v-navigation-drawer dark app clipped temporary v-model="menu">
+        <v-list >
+            <v-list-tile
+               v-for="item in items" :key="item.title" @click="$router.push(item.path)">
+                <v-list-tile-action>
+                    <v-icon>{{ item.icon }}</v-icon>
+                </v-list-tile-action>
+                <v-list-tile-content>
+                    <v-list-tile-title>{{ item.title }}</v-list-tile-title>
+                </v-list-tile-content>
+            </v-list-tile>
+        </v-list>
+    </v-navigation-drawer>
+    
+    <v-toolbar fixed flat dense dark color="transparent" scroll-off-screen > 
+        <v-layout align-center>
+            <v-btn icon v-on:click="menu=!menu">
+              <v-icon>menu</v-icon>
+            </v-btn>
+            <v-btn @click="$router.go(-1)" icon>
+              <v-icon>arrow_back</v-icon>
+            </v-btn>
+            <v-spacer></v-spacer>
+            <v-spacer></v-spacer>
+            <v-btn icon>
+                <v-icon>search</v-icon>
+              </v-btn>
+        </v-layout>
+    </v-toolbar>
+</div>`,
+  props: [],
+  $_veeValidate: {
+    validator: "new"
+  },
+  data() {
+    return {
+      menu: false,
+      items: [
+        { title: "Home", icon: "home", path: "/" },
+        { title: "Artists", icon: "person", path: "/artists" },
+        { title: "Albums", icon: "album", path: "/albums" },
+        { title: "Tracks", icon: "audiotrack", path: "/tracks" },
+        { title: "Playlists", icon: "playlist_play", path: "/playlists" },
+        { title: "Search", icon: "search", path: "/search" },
+        { title: "Config", icon: "settings", path: "/config" }
+      ]
+    }
+  },
+  mounted() { },
+  methods: { }
+})
diff --git a/music_assistant/web/components/infoheader.vue.js b/music_assistant/web/components/infoheader.vue.js
new file mode 100644 (file)
index 0000000..8ccf661
--- /dev/null
@@ -0,0 +1,132 @@
+Vue.component("infoheader", {\r
+       template: `\r
+               <v-flex xs12>\r
+          <v-card color="cyan darken-2" class="white--text" img="../images/info_gradient.jpg">\r
+            <v-img\r
+              class="white--text"\r
+              width="100%"\r
+              height="370"\r
+              position="center top" \r
+              :src="getFanartImage()"\r
+              gradient="to top right, rgba(100,115,201,.33), rgba(25,32,72,.7)"\r
+            >\r
+            <div class="text-xs-center" style="height:40px" id="whitespace_top"/>\r
+\r
+            <v-layout style="margin-left:5px;margin-right:5px">\r
+              \r
+              <!-- left side: cover image -->\r
+              <v-flex xs5 pa-4 v-if="!isMobile()">\r
+                                                               <v-img :src="getThumb()" lazy-src="/images/default_artist.png" width="250px" height="250px" style="border: 4px solid grey;border-radius: 15px;"></v-img>\r
+                                                               \r
+                                                               <!-- tech specs and provider icons -->\r
+                                                               <div style="margin-top:10px;">\r
+                                                                       <a v-for="(value, key) in info.provider_ids" :href="info.metadata[key + '_url']" target="_blank" :key="key">\r
+                                                                               <img height="30" :src="'/images/icons/' + key + '.png'" style="padding-right:5px" />\r
+                                                                       </a>\r
+                                                                       <div style="text-shadow: 1px 1px #000000;vertical-align:top;margin-left:30px;margin-top:-35px;">\r
+                                                                                       <qualityicon v-if="info.media_type == 3" v-bind:item="info" :height="30" :compact="false"/>\r
+                                                                       </div>\r
+                                                               </div>\r
+              </v-flex>\r
+              \r
+              <v-flex>\r
+                  <!-- Main title -->\r
+                  <v-card-title class="display-1" style="text-shadow: 1px 1px #000000;padding-bottom:0px;">\r
+                                                                                       {{ info.name }} \r
+                                                                                       <span class="subheading" v-if="!!info.version" style="padding-left:10px;"> ({{ info.version }})</span>\r
+                                                                       </v-card-title>\r
+                                                                       \r
+                                                                       <!-- item artists -->\r
+                                                                       <v-card-title style="text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
+                                                                                       <span v-if="!!info.artists" v-for="(artist, artistindex) in info.artists" class="headline" :key="artist.db_id">\r
+                                                                                                       <a style="color:#2196f3" v-on:click="clickItem(artist)">{{ artist.name }}</a>\r
+                                                                                                       <label style="color:#2196f3" v-if="artistindex + 1 < info.artists.length" :key="artistindex"> / </label>\r
+                                                                                       </span>\r
+                                                                                       <span v-if="!!info.artist" class="headline">\r
+                                                                                                       <a style="color:#2196f3" v-on:click="clickItem(artist)">{{ info.artist.name }}</a>\r
+                                                                                       </span>\r
+                                                                                       <span v-if="!!info.owner" class="headline">\r
+                                                                                                       <a style="color:#2196f3" v-on:click="">{{ info.owner }}</a>\r
+                                                                                       </span>\r
+                                                                       </v-card-title>\r
+\r
+                                                                       <v-card-title v-if="info.album" style="color:#ffffff;text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
+                                                                                       <a class="headline" style="color:#ffffff" v-on:click="clickItem(info.album)">{{ info.album.name }}</a>\r
+                                                                       </v-card-title>\r
+\r
+                  <!-- play/info buttons -->\r
+                  <div style="margin-left:8px;">\r
+                      <v-btn color="blue-grey" @click="showPlayMenu(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>play_circle_outline</v-icon>Play</v-btn>\r
+                      <v-btn v-if="!!info.in_library && info.in_library.length == 0" color="blue-grey" @click="toggleLibrary(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>favorite_border</v-icon>Add to library</v-btn>\r
+                      <v-btn v-if="!!info.in_library && info.in_library.length > 0" color="blue-grey" @click="toggleLibrary(info)"  class="white--text"><v-icon v-if="!isMobile()" left dark>favorite</v-icon>Remove from library</v-btn>\r
+                  </div>\r
+\r
+                  <!-- Description/metadata -->\r
+                  <v-card-title class="subheading">\r
+                      <div class="justify-left" style="text-shadow: 1px 1px #000000;">\r
+                          <read-more :text="getDescription()" :max-chars="isMobile() ? 200 : 350"></read-more>\r
+                      </div>\r
+                  </v-card-title>\r
+\r
+              </v-flex>\r
+            </v-layout>\r
+              \r
+            </v-img>\r
+            <div class="text-xs-center" v-if="info.tags">\r
+                <v-chip small color="white"  outline v-for="(tag, index) in info.tags" :key="tag" >{{ tag }}</v-chip>\r
+            </div>\r
+            \r
+          </v-card>\r
+        </v-flex>\r
+`,\r
+       props: ['info'],\r
+       data (){\r
+               return{}\r
+       },\r
+       mounted() { },\r
+       created() { },\r
+       methods: { \r
+               getFanartImage() {\r
+                       var img = '';\r
+      if (this.info.metadata && this.info.metadata.fanart)\r
+                               img = this.info.metadata.fanart;\r
+                       else if (this.info.artists)\r
+                                       this.info.artists.forEach(function(artist) {\r
+                                               if (artist.metadata && artist.metadata.fanart)\r
+                                                       img = artist.metadata.fanart;\r
+                                       });\r
+                       return img;\r
+               },\r
+               getThumb() {\r
+                       var img = '';\r
+      if (this.info.metadata && this.info.metadata.image)\r
+                               img = this.info.metadata.image;\r
+                       else if (this.info.album && this.info.album.metadata && this.info.album.metadata.image)\r
+                               img = this.info.album.metadata.image;\r
+                       else if (this.info.artists)\r
+                                       this.info.artists.forEach(function(artist) {\r
+                                               if (artist.metadata && artist.metadata.image)\r
+                                                       img = artist.metadata.image;\r
+                                       });\r
+                       return img;\r
+               },\r
+               getDescription() {\r
+                       var desc = '';\r
+      if (this.info.metadata && this.info.metadata.description)\r
+                               return this.info.metadata.description;\r
+                       else if (this.info.metadata && this.info.metadata.biography)\r
+                               return this.info.metadata.biography;\r
+                       else if (this.info.metadata && this.info.metadata.copyright)\r
+                               return this.info.metadata.copyright;\r
+                       else if (this.info.artists)\r
+                       {\r
+                               this.info.artists.forEach(function(artist) {\r
+                                       console.log(artist.metadata.biography);\r
+                                       if (artist.metadata && artist.metadata.biography)\r
+                                                       desc = artist.metadata.biography;\r
+                               });\r
+                       }\r
+                       return desc;\r
+               },\r
+       }\r
+})\r
diff --git a/music_assistant/web/components/listviewItem.vue.js b/music_assistant/web/components/listviewItem.vue.js
new file mode 100755 (executable)
index 0000000..e54b811
--- /dev/null
@@ -0,0 +1,89 @@
+Vue.component("listviewItem", {
+  template: `
+    <div>
+    <v-list-tile
+    avatar
+    ripple
+    @click="clickItem(item)">
+
+          <v-list-tile-avatar color="grey" v-if="!hideavatar">
+              <img v-if="(item.media_type != 3) && item.metadata && item.metadata.image" :src="item.metadata.image"/>
+              <img v-if="(item.media_type == 3) && item.album && item.album.metadata && item.album.metadata.image" :src="item.album.metadata.image"/>
+              <v-icon v-if="(item.media_type == 3) && item.album && item.album.metadata && !item.album.metadata.image">audiotrack</v-icon>
+              <v-icon v-if="(item.media_type != 1 && item.media_type != 3) && (!item.metadata || !item.metadata.image)">album</v-icon>
+              <v-icon v-if="(item.media_type == 1) && (!item.metadata || !item.metadata.image)">person</v-icon>
+              <v-icon v-if="(item.media_type == 3) && (!item.metadata || !item.album.metadata.image)">audiotrack</v-icon>
+          </v-list-tile-avatar>
+          
+          <v-list-tile-content>
+            
+            <v-list-tile-title>
+                {{ item.name }}<span v-if="!!item.version"> ({{ item.version }})</span>
+            </v-list-tile-title>
+            
+            <v-list-tile-sub-title v-if="item.artists">
+                <span v-for="(artist, artistindex) in item.artists">
+                    <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
+                    <label v-if="artistindex + 1 < item.artists.length" :key="artistindex"> / </label>
+                </span>
+                <a v-if="!!item.album && !!hidetracknum" v-on:click="clickItem(item.album)" @click.stop="" style="color:grey">  -  {{ item.album.name }}</a>
+                <label v-if="!hidetracknum && item.track_number" style="color:grey">  -  disc {{ item.disc_number }} track {{ item.track_number }}</label>
+            </v-list-tile-sub-title>
+            <v-list-tile-sub-title v-if="item.artist">
+                <a v-on:click="clickItem(artist)" @click.stop="">{{ item.artist.name }}</a>
+            </v-list-tile-sub-title>
+
+            <v-list-tile-sub-title v-if="!!item.owner">
+                {{ item.owner }}
+            </v-list-tile-sub-title>
+
+          </v-list-tile-content>
+
+          <qualityicon v-if="item.media_type == 3" v-bind:item="item" :height="25" :compact="true" :dark="true" :hiresonly="true"/>
+
+          <v-list-tile-action v-if="!hideproviders" v-for="provider in item.provider_ids" :key="provider.provider + provider.item_id">
+            <v-tooltip bottom>
+              <template v-slot:activator="{ on }">
+                <img v-on="on" height="20" :src="'images/icons/' + provider.provider + '.png'"/>
+              </template>
+              <span v-if="provider.details">{{ provider.details }}</span>
+              <span v-if="!provider.details">{{ provider.quality }}</span>
+            </v-tooltip>
+          </v-list-tile-action> 
+
+          <v-list-tile-action v-if="!hidelibrary">
+              <v-tooltip bottom>
+                  <template v-slot:activator="{ on }">
+                      <v-btn icon ripple v-on="on" v-on:click="toggleLibrary(item)" @click.stop="" >
+                          <v-icon height="20" v-if="item.in_library.length > 0">favorite</v-icon>
+                          <v-icon height="20" v-if="item.in_library.length == 0">favorite_border</v-icon>
+                      </v-btn>
+                  </template>
+                  <span v-if="item.in_library.length > 0">Item is added to the library</span>
+                  <span v-if="item.in_library.length == 0">Add item to the library</span>
+              </v-tooltip>
+          </v-list-tile-action>
+
+          <v-list-tile-action v-if="!hideduration && !!item.duration">
+              {{ item.duration.toString().formatDuration() }}
+          </v-list-tile-action> 
+        
+          <!-- menu button/icon -->
+          <v-icon v-if="!hidemenu" @click="showPlayMenu(item)" @click.stop="" color="grey lighten-1" style="margin-right:-10px;padding-left:10px">more_vert</v-icon>
+          
+
+        </v-list-tile>
+        <v-divider v-if="index + 1 < totalitems" :key="index"></v-divider>
+        </div>
+     `,
+props: ['item', 'index', 'totalitems', 'hideavatar', 'hidetracknum', 'hideproviders', 'hidemenu', 'hidelibrary', 'hideduration'],
+data() {
+  return {
+    selected: [2],
+    items: [],
+    offset: 0,
+  }
+  },
+methods: {
+  }
+})
diff --git a/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js
new file mode 100755 (executable)
index 0000000..7b87ac3
--- /dev/null
@@ -0,0 +1,286 @@
+Vue.component("player", {
+  template: `
+  <div>
+
+    <!-- player bar in footer -->
+    <v-footer app light height="auto">
+      
+      <v-card class="flex" tile style="background-color:#e8eaed;">
+        <v-list-tile avatar ripple style="margin-bottom:15px;">
+
+              <v-list-tile-avatar v-if="!isMobile() && active_player.cur_item" style="align-items:center;padding-top:15px;">
+                  <img v-if="active_player.cur_item.metadata && active_player.cur_item.metadata.image" :src="active_player.cur_item.metadata.image"/>
+                  <img v-if="!active_player.cur_item.metadata.image && active_player.cur_item.album && active_player.cur_item.album.metadata && active_player.cur_item.album.metadata.image" :src="active_player.cur_item.album.metadata.image"/>
+              </v-list-tile-avatar>
+
+              <v-list-tile-content v-if="!isMobile()" style="align-items:center;padding-top:15px;">
+                  <v-list-tile-title class="title">{{ active_player.cur_item ? active_player.cur_item.name : active_player.name }}</v-list-tile-title>
+                  <v-list-tile-sub-title v-if="active_player.cur_item && active_player.cur_item.artists">
+                      <span v-for="(artist, artistindex) in active_player.cur_item.artists">
+                          <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
+                          <label v-if="artistindex + 1 < active_player.cur_item.artists.length" :key="artistindex"> / </label>
+                      </span>
+                  </v-list-tile-sub-title>
+              </v-list-tile-content>
+
+
+              <!-- player controls -->
+              <v-list-tile-content>
+                  <v-layout row style="content-align: center;vertical-align: middle; margin-top:10px;">
+                    <v-btn icon style="padding:5px;" @click="playerCommand('previous')"><v-icon color="rgba(0,0,0,.54)">skip_previous</v-icon></v-btn>
+                    <v-btn icon style="padding:5px;" v-if="active_player.state == 'playing'" @click="playerCommand('pause')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">pause</v-icon></v-btn>
+                    <v-btn icon style="padding:5px;" v-if="active_player.state != 'playing'" @click="playerCommand('play')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">play_arrow</v-icon></v-btn>
+                    <v-btn icon style="padding:5px;" @click="playerCommand('next')"><v-icon color="rgba(0,0,0,.54)">skip_next</v-icon></v-btn>
+                  </v-layout>
+              </v-list-tile-content>
+
+              <!-- active player queue button -->
+              <v-list-tile-action style="padding:30px;" v-if="!isMobile() && active_player_id">
+                  <v-btn flat icon @click="$router.push('/queue/' + active_player_id)">
+                      <v-flex xs12 class="vertical-btn">
+                      <v-icon large>queue_music</v-icon>
+                      <span class="caption">Queue</span>
+                    </v-flex>    
+                  </v-btn>
+              </v-list-tile-action> 
+
+              <!-- active player volume -->
+              <v-list-tile-action style="padding:30px;" v-if="active_player_id">
+                  <v-menu :close-on-content-click="false" :nudge-width="250" offset-x top>
+                    <template v-slot:activator="{ on }">
+                        <v-btn flat icon v-on="on">
+                            <v-flex xs12 class="vertical-btn">
+                            <v-icon large>volume_up</v-icon>
+                            <span class="caption">{{ Math.round(players[active_player_id].volume_level) }}</span>
+                          </v-flex>    
+                        </v-btn>
+                    </template>
+                    <volumecontrol v-bind:players="players" v-bind:player_id="active_player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
+                  </v-menu>
+              </v-list-tile-action> 
+
+              <!-- active player btn -->
+              <v-list-tile-action style="padding:30px;margin-right:-13px;">
+                  <v-btn flat icon @click="menu = !menu">
+                      <v-flex xs12 class="vertical-btn">
+                      <v-icon large>speaker</v-icon>
+                      <span class="caption">{{ active_player_id ? players[active_player_id].name : '' }}</span>
+                    </v-flex>    
+                  </v-btn>
+              </v-list-tile-action>
+          </v-list-tile>
+
+          <!-- progress bar -->
+          <div style="color:rgba(0,0,0,.65); height:35px;width:100%; vertical-align: middle; left:15px; right:0; bottom:0" v-if="!isMobile()">
+            <v-layout row style="vertical-align: middle">
+              <span style="text-align:left; width:60px; margin-top:7px; margin-left:15px;">{{ player_time_str_cur }}</span>
+              <v-progress-linear v-model="progress"></v-progress-linear>
+              <span style="text-align:right; width:60px; margin-top:7px; margin-right: 15px;">{{ player_time_str_total }}</span>
+            </v-layout>
+        </div>
+
+      </v-card>
+    </v-footer>
+
+    <!-- players side menu -->
+    <v-navigation-drawer right app clipped temporary v-model="menu">
+        <v-card-title class="headline">
+            <b>Players</b>
+        </v-card-title>
+        <v-list two-line>
+            <v-divider></v-divider>
+            <div v-for="(player, player_id, index) in players" :key="player_id" v-if="player.enabled && !player.group_parent">
+              <v-list-tile avatar ripple style="margin-left: -5px; margin-right: -15px" @click="switchPlayer(player.player_id)" :style="active_player_id == player.player_id ? 'background-color: rgba(50, 115, 220, 0.3);' : ''">
+                  <v-list-tile-avatar>
+                      <v-icon size="45">{{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }}</v-icon>
+                  </v-list-tile-avatar>
+                  <v-list-tile-content>
+                      <v-list-tile-title class="title">{{ player.name }}</v-list-tile-title>
+
+                      <v-list-tile-sub-title v-if="player.cur_item" class="body-1" :key="player.state">
+                          {{ player.state }}
+                      </v-list-tile-sub-title>
+
+                  </v-list-tile-content>
+
+                  <v-list-tile-action style="padding:30px;" v-if="active_player_id">
+                      <v-menu :close-on-content-click="false" :nudge-width="250" offset-x right>
+                        <template v-slot:activator="{ on }">
+                            <v-btn flat icon style="color:rgba(0,0,0,.54);" v-on="on">
+                                <v-flex xs12 class="vertical-btn">
+                                <v-icon>volume_up</v-icon>
+                                <span class="caption">{{ Math.round(player.volume_level) }}</span>
+                              </v-flex>    
+                            </v-btn>
+                        </template>
+                        <volumecontrol v-bind:players="players" v-bind:player_id="player.player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
+                      </v-menu>
+                  </v-list-tile-action> 
+
+
+
+              </v-list-tile>
+            <v-divider></v-divider>
+            </div>
+        </v-list>
+    </v-navigation-drawer>
+    <playmenu v-model="$globals.showplaymenu" v-on:playItem="playItem"/>
+  </div>
+  
+  `,
+  props: [],
+  $_veeValidate: {
+    validator: "new"
+  },
+  watch: {},
+  data() {
+    return {
+      menu: false,
+      players: {},
+      active_player_id: "",
+      ws: null
+    }
+  },
+  mounted() { },
+  created() {
+    this.connectWS();
+    this.updateProgress();
+  },
+  computed: {
+
+    active_player() {
+      if (this.players && this.active_player_id && this.active_player_id in this.players)
+          return this.players[this.active_player_id];
+      else
+          return {
+            name: 'no player selected',
+            cur_item: null,
+            cur_item_time: 0,
+            player_id: '',
+            volume_level: 0,
+            state: 'stopped'
+          };
+    },
+    progress() {
+      if (!this.active_player.cur_item)
+        return 0;
+      var total_sec = this.active_player.cur_item.duration;
+      var cur_sec = this.active_player.cur_item_time;
+      var cur_percent = cur_sec/total_sec*100;
+      return cur_percent;
+    },
+    player_time_str_cur() {
+      if (!this.active_player.cur_item || !this.active_player.cur_item_time)
+        return "0:00";
+      var cur_sec = this.active_player.cur_item_time;
+      return cur_sec.toString().formatDuration();
+    },
+    player_time_str_total() {
+      if (!this.active_player.cur_item)
+        return "0:00";
+      var total_sec = this.active_player.cur_item.duration;
+      return total_sec.toString().formatDuration();
+    }
+  },
+  methods: { 
+    playerCommand (cmd, cmd_opt=null, player_id=this.active_player_id) {
+      if (cmd_opt)
+        cmd = cmd + '/' + cmd_opt
+      cmd = 'players/' + player_id + '/cmd/' + cmd;
+      this.ws.send(cmd);
+    },
+    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);
+    },
+    switchPlayer (new_player_id) {
+      this.active_player_id = new_player_id;
+    },
+    isGroup(player_id) {
+                       for (var item in this.players)
+                               if (this.players[item].group_parent == player_id && this.players[item].enabled)
+                                       return true;
+                       return false;
+    },
+    updateProgress: function(){           
+      this.intervalid2 = setInterval(function(){
+          if (this.active_player.state == 'playing')
+              this.active_player.cur_item_time +=1;
+      }.bind(this), 1000);
+    },
+    setPlayerVolume: function(player_id, new_volume) {
+      this.players[player_id].volume_level = new_volume;
+      this.playerCommand('volume', new_volume, player_id);
+    },
+    togglePlayerPower: function(player_id) {
+      if (this.players[player_id].powered)
+        this.playerCommand('power', 'off', player_id);
+      else
+        this.playerCommand('power', 'on', player_id);
+    },
+    connectWS() {
+      var loc = window.location, new_uri;
+      if (loc.protocol === "https:") {
+          new_uri = "wss:";
+      } else {
+          new_uri = "ws:";
+      }
+      new_uri += "/" + loc.host;
+      new_uri += loc.pathname + "ws";
+      this.ws = new WebSocket(new_uri);
+
+      this.ws.onopen = function() {
+        console.log('websocket connected!');
+        this.ws.send('players');
+      }.bind(this);
+    
+      this.ws.onmessage = function(e) {
+        var msg = JSON.parse(e.data);
+        var players = [];
+        if (msg.message == 'player updated')
+          players = [msg.message_details];
+        else if (msg.message == 'players')
+          players = msg.message_details;
+        
+        for (var item of players)
+          if (item.player_id in this.players)
+              this.players[item.player_id] = Object.assign({}, this.players[item.player_id], item);
+          else
+            this.$set(this.players, item.player_id, item)
+
+        // select new active player
+        // TODO: store previous player in local storage
+        if (!this.active_player_id)
+          for (var player_id in this.players)
+            if (this.players[player_id].state == 'playing' && this.players[player_id].enabled) {
+              // prefer the first playing player
+              this.active_player_id = player_id;
+              break; 
+            }
+        if (!this.active_player_id)
+          for (var player_id in this.players) {
+            // fallback to just the first player
+            if (this.players[player_id].enabled)
+            {
+              this.active_player_id = player_id;
+              break; 
+            }
+          }
+      }.bind(this);
+    
+      this.ws.onclose = function(e) {
+        console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason);
+        setTimeout(function() {
+          this.connectWS();
+        }.bind(this), 5000);
+      }.bind(this);
+    
+      this.ws.onerror = function(err) {
+        console.error('Socket encountered error: ', err.message, 'Closing socket');
+        this.ws.close();
+      }.bind(this);
+    }
+  }
+})
diff --git a/music_assistant/web/components/playmenu.vue.js b/music_assistant/web/components/playmenu.vue.js
new file mode 100644 (file)
index 0000000..54fae32
--- /dev/null
@@ -0,0 +1,74 @@
+Vue.component("playmenu", {\r
+       template: `\r
+       <v-dialog :value="value" @input="$emit('input', $event)" max-width="500px" v-if="$globals.playmenuitem">\r
+        <v-card>\r
+               <v-list>\r
+               <v-subheader>{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : 'nix' }}</v-subheader>\r
+                       <v-subheader>Play on: beneden</v-subheader>\r
+                       \r
+                       <v-list-tile avatar @click="$emit('playItem', $globals.playmenuitem, 'play')">\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>Play Now</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
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>Play Next</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, 'add')">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>playlist_add</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>Add to Queue</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="" v-if="$globals.playmenuitem.media_type != 3">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>shuffle</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>Play now (shuffle)</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider v-if="$globals.playmenuitem.media_type != 3"/>\r
+\r
+                       <v-list-tile avatar @click="" v-if="$globals.playmenuitem.media_type == 3">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>info</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>Show info</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
+                       \r
+               </v-list>\r
+        </v-card>\r
+      </v-dialog>\r
+`,\r
+       props: ['value'],\r
+       data (){\r
+               return{\r
+                       fav: true,\r
+                       message: false,\r
+                       hints: true,\r
+                       }\r
+       },\r
+       mounted() { },\r
+       created() { },\r
+       methods: { }\r
+  })\r
diff --git a/music_assistant/web/components/qualityicon.vue.js b/music_assistant/web/components/qualityicon.vue.js
new file mode 100644 (file)
index 0000000..48baf2b
--- /dev/null
@@ -0,0 +1,60 @@
+Vue.component("qualityicon", {\r
+       template: `\r
+                       <div :style="'height:' + height + 'px;'">\r
+                               \r
+                               <v-tooltip bottom>\r
+                                               <template v-slot:activator="{ on }">\r
+                                                               <img height="100%" v-on="on" v-if="item.metadata && item.metadata.hires" src="images/icons/hires.png" style="align:center;vertical-align: middle;padding-right:10px;padding-left:10px;"/>\r
+                                                               <img height="100%" v-on="on" v-if="!dark && !hiresonly" :src="getFileFormatLogo()" style="align:center;vertical-align: middle;"/>\r
+                                                               <img height="100%" v-on="on" v-if="dark && !hiresonly" :src="getFileFormatLogo()" style="filter: invert(1);align:center;vertical-align: middle;"/>\r
+                                               </template>\r
+                                               <span>{{ getFileFormatDesc() }}</span>\r
+                               </v-tooltip>\r
+                               <span v-if="!compact" class="body-2" style="vertical-align: middle;">{{ getFileFormatDesc() }}</span>\r
+                       </div>\r
+`,\r
+       props: ['item','height','compact', 'dark', 'hiresonly'],\r
+       data (){\r
+               return{}\r
+       },\r
+       mounted() { },\r
+       created() { },\r
+       methods: { \r
+\r
+               getFileFormatLogo() {\r
+                       if (this.item.quality == 0)\r
+                               return 'images/icons/mp3.png'\r
+                       else if (this.item.quality == 1)\r
+                               return 'images/icons/vorbis.png'\r
+                       else if (this.item.quality == 2)\r
+                               return 'images/icons/aac.png'\r
+                       else if (this.item.quality > 2)\r
+                               return 'images/icons/flac.png'\r
+                       },\r
+                       getFileFormatDesc() {\r
+                               var desc = '';\r
+                               if (this.item && this.item.quality)\r
+                               {\r
+                                       if (this.item.quality == 0)\r
+                                               desc = 'MP3';\r
+                                       else if (this.item.quality == 1)\r
+                                               desc = 'Ogg Vorbis';\r
+                                       else if (this.item.quality == 2)\r
+                                               desc = 'AAC';\r
+                                       else if (this.item.quality > 2)\r
+                                               desc = 'FLAC';\r
+                                       else\r
+                                               desc = 'unknown';\r
+                               }\r
+                               // append details\r
+                               if (this.item && this.item.metadata)\r
+                               {\r
+                                       if (!!this.item.metadata && this.item.metadata.maximum_technical_specifications)\r
+                                               desc += ' ' + this.item.metadata.maximum_technical_specifications;\r
+                                       if (!!this.item.metadata && this.item.metadata.sample_rate && this.item.metadata.bit_depth)\r
+                                               desc += ' ' + this.item.metadata.sample_rate + 'kHz ' + this.item.metadata.bit_depth + 'bit';\r
+                               }\r
+                               return desc;\r
+                               }\r
+    }\r
+  })\r
diff --git a/music_assistant/web/components/readmore.vue.js b/music_assistant/web/components/readmore.vue.js
new file mode 100644 (file)
index 0000000..6af2fd3
--- /dev/null
@@ -0,0 +1,63 @@
+Vue.component("read-more", {\r
+       template: `\r
+       <div>\r
+               <span v-html="formattedString"/> <a style="color:white" :href="link" id="readmore" v-if="text.length > maxChars" v-on:click="triggerReadMore($event, true)">{{moreStr}}</a></p>\r
+               <v-dialog v-model="isReadMore" width="80%">\r
+                       <v-card>\r
+                               <v-card-text class="subheading"><span v-html="text"/></v-card-text>\r
+                       </v-card>\r
+                       </v-dialog>\r
+       </div>`,\r
+       props: {\r
+               moreStr: {\r
+                       type: String,\r
+                       default: 'read more'\r
+               },\r
+               lessStr: {\r
+                       type: String,\r
+                       default: ''\r
+               },\r
+               text: {\r
+                       type: String,\r
+                       required: true\r
+               },\r
+               link: {\r
+                       type: String,\r
+                       default: '#'\r
+               },\r
+               maxChars: {\r
+                       type: Number,\r
+                       default: 100\r
+               }\r
+       },\r
+       $_veeValidate: {\r
+         validator: "new"\r
+       },\r
+       data (){\r
+               return{\r
+                       isReadMore: false\r
+               }\r
+       },\r
+       mounted() { },\r
+       computed: {\r
+               formattedString(){\r
+                       var val_container = this.text;\r
+                       if(this.text.length > this.maxChars){\r
+                               val_container = val_container.substring(0,this.maxChars) + '...';\r
+                       }\r
+                       return(val_container);\r
+               }\r
+       },\r
+\r
+       methods: {\r
+               triggerReadMore(e, b){\r
+                       if(this.link == '#'){\r
+                               e.preventDefault();\r
+                       }\r
+                       if(this.lessStr !== null || this.lessStr !== '')\r
+                       {\r
+                               this.isReadMore = b;\r
+                       }\r
+               }\r
+       }\r
+  })\r
diff --git a/music_assistant/web/components/searchbar.vue.js b/music_assistant/web/components/searchbar.vue.js
new file mode 100644 (file)
index 0000000..5efcf04
--- /dev/null
@@ -0,0 +1,92 @@
+Vue.component("searchbar", {
+  template: `
+  <section class="section searchbar">
+    <div class="container">
+      <b-field>
+        <b-autocomplete size="is-medium"
+        expanded
+          v-model="searchQuery"
+          :data="filteredDataArray"
+          placeholder="e.g. Eminem"
+          icon="magnify"
+          @select="option => selected = option"
+          @keyup.enter="onClickSearch"
+        ></b-autocomplete>
+        <p class="control" v-if="searchQuery">
+               <button @click="onClickClearSearch" class="button  is-medium "><i class="fas fa-times"></i></button>
+            </p>
+      </b-field>
+    </div>
+  </section>
+  `,
+  data () {
+    return {
+      data: [],
+      searchQuery: '',
+      selected: null
+    }
+  },
+  props: {
+    recentSearch: {
+      type: Array,
+      required: true
+    },
+    newSearchQuery: {
+      type: String,
+      required: true
+    },
+    settings: {
+      type: Object,
+      required: true
+    }
+  },
+  mounted () {
+    this.searchQuery = this.settings.initialSearchQuery
+    this.onClickSearch()
+  },
+  watch: {
+    searchQuery: {
+      handler: _.debounce(function (val) {
+        if (val === '') {
+          this.$store.commit('CLEAR_SEARCH')
+        } else {
+          if (val !== this.newSearchQuery) {
+            this.onClickSearch()
+          }
+        }
+      }, 1000)
+    },
+    newSearchQuery (val) {
+      this.searchQuery = val
+    }
+  },
+  computed: {
+    filteredDataArray () {
+      return this.recentSearch.filter(option => {
+        return (
+          option
+            .toString()
+            .toLowerCase()
+            .indexOf(this.searchQuery.toLowerCase()) >= 0
+        )
+      })
+    }
+  },
+  methods: {
+    onClickSearch () {
+      this.$emit('clickSearch', this.searchQuery)
+    },
+    onClickClearSearch () {
+      this.searchQuery = ''
+      this.$emit('clickClearSearch')
+    }
+  }
+})
+/* <style>
+.searchbar {
+  padding: 1rem 1.5rem!important;
+  width: 100%;
+  box-shadow: 0 0 70px 0 rgba(0, 0, 0, 0.3);
+  background: #fff;
+}
+</style> */
\ No newline at end of file
diff --git a/music_assistant/web/components/volumecontrol.vue.js b/music_assistant/web/components/volumecontrol.vue.js
new file mode 100644 (file)
index 0000000..c6d264b
--- /dev/null
@@ -0,0 +1,74 @@
+Vue.component("volumecontrol", {\r
+       template: `\r
+       <v-card>\r
+                               <v-list>\r
+                                       <v-list-tile avatar>\r
+                                               <v-list-tile-avatar>\r
+                                                               <v-icon large>{{ isGroup ? 'speaker_group' : 'speaker' }}</v-icon>\r
+                                               </v-list-tile-avatar>\r
+                                               <v-list-tile-content>\r
+                                                       <v-list-tile-title>{{ players[player_id].name }}</v-list-tile-title>\r
+                                                       <v-list-tile-sub-title>{{ players[player_id].state }}</v-list-tile-sub-title>\r
+                                               </v-list-tile-content>\r
+                                               </v-list-tile-action>\r
+                                       </v-list-tile>\r
+                               </v-list>\r
+\r
+                               <v-divider></v-divider>\r
+\r
+                               <v-list two-line>\r
+\r
+                                       <div v-for="child_id in volumePlayerIds" :key="child_id">\r
+                                                       <v-list-tile>\r
+                                                       \r
+                                                       <v-list-tile-content>\r
+\r
+                                                               <v-list-tile-title>\r
+                                                               </v-list-tile-title>\r
+                                                               <div class="v-list__tile__sub-title" style="position: absolute; left:47px; top:10px; z-index:99;">\r
+                                                                       <span :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">{{ players[child_id].name }}</span>\r
+                                                               </div>\r
+                                                               <div class="v-list__tile__sub-title" style="position: absolute; left:0px; top:-4px; z-index:99;">\r
+                                                                       <v-btn icon @click="$emit('togglePlayerPower', child_id)">\r
+                                                                               <v-icon :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">power_settings_new</v-icon>\r
+                                                                       </v-btn>\r
+                                                               </div>\r
+                                                               <v-list-tile-sub-title>\r
+                                                                       <v-slider lazy :disabled="!players[child_id].powered" v-if="!players[child_id].disable_volume"\r
+                                                                               :value="Math.round(players[child_id].volume_level)"\r
+                                                                               prepend-icon="volume_down"\r
+                                                                               append-icon="volume_up"\r
+                                                                               @end="$emit('setPlayerVolume', child_id, $event)"\r
+                                                                       ></v-slider>\r
+                                                               </v-list-tile-sub-title>\r
+                                                       </v-list-tile-content>\r
+                                               </v-list-tile>\r
+                                               <v-divider></v-divider>\r
+                                       </div>\r
+                                       \r
+                               </v-list>\r
+\r
+                               <v-spacer></v-spacer>\r
+                       </v-card>\r
+`,\r
+       props: ['value', 'players', 'player_id'],\r
+       data (){\r
+               return{\r
+                       }\r
+       },\r
+       computed: {\r
+                       volumePlayerIds() {\r
+                       var volume_ids = [this.player_id];\r
+                       for (var player_id in this.players)\r
+                               if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled)\r
+                                       volume_ids.push(player_id);\r
+                       return volume_ids;\r
+               },\r
+               isGroup() {\r
+                       return this.volumePlayerIds.length > 1;\r
+               }\r
+  },\r
+       mounted() { },\r
+       created() { },\r
+       methods: {}\r
+  })\r
diff --git a/music_assistant/web/css/nprogress.css b/music_assistant/web/css/nprogress.css
new file mode 100644 (file)
index 0000000..e4cb811
--- /dev/null
@@ -0,0 +1,74 @@
+/* Make clicks pass-through */
+#nprogress {
+    pointer-events: none;
+  }
+  
+  #nprogress .bar {
+    background: rgb(119, 205, 255);
+  
+    position: fixed;
+    z-index: 1031;
+    top: 0;
+    left: 0;
+  
+    width: 100%;
+    height: 10px;
+  }
+  
+  /* Fancy blur effect */
+  #nprogress .peg {
+    display: block;
+    position: absolute;
+    right: 0px;
+    width: 100px;
+    height: 100%;
+    box-shadow: 0 0 10px #29d, 0 0 5px #29d;
+    opacity: 1.0;
+  
+    -webkit-transform: rotate(3deg) translate(0px, -4px);
+        -ms-transform: rotate(3deg) translate(0px, -4px);
+            transform: rotate(3deg) translate(0px, -4px);
+  }
+  
+  /* Remove these to get rid of the spinner */
+  #nprogress .spinner {
+    display: block;
+    position: fixed;
+    z-index: 1031;
+    top: 15px;
+    right: 15px;
+  }
+  
+  #nprogress .spinner-icon {
+    width: 18px;
+    height: 18px;
+    box-sizing: border-box;
+  
+    border: solid 2px transparent;
+    border-top-color: #29d;
+    border-left-color: #29d;
+    border-radius: 50%;
+  
+    -webkit-animation: nprogress-spinner 400ms linear infinite;
+            animation: nprogress-spinner 400ms linear infinite;
+  }
+  
+  .nprogress-custom-parent {
+    overflow: hidden;
+    position: relative;
+  }
+  
+  .nprogress-custom-parent #nprogress .spinner,
+  .nprogress-custom-parent #nprogress .bar {
+    position: absolute;
+  }
+  
+  @-webkit-keyframes nprogress-spinner {
+    0%   { -webkit-transform: rotate(0deg); }
+    100% { -webkit-transform: rotate(360deg); }
+  }
+  @keyframes nprogress-spinner {
+    0%   { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+  }
+  
\ No newline at end of file
diff --git a/music_assistant/web/css/site.css b/music_assistant/web/css/site.css
new file mode 100755 (executable)
index 0000000..2071f04
--- /dev/null
@@ -0,0 +1,73 @@
+[v-cloak] {
+    display: none;
+}
+
+.navbar {
+    margin-bottom: 20px;
+}
+
+/*.body-content {
+    padding-left: 25px;
+    padding-right: 25px;
+}*/
+
+input,
+select {
+    max-width: 30em;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+    transition: opacity .5s;
+}
+
+.fade-enter,
+.fade-leave-to
+/* .fade-leave-active below version 2.1.8 */
+
+    {
+    opacity: 0;
+}
+
+.bounce-enter-active {
+    animation: bounce-in .5s;
+}
+
+.bounce-leave-active {
+    animation: bounce-in .5s reverse;
+}
+
+@keyframes bounce-in {
+    0% {
+        transform: scale(0);
+    }
+    50% {
+        transform: scale(1.5);
+    }
+    100% {
+        transform: scale(1);
+    }
+}
+
+.slide-fade-enter-active {
+    transition: all .3s ease;
+}
+
+.slide-fade-leave-active {
+    transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
+}
+
+.slide-fade-enter,
+.slide-fade-leave-to
+/* .slide-fade-leave-active below version 2.1.8 */
+
+    {
+    transform: translateX(10px);
+    opacity: 0;
+}
+
+.vertical-btn {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+  }
\ No newline at end of file
diff --git a/music_assistant/web/images/default_artist.png b/music_assistant/web/images/default_artist.png
new file mode 100644 (file)
index 0000000..a530d5b
Binary files /dev/null and b/music_assistant/web/images/default_artist.png differ
diff --git a/music_assistant/web/images/icons/aac.png b/music_assistant/web/images/icons/aac.png
new file mode 100644 (file)
index 0000000..7dafab2
Binary files /dev/null and b/music_assistant/web/images/icons/aac.png differ
diff --git a/music_assistant/web/images/icons/file.png b/music_assistant/web/images/icons/file.png
new file mode 100644 (file)
index 0000000..bd2df04
Binary files /dev/null and b/music_assistant/web/images/icons/file.png differ
diff --git a/music_assistant/web/images/icons/flac.png b/music_assistant/web/images/icons/flac.png
new file mode 100644 (file)
index 0000000..33e1f17
Binary files /dev/null and b/music_assistant/web/images/icons/flac.png differ
diff --git a/music_assistant/web/images/icons/hires.png b/music_assistant/web/images/icons/hires.png
new file mode 100644 (file)
index 0000000..a398c6e
Binary files /dev/null and b/music_assistant/web/images/icons/hires.png differ
diff --git a/music_assistant/web/images/icons/icon-128x128.png b/music_assistant/web/images/icons/icon-128x128.png
new file mode 100755 (executable)
index 0000000..b770cb4
Binary files /dev/null and b/music_assistant/web/images/icons/icon-128x128.png differ
diff --git a/music_assistant/web/images/icons/icon-144x144.png b/music_assistant/web/images/icons/icon-144x144.png
new file mode 100755 (executable)
index 0000000..5bc3a3e
Binary files /dev/null and b/music_assistant/web/images/icons/icon-144x144.png differ
diff --git a/music_assistant/web/images/icons/icon-152x152.png b/music_assistant/web/images/icons/icon-152x152.png
new file mode 100755 (executable)
index 0000000..2fdb125
Binary files /dev/null and b/music_assistant/web/images/icons/icon-152x152.png differ
diff --git a/music_assistant/web/images/icons/icon-192x192.png b/music_assistant/web/images/icons/icon-192x192.png
new file mode 100755 (executable)
index 0000000..770f2d5
Binary files /dev/null and b/music_assistant/web/images/icons/icon-192x192.png differ
diff --git a/music_assistant/web/images/icons/icon-384x384.png b/music_assistant/web/images/icons/icon-384x384.png
new file mode 100755 (executable)
index 0000000..f408f72
Binary files /dev/null and b/music_assistant/web/images/icons/icon-384x384.png differ
diff --git a/music_assistant/web/images/icons/icon-512x512.png b/music_assistant/web/images/icons/icon-512x512.png
new file mode 100755 (executable)
index 0000000..f408f72
Binary files /dev/null and b/music_assistant/web/images/icons/icon-512x512.png differ
diff --git a/music_assistant/web/images/icons/icon-72x72.png b/music_assistant/web/images/icons/icon-72x72.png
new file mode 100755 (executable)
index 0000000..efd1391
Binary files /dev/null and b/music_assistant/web/images/icons/icon-72x72.png differ
diff --git a/music_assistant/web/images/icons/icon-96x96.png b/music_assistant/web/images/icons/icon-96x96.png
new file mode 100755 (executable)
index 0000000..055583c
Binary files /dev/null and b/music_assistant/web/images/icons/icon-96x96.png differ
diff --git a/music_assistant/web/images/icons/info_gradient.jpg b/music_assistant/web/images/icons/info_gradient.jpg
new file mode 100644 (file)
index 0000000..9d0c0e3
Binary files /dev/null and b/music_assistant/web/images/icons/info_gradient.jpg differ
diff --git a/music_assistant/web/images/icons/mp3.png b/music_assistant/web/images/icons/mp3.png
new file mode 100644 (file)
index 0000000..b894bda
Binary files /dev/null and b/music_assistant/web/images/icons/mp3.png differ
diff --git a/music_assistant/web/images/icons/qobuz.png b/music_assistant/web/images/icons/qobuz.png
new file mode 100644 (file)
index 0000000..9d7b726
Binary files /dev/null and b/music_assistant/web/images/icons/qobuz.png differ
diff --git a/music_assistant/web/images/icons/spotify.png b/music_assistant/web/images/icons/spotify.png
new file mode 100644 (file)
index 0000000..805f5c7
Binary files /dev/null and b/music_assistant/web/images/icons/spotify.png differ
diff --git a/music_assistant/web/images/icons/vorbis.png b/music_assistant/web/images/icons/vorbis.png
new file mode 100644 (file)
index 0000000..c6d6914
Binary files /dev/null and b/music_assistant/web/images/icons/vorbis.png differ
diff --git a/music_assistant/web/images/info_gradient.jpg b/music_assistant/web/images/info_gradient.jpg
new file mode 100644 (file)
index 0000000..9d0c0e3
Binary files /dev/null and b/music_assistant/web/images/info_gradient.jpg differ
diff --git a/music_assistant/web/index.html b/music_assistant/web/index.html
new file mode 100755 (executable)
index 0000000..2b1b5a4
--- /dev/null
@@ -0,0 +1,234 @@
+<!DOCTYPE html>
+<html>
+
+    <head>
+        <meta charset="utf-8" />
+        <title>Music Assistant</title>
+        <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
+        <link href="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.min.css" rel="stylesheet">
+        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
+        <link rel="icon" href="./images/icons/icon-128x128.png">
+        <link rel="manifest" href="./manifest.json">
+        <link rel="apple-touch-icon" sizes="180x180" href="./images/icons/icon-192x192.png">
+        <link href="./css/site.css" rel="stylesheet">
+    </head>
+
+    <body>
+
+        <div id="app">
+            <v-app light>
+                <v-content>
+                    <headermenu></headermenu>
+                    <player></player>
+                    <router-view :key="$route.path"></router-view>
+                </v-content>
+                <v-dialog
+                    v-model="$globals.loading"
+                    persistent
+                    width="300"
+                    >
+                    <v-card
+                        color="primary"
+                        dark
+                    >
+                        <v-card-text>
+                        Please stand by
+                        <v-progress-linear
+                            indeterminate
+                            color="white"
+                            class="mb-0"
+                        ></v-progress-linear>
+                        </v-card-text>
+                    </v-card>
+                </v-dialog>
+                
+            </v-app>
+        </div>
+
+
+        <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
+        <script src="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.js"></script>
+        <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
+        <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
+        <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
+        <script src="https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js"></script>
+        <script src="https://unpkg.com/vee-validate@2.0.0-rc.25/dist/vee-validate.js"></script>
+        <script src="https://unpkg.com/http-vue-loader"></script>
+
+        <script>
+            function isMobile() {
+                return document.body.clientWidth < 800;
+            }
+            function showPlayMenu (item) {
+                this.$globals.playmenuitem = item;
+                this.$globals.showplaymenu = !this.$globals.showplaymenu;
+                }
+
+            function clickItem (item) {
+                var endpoint = "";
+                if (item.media_type == 1)
+                    endpoint = "/artists/"
+                else if (item.media_type == 2)
+                    endpoint = "/albums/"
+                else if (item.media_type == 3)
+                    {
+                    this.showPlayMenu(item);
+                    return;
+                    }
+                else if (item.media_type == 4)
+                    endpoint = "/playlists/"
+                item_id = item.item_id.toString();
+                var url = endpoint + item_id;
+                router.push({ path: url});
+            }
+
+            String.prototype.formatDuration = function () {
+                var sec_num = parseInt(this, 10); // don't forget the second param
+                var hours   = Math.floor(sec_num / 3600);
+                var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
+                var seconds = sec_num - (hours * 3600) - (minutes * 60);
+
+                if (hours   < 10) {hours   = "0"+hours;}
+                if (minutes < 10) {minutes = "0"+minutes;}
+                if (seconds < 10) {seconds = "0"+seconds;}
+                if (hours == '00')
+                    return minutes+':'+seconds;
+                else
+                    return hours+':'+minutes+':'+seconds;
+            }
+            function toggleLibrary (item) {
+                var endpoint = "/api/" + item.media_type + "/";
+                item_id = item.item_id.toString();
+                var action = "/remove"
+                if (item.in_library.length == 0)
+                    action = "/add"
+                var url = endpoint + item_id + action;
+                console.log('loading ' + url);
+                axios
+                    .get(url)
+                    .then(result => {
+                        data = result.data;
+                        console.log(data);
+                        if (action == "/remove")
+                            item.in_library = []
+                        else
+                        item.in_library = [provider]
+                        })
+                    .catch(error => {
+                        console.log("error", error);
+                    });
+
+            };
+        </script>
+
+        <!-- Vue Pages and Components here -->
+        <script src='./pages/home.vue.js'></script>
+        <script src='./pages/browse.vue.js'></script>
+
+        <script src='./pages/artistdetails.vue.js'></script>
+        <script src='./pages/albumdetails.vue.js'></script>
+        <script src='./pages/trackdetails.vue.js'></script>
+        <script src='./pages/playlistdetails.vue.js'></script>
+        <script src='./pages/search.vue.js'></script>
+        <script src='./pages/queue.vue.js'></script>
+        
+
+        <script src='./components/headermenu.vue.js'></script>
+        <script src='./components/player.vue.js'></script>
+        <script src='./components/listviewItem.vue.js'></script>
+        <script src='./components/readmore.vue.js'></script>
+        <script src='./components/playmenu.vue.js'></script>
+        <script src='./components/volumecontrol.vue.js'></script>
+        <script src='./components/infoheader.vue.js'></script>
+        <script src='./components/qualityicon.vue.js'></script>
+        <script src='./pages/config.vue.js'></script>
+        
+        <script>
+        Vue.use(VueRouter);
+        Vue.use(VeeValidate);
+        Vue.use(Vuetify);
+
+
+        const routes = [
+            {
+            path: '/',
+            component: home
+            },
+            {
+                path: '/config',
+                component: Config,
+            },
+            {
+                path: '/queue/:player_id',
+                component: Queue,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/artists/:media_id',
+                component: ArtistDetails,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/albums/:media_id',
+                component: AlbumDetails,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/tracks/:media_id',
+                component: TrackDetails,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/playlists/:media_id',
+                component: PlaylistDetails,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/search',
+                component: Search,
+                props: route => ({ ...route.params, ...route.query })
+            },
+            {
+                path: '/:mediatype',
+                component: Browse,
+                props: route => ({ ...route.params, ...route.query })
+            },
+        ]
+
+        let router = new VueRouter({
+            //mode: 'history',
+            routes // short for `routes: routes`
+        })
+
+        router.beforeEach((to, from, next) => {
+            next()
+        })
+
+        const globalStore = new Vue({
+            data: {
+                windowtitle: 'Home',
+                loading: false,
+                showplaymenu: false,
+                playmenuitem: null
+            }
+        })
+        Vue.prototype.$globals = globalStore;
+        Vue.prototype.isMobile = isMobile;
+        Vue.prototype.toggleLibrary = toggleLibrary;
+        Vue.prototype.showPlayMenu = showPlayMenu;
+        Vue.prototype.clickItem= clickItem;
+
+        var app = new Vue({
+            el: '#app',
+            watch: {},
+            mounted() {
+
+            },
+            data: { },
+            methods: {},
+            router
+        })
+    </script>
+    </body>
+
+</html>
\ No newline at end of file
diff --git a/music_assistant/web/manifest.json b/music_assistant/web/manifest.json
new file mode 100755 (executable)
index 0000000..5fbca0d
--- /dev/null
@@ -0,0 +1,51 @@
+{
+  "name": "Music Assistant",
+  "short_name": "MusicAssistant",
+  "theme_color": "#2196f3",
+  "background_color": "#2196f3",
+  "display": "standalone",
+  "Scope": "/",
+  "start_url": "/",
+  "icons": [{
+      "src": "images/icons/icon-72x72.png",
+      "sizes": "72x72",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-96x96.png",
+      "sizes": "96x96",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-128x128.png",
+      "sizes": "128x128",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-144x144.png",
+      "sizes": "144x144",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-152x152.png",
+      "sizes": "152x152",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-384x384.png",
+      "sizes": "384x384",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ],
+  "splash_pages": null
+}
\ No newline at end of file
diff --git a/music_assistant/web/pages/albumdetails.vue.js b/music_assistant/web/pages/albumdetails.vue.js
new file mode 100755 (executable)
index 0000000..b8d59af
--- /dev/null
@@ -0,0 +1,107 @@
+var AlbumDetails = Vue.component('AlbumDetails', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Album tracks</v-tab>
+          <v-tab-item>
+            <v-card flat>
+            <v-list two-line>
+                <listviewItem 
+                    v-for="(item, index) in albumtracks" 
+                    v-bind:item="item"
+                    :key="item.db_id"
+                    v-bind:totalitems="albumtracks.length"
+                    v-bind:index="index"
+                    :hideavatar="true"
+                    :hideproviders="isMobile()"
+                    >
+                </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple>Versions</v-tab>
+          <v-tab-item>
+            <v-card flat>
+                <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in albumversions" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      v-bind:totalitems="albumversions.length"
+                      v-bind:index="index"
+                      >
+                  </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+
+      </section>`,
+  props: ['provider', 'media_id'],
+  data() {
+    return {
+      selected: [2],
+      info: {},
+      albumtracks: [],
+      albumversions: [],
+      offset: 0,
+      active: null,
+    }
+  },
+  created() {
+    this.$globals.windowtitle = "Album info"
+    this.getInfo();
+    this.getAlbumTracks();
+  },
+  methods: {
+    getInfo () {
+      this.$globals.loading = true;
+      const api_url = '/api/albums/' + this.media_id
+      axios
+        .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+          this.getAlbumVersions()
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getAlbumTracks () {
+      const api_url = '/api/albums/' + this.media_id + '/tracks'
+      axios
+        .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider}})
+        .then(result => {
+          data = result.data;
+          this.albumtracks.push(...data);
+          this.offset += 50;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getAlbumVersions () {
+      const api_url = '/api/search';
+      var searchstr = this.info.artist.name + " - " + this.info.name
+      axios
+        .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'albums', online: true}})
+        .then(result => {
+          data = result.data;
+          this.albumversions.push(...data.albums);
+          this.offset += 50;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+  }
+})
diff --git a/music_assistant/web/pages/artistdetails.vue.js b/music_assistant/web/pages/artistdetails.vue.js
new file mode 100755 (executable)
index 0000000..785e94b
--- /dev/null
@@ -0,0 +1,126 @@
+var ArtistDetails = Vue.component('ArtistDetails', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Top tracks</v-tab>
+          <v-tab-item>
+            <v-card flat>
+            <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in toptracks" 
+                      v-bind:item="item"
+                      v-bind:totalitems="toptracks.length"
+                      v-bind:index="index"
+                      :key="item.db_id"
+                      :hideavatar="isMobile()"
+                      :hidetracknum="true"
+                      :hideproviders="isMobile()"
+                      :hidelibrary="isMobile()">
+                  </listviewItem>
+                </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple>Albums</v-tab>
+          <v-tab-item>
+            <v-card flat>
+                <v-list two-line>
+                    <listviewItem 
+                        v-for="(item, index) in artistalbums" 
+                        v-bind:item="item"
+                        :key="item.db_id"
+                        v-bind:totalitems="artistalbums.length"
+                        v-bind:index="index"
+                        :hideproviders="isMobile()"
+                        >
+                    </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+      </section>`,
+  props: ['media_id', 'provider'],
+  data() {
+    return {
+      selected: [2],
+      info: {},
+      toptracks: [],
+      artistalbums: [],
+      bg_image: "../images/info_gradient.jpg",
+      active: null,
+      playmenu: false,
+      playmenuitem: null
+    }
+  },
+  created() {
+    this.$globals.windowtitle = "Artist info"
+    this.getInfo();
+  },
+  methods: {
+    getFanartImage() {
+      if (this.info.metadata && this.info.metadata.fanart)
+        return this.info.metadata.fanart;
+      else if (this.info.artists)
+        for (artist in this.info.artists)
+          if (artist.info.metadata && artist.data.metadata.fanart)
+              return artist.metadata.fanart;
+    },
+    getInfo (lazy=true) {
+      this.$globals.loading = true;
+      const api_url = '/api/artists/' + this.media_id
+      axios
+        .get(api_url, { params: { lazy: lazy }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+          this.$globals.loading = false;
+          if (data.is_lazy == true)
+              // refresh the info if we got a lazy object
+              this.timeout1 = setTimeout(function(){
+                  this.getInfo(false);
+              }.bind(this), 1000);
+          else {
+            this.getArtistTopTracks();
+            this.getArtistAlbums();
+          }
+        })
+        .catch(error => {
+          console.log("error", error);
+          this.$globals.loading = false;
+        });
+    },
+    getArtistTopTracks () {
+      
+      const api_url = '/api/artists/' + this.media_id + '/toptracks'
+      axios
+      .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.toptracks = data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+        
+    },
+    getArtistAlbums () {
+      const api_url = '/api/artists/' + this.media_id + '/albums'
+      console.log('loading ' + api_url);
+      axios
+      .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.artistalbums = data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+  }
+})
diff --git a/music_assistant/web/pages/browse.vue.js b/music_assistant/web/pages/browse.vue.js
new file mode 100755 (executable)
index 0000000..fae6b29
--- /dev/null
@@ -0,0 +1,68 @@
+var Browse = Vue.component('Browse', {
+  template: `
+    <section>
+      <v-flex xs12>
+        <v-card class="flex" tile style="background-color:rgba(0,0,0,.54);color:#ffffff;"> 
+          <v-card-title class="title justify-center">
+              {{ $globals.windowtitle }}
+          </v-card-title>
+        </v-card>
+      </v-flex>    
+      <v-list two-line>
+        <listviewItem 
+            v-for="(item, index) in items"
+            :key="item.db_id"
+            v-bind:item="item"
+            v-bind:totalitems="items.length"
+            v-bind:index="index"
+            :hideavatar="item.media_type == 3 ? isMobile() : false"
+            :hidetracknum="true"
+            :hideproviders="isMobile()"
+            :hidelibrary="isMobile() ? true : item.media_type != 3">
+        </listviewItem>
+      </v-list>
+    </section>
+  `,
+  props: ['mediatype'],
+  data() {
+    return {
+      selected: [2],
+      items: [],
+      offset: 0
+    }
+  },
+  created() {
+    this.showavatar = true;
+    mediatitle = 
+    this.$globals.windowtitle = this.mediatype.charAt(0).toUpperCase() + this.mediatype.slice(1);
+    this.scroll(this.Browse);
+    this.getItems();
+  },
+  methods: {
+    getItems () {
+      this.$globals.loading = true
+      const api_url = '/api/' + this.mediatype;
+      axios
+        .get(api_url, { params: { offset: this.offset, limit: 50 }})
+        .then(result => {
+          data = result.data;
+          this.items.push(...data);
+          this.offset += 50;
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+          this.showProgress = false;
+        });
+    },
+    scroll (Browse) {
+      window.onscroll = () => {
+        let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight;
+
+        if (bottomOfWindow) {
+          this.getItems();
+        }
+      };
+    }
+  }
+})
diff --git a/music_assistant/web/pages/config.vue.js b/music_assistant/web/pages/config.vue.js
new file mode 100755 (executable)
index 0000000..e9b7211
--- /dev/null
@@ -0,0 +1,180 @@
+var Config = Vue.component('Config', {
+  template: `
+    <section>
+      <v-flex xs12>
+        <v-card class="flex" tile style="background-color:rgba(0,0,0,.54);color:#ffffff;"> 
+          <v-card-title class="title justify-center">
+              {{ $globals.windowtitle }}
+          </v-card-title>
+        </v-card>
+      </v-flex>    
+
+      <v-list two-line>
+
+          <!-- music providers -->
+          <v-list-group prepend-icon="library_music" no-action>
+              <template v-slot:activator>
+                <v-list-tile>
+                  <v-list-tile-content>
+                    <v-list-tile-title>Music Providers</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+              </template>
+              <template v-for="(conf_value, conf_key) in conf.musicproviders">
+                  <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.musicproviders[conf_key].__desc__">
+                    <v-list-tile>
+                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.musicproviders[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.musicproviders[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.musicproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-select>
+                          <v-text-field v-else v-model="conf.musicproviders[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>
+
+          <!-- player providers -->
+          <v-list-group prepend-icon="speaker_group" no-action>
+              <template v-slot:activator>
+                <v-list-tile>
+                  <v-list-tile-content>
+                    <v-list-tile-title>Player Providers</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+              </template>
+              <template v-for="(conf_value, conf_key) in conf.playerproviders">
+                  <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.playerproviders[conf_key].__desc__">
+                    <v-list-tile>
+                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.playerproviders[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.playerproviders[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.playerproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-select>
+                          <v-text-field v-else v-model="conf.playerproviders[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>
+
+          <!-- player settings -->
+          <v-list-group prepend-icon="speaker" no-action>
+              <template v-slot:activator>
+                <v-list-tile>
+                  <v-list-tile-content>
+                    <v-list-tile-title>Player settings</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+              </template>
+              <template v-for="(player, key) in players" v-if="key != '__desc__' && key in players">
+                  <v-list-tile>
+                    <v-list-tile-content>
+                      <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
+                      <v-list-tile-sub-title class="title">ID: {{ key }} Provider: {{ players[key].player_provider }}</v-list-tile-sub-title>
+                    </v-list-tile-content>
+                  </v-list-tile>
+                  
+                  <div v-for="conf_item_key in conf.player_settings.__desc__" v-if="conf.player_settings[key].enabled">
+                    <v-list-tile>
+                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.player_settings[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.player_settings[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.player_settings[key][conf_item_key[0]]" :label="conf_item_key[2]" 
+                            :items="playersLst"
+                            item-text="name"
+                            item-value="id" box>
+                          </v-select>
+                          <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="conf_item_key[2]" box></v-text-field>
+                    </v-list-tile>
+                    <v-list-tile v-if="!conf.player_settings[key].enabled">
+                          <v-switch v-model="conf.player_settings[key].enabled" label="Enabled"></v-switch>
+                    </v-list-tile>
+                </div>
+                <div v-if="!conf.player_settings[key].enabled">
+                    <v-list-tile>
+                        <v-switch v-model="conf.player_settings[key].enabled" label="Enabled"></v-switch>
+                    </v-list-tile>
+                </div>
+                <v-divider></v-divider>
+              </template>
+            </v-list-group>
+
+            <v-btn @click="saveConfig()">Save</v-btn>
+        </v-list>
+    </section>
+  `,
+  props: [],
+  data() {
+    return {
+      conf: {},
+      players: {}
+    }
+  },
+  computed: {
+    playersLst()
+    {
+      var playersLst = [];
+      for (player_id in this.conf.player_settings)
+        if (player_id != '__desc__')
+          playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
+      return playersLst;
+    }
+
+  },
+  created() {
+    this.$globals.windowtitle = "Configuration";
+    this.getPlayers();
+    this.getConfig();
+    console.log(this.$globals.all_players);
+  },
+  methods: {
+    getConfig () {
+      axios
+        .get('/api/config')
+        .then(result => {
+          this.conf = result.data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    saveConfig () {
+      axios
+        .post('/api/config', this.conf)
+        .then(result => {
+          console.log(result);
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getPlayers () {
+      const api_url = '/api/players';
+      axios
+        .get(api_url)
+        .then(result => {
+          for (var item of result.data)
+            this.$set(this.players, item.player_id, item)
+        })
+        .catch(error => {
+          console.log("error", error);
+          this.showProgress = false;
+        });
+    },
+  }
+})
diff --git a/music_assistant/web/pages/home.vue.js b/music_assistant/web/pages/home.vue.js
new file mode 100755 (executable)
index 0000000..348a407
--- /dev/null
@@ -0,0 +1,40 @@
+var home = Vue.component("Home", {
+  template: `
+  <v-list>
+    <v-list-tile 
+      v-for="item in items" :key="item.title" @click="$router.push(item.path)">
+        <v-list-tile-action style="margin-left:15px">
+            <v-icon>{{ item.icon }}</v-icon>
+        </v-list-tile-action>
+        <v-list-tile-content>
+            <v-list-tile-title>{{ item.title }}</v-list-tile-title>
+        </v-list-tile-content>
+    </v-list-tile>
+  </v-list>
+`,
+  props: ["title"],
+  $_veeValidate: {
+    validator: "new"
+  },
+  data() {
+    return {
+      result: null,
+      showProgress: false
+    };
+  },
+  created() {
+    this.$globals.windowtitle = "Home"
+    this.items= [
+      { title: 'Artists', path: '/browse/library/artists', icon: "person" },
+      { title: 'Albums', path: '/browse/library/albums', icon: "album" },
+      { title: 'Tracks', path: '/browse/library/tracks', icon: "audiotrack" },
+      { title: 'Playlists', path: '/browse/library/playlists', icon: "playlist_play" }
+    ]
+  },
+  methods: {
+    click (item) {
+      console.log("selected: "+ item.path);
+      router.push({path: item.path})
+    }
+  }
+});
diff --git a/music_assistant/web/pages/playlistdetails.vue.js b/music_assistant/web/pages/playlistdetails.vue.js
new file mode 100755 (executable)
index 0000000..b2f97dc
--- /dev/null
@@ -0,0 +1,82 @@
+var PlaylistDetails = Vue.component('PlaylistDetails', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Playlist tracks</v-tab>
+          <v-tab-item>
+            <v-card flat>
+            <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in items" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      :hideavatar="isMobile()"
+                      :hidetracknum="true"
+                      :hideproviders="isMobile()"
+                      :hidelibrary="isMobile()">
+                  </listviewItem>
+                </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+      </section>`,
+  props: ['provider', 'media_id'],
+  data() {
+    return {
+      selected: [2],
+      info: {},
+      items: [],
+      offset: 0,
+    }
+  },
+  created() {
+    this.$globals.windowtitle = "Playlist info"
+    this.getInfo();
+    this.getPlaylistTracks();
+    this.scroll(this.Browse);
+  },
+  methods: {
+    getInfo () {
+      const api_url = '/api/playlists/' + this.media_id
+      axios
+      .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getPlaylistTracks () {
+      this.$globals.loading = true
+      const api_url = '/api/playlists/' + this.media_id + '/tracks'
+      axios
+        .get(api_url, { params: { offset: this.offset, limit: 25, provider: this.provider}})
+        .then(result => {
+          data = result.data;
+          this.items.push(...data);
+          this.offset += 25;
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+        
+    },
+    scroll (Browse) {
+      window.onscroll = () => {
+        let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight;
+        if (bottomOfWindow) {
+          this.getPlaylistTracks();
+        }
+      };
+    }
+  }
+})
diff --git a/music_assistant/web/pages/queue.vue.js b/music_assistant/web/pages/queue.vue.js
new file mode 100755 (executable)
index 0000000..7f76632
--- /dev/null
@@ -0,0 +1,82 @@
+var Queue = Vue.component('Queue', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Queue</v-tab>
+          <v-tab-item>
+            <v-card flat>
+            <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in items" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      :hideavatar="isMobile()"
+                      :hidetracknum="true"
+                      :hideproviders="isMobile()"
+                      :hidelibrary="isMobile()">
+                  </listviewItem>
+                </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+      </section>`,
+  props: ['player_id'],
+  data() {
+    return {
+      selected: [0],
+      info: {},
+      items: [],
+      offset: 0,
+    }
+  },
+  created() {
+    this.$globals.windowtitle = "Queue"
+    //this.getInfo();
+    this.getQueueTracks();
+    this.scroll(this.Queue);
+  },
+  methods: {
+    getInfo () {
+      const api_url = '/api/players/' + this.media_id
+      axios
+      .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getQueueTracks () {
+      this.$globals.loading = true
+      const api_url = '/api/players/' + this.player_id + '/queue'
+      axios
+        .get(api_url, { params: { offset: this.offset, limit: 50}})
+        .then(result => {
+          data = result.data;
+          this.items.push(...data);
+          this.offset += 25;
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+        
+    },
+    scroll (Browse) {
+      window.onscroll = () => {
+        let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight;
+        if (bottomOfWindow) {
+          this.getQueueTracks();
+        }
+      };
+    }
+  }
+})
diff --git a/music_assistant/web/pages/search.vue.js b/music_assistant/web/pages/search.vue.js
new file mode 100755 (executable)
index 0000000..2bff1a6
--- /dev/null
@@ -0,0 +1,166 @@
+var Search = Vue.component('Search', {
+  template: `
+  <section>
+      <v-flex xs12 justify-center>
+        <v-card color="cyan darken-2" class="white--text" img="../images/info_gradient.jpg">
+        
+          <div class="text-xs-center" style="height:40px" id="whitespace_top"/>      
+          <v-card-title class="display-1 justify-center" style="text-shadow: 1px 1px #000000;">
+              {{ $globals.windowtitle }}
+          </v-card-title>
+        </v-card>
+      </v-flex>    
+      <v-text-field
+        solo
+        clearable
+        label="Type here to search..."
+        prepend-inner-icon="search"
+        v-on:input="onSearchBoxInput"
+        v-model="searchquery">
+      </v-text-field>
+
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+
+        <v-tab ripple v-if="tracks.length">Tracks</v-tab>
+          <v-tab-item v-if="tracks.length">
+            <v-card flat>
+                <v-list two-line style="margin-left:15px; margin-right:15px">
+                    <listviewItem 
+                        v-for="(item, index) in tracks" 
+                        v-bind:item="item"
+                        :key="item.db_id"
+                        v-bind:totalitems="tracks.length"
+                        v-bind:index="index"
+                        :hideavatar="isMobile()"
+                        :hidetracknum="true"
+                        :hideproviders="isMobile()"
+                        :hideduration="isMobile()"
+                        :showlibrary="true">
+                    </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple v-if="artists.length">Artists</v-tab>
+          <v-tab-item v-if="artists.length">
+            <v-card flat>
+            <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in artists" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      v-bind:totalitems="artists.length"
+                      v-bind:index="index"
+                      :hideproviders="isMobile()"
+                      >
+                  </listviewItem>
+                </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple v-if="albums.length">Albums</v-tab>
+          <v-tab-item v-if="albums.length">
+            <v-card flat>
+                <v-list two-line>
+                    <listviewItem 
+                        v-for="(item, index) in albums" 
+                        v-bind:item="item"
+                        :key="item.db_id"
+                        v-bind:totalitems="albums.length"
+                        v-bind:index="index"
+                        :hideproviders="isMobile()"
+                        >
+                    </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+
+          <v-tab ripple v-if="playlists.length">Playlists</v-tab>
+          <v-tab-item v-if="playlists.length">
+            <v-card flat>
+                <v-list two-line>
+                    <listviewItem 
+                        v-for="(item, index) in playlists" 
+                        v-bind:item="item"
+                        :key="item.db_id"
+                        v-bind:totalitems="playlists.length"
+                        v-bind:index="index"
+                        :hidelibrary="true">
+                    </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+
+        </v-tabs>
+
+      </section>`,
+  props: [],
+  data() {
+    return {
+      selected: [2],
+      artists: [],
+      albums: [],
+      tracks: [],
+      playlists: [],
+      timeout: null,
+      searchquery: ''
+    }
+  },
+  created() {
+    this.$globals.windowtitle = "Search";
+  },
+  methods: {
+    toggle (index) {
+      const i = this.selected.indexOf(index)
+      if (i > -1) {
+        this.selected.splice(i, 1)
+      } else {
+        this.selected.push(index)
+        console.log("selected: "+ this.items[index].name);
+      }
+    },
+    onSearchBoxInput (index) {
+      clearTimeout(this.timeout);
+      this.timeout = setTimeout(this.Search, 600);
+    },
+    Search () {
+      if (!this.searchquery) {
+        this.artists = [];
+        this.albums = [];
+        this.tracks = [];
+        this.playlists = [];
+      }
+      else {
+        this.$globals.loading = true;
+        console.log(this.searchquery);
+        const api_url = '/api/search'
+        console.log('loading ' + api_url);
+          axios
+            .get(api_url, {
+              params: {
+                query: this.searchquery,
+                online: true,
+                limit: 3
+              }
+            })
+            .then(result => {
+              data = result.data;
+              this.artists = data.artists;
+              this.albums = data.albums;
+              this.tracks = data.tracks;
+              this.playlists = data.playlists;
+              this.$globals.loading = false;
+            })
+            .catch(error => {
+              console.log("error", error);
+            });
+        } 
+        
+    },
+  }
+})
diff --git a/music_assistant/web/pages/trackdetails.vue.js b/music_assistant/web/pages/trackdetails.vue.js
new file mode 100755 (executable)
index 0000000..497f6b3
--- /dev/null
@@ -0,0 +1,77 @@
+var TrackDetails = Vue.component('TrackDetails', {
+  template: `
+  <section>
+      <infoheader v-bind:info="info"/>
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+          <v-tab ripple>Other versions</v-tab>
+          <v-tab-item>
+            <v-card flat>
+                <v-list two-line>
+                  <listviewItem 
+                      v-for="(item, index) in trackversions" 
+                      v-bind:item="item"
+                      :key="item.db_id"
+                      v-bind:totalitems="trackversions.length"
+                      v-bind:index="index"
+                      :hideavatar="isMobile()"
+                      :hidetracknum="true"
+                      :hideproviders="isMobile()"
+                      :hidelibrary="isMobile()">
+                  </listviewItem>
+              </v-list>
+            </v-card>
+          </v-tab-item>
+        </v-tabs>
+
+      </section>`,
+  props: ['provider', 'media_id'],
+  data() {
+    return {
+      selected: [2],
+      info: {},
+      trackversions: [],
+      offset: 0,
+      active: null,
+    }
+  },
+  created() {
+    this.$globals.windowtitle = "Track info"
+    this.getInfo();
+  },
+  methods: {
+    getInfo () {
+      this.$globals.loading = true;
+      const api_url = '/api/tracks/' + this.media_id
+      axios
+        .get(api_url, { params: { provider: this.provider }})
+        .then(result => {
+          data = result.data;
+          this.info = data;
+          this.getTrackVersions()
+          this.$globals.loading = false;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+    getTrackVersions () {
+      const api_url = '/api/search';
+      var searchstr = this.info.artists[0].name + " - " + this.info.name
+      axios
+        .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'tracks', online: true}})
+        .then(result => {
+          data = result.data;
+          this.trackversions.push(...data.tracks);
+          this.offset += 50;
+        })
+        .catch(error => {
+          console.log("error", error);
+        });
+    },
+  }
+})
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..ee89b4a
--- /dev/null
@@ -0,0 +1,4 @@
+cytoolz
+aiohttp
+spotify_token
+pychromecast
\ No newline at end of file