refactor in progress
authormarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Sat, 12 Oct 2019 00:28:58 +0000 (02:28 +0200)
committermarcelveldt <marcelvanderveldt@MacBook-Silvia.local>
Sat, 12 Oct 2019 00:28:58 +0000 (02:28 +0200)
149 files changed:
.vscode/.ropeproject/config.py [new file with mode: 0644]
Dockerfile
main.py [new file with mode: 0755]
music_assistant/__init__.py
music_assistant/cache.py [new file with mode: 0644]
music_assistant/database.py
music_assistant/homeassistant.py [new file with mode: 0644]
music_assistant/http_streamer.py [new file with mode: 0755]
music_assistant/main.py [deleted file]
music_assistant/main.spec [deleted file]
music_assistant/metadata.py [new file with mode: 0755]
music_assistant/models/__init__.py
music_assistant/models/media_types.py
music_assistant/models/musicprovider.py
music_assistant/models/player.py
music_assistant/models/player_queue.py
music_assistant/models/playerprovider.py
music_assistant/modules/__init__.py [deleted file]
music_assistant/modules/cache.py [deleted file]
music_assistant/modules/homeassistant.py [deleted file]
music_assistant/modules/http_streamer.py [deleted file]
music_assistant/modules/metadata.py [deleted file]
music_assistant/modules/music_manager.py [deleted file]
music_assistant/modules/musicproviders/__init__.py [deleted file]
music_assistant/modules/musicproviders/file.py [deleted file]
music_assistant/modules/musicproviders/qobuz.py [deleted file]
music_assistant/modules/musicproviders/spotify.py [deleted file]
music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf [deleted file]
music_assistant/modules/musicproviders/spotty/darwin/spotty [deleted file]
music_assistant/modules/musicproviders/spotty/windows/spotty.exe [deleted file]
music_assistant/modules/musicproviders/spotty/x86-linux/spotty [deleted file]
music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 [deleted file]
music_assistant/modules/musicproviders/tunein.py [deleted file]
music_assistant/modules/player_manager.py [deleted file]
music_assistant/modules/playerproviders/__init__.py [deleted file]
music_assistant/modules/playerproviders/chromecast.py [deleted file]
music_assistant/modules/playerproviders/lms.py [deleted file]
music_assistant/modules/playerproviders/pylms.py [deleted file]
music_assistant/modules/web.py [deleted file]
music_assistant/music_manager.py [new file with mode: 0755]
music_assistant/musicproviders/__init__.py [new file with mode: 0644]
music_assistant/musicproviders/file.py [new file with mode: 0644]
music_assistant/musicproviders/qobuz.py [new file with mode: 0644]
music_assistant/musicproviders/spotify.py [new file with mode: 0644]
music_assistant/musicproviders/spotty/arm-linux/spotty-hf [new file with mode: 0755]
music_assistant/musicproviders/spotty/darwin/spotty [new file with mode: 0755]
music_assistant/musicproviders/spotty/windows/spotty.exe [new file with mode: 0755]
music_assistant/musicproviders/spotty/x86-linux/spotty [new file with mode: 0755]
music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64 [new file with mode: 0755]
music_assistant/musicproviders/tunein.py [new file with mode: 0644]
music_assistant/player_manager.py [new file with mode: 0755]
music_assistant/playerproviders/__init__.py [new file with mode: 0644]
music_assistant/playerproviders/chromecast.py [new file with mode: 0644]
music_assistant/playerproviders/lms.py [new file with mode: 0644]
music_assistant/playerproviders/pylms.py [new file with mode: 0644]
music_assistant/web.py [new file with mode: 0755]
music_assistant/web/components/headermenu.vue.js [deleted file]
music_assistant/web/components/infoheader.vue.js [deleted file]
music_assistant/web/components/listviewItem.vue.js [deleted file]
music_assistant/web/components/player.vue.js [deleted file]
music_assistant/web/components/playmenu.vue.js [deleted file]
music_assistant/web/components/providericons.vue.js [deleted file]
music_assistant/web/components/readmore.vue.js [deleted file]
music_assistant/web/components/searchbox.vue.js [deleted file]
music_assistant/web/components/volumecontrol.vue.js [deleted file]
music_assistant/web/css/nprogress.css [deleted file]
music_assistant/web/css/site.css [deleted file]
music_assistant/web/css/vue-loading.css [deleted file]
music_assistant/web/images/default_artist.png [deleted file]
music_assistant/web/images/icons/aac.png [deleted file]
music_assistant/web/images/icons/chromecast.png [deleted file]
music_assistant/web/images/icons/file.png [deleted file]
music_assistant/web/images/icons/flac.png [deleted file]
music_assistant/web/images/icons/hires.png [deleted file]
music_assistant/web/images/icons/homeassistant.png [deleted file]
music_assistant/web/images/icons/http_streamer.png [deleted file]
music_assistant/web/images/icons/icon-128x128.png [deleted file]
music_assistant/web/images/icons/icon-256x256.png [deleted file]
music_assistant/web/images/icons/icon-apple.png [deleted file]
music_assistant/web/images/icons/info_gradient.jpg [deleted file]
music_assistant/web/images/icons/lms.png [deleted file]
music_assistant/web/images/icons/mp3.png [deleted file]
music_assistant/web/images/icons/pylms.png [deleted file]
music_assistant/web/images/icons/qobuz.png [deleted file]
music_assistant/web/images/icons/spotify.png [deleted file]
music_assistant/web/images/icons/tunein.png [deleted file]
music_assistant/web/images/icons/vorbis.png [deleted file]
music_assistant/web/images/icons/web.png [deleted file]
music_assistant/web/images/info_gradient.jpg [deleted file]
music_assistant/web/index.html [deleted file]
music_assistant/web/lib/vue-loading-overlay.js [deleted file]
music_assistant/web/manifest.json [deleted file]
music_assistant/web/pages/albumdetails.vue.js [deleted file]
music_assistant/web/pages/artistdetails.vue.js [deleted file]
music_assistant/web/pages/browse.vue.js [deleted file]
music_assistant/web/pages/config.vue.js [deleted file]
music_assistant/web/pages/home.vue.js [deleted file]
music_assistant/web/pages/playlistdetails.vue.js [deleted file]
music_assistant/web/pages/queue.vue.js [deleted file]
music_assistant/web/pages/search.vue.js [deleted file]
music_assistant/web/pages/trackdetails.vue.js [deleted file]
music_assistant/web/strings.js [deleted file]
run.sh
web/components/headermenu.vue.js [new file with mode: 0755]
web/components/infoheader.vue.js [new file with mode: 0644]
web/components/listviewItem.vue.js [new file with mode: 0755]
web/components/player.vue.js [new file with mode: 0755]
web/components/playmenu.vue.js [new file with mode: 0644]
web/components/providericons.vue.js [new file with mode: 0644]
web/components/readmore.vue.js [new file with mode: 0644]
web/components/searchbox.vue.js [new file with mode: 0644]
web/components/volumecontrol.vue.js [new file with mode: 0644]
web/css/nprogress.css [new file with mode: 0644]
web/css/site.css [new file with mode: 0755]
web/css/vue-loading.css [new file with mode: 0644]
web/images/default_artist.png [new file with mode: 0644]
web/images/icons/aac.png [new file with mode: 0644]
web/images/icons/chromecast.png [new file with mode: 0644]
web/images/icons/file.png [new file with mode: 0644]
web/images/icons/flac.png [new file with mode: 0644]
web/images/icons/hires.png [new file with mode: 0644]
web/images/icons/homeassistant.png [new file with mode: 0644]
web/images/icons/http_streamer.png [new file with mode: 0644]
web/images/icons/icon-128x128.png [new file with mode: 0644]
web/images/icons/icon-256x256.png [new file with mode: 0644]
web/images/icons/icon-apple.png [new file with mode: 0644]
web/images/icons/info_gradient.jpg [new file with mode: 0644]
web/images/icons/lms.png [new file with mode: 0644]
web/images/icons/mp3.png [new file with mode: 0644]
web/images/icons/pylms.png [new file with mode: 0644]
web/images/icons/qobuz.png [new file with mode: 0644]
web/images/icons/spotify.png [new file with mode: 0644]
web/images/icons/tunein.png [new file with mode: 0644]
web/images/icons/vorbis.png [new file with mode: 0644]
web/images/icons/web.png [new file with mode: 0644]
web/images/info_gradient.jpg [new file with mode: 0644]
web/index.html [new file with mode: 0755]
web/lib/vue-loading-overlay.js [new file with mode: 0644]
web/manifest.json [new file with mode: 0755]
web/pages/albumdetails.vue.js [new file with mode: 0755]
web/pages/artistdetails.vue.js [new file with mode: 0755]
web/pages/browse.vue.js [new file with mode: 0755]
web/pages/config.vue.js [new file with mode: 0755]
web/pages/home.vue.js [new file with mode: 0755]
web/pages/playlistdetails.vue.js [new file with mode: 0755]
web/pages/queue.vue.js [new file with mode: 0755]
web/pages/search.vue.js [new file with mode: 0755]
web/pages/trackdetails.vue.js [new file with mode: 0755]
web/strings.js [new file with mode: 0644]

diff --git a/.vscode/.ropeproject/config.py b/.vscode/.ropeproject/config.py
new file mode 100644 (file)
index 0000000..dee2d1a
--- /dev/null
@@ -0,0 +1,114 @@
+# The default ``config.py``
+# flake8: noqa
+
+
+def set_prefs(prefs):
+    """This function is called before opening the project"""
+
+    # Specify which files and folders to ignore in the project.
+    # Changes to ignored resources are not added to the history and
+    # VCSs.  Also they are not returned in `Project.get_files()`.
+    # Note that ``?`` and ``*`` match all characters but slashes.
+    # '*.pyc': matches 'test.pyc' and 'pkg/test.pyc'
+    # 'mod*.pyc': matches 'test/mod1.pyc' but not 'mod/1.pyc'
+    # '.svn': matches 'pkg/.svn' and all of its children
+    # 'build/*.o': matches 'build/lib.o' but not 'build/sub/lib.o'
+    # 'build//*.o': matches 'build/lib.o' and 'build/sub/lib.o'
+    prefs['ignored_resources'] = ['*.pyc', '*~', '.ropeproject',
+                                  '.hg', '.svn', '_svn', '.git', '.tox']
+
+    # Specifies which files should be considered python files.  It is
+    # useful when you have scripts inside your project.  Only files
+    # ending with ``.py`` are considered to be python files by
+    # default.
+    # prefs['python_files'] = ['*.py']
+
+    # Custom source folders:  By default rope searches the project
+    # for finding source folders (folders that should be searched
+    # for finding modules).  You can add paths to that list.  Note
+    # that rope guesses project source folders correctly most of the
+    # time; use this if you have any problems.
+    # The folders should be relative to project root and use '/' for
+    # separating folders regardless of the platform rope is running on.
+    # 'src/my_source_folder' for instance.
+    # prefs.add('source_folders', 'src')
+
+    # You can extend python path for looking up modules
+    # prefs.add('python_path', '~/python/')
+
+    # Should rope save object information or not.
+    prefs['save_objectdb'] = True
+    prefs['compress_objectdb'] = False
+
+    # If `True`, rope analyzes each module when it is being saved.
+    prefs['automatic_soa'] = True
+    # The depth of calls to follow in static object analysis
+    prefs['soa_followed_calls'] = 0
+
+    # If `False` when running modules or unit tests "dynamic object
+    # analysis" is turned off.  This makes them much faster.
+    prefs['perform_doa'] = True
+
+    # Rope can check the validity of its object DB when running.
+    prefs['validate_objectdb'] = True
+
+    # How many undos to hold?
+    prefs['max_history_items'] = 32
+
+    # Shows whether to save history across sessions.
+    prefs['save_history'] = True
+    prefs['compress_history'] = False
+
+    # Set the number spaces used for indenting.  According to
+    # :PEP:`8`, it is best to use 4 spaces.  Since most of rope's
+    # unit-tests use 4 spaces it is more reliable, too.
+    prefs['indent_size'] = 4
+
+    # Builtin and c-extension modules that are allowed to be imported
+    # and inspected by rope.
+    prefs['extension_modules'] = []
+
+    # Add all standard c-extensions to extension_modules list.
+    prefs['import_dynload_stdmods'] = True
+
+    # If `True` modules with syntax errors are considered to be empty.
+    # The default value is `False`; When `False` syntax errors raise
+    # `rope.base.exceptions.ModuleSyntaxError` exception.
+    prefs['ignore_syntax_errors'] = False
+
+    # If `True`, rope ignores unresolvable imports.  Otherwise, they
+    # appear in the importing namespace.
+    prefs['ignore_bad_imports'] = False
+
+    # If `True`, rope will insert new module imports as
+    # `from <package> import <module>` by default.
+    prefs['prefer_module_from_imports'] = False
+
+    # If `True`, rope will transform a comma list of imports into
+    # multiple separate import statements when organizing
+    # imports.
+    prefs['split_imports'] = False
+
+    # If `True`, rope will remove all top-level import statements and
+    # reinsert them at the top of the module when making changes.
+    prefs['pull_imports_to_top'] = True
+
+    # If `True`, rope will sort imports alphabetically by module name instead
+    # of alphabetically by import statement, with from imports after normal
+    # imports.
+    prefs['sort_imports_alphabetically'] = False
+
+    # Location of implementation of
+    # rope.base.oi.type_hinting.interfaces.ITypeHintingFactory In general
+    # case, you don't have to change this value, unless you're an rope expert.
+    # Change this value to inject you own implementations of interfaces
+    # listed in module rope.base.oi.type_hinting.providers.interfaces
+    # For example, you can add you own providers for Django Models, or disable
+    # the search type-hinting in a class hierarchy, etc.
+    prefs['type_hinting_factory'] = (
+        'rope.base.oi.type_hinting.factory.default_type_hinting_factory')
+
+
+def project_opened(project):
+    """This function is called after opening the project"""
+    # Do whatever you like here!
index 5c1ed6b8413ee8f08cef1ade2c6d9bb9ab97521e..5a0d990a8cd184484e9ed0ffda278c25e26b2020 100755 (executable)
@@ -12,7 +12,8 @@ RUN apk --no-cache add --virtual .builddeps build-base python3-dev taglib-dev &&
 # copy files
 RUN mkdir -p /usr/src/app
 WORKDIR /usr/src/app
-COPY music_assistant /usr/src/app
+COPY music_assistant /usr/src/app/music_assistant
+COPY main.py /usr/src/app/main.py
 RUN chmod a+x /usr/src/app/main.py
 
 VOLUME ["/data"]
diff --git a/main.py b/main.py
new file mode 100755 (executable)
index 0000000..0462016
--- /dev/null
+++ b/main.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import sys
+import os
+
+from music_assistant import MusicAssistant
+
+if __name__ == "__main__":
+    datapath = sys.argv[1]
+    if not datapath:
+        datapath = os.path.dirname(os.path.abspath(__file__))
+    MusicAssistant(datapath)
+    
\ No newline at end of file
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..984e33b8c89df986c52874f0387b120bbcdd768b 100644 (file)
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+# import os, sys; sys.path.append(os.path.dirname(os.path.realpath(__file__)))
+
+import sys
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import suppress
+import re
+import uvloop
+import os
+import shutil
+import slugify as unicode_slug
+import uuid
+import json
+import time
+# import stackimpact
+
+# __package__ = 'music_assistant'
+
+from .database import Database
+from .utils import run_periodic, LOGGER
+from .metadata import MetaData
+from .cache import Cache
+from .music_manager import Music
+from .player_manager import PlayerManager
+from .http_streamer import HTTPStreamer
+from .homeassistant import setup as hass_setup
+from .web import setup as web_setup
+
+def handle_exception(loop, context):
+    # context["message"] will always be there; but context["exception"] may not
+    msg = context.get("exception", context["message"])
+    LOGGER.error(f"Caught exception: {msg}")
+
+class MusicAssistant():
+
+    def __init__(self, datapath):
+        uvloop.install()
+        self.datapath = datapath
+        self.parse_config()
+        self.event_loop = asyncio.get_event_loop()
+        self.bg_executor = ThreadPoolExecutor()
+        self.event_loop.set_default_executor(self.bg_executor)
+        self.event_loop.set_exception_handler(handle_exception)
+        self.event_listeners = {}
+
+        # init database and metadata modules
+        self.db = Database(datapath, self.event_loop)
+        # allow some time for the database to initialize
+        while not self.db.db_ready:
+            time.sleep(0.15)
+        self.cache = Cache(datapath)
+        self.metadata = MetaData(self.event_loop, self.db, self.cache)
+
+        # init modules
+        self.web = web_setup(self)
+        self.hass = hass_setup(self)
+        self.music = Music(self)
+        self.player = PlayerManager(self)
+        self.http_streamer = HTTPStreamer(self)
+
+        # agent = stackimpact.start(
+        #     agent_key = '4a00b6f2c7da20f692807d204ab3760318978ba3',
+        #     app_name = 'MusicAssistant')
+        # print("profiler started...")
+
+        # start the event loop
+        try:
+            self.event_loop.run_forever()
+        except (KeyboardInterrupt, SystemExit):
+            LOGGER.info('Exit requested!')
+            self.signal_event("system_shutdown")
+            self.event_loop.stop()
+            self.save_config()
+            time.sleep(5)
+            self.event_loop.close()
+            LOGGER.info('Shutdown complete.')
+
+    def signal_event(self, msg, msg_details=None):
+        ''' signal (systemwide) event '''
+        LOGGER.debug("Event: %s - %s" %(msg, msg_details))
+        listeners = list(self.event_listeners.values())
+        for callback, eventfilter in listeners:
+            if not eventfilter or eventfilter in msg:
+                if not asyncio.iscoroutinefunction(callback):
+                    callback(msg, msg_details)
+                else:
+                    self.event_loop.create_task(callback(msg, msg_details))
+
+    def add_event_listener(self, cb, eventfilter=None):
+        ''' add callback to our event listeners '''
+        cb_id = str(uuid.uuid4())
+        self.event_listeners[cb_id] = (cb, eventfilter)
+        return cb_id
+
+    def remove_event_listener(self, cb_id):
+        ''' remove callback from 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.backup')
+        if os.path.isfile(conf_file):
+            shutil.move(conf_file, conf_file_backup)
+        # remove description keys from config
+        final_conf = {}
+        for key, value in self.config.items():
+            final_conf[key] = {}
+            for subkey, subvalue in value.items():
+                if subkey != "__desc__":
+                    final_conf[key][subkey] = subvalue
+        with open(conf_file, 'w') as f:
+            f.write(json.dumps(final_conf, indent=4))
+        
+    def parse_config(self):
+        '''get config from config file'''
+        config = {
+            "base": {},
+            "musicproviders": {},
+            "playerproviders": {},
+            "player_settings": {}
+            }
+        conf_file = os.path.join(self.datapath, 'config.json')
+        if os.path.isfile(conf_file):
+            with open(conf_file) as f:
+                data = f.read()
+                if data:
+                    data = json.loads(data)
+                    for key, value in data.items():
+                        config[key] = value
+        self.config = config
+
+    
\ 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..583080f
--- /dev/null
@@ -0,0 +1,237 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+
+'''provides a simple stateless caching system'''
+
+import datetime
+import time
+import sqlite3
+from functools import reduce
+import os
+import functools
+import asyncio
+
+from .utils import run_periodic, LOGGER, parse_track_title
+
+class Cache(object):
+    '''basic stateless caching system '''
+    _exit = False
+    _mem_cache = {}
+    _busy_tasks = []
+    _database = None
+
+    def __init__(self, datapath):
+        '''Initialize our caching class'''
+        self._datapath = datapath
+        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 = os.path.join(self._datapath, "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
index dcd3715c6ca9f3a283b8fb3f3a0748932c649c81..7503eb37d0d8669fa894b8d6f63a1d918cc9a363 100755 (executable)
@@ -3,12 +3,13 @@
 
 import asyncio
 import os
-from utils import run_periodic, LOGGER, get_sort_name, try_parse_int
-from models import MediaType, Artist, Album, Track, Playlist, Radio
 from typing import List
 import aiosqlite
 import operator
 
+from .utils import run_periodic, LOGGER, get_sort_name, try_parse_int
+from .models.media_types import MediaType, Artist, Album, Track, Playlist, Radio
+
 class Database():
 
     def __init__(self, datapath, event_loop):
diff --git a/music_assistant/homeassistant.py b/music_assistant/homeassistant.py
new file mode 100644 (file)
index 0000000..ae30d1f
--- /dev/null
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import random
+import aiohttp
+import time
+import datetime
+import hashlib
+from asyncio_throttle import Throttler
+from aiocometd import Client, ConnectionType, Extension
+import copy
+import slugify as slug
+import json
+from .utils import run_periodic, LOGGER, parse_track_title, try_parse_int
+from .models.media_types import Track
+from .constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
+from .cache import use_cache
+
+
+'''
+    Homeassistant integration
+    allows publishing of our players to hass
+    allows using hass entities (like switches, media_players or gui inputs) to be triggered
+'''
+
+def setup(mass):
+    ''' setup the module and read/apply config'''
+    create_config_entries(mass.config)
+    conf = mass.config['base']['homeassistant']
+    enabled = conf.get(CONF_ENABLED)
+    token = conf.get('token')
+    url = conf.get('url')
+    if enabled and url and token:
+        return HomeAssistant(mass, url, token)
+    return None
+
+def create_config_entries(config):
+    ''' get the config entries for this module (list with key/value pairs)'''
+    config_entries = [
+        (CONF_ENABLED, False, 'enabled'),
+        ('url', 'localhost', 'hass_url'), 
+        ('token', '<password>', 'hass_token'),
+        ('publish_players', True, 'hass_publish')
+        ]
+    if not config['base'].get('homeassistant'):
+        config['base']['homeassistant'] = {}
+    config['base']['homeassistant']['__desc__'] = config_entries
+    for key, def_value, desc in config_entries:
+        if not key in config['base']['homeassistant']:
+            config['base']['homeassistant'][key] = def_value
+
+class HomeAssistant():
+    ''' HomeAssistant integration '''
+
+    def __init__(self, mass, url, token):
+        self.mass = mass
+        self._published_players = {}
+        self._tracked_states = {}
+        self._state_listeners = []
+        self._sources = []
+        self._token = token
+        if url.startswith('https://'):
+            self._use_ssl = True
+            self._host = url.replace('https://','').split('/')[0]
+        else:
+            self._use_ssl = False
+            self._host = url.replace('http://','').split('/')[0]
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.__send_ws = None
+        self.__last_id = 10
+        LOGGER.info('Homeassistant integration is enabled')
+        mass.event_loop.create_task(self.__hass_websocket())
+        self.mass.add_event_listener(self.mass_event, "player updated")
+        mass.event_loop.create_task(self.__get_sources())
+
+    async def get_state(self, entity_id, attribute='state', register_listener=None):
+        ''' get state of a hass entity'''
+        if entity_id in self._tracked_states:
+            state_obj = self._tracked_states[entity_id]
+        else:
+            # first request
+            state_obj = await self.__get_data('states/%s' % entity_id)
+            if register_listener:
+                # register state listener
+                self._state_listeners.append( (entity_id, register_listener) )
+            self._tracked_states[entity_id] = state_obj
+        if attribute == 'state':
+            return state_obj['state']
+        elif not attribute:
+            return state_obj
+        else:
+            return state_obj['attributes'].get(attribute)
+    
+    async def mass_event(self, msg, msg_details):
+        ''' received event from mass '''
+        if msg == "player updated":
+            await self.publish_player(msg_details)
+
+    async def hass_event(self, event_type, event_data):
+        ''' received event from hass '''
+        if event_type == 'state_changed':
+            if event_data['entity_id'] in self._tracked_states:
+                self._tracked_states[event_data['entity_id']] = event_data['new_state']
+                for entity_id, handler in self._state_listeners:
+                    if entity_id == event_data['entity_id']:
+                        asyncio.create_task(handler())
+        elif event_type == 'call_service' and event_data['domain'] == 'media_player':
+            await self.__handle_player_command(event_data['service'], event_data['service_data'])
+
+    async def __handle_player_command(self, service, service_data):
+        ''' handle forwarded service call for one of our players '''
+        if isinstance(service_data['entity_id'], list):
+            # can be a list of entity ids if action fired on multiple items
+            entity_ids = service_data['entity_id']
+        else:
+            entity_ids = [service_data['entity_id']]
+        for entity_id in entity_ids:
+            if entity_id in self._published_players:
+                # call is for one of our players so handle it
+                player_id = self._published_players[entity_id]
+                if service == 'turn_on':
+                    await self.mass.player.player_command(player_id, 'power', 'on')
+                elif service == 'turn_off':
+                    await self.mass.player.player_command(player_id, 'power', 'off')
+                elif service == 'toggle':
+                    await self.mass.player.player_command(player_id, 'power', 'toggle')
+                elif service == 'volume_mute':
+                    args = 'on' if service_data['is_volume_muted'] else 'off'
+                    await self.mass.player.player_command(player_id, 'mute', args)
+                elif service == 'volume_up':
+                    await self.mass.player.player_command(player_id, 'volume', 'up')
+                elif service == 'volume_down':
+                    await self.mass.player.player_command(player_id, 'volume', 'down')
+                elif service == 'volume_set':
+                    volume_level = service_data['volume_level']*100
+                    await self.mass.player.player_command(player_id, 'volume', volume_level)
+                elif service == 'media_play':
+                    await self.mass.player.player_command(player_id, 'play')
+                elif service == 'media_pause':
+                    await self.mass.player.player_command(player_id, 'pause')
+                elif service == 'media_stop':
+                    await self.mass.player.player_command(player_id, 'stop')
+                elif service == 'media_next_track':
+                    await self.mass.player.player_command(player_id, 'next')
+                elif service == 'media_play_pause':
+                    await self.mass.player.player_command(player_id, 'pause', 'toggle')
+                elif service == 'play_media':
+                    return await self.__handle_play_media(player_id, service_data)
+
+    async def __handle_play_media(self, player_id, service_data):
+        ''' handle play_media request from homeassistant'''
+        media_content_type = service_data['media_content_type'].lower()
+        media_content_id = service_data['media_content_id']
+        queue_opt = 'add' if service_data.get('enqueue') else 'play'
+        if media_content_type == 'playlist' and not '://' in media_content_id:
+            media_items = []
+            for playlist_str in media_content_id.split(','):
+                playlist_str = playlist_str.strip()
+                playlist = await self.mass.music.playlist_by_name(playlist_str)
+                if playlist:
+                    media_items.append(playlist)
+            return await self.mass.player.play_media(player_id, media_items, queue_opt)
+        elif media_content_type == 'playlist' and 'spotify://playlist' in media_content_id:
+            # TODO: handle parsing of other uri's here
+            playlist = self.mass.music.providers['spotify'].playlist(media_content_id.split(':')[-1])
+            return await self.mass.player.play_media(player_id, playlist, queue_opt)
+        elif media_content_id.startswith('http'):
+            track = Track()
+            track.uri = media_content_id
+            track.provider = 'http'
+            return await self.mass.player.play_media(player_id, track, queue_opt)
+    
+    async def publish_player(self, player):
+        ''' publish player details to hass'''
+        if not self.mass.config['base']['homeassistant']['publish_players']:
+            return False
+        player_id = player.player_id
+        entity_id = 'media_player.mass_' + slug.slugify(player.name, separator='_').lower()
+        state = player.state if player.powered else 'off'
+        state_attributes = {
+                "supported_features": 65471, 
+                "friendly_name": player.name,
+                "source_list": self._sources,
+                "source": 'unknown',
+                "volume_level": player.volume_level/100,
+                "is_volume_muted": player.muted,
+                "media_duration": player.cur_item.duration if player.cur_item else 0,
+                "media_position": player.cur_time,
+                "media_title": player.cur_item.name if player.cur_item else "",
+                "media_artist": player.cur_item.artists[0].name if player.cur_item and player.cur_item.artists else "",
+                "media_album_name": player.cur_item.album.name if player.cur_item and player.cur_item.album else "",
+                "entity_picture": player.cur_item.album.metadata.get('image') if player.cur_item and player.cur_item.album else ""
+                }
+        self._published_players[entity_id] = player_id
+        await self.__set_state(entity_id, state, state_attributes)
+
+    async def call_service(self, domain, service, service_data=None):
+        ''' call service on hass '''
+        if not self.__send_ws:
+            return False
+        msg = {
+            "type": "call_service",
+            "domain": domain,
+            "service": service,
+            }
+        if service_data:
+            msg['service_data'] = service_data
+        return await self.__send_ws(msg)
+
+    @run_periodic(120)
+    async def __get_sources(self):
+        ''' we build a list of all playlists to use as player sources '''
+        self._sources = [playlist.name for playlist in await self.mass.music.playlists()]
+
+    async def __set_state(self, entity_id, new_state, state_attributes={}):
+        ''' set state to hass entity '''
+        data = {
+            "state": new_state,
+            "entity_id": entity_id,
+            "attributes": state_attributes
+            }
+        return await self.__post_data('states/%s' % entity_id, data)
+    
+    async def __hass_websocket(self):
+        ''' Receive events from Hass through websockets '''
+        while self.mass.event_loop.is_running():
+            try:
+                protocol = 'wss' if self._use_ssl else 'ws'
+                async with self.http_session.ws_connect('%s://%s/api/websocket' % (protocol, self._host)) as ws:
+                    
+                    async def send_msg(msg):
+                        ''' callback to send message to the websockets client'''
+                        self.__last_id += 1
+                        msg['id'] = self.__last_id
+                        await ws.send_json(msg)
+
+                    async for msg in ws:
+                        if msg.type == aiohttp.WSMsgType.TEXT:
+                            if msg.data == 'close cmd':
+                                await ws.close()
+                                break
+                            else:
+                                data = msg.json()
+                                if data['type'] == 'auth_required':
+                                    # send auth token
+                                    auth_msg = {"type": "auth", "access_token": self._token}
+                                    await ws.send_json(auth_msg)
+                                elif data['type'] == 'auth_invalid':
+                                    raise Exception(data)
+                                elif data['type'] == 'auth_ok':
+                                    # register callback
+                                    self.__send_ws = send_msg
+                                    # subscribe to events
+                                    subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"}
+                                    await send_msg(subscribe_msg)
+                                    subscribe_msg = {"type": "subscribe_events", "event_type": "call_service"}
+                                    await send_msg(subscribe_msg)
+                                elif data['type'] == 'event':
+                                    asyncio.create_task(self.hass_event(data['event']['event_type'], data['event']['data']))
+                                elif data['type'] == 'result' and data.get('result'):
+                                    # reply to our get_states request
+                                    asyncio.create_task(self.hass_event('all_states', data['result']))
+                                else:
+                                    LOGGER.info(data)
+                        elif msg.type == aiohttp.WSMsgType.ERROR:
+                            raise Exception("error in websocket")
+            except Exception as exc:
+                LOGGER.exception(exc)
+                await asyncio.sleep(10)
+
+    async def __get_data(self, endpoint):
+        ''' get data from hass rest api'''
+        url = "http://%s/api/%s" % (self._host, endpoint)
+        if self._use_ssl:
+            url = "https://%s/api/%s" % (self._host, endpoint)
+        headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"}
+        async with self.http_session.get(url, headers=headers) as response:
+            return await response.json()
+
+    async def __post_data(self, endpoint, data):
+        ''' post data to hass rest api'''
+        url = "http://%s/api/%s" % (self._host, endpoint)
+        if self._use_ssl:
+            url = "https://%s/api/%s" % (self._host, endpoint)
+        headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"}
+        async with self.http_session.post(url, headers=headers, json=data) as response:
+            return await response.json()
\ No newline at end of file
diff --git a/music_assistant/http_streamer.py b/music_assistant/http_streamer.py
new file mode 100755 (executable)
index 0000000..0c5a343
--- /dev/null
@@ -0,0 +1,380 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+import operator
+from aiohttp import web
+import threading
+import urllib
+from memory_tempfile import MemoryTempfile
+import io
+import soundfile as sf
+import pyloudnorm as pyln
+import aiohttp
+from .utils import LOGGER, try_parse_int, get_ip, run_async_background_task, run_periodic, get_folder_size
+from .models.media_types import TrackQuality, MediaType
+from .models.player import PlayerState
+
+
+class HTTPStreamer():
+    ''' Built-in streamer using sox and webserver '''
+    
+    def __init__(self, mass):
+        self.mass = mass
+        self.local_ip = get_ip()
+        self.analyze_jobs = {}
+    
+    async def stream(self, http_request):
+        ''' 
+            start stream for a player
+        '''
+        # make sure we have a valid player
+        player_id = http_request.match_info.get('player_id','')
+        player = await self.mass.player.get_player(player_id)
+        if not player:
+            LOGGER.error("Received stream request for non-existing player %s" %(player_id))
+            return
+        queue_item_id = http_request.query.get('queue_item_id')
+        queue_item = await player.queue.by_item_id(queue_item_id)
+        # prepare headers as audio/flac content
+        resp = web.StreamResponse(status=200, reason='OK', headers={'Content-Type': 'audio/flac'})
+        await resp.prepare(http_request)
+        # send content only on GET request
+        if http_request.method.upper() != 'HEAD':
+            # stream audio
+            queue = asyncio.Queue()
+            cancelled = threading.Event()
+            if queue_item:
+                # single stream requested
+                run_async_background_task(
+                    self.mass.bg_executor, 
+                    self.__stream_single, player, queue_item,  queue, cancelled)
+            else:
+                # no item is given, start queue stream
+                run_async_background_task(
+                    self.mass.bg_executor, 
+                    self.__stream_queue, player, queue, cancelled)
+                await asyncio.sleep(2)
+            try:
+                while True:
+                    chunk = await queue.get()
+                    if not chunk:
+                        queue.task_done()
+                        break
+                    await resp.write(chunk)
+                    queue.task_done()
+                LOGGER.info("stream fininished for player %s" % player.name)
+            except asyncio.CancelledError:
+                cancelled.set()
+                LOGGER.warning("stream interrupted for player %s" % player.name)
+                raise asyncio.CancelledError()
+        return resp
+        
+    async def __stream_single(self, player, queue_item, buffer, cancelled):
+        ''' start streaming single track from provider '''
+        try:
+            audio_stream = self.__get_audio_stream(player, queue_item, cancelled)
+            async for is_last_chunk, audio_chunk in audio_stream:
+                await buffer.put(audio_chunk)
+                # wait for the queue to consume the data
+                # this prevents that the entire track is sitting in memory
+                # while buffer.qsize() > 1 and not cancelled.is_set():
+                #     await asyncio.sleep(1)
+            await buffer.put(b'') # EOF
+        except (asyncio.CancelledError, asyncio.TimeoutError):
+            cancelled.set()
+            LOGGER.info("stream_track interrupted for %s" % queue_item.name)
+            raise asyncio.CancelledError()
+        else:
+            LOGGER.info("stream_track fininished for %s" % queue_item.name)
+
+    async def __stream_queue(self, player, buffer, cancelled):
+        ''' start streaming all queue tracks '''
+        sample_rate = player.settings['max_sample_rate']
+        fade_length = player.settings["crossfade_duration"]
+        fade_bytes = int(sample_rate * 4 * 2 * fade_length)
+        pcm_args = 'raw -b 32 -c 2 -e signed-integer -r %s' % sample_rate
+        args = 'sox -t %s - -t flac -C 0 -' % pcm_args
+        sox_proc = await asyncio.create_subprocess_shell(args, 
+                stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE)
+
+        async def fill_buffer():
+            while not sox_proc.stdout.at_eof():
+                chunk = await sox_proc.stdout.read(256000)
+                if not chunk:
+                    break
+                await buffer.put(chunk)
+            await buffer.put(b'') # indicate EOF
+        asyncio.create_task(fill_buffer())
+
+        LOGGER.info("Start Queue Stream for player %s" %(player.name))
+        last_fadeout_data = b''
+        # report start of queue playback so we can calculate current track/duration etc.
+        # self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True))
+        while True:
+            # get the (next) track in queue
+            queue_track = player.queue.next_item
+            LOGGER.info("got queue track %s" % queue_track.name)
+            if not queue_track:
+                break
+            LOGGER.debug("Start Streaming queue track: %s (%s) on player %s" % (queue_track.item_id, queue_track.name, player.name))
+            fade_in_part = b''
+            cur_chunk = 0
+            prev_chunk = None
+            bytes_written = 0
+            async for is_last_chunk, chunk in self.__get_audio_stream(
+                    player, queue_track, cancelled, chunksize=fade_bytes, resample=sample_rate):
+                cur_chunk += 1
+                if cur_chunk <= 2 and not last_fadeout_data:
+                    # fade-in part but no fadeout_part available so just pass it to the output directly
+                    sox_proc.stdin.write(chunk)
+                    await sox_proc.stdin.drain()
+                    bytes_written += len(chunk)
+                elif cur_chunk == 1 and last_fadeout_data:
+                    prev_chunk = chunk
+                elif cur_chunk == 2 and last_fadeout_data:
+                    # combine the first 2 chunks and strip off silence
+                    args = 'sox --ignore-length -t %s - -t %s - silence 1 0.1 1%%' % (pcm_args, pcm_args)
+                    process = await asyncio.create_subprocess_shell(args,
+                            stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
+                    first_part, stderr = await process.communicate(prev_chunk + chunk)
+                    fade_in_part = first_part[:fade_bytes]
+                    remaining_bytes = first_part[fade_bytes:]
+                    del first_part
+                    # do crossfade
+                    crossfade_part = await self.__crossfade_pcm_parts(fade_in_part, last_fadeout_data, pcm_args, fade_length) 
+                    sox_proc.stdin.write(crossfade_part)
+                    await sox_proc.stdin.drain()
+                    bytes_written += len(crossfade_part)
+                    del crossfade_part
+                    del fade_in_part
+                    last_fadeout_data = b''
+                    # also write the leftover bytes from the strip action
+                    sox_proc.stdin.write(remaining_bytes)
+                    await sox_proc.stdin.drain()
+                    bytes_written += len(remaining_bytes)
+                    del remaining_bytes
+                    prev_chunk = None # needed to prevent this chunk being sent again
+                elif prev_chunk and is_last_chunk:
+                    # last chunk received so create the fadeout_part with the previous chunk and this chunk
+                    # and strip off silence
+                    args = 'sox --ignore-length -t %s - -t %s - reverse silence 1 0.1 1%% reverse' % (pcm_args, pcm_args)
+                    process = await asyncio.create_subprocess_shell(args,
+                            stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
+                    last_part, stderr = await process.communicate(prev_chunk + chunk)
+                    if len(last_part) < fade_bytes:
+                        # not enough data for crossfade duration after the strip action...
+                        last_part = prev_chunk + chunk
+                    if len(last_part) < fade_bytes:
+                        # still not enough data so we'll skip the crossfading
+                        LOGGER.warning("not enough data for fadeout so skip crossfade... %s" % len(last_part))
+                        sox_proc.stdin.write(last_part)
+                        bytes_written += len(last_part)
+                        await sox_proc.stdin.drain()
+                        del last_part
+                    else:
+                        # store fade section to be picked up for next track
+                        last_fadeout_data = last_part[-fade_bytes:]
+                        remaining_bytes = last_part[:-fade_bytes]
+                        # write remaining bytes
+                        sox_proc.stdin.write(remaining_bytes)
+                        bytes_written += len(remaining_bytes)
+                        await sox_proc.stdin.drain()
+                        del last_part
+                        del remaining_bytes
+                else:
+                    # middle part of the track
+                    # keep previous chunk in memory so we have enough samples to perform the crossfade
+                    if prev_chunk:
+                        sox_proc.stdin.write(prev_chunk)
+                        await sox_proc.stdin.drain()
+                        bytes_written += len(prev_chunk)
+                        prev_chunk = chunk
+                    else:
+                        prev_chunk = chunk
+                # wait for the queue to consume the data
+                # this prevents that the entire track is sitting in memory
+                # and it helps a bit in the quest to follow where we are in the queue
+                while buffer.qsize() > 1 and not cancelled.is_set():
+                    await asyncio.sleep(1)
+            # end of the track reached
+            if cancelled.is_set():
+                # break out the loop if the http session is cancelled
+                LOGGER.warning("session cancelled")
+                break
+            else:
+                # WIP: update actual duration to the queue for more accurate now playing info
+                accurate_duration = bytes_written / int(sample_rate * 4 * 2)
+                queue_track.duration = accurate_duration
+                #self.mass.player.providers[player.player_provider]._player_queue[player_id][queue_index] = queue_track
+                # move to next queue index
+                #queue_index += 1
+                #self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, False))
+                LOGGER.info("Finished Streaming queue track: %s (%s) on player %s" % (queue_track.item_id, queue_track.name, player.name))
+                LOGGER.info("bytes written: %s - duration: %s" % (bytes_written, accurate_duration))
+            break
+        # end of queue reached, pass last fadeout bits to final output
+        if last_fadeout_data and not cancelled.is_set():
+            sox_proc.stdin.write(last_fadeout_data)
+            await sox_proc.stdin.drain()
+        sox_proc.stdin.close()
+        await sox_proc.wait()
+        LOGGER.info("streaming of queue for player %s completed" % player.name)
+
+    async def __get_audio_stream(self, player, queue_item, cancelled,
+                chunksize=512000, resample=None):
+        ''' get audio stream from provider and apply additional effects/processing where/if needed'''
+        sox_effects = await self.__get_player_sox_options(player, queue_item)
+        outputfmt = 'flac -C 0'
+        if resample:
+            outputfmt = 'raw -b 32 -c 2 -e signed-integer'
+            sox_effects += ' rate -v %s' % resample
+        # stream audio from provider
+        streamdetails = asyncio.run_coroutine_threadsafe(
+                self.mass.music.providers[queue_item.provider].get_stream_details(queue_item.item_id), 
+                self.mass.event_loop).result()
+        if not streamdetails:
+            LOGGER.warning("no stream details!")
+            yield (True, b'')
+            return
+        if streamdetails["content_type"] == 'aac':
+            # support for AAC created with ffmpeg in between
+            args = 'ffmpeg -i "%s" -f flac - | sox -t flac - -t %s - %s' % (streamdetails["path"], outputfmt, sox_effects)
+        elif streamdetails['type'] == 'url':
+            args = 'sox -t %s "%s" -t %s - %s' % (streamdetails["content_type"], 
+                    streamdetails["path"], outputfmt, sox_effects)
+        elif streamdetails['type'] == 'executable':
+            args = '%s | sox -t %s - -t %s - %s' % (streamdetails["path"], 
+                    streamdetails["content_type"], outputfmt, sox_effects)
+        
+        LOGGER.info("Running sox with args: %s" % args)
+        process = await asyncio.create_subprocess_shell(args,
+                stdout=asyncio.subprocess.PIPE)
+        # fire event that streaming has started for this track (needed by some streaming providers)
+        streamdetails["provider"] = queue_item.provider
+        streamdetails["track_id"] = queue_item.item_id
+        streamdetails["player_id"] = player.player_id
+        self.mass.signal_event('streaming_started', streamdetails)
+        # yield chunks from stdout
+        # we keep 1 chunk behind to detect end of stream properly
+        prev_chunk = b''
+        bytes_sent = 0
+        while not process.stdout.at_eof():
+            if cancelled.is_set():
+                process.terminate()
+            try:
+                chunk = await process.stdout.readexactly(chunksize)
+            except asyncio.streams.IncompleteReadError:
+                chunk = await process.stdout.read(chunksize)
+            if not chunk:
+                break
+            if prev_chunk:
+                yield (False, prev_chunk)
+                bytes_sent += len(prev_chunk)
+            prev_chunk = chunk
+        # yield last chunk
+        if not cancelled.is_set():
+            yield (True, prev_chunk)
+            bytes_sent += len(prev_chunk)
+        await process.wait()
+        if cancelled.is_set():
+            LOGGER.warning("__get_audio_stream for track_id %s interrupted - bytes_sent: %s" % (queue_item.item_id, bytes_sent))
+        else:
+            LOGGER.info("__get_audio_stream for track_id %s completed- bytes_sent: %s" % (queue_item.item_id, bytes_sent))
+        # fire event that streaming has ended for this track (needed by some streaming providers)
+        if resample:
+            bytes_per_second = resample * (32/8) * 2
+        else:
+            bytes_per_second = streamdetails["sample_rate"] * (streamdetails["bit_depth"]/8) * 2
+        seconds_streamed = int(bytes_sent/bytes_per_second)
+        streamdetails["seconds"] = seconds_streamed
+        self.mass.signal_event('streaming_ended', streamdetails)
+        # send task to background to analyse the audio
+        self.mass.event_loop.create_task(self.__analyze_audio(queue_item.item_id, queue_item.provider))
+
+    async def __get_player_sox_options(self, player, queue_item):
+        ''' get player specific sox effect options '''
+        sox_effects = []
+        # volume normalisation enabled but not natively handled by player so handle with sox
+        if not player.supports_replay_gain and player.settings['volume_normalisation']:
+            target_gain = int(player.settings['target_volume'])
+            fallback_gain = int(player.settings['fallback_gain_correct'])
+            track_loudness = await self.mass.db.get_track_loudness(
+                    queue_item.item_id, queue_item.provider)
+            if track_loudness == None:
+                gain_correct = fallback_gain
+            else:
+                gain_correct = target_gain - track_loudness
+            gain_correct = round(gain_correct,2)
+            sox_effects.append('vol %s dB ' % gain_correct)
+        else:
+            gain_correct = ''
+        # downsample if needed
+        if player.settings['max_sample_rate']:
+            max_sample_rate = try_parse_int(player.settings['max_sample_rate'])
+            if max_sample_rate:
+                quality = queue_item.quality
+                if quality > TrackQuality.FLAC_LOSSLESS_HI_RES_3 and max_sample_rate == 192000:
+                    sox_effects.append('rate -v 192000')
+                elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_2 and max_sample_rate == 96000:
+                    sox_effects.append('rate -v 96000')
+                elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_1 and max_sample_rate == 48000:
+                    sox_effects.append('rate -v 48000')
+        if player.settings.get('sox_effects'):
+            sox_effects.append(player.settings['sox_effects'])
+        return " ".join(sox_effects)
+        
+    async def __analyze_audio(self, track_id, provider):
+        ''' analyze track audio, for now we only calculate EBU R128 loudness '''
+        track_key = '%s%s' %(track_id, provider)
+        if track_key in self.analyze_jobs:
+            return # prevent multiple analyze jobs for same track
+        self.analyze_jobs[track_key] = True
+        streamdetails = await self.mass.music.providers[provider].get_stream_details(track_id)
+        track_loudness = await self.mass.db.get_track_loudness(track_id, provider)
+        if track_loudness == None:
+            # only when needed we do the analyze stuff
+            LOGGER.debug('Start analyzing track %s' % track_id)
+            if streamdetails['type'] == 'url':
+                async with aiohttp.ClientSession() as session:
+                    async with session.get(streamdetails["path"]) as resp:
+                        audio_data = await resp.read()
+            elif streamdetails['type'] == 'executable':
+                process = await asyncio.create_subprocess_shell(streamdetails["path"],
+                    stdout=asyncio.subprocess.PIPE)
+                audio_data, stderr = await process.communicate()
+            # calculate BS.1770 R128 integrated loudness
+            if track_loudness == None:
+                with io.BytesIO(audio_data) as tmpfile:
+                    data, rate = sf.read(tmpfile)
+                meter = pyln.Meter(rate) # create BS.1770 meter
+                loudness = meter.integrated_loudness(data) # measure loudness
+                del data
+                LOGGER.debug("Integrated loudness of track %s is: %s" %(track_id, loudness))
+                await self.mass.db.set_track_loudness(track_id, provider, loudness)
+            del audio_data
+            LOGGER.debug('Finished analyzing track %s' % track_id)
+        self.analyze_jobs.pop(track_key, None)
+    
+    async def __crossfade_pcm_parts(self, fade_in_part, fade_out_part, pcm_args, fade_length):
+        ''' crossfade two chunks of audio using sox '''
+        # create fade-in part
+        fadeinfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0)
+        args = 'sox --ignore-length -t %s - -t %s %s fade t %s' % (pcm_args, pcm_args, fadeinfile.name, fade_length)
+        process = await asyncio.create_subprocess_shell(args, stdin=asyncio.subprocess.PIPE)
+        await process.communicate(fade_in_part)
+        # create fade-out part
+        fadeoutfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0)
+        args = 'sox --ignore-length -t %s - -t %s %s reverse fade t %s reverse' % (pcm_args, pcm_args, fadeoutfile.name, fade_length)
+        process = await asyncio.create_subprocess_shell(args,
+                stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
+        await process.communicate(fade_out_part)
+        # create crossfade using sox and some temp files
+        # TODO: figure out how to make this less complex and without the tempfiles
+        args = 'sox -m -v 1.0 -t %s %s -v 1.0 -t %s %s -t %s -' % (pcm_args, fadeoutfile.name, pcm_args, fadeinfile.name, pcm_args)
+        process = await asyncio.create_subprocess_shell(args,
+                stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
+        crossfade_part, stderr = await process.communicate()
+        LOGGER.debug("Got %s bytes in memory for crossfade_part after sox" % len(crossfade_part))
+        return crossfade_part
diff --git a/music_assistant/main.py b/music_assistant/main.py
deleted file mode 100755 (executable)
index 8b31d17..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import sys
-import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from contextlib import suppress
-import re
-import uvloop
-import os
-import shutil
-import slugify as unicode_slug
-import uuid
-import json
-import time
-# import stackimpact
-
-from database import Database
-from utils import run_periodic, LOGGER
-from modules.metadata import MetaData
-from modules.cache import Cache
-from modules.music import Music
-from modules.playermanager import PlayerManager
-from modules.http_streamer import HTTPStreamer
-from modules.homeassistant import setup as hass_setup
-from modules.web import setup as web_setup
-
-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()
-        self.event_loop.set_default_executor(self.bg_executor)
-        self.event_listeners = {}
-
-        # init database and metadata modules
-        self.db = Database(datapath, self.event_loop)
-        # allow some time for the database to initialize
-        while not self.db.db_ready:
-            time.sleep(0.15)
-        self.cache = Cache(datapath)
-        self.metadata = MetaData(self.event_loop, self.db, self.cache)
-
-        # init modules
-        self.web = web_setup(self)
-        self.hass = hass_setup(self)
-        self.music = Music(self)
-        self.player = PlayerManager(self)
-        self.http_streamer = HTTPStreamer(self)
-
-        # agent = stackimpact.start(
-        #     agent_key = '4a00b6f2c7da20f692807d204ab3760318978ba3',
-        #     app_name = 'MusicAssistant')
-        # print("profiler started...")
-
-        # start the event loop
-        try:
-            self.event_loop.run_forever()
-        except (KeyboardInterrupt, SystemExit):
-            LOGGER.info('Exit requested!')
-            self.signal_event("system_shutdown")
-            self.event_loop.stop()
-            self.save_config()
-            time.sleep(5)
-            self.event_loop.close()
-            LOGGER.info('Shutdown complete.')
-
-    def signal_event(self, msg, msg_details=None):
-        ''' signal (systemwide) event '''
-        LOGGER.debug("Event: %s - %s" %(msg, msg_details))
-        listeners = list(self.event_listeners.values())
-        for callback, eventfilter in listeners:
-            if not eventfilter or eventfilter in msg:
-                if not asyncio.iscoroutinefunction(callback):
-                    callback(msg, msg_details)
-                else:
-                    self.event_loop.create_task(callback(msg, msg_details))
-
-    def add_event_listener(self, cb, eventfilter=None):
-        ''' add callback to our event listeners '''
-        cb_id = str(uuid.uuid4())
-        self.event_listeners[cb_id] = (cb, eventfilter)
-        return cb_id
-
-    def remove_event_listener(self, cb_id):
-        ''' remove callback from 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.backup')
-        if os.path.isfile(conf_file):
-            shutil.move(conf_file, conf_file_backup)
-        # remove description keys from config
-        final_conf = {}
-        for key, value in self.config.items():
-            final_conf[key] = {}
-            for subkey, subvalue in value.items():
-                if subkey != "__desc__":
-                    final_conf[key][subkey] = subvalue
-        with open(conf_file, 'w') as f:
-            f.write(json.dumps(final_conf, indent=4))
-        
-    def parse_config(self):
-        '''get config from config file'''
-        config = {
-            "base": {},
-            "musicproviders": {},
-            "playerproviders": {},
-            "player_settings": {}
-            }
-        conf_file = os.path.join(self.datapath, 'config.json')
-        if os.path.isfile(conf_file):
-            with open(conf_file) as f:
-                data = f.read()
-                if data:
-                    config = json.loads(data)
-        self.config = config
-
-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/main.spec b/music_assistant/main.spec
deleted file mode 100644 (file)
index 9a97d32..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-# -*- mode: python ; coding: utf-8 -*-
-
-block_cipher = None
-
-
-a = Analysis(['main.py'],
-             pathex=['/Users/marcelveldt/Workdir/musicassistant/music_assistant'],
-             binaries=[],
-             datas=[],
-             hiddenimports=[],
-             hookspath=[],
-             runtime_hooks=[],
-             excludes=[],
-             win_no_prefer_redirects=False,
-             win_private_assemblies=False,
-             cipher=block_cipher,
-             noarchive=False)
-pyz = PYZ(a.pure, a.zipped_data,
-             cipher=block_cipher)
-exe = EXE(pyz,
-          a.scripts,
-          a.binaries,
-          a.zipfiles,
-          a.datas,
-          [],
-          name='main',
-          debug=False,
-          bootloader_ignore_signals=False,
-          strip=False,
-          upx=True,
-          upx_exclude=[],
-          runtime_tmpdir=None,
-          console=True )
diff --git a/music_assistant/metadata.py b/music_assistant/metadata.py
new file mode 100755 (executable)
index 0000000..0f08789
--- /dev/null
@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+import json
+import aiohttp
+from asyncio_throttle import Throttler
+from difflib import SequenceMatcher as Matcher
+from yarl import URL
+import re
+
+from .utils import run_periodic, LOGGER
+from .cache import use_cache
+
+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, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.throttler = Throttler(rate_limit=1, period=1)
+
+    async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
+        ''' 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]:
+                for item in result['releases']:
+                    if album_upc or Matcher(None, item['title'].lower(), albumname.lower()).ratio() >= strictness:
+                        for artist in item['artist-credit']:
+                            artist = artist['artist']
+                            if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
+                                return artist['id']
+                            for item in artist.get('aliases',[]):
+                                if item['name'].lower() == artistname.lower():
+                                    return artist['id']
+        return ''
+
+    async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
+        ''' 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]:
+                for item in result['recordings']:
+                    if track_isrc or Matcher(None, item['title'].lower(), trackname.lower()).ratio() >= strictness:
+                        for artist in item['artist-credit']:
+                            artist = artist['artist']
+                            if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
+                                return artist['id']
+                            for item in artist.get('aliases',[]):
+                                if item['name'].lower() == artistname.lower():
+                                    return artist['id']
+        return ''
+
+    @use_cache(30)
+    async def get_data(self, endpoint, params={}):
+        ''' get data from api'''
+        url = 'http://musicbrainz.org/ws/2/%s' % endpoint
+        headers = {'User-Agent': 'Music Assistant/1.0.0 https://github.com/marcelveldt'}
+        params['fmt'] = 'json'
+        async with self.throttler:
+            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, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.throttler = Throttler(rate_limit=1, period=1)
+
+    async def artist_images(self, mb_artist_id):
+        ''' retrieve images by musicbrainz artist id '''
+        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
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..05b722196101bd5e797976ba6766a1cf61014769 100644 (file)
@@ -0,0 +1,5 @@
+from .media_types import *
+from .musicprovider import *
+from .player_queue import *
+from .player import *
+from .playerprovider import *
\ No newline at end of file
index 1c968a653ee93436da6e4de691d6616f1a7c6c9f..13d98bd3922207b984021f2afbebdf10d7cfb577 100755 (executable)
@@ -45,96 +45,70 @@ class TrackQuality(IntEnum):
     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 '''
+class MediaItem(object):
+    ''' representation of a media item '''
     def __init__(self):
         self.item_id = None
         self.provider = 'database'
         self.name = ''
-        self.sort_name = ''
         self.metadata = {}
         self.tags = []
         self.external_ids = []
         self.provider_ids = []
-        self.media_type = MediaType.Artist
         self.in_library = []
         self.is_lazy = False
+    def __eq__(self, other): 
+        if not isinstance(other, self.__class__):
+            return NotImplemented
+        return (self.name == other.name and 
+                self.item_id == other.item_id and
+                self.provider == other.provider)
+    def __ne__(self, other):
+        return not self.__eq__(other)
 
-class Album(object):
+class Artist(MediaItem):
+    ''' representation of an artist '''
+    def __init__(self):
+        super().__init__()
+        self.sort_name = ''
+        self.media_type = MediaType.Artist
+
+class Album(MediaItem):
     ''' representation of an album '''
     def __init__(self):
-        self.item_id = None
-        self.provider = 'database'
-        self.name = '' 
-        self.metadata = {}
+        super().__init__()
         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):
+class Track(MediaItem):
     ''' representation of a track '''
     def __init__(self):
-        self.item_id = None
-        self.provider = 'database'
-        self.name = ''
+        super().__init__()
         self.duration = 0
         self.version = ''
-        self.external_ids = []
-        self.metadata = { }
-        self.tags = []
         self.artists = []
-        self.provider_ids = []
         self.album = None
         self.disc_number = 1
         self.track_number = 1
         self.media_type = MediaType.Track
-        self.in_library = []
-        self.is_lazy = False
-        self.uri = ""
-    def __eq__(self, other): 
-        if not isinstance(other, self.__class__):
-            return NotImplemented
-        return (self.name == other.name and 
-                self.version == other.version and
-                self.item_id == other.item_id and
-                self.provider == other.provider)
-    def __ne__(self, other):
-        return not self.__eq__(other)
 
-class Playlist(object):
+class Playlist(MediaItem):
     ''' representation of a playlist '''
     def __init__(self):
-        self.item_id = None
-        self.provider = 'database'
-        self.name = ''
+        super().__init__()
         self.owner = ''
-        self.provider_ids = []
-        self.metadata = {}
         self.media_type = MediaType.Playlist
-        self.in_library = []
         self.is_editable = False
 
-class Radio(Track):
+class Radio(MediaItem):
     ''' representation of a radio station '''
     def __init__(self):
         super().__init__()
-        self.item_id = None
-        self.provider = 'database'
-        self.name = ''
-        self.provider_ids = []
-        self.metadata = {}
         self.media_type = MediaType.Radio
-        self.in_library = []
-        self.is_editable = False
         self.duration = 0
 
 
index c75e16a5db9f20041ef916c3024a4e96f75eba44..41c1ca2ba6e581567a5035555fcd392655326c2f 100755 (executable)
@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
 # -*- coding:utf-8 -*-
 
+import asyncio
 from typing import List
 from ..utils import run_periodic, LOGGER, parse_track_title
-import asyncio
-from ..modules.cache import use_cache
-from media_types import *
+from ..cache import use_cache
+from ..constants import CONF_ENABLED
+from .media_types import Album, Artist, Track, Playlist, MediaType, Radio
 
 
 class MusicProvider():
@@ -373,20 +374,20 @@ class PlayerProvider():
 
     async def players(self):
         ''' return all players for this provider '''
-        return self.mass.provider_players(self.prov_id)
+        return await self.mass.provider_players(self.prov_id)
     
     async def get_player(self, player_id):
         ''' return player by id '''
-        return self.mass.get_player(player_id)
+        return await self.mass.get_player(player_id)
 
     async def add_player(self, player_id, name='', is_group=False):
         ''' register a new player '''
-        return self.mass.player.add_player(player_id, 
+        return await self.mass.player.add_player(player_id, 
                 self.prov_id, name=name, is_group=is_group)
 
     async def remove_player(self, player_id):
         ''' remove a player '''
-        return self.mass.player.remove_player(player_id)
+        return await self.mass.player.remove_player(player_id)
 
     ### Provider specific implementation #####
 
index 05f7ad55cc0090a6c4e3ffeffbe871691cf9988e..4ae2c4f64c7476009b556b4e1dafb1418acc9322 100755 (executable)
@@ -1,13 +1,15 @@
 #!/usr/bin/env python3
 # -*- coding:utf-8 -*-
 
+import asyncio
 from enum import Enum
 from typing import List
+import operator
 from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, try_parse_bool, try_parse_float
 from ..constants import CONF_ENABLED
-from ..modules.cache import use_cache
-from media_types import Track, MediaType
-from player_queue import PlayerQueue, QueueItem
+from ..cache import use_cache
+from .media_types import Track, MediaType
+from .player_queue import PlayerQueue, QueueItem
 
 
 class PlayerState(str, Enum):
@@ -21,57 +23,104 @@ class Player():
 
     #### Provider specific implementation, should be overridden ####
 
-    async def get_config_entries(self):
-        ''' [MAY OVERRIDE] get the player-specific config entries for this player (list with key/value pairs)'''
-        return []
-
-    async def __stop(self):
+    async def cmd_stop(self):
         ''' [MUST OVERRIDE] send stop command to player '''
         raise NotImplementedError
 
-    async def __play(self):
+    async def cmd_play(self):
         ''' [MUST OVERRIDE] send play (unpause) command to player '''
         raise NotImplementedError
 
-    async def __pause(self):
+    async def cmd_pause(self):
         ''' [MUST OVERRIDE] send pause command to player '''
         raise NotImplementedError
+
+    async def cmd_next(self):
+        ''' [CAN OVERRIDE] send next track command to player '''
+        return await self.queue.play_index(self.queue.cur_index+1)
+
+    async def cmd_previous(self):
+        ''' [CAN OVERRIDE] send previous track command to player '''
+        return await self.queue.play_index(self.queue.cur_index-1)
     
-    async def __power_on(self):
+    async def cmd_power_on(self):
         ''' [MUST OVERRIDE] send power ON command to player '''
         raise NotImplementedError
 
-    async def __power_off(self):
+    async def cmd_power_off(self):
         ''' [MUST OVERRIDE] send power TOGGLE command to player '''
         raise NotImplementedError
 
-    async def __volume_set(self, volume_level):
+    async def cmd_volume_set(self, volume_level):
         ''' [MUST OVERRIDE] send new volume level command to player '''
         raise NotImplementedError
 
-    async def __volume_mute(self, is_muted=False):
+    async def cmd_volume_mute(self, is_muted=False):
         ''' [MUST OVERRIDE] send mute command to player '''
         raise NotImplementedError
 
-    async def __play_queue(self):
-        ''' [MUST OVERRIDE] tell player to start playing the queue '''
+    async def cmd_queue_play_index(self, index:int):
+        '''
+            [OVERRIDE IF SUPPORTED]
+            play item at index X on player's queue
+            :attrib index: (int) index of the queue item that should start playing
+        '''
+        raise NotImplementedError
+
+    async def cmd_queue_load(self, queue_items):
+        ''' 
+            [OVERRIDE IF SUPPORTED]
+            load/overwrite given items in the player's own queue implementation
+            :param queue_items: a list of QueueItems
+        '''
+        raise NotImplementedError
+
+    async def cmd_queue_insert(self, queue_items, offset=0):
+        ''' 
+            [OVERRIDE IF SUPPORTED]
+            insert new items at position X into existing queue
+            if offset 0 or None, will start playing newly added item(s)
+                :param queue_items: a list of QueueItems
+                :param offset: offset from current queue position to insert new items
+        '''
+        raise NotImplementedError
+
+    async def cmd_queue_append(self, queue_items):
+        ''' 
+            append new items at the end of the queue
+            :param queue_items: a list of QueueItems
+        '''
+        raise NotImplementedError
+
+    async def cmd_play_uri(self, uri:str):
+        '''
+            [MUST OVERRIDE]
+            tell player to start playing a single uri
+        '''
         raise NotImplementedError
 
     #### Common implementation, should NOT be overrridden #####
 
     def __init__(self, mass, player_id, prov_id):
+        # private attributes
         self.mass = mass
-        self._player_id = player_id
-        self._prov_id = prov_id
+        self._player_id = player_id # unique id for this player
+        self._prov_id = prov_id # unique provider id for the player
         self._name = ''
-        self._is_group = False
-        self._state = PlayerState.Stopped
-        self._powered = False
+        self._is_group = False 
+        self._state = PlayerState.Stopped 
+        self._powered = False 
         self._cur_time = 0
+        self._cur_uri = ''
         self._volume_level = 0
         self._muted = False
         self._group_parent = None
         self._queue = PlayerQueue(mass, self)
+        # public attributes
+        self.supports_queue = True # has native support for a queue
+        self.supports_gapless = True # has native gapless support
+        self.supports_crossfade = False # has native crossfading support
+        self.supports_replay_gain = False # has native support for replaygain volume leveling
 
     @property
     def player_id(self):
@@ -116,8 +165,8 @@ class Player():
         if not self.powered:
             return PlayerState.Off
         if self.group_parent:
-            group_player = self.mass.event_loop.run_until_complete(
-                    self.mass.player.get_player(self.group_parent))
+            group_player = self.mass.bg_executor.submit(asyncio.run, 
+                self.mass.player.get_player(self.group_parent)).result()
             if group_player:
                 return group_player.state
         return self._state
@@ -125,7 +174,7 @@ class Player():
     @state.setter
     def state(self, state:PlayerState):
         ''' [PROTECTED] set state property of this player '''
-        if state != self.state:
+        if state != self._state:
             self._state = state
             self.mass.event_loop.create_task(self.update())
 
@@ -134,18 +183,18 @@ class Player():
         ''' [PROTECTED] return power state for this player '''
         # homeassistant integration
         if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'):
-            hass_state = self.mass.event_loop.run_until_complete(
+            hass_state = self.mass.bg_executor.submit(asyncio.run, 
                 self.mass.hass.get_state(
                     self.settings['hass_power_entity'],
                     attribute='source',
-                    register_listener=self.update()))
+                    register_listener=self.update())).result()
             return hass_state == self.settings['hass_power_entity_source']
         elif self.settings.get('hass_power_entity'):
-            hass_state = self.mass.event_loop.run_until_complete(
+            hass_state = self.mass.bg_executor.submit(asyncio.run, 
                 self.mass.hass.get_state(
                     self.settings['hass_power_entity'],
                     attribute='state',
-                    register_listener=self.update()))
+                    register_listener=self.update())).result()
             return hass_state != 'off'
         # mute as power
         elif self.settings.get('mute_as_power'):
@@ -156,15 +205,17 @@ class Player():
     @powered.setter
     def powered(self, powered):
         ''' [PROTECTED] set (real) power state for this player '''
-        self._powered = powered
+        if powered != self._powered:
+            self._powered = powered
+        self.mass.event_loop.create_task(self.update())
 
     @property
     def cur_time(self):
         ''' [PROTECTED] cur_time (player's elapsed time) property of this player '''
         # handle group player
         if self.group_parent:
-            group_player = self.mass.event_loop.run_until_complete(
-                    self.mass.player.get_player(self.group_parent))
+            group_player = self.mass.bg_executor.submit(asyncio.run, 
+                self.mass.player.get_player(self.group_parent)).result()
             if group_player:
                 return group_player.cur_time
         return self._cur_time
@@ -176,6 +227,24 @@ class Player():
             self._cur_time = cur_time
             self.mass.event_loop.create_task(self.update())
 
+    @property
+    def cur_uri(self):
+        ''' [PROTECTED] cur_uri (uri loaded in player) property of this player '''
+        # handle group player
+        if self.group_parent:
+            group_player = self.mass.bg_executor.submit(asyncio.run, 
+                self.mass.player.get_player(self.group_parent)).result()
+            if group_player:
+                return group_player.cur_uri
+        return self._cur_uri
+
+    @cur_uri.setter
+    def cur_uri(self, cur_uri:str):
+        ''' [PROTECTED] set cur_uri (uri loaded in player) property of this player '''
+        if cur_uri != self._cur_uri:
+            self._cur_uri = cur_uri
+            self.mass.event_loop.create_task(self.update())
+
     @property
     def volume_level(self):
         ''' [PROTECTED] volume_level property of this player '''
@@ -192,11 +261,11 @@ class Player():
             return group_volume
         # handle hass integration
         elif self.mass.hass and self.settings.get('hass_volume_entity'):
-            hass_state = self.mass.event_loop.run_until_complete(
+            hass_state = self.mass.bg_executor.submit(asyncio.run, 
                 self.mass.hass.get_state(
-                    self.settings['hass_volume_entity'], 
+                    self.settings['hass_volume_entity'],
                     attribute='volume_level',
-                    register_listener=self.update()))
+                    register_listener=self.update())).result()
             return int(try_parse_float(hass_state)*100)
         else:
             return self._volume_level
@@ -246,7 +315,9 @@ class Player():
         ''' [PROTECTED] get the player config settings '''
         player_settings = self.mass.config['player_settings'].get(self.player_id)
         if not player_settings:
-            return self.mass.event_loop.run_until_complete(self.__update_player_settings())
+            player_settings = self.mass.bg_executor.submit(asyncio.run, 
+                self.__update_player_settings()).result()
+        return player_settings
 
     @property
     def enabled(self):
@@ -258,12 +329,17 @@ class Player():
         ''' [PROTECTED] player's queue '''
         # handle group player
         if self.group_parent:
-            group_player = self.mass.event_loop.run_until_complete(
-                    self.mass.player.get_player(self.group_parent))
+            group_player = self.mass.bg_executor.submit(asyncio.run, 
+                self.mass.player.get_player(self.group_parent)).result()
             if group_player:
                 return group_player.queue
         return self._queue
 
+    @property
+    def cur_item(self):
+        ''' current item in the player's queue '''
+        return self.queue.cur_item
+
     async def stop(self):
         ''' [PROTECTED] send stop command to player '''
         if self.group_parent:
@@ -272,7 +348,7 @@ class Player():
             if group_player:
                 return await group_player.stop()
         else:
-            return await self.__stop()
+            return await self.cmd_stop()
 
     async def play(self):
         ''' [PROTECTED] send play (unpause) command to player '''
@@ -282,9 +358,9 @@ class Player():
             if group_player:
                 return await group_player.play()
         elif self.state == PlayerState.Paused:
-            return await self.__play()
+            return await self.cmd_play()
         elif self.state != PlayerState.Playing:
-            return await self.play_queue()
+            return await self.queue.resume()
 
     async def pause(self):
         ''' [PROTECTED] send pause command to player '''
@@ -294,14 +370,42 @@ class Player():
             if group_player:
                 return await group_player.pause()
         else:
-            return await self.__pause()
+            return await self.cmd_pause()
     
+    async def next(self):
+        ''' [PROTECTED] send next command to player '''
+        if self.group_parent:
+            # redirect playback related commands to parent player
+            group_player = await self.mass.player.get_player(self.group_parent)
+            if group_player:
+                return await group_player.next()
+        else:
+            return await self.queue.next()
+
+    async def previous(self):
+        ''' [PROTECTED] send previous command to player '''
+        if self.group_parent:
+            # redirect playback related commands to parent player
+            group_player = await self.mass.player.get_player(self.group_parent)
+            if group_player:
+                return await group_player.previous()
+        else:
+            return await self.queue.previous()
+    
+    async def power(self, power):
+        ''' [PROTECTED] send power ON command to player '''
+        power = try_parse_bool(power)
+        if power:
+            return await self.power_on()
+        else:
+            return await self.power_off()
+
     async def power_on(self):
         ''' [PROTECTED] send power ON command to player '''
-        self.__power_on()
+        await self.cmd_power_on()
         # handle mute as power
         if self.settings['mute_as_power']:
-            self.volume_mute(False)
+            await self.volume_mute(False)
         # handle hass integration
         if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'):
             cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source')
@@ -317,7 +421,7 @@ class Player():
             await self.mass.hass.call_service(domain, 'turn_on', service_data)
         # handle play on power on
         if self.settings['play_power_on']:
-            self.play()
+            await self.play()
         # handle group power
         if self.group_parent:
             # player has a group parent, check if it should be turned on
@@ -327,10 +431,10 @@ class Player():
 
     async def power_off(self):
         ''' [PROTECTED] send power TOGGLE command to player '''
-        self.__power_off()
+        await self.cmd_power_off()
         # handle mute as power
         if self.settings['mute_as_power']:
-            self.volume_mute(True)
+            await self.volume_mute(True)
         # handle hass integration
         if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'):
             cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source')
@@ -390,119 +494,46 @@ class Player():
                 'volume_level': volume_level/100
             }
             await self.mass.hass.call_service('media_player', 'volume_set', service_data)
-            await self.__volume_set(100) # just force full volume on actual player if volume is outsourced to hass
+            await self.cmd_volume_set(100) # just force full volume on actual player if volume is outsourced to hass
         else:
-            await self.__volume_set(volume_level)
+            await self.cmd_volume_set(volume_level)
 
     async def volume_up(self):
-        ''' [MAY OVERRIDE] send volume up command to player '''
+        ''' [PROTECTED] send volume up command to player '''
         new_level = self.volume_level + 1
         return await self.volume_set(new_level)
 
     async def volume_down(self):
-        ''' [MAY OVERRIDE] send volume down command to player '''
+        ''' [PROTECTED] send volume down command to player '''
         new_level = self.volume_level - 1
         if new_level < 0:
             new_level = 0
         return await self.volume_set(new_level)
 
     async def volume_mute(self, is_muted=False):
-        ''' [MUST OVERRIDE] send mute command to player '''
-        return await self.__volume_mute(is_muted)
-
-    async def play_queue(self):
-        ''' [PROTECTED] send play_queue (start stream) command to player '''
-        if self.group_parent:
-            # redirect playback related commands to parent player
-            group_player = await self.mass.player.get_player(self.group_parent)
-            if group_player:
-                return await group_player.play_queue()
-        elif self.queue.items:
-            return await self.__play_queue()
+        ''' [PROTECTED] send mute command to player '''
+        return await self.cmd_volume_mute(is_muted)
 
-    async def play_media(self, media_item, queue_opt='play'):
-        ''' 
-            play media item(s) on this player 
-            media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio)
-                        single item or list of items
-            queue_opt: 
-                play -> insert new items in queue and start playing at the inserted position
-                replace -> replace queue contents with these items
-                next -> play item(s) after current playing item
-                add -> append new items at end of the queue
-        '''
-        # a single item or list of items may be provided
-        media_items = media_item if isinstance(media_item, list) else [media_item]
-        queue_tracks = []
-        for media_item in media_items:
-            # collect tracks to play
-            if media_item.media_type == MediaType.Artist:
-                tracks = await self.mass.music.artist_toptracks(media_item.item_id, 
-                        provider=media_item.provider)
-            elif media_item.media_type == MediaType.Album:
-                tracks = await self.mass.music.album_tracks(media_item.item_id, 
-                        provider=media_item.provider)
-            elif media_item.media_type == MediaType.Playlist:
-                tracks = await self.mass.music.playlist_tracks(media_item.item_id, 
-                        provider=media_item.provider, offset=0, limit=0) 
-            else:
-                tracks = [media_item] # single track
-            for track in tracks:
-                queue_item = QueueItem()
-                queue_item.name = track.name
-                queue_item.artists = track.artists
-                queue_item.album = track.album
-                queue_item.duration = track.duration
-                queue_item.version = track.version
-                queue_item.metadata = track.metadata
-                queue_item.media_type = track.media_type
-                queue_item.uri = 'http://%s:%s/stream_queue?player_id=%s'% (
-                        self.local_ip, self.mass.config['base']['web']['http_port'], player_id)
-                # sort by quality and check track availability
-                for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True):
-                    media_provider = prov_media['provider']
-                    media_item_id = prov_media['item_id']
-                    player_supported_provs = player_prov.supported_musicproviders
-                    if media_provider in player_supported_provs and not self.mass.config['player_settings'][player_id]['force_http_streamer']:
-                        # the provider can handle this media_type directly !
-                        track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, is_radio=is_radio)
-                        playable_tracks.append(track)
-                        match_found = True
-                    elif 'http' in player_prov.supported_musicproviders:
-                        # fallback to http streaming if supported
-                        track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True, is_radio=is_radio)
-                        queue_tracks.append(track)
-                        match_found = True
-                    if match_found:
-                        break
-        if queue_tracks:
-            if self._players[player_id].shuffle_enabled:
-                random.shuffle(playable_tracks)
-            if queue_opt in ['next', 'play'] and len(playable_tracks) > 1:
-                queue_opt = 'replace' # always assume playback of multiple items as new queue
-            return await player_prov.play_media(player_id, playable_tracks, queue_opt)
-        else:
-            raise Exception("Musicprovider and/or media not supported by player %s !" % (player_id) )
-    
     async def update(self):
         ''' [PROTECTED] signal player updated '''
-        self.__update_player_settings()
+        await self.__update_player_settings()
         LOGGER.info("player updated: %s" % self.name)
         self.mass.signal_event('player changed', self)
     
     async def __update_player_settings(self):
         ''' [PROTECTED] get (or create) player config settings '''
         config_entries = [ # default config entries for a player
-            ("enabled", False, "player_enabled"),
+            ("enabled", True, "player_enabled"),
             ("name", "", "player_name"),
             ("mute_as_power", False, "player_mute_power"),
             ("max_sample_rate", 96000, "max_sample_rate"),
             ('volume_normalisation', True, 'enable_r128_volume_normalisation'), 
             ('target_volume', '-23', 'target_volume_lufs'),
-            ('fallback_gain_correct', '-12', 'fallback_gain_correct')
+            ('fallback_gain_correct', '-12', 'fallback_gain_correct'),
+            ("crossfade_duration", 0, "crossfade_duration"),
         ]
         # append player specific settings
-        config_entries += await self.get_config_entries()
+        config_entries += await self.mass.player.providers[self._prov_id].get_player_config_entries()
         if self.is_group or not self.group_parent:
             config_entries += [ # play on power on setting
                 ("play_power_on", False, "player_power_play"),
@@ -522,4 +553,22 @@ class Player():
         self.mass.config['player_settings'][self.player_id] = player_settings
         self.mass.config['player_settings'][self.player_id]['__desc__'] = config_entries
         return player_settings
-    
\ No newline at end of file
+    
+    @property
+    def __dict__(self):
+        ''' instance attributes as dict so it can be serialized to json '''
+        return {
+            "player_id": self.player_id,
+            "player_provider": self.player_provider,
+            "name": self.name,
+            "is_group": self.is_group,
+            "state": self.state,
+            "powered": self.powered,
+            "cur_time": self.cur_time,
+            "cur_uri": self.cur_uri,
+            "volume_level": self.volume_level,
+            "muted": self.muted,
+            "group_parent": self.group_parent,
+            "enabled": self.enabled,
+            "cur_item": self.cur_item.__dict__ if self.cur_item else None
+        }
\ No newline at end of file
index 95d643753516766dd426686e3a1117ab79834ee2..ccf495d8425ef354ae1cb354eb5b92ff63996d65 100755 (executable)
@@ -1,37 +1,28 @@
 #!/usr/bin/env python3
 # -*- coding:utf-8 -*-
 
-from ..utils import LOGGER
-from ..constants import CONF_ENABLED
+import asyncio
 from typing import List
-from player import PlayerState
-from media_types import Track, TrackQuality
 import operator
 import random
+import uuid
+
+from ..utils import LOGGER
+from ..constants import CONF_ENABLED
+from .media_types import Track, TrackQuality
+
 
-class QueueItem(object):
+class QueueItem(Track):
     ''' representation of a queue item, simplified version of track '''
-    def __init__(self):
-        self.item_id = None
-        self.provider = None
-        self.name = ''
-        self.duration = 0
-        self.version = ''
+    def __init__(self, media_item=None):
+        super().__init__()
         self.quality = TrackQuality.FLAC_LOSSLESS
-        self.metadata = {}
-        self.artists = []
-        self.album = None
         self.uri = ""
-        self.is_radio = False
-    def __eq__(self, other): 
-        if not isinstance(other, self.__class__):
-            return NotImplemented
-        return (self.name == other.name and 
-                self.version == other.version and
-                self.item_id == other.item_id and
-                self.provider == other.provider)
-    def __ne__(self, other):
-        return not self.__eq__(other)
+        self.queue_item_id = str(uuid.uuid4())
+        # if existing media_item given, load those values
+        if media_item:
+            for attribute, value in media_item.__dict__.items():
+                setattr(self, attribute, value)
 
 class PlayerQueue():
     ''' 
@@ -45,7 +36,7 @@ class PlayerQueue():
         self._player = player
         self._items = []
         self._shuffle_enabled = True
-        self._repeat_enabled = True
+        self._repeat_enabled = False
         self._cur_index = None
 
     @property
@@ -56,18 +47,30 @@ class PlayerQueue():
     def repeat_enabled(self):
         return self._repeat_enabled
 
+    @property
+    def crossfade_enabled(self):
+        return self._player.settings['crossfade_duration']
+
+    @property
+    def gapless_enabled(self):
+        return self._player.settings.get('gapless_enabled', True)
+
     @property
     def cur_index(self):
+        ''' match current uri with queue items to determine queue index '''
+        for index, queue_item in enumerate(self.items):
+            if queue_item.uri == self._player.cur_uri:
+                return index
         return self._cur_index
 
     @property
     def cur_item(self):
-        if self._cur_index == None:
+        if self.cur_index == None:
             return None
-        return self.mass.event_loop.run_until_complete(self.get_item(self._cur_index))
+        return self.mass.bg_executor.submit(asyncio.run,self.get_item(self.cur_index)).result()
 
     @property
-    async def next_index(self):
+    def next_index(self):
         ''' 
             return the next queue index for this player
         '''
@@ -87,63 +90,128 @@ class PlayerQueue():
         return None
 
     @property
-    async def next_item(self):
+    def next_item(self):
         ''' 
             return the next item in the queue
         '''
-        return self.mass.event_loop.run_until_complete(
-                self.get_item(self.next_index))
+        return self.mass.bg_executor.submit(
+                asyncio.run, self.get_item(self.next_index)).result()
     
     @property
-    async def items(self):
+    def items(self):
         ''' 
             return all queue items for this player 
         '''
         return self._items
 
+    @property
+    def use_queue_stream(self):
+        ''' 
+            bool to indicate that we need to use the queue stream
+            for example if crossfading is requested but a player doesn't natively support it
+            it will send a constant stream of audio to the player and all tracks
+        '''
+        return ((self.crossfade_enabled and not self._player.supports_crossfade) or 
+            (self.gapless_enabled and not self._player.supports_gapless))
+    
     async def get_item(self, index):
         ''' get item by index from queue '''
-        if len(self._items) > index:
-            return self._items[index]
+        if index != None and len(self.items) > index:
+            return self.items[index]
         return None
 
+    async def by_item_id(self, queue_item_id:str):
+        ''' get item by queue_item_id from queue '''
+        if not queue_item_id:
+            return None
+        for item in self.items:
+            if item.queue_item_id == queue_item_id:
+                return item
+        return None
+    
     async def shuffle(self, enable_shuffle:bool):
         ''' enable/disable shuffle '''
         if not self._shuffle_enabled and enable_shuffle:
             # shuffle requested
             self._shuffle_enabled = True
-            self._items = await self.__shuffle_items(self._items)
-            self._cur_index = None
-            await self._player.play_queue()
+            await self.load(self._items)
             self.mass.event_loop.create_task(self._player.update())
         elif self._shuffle_enabled and not enable_shuffle:
             self._shuffle_enabled = False
             # TODO: Unshuffle the list ?
             self.mass.event_loop.create_task(self._player.update())
     
+    async def next(self):
+        ''' request next track in queue '''
+        if self.use_queue_stream:
+            return await self.play_index(self.cur_index+1)
+        else:
+            return await self._player.cmd_next()
+
+    async def previous(self):
+        ''' request previous track in queue '''
+        if self.use_queue_stream:
+            return await self.play_index(self.cur_index-1)
+        else:
+            return await self._player.cmd_previous()
+
+    async def resume(self):
+        ''' resume previous queue '''
+        if self.items:
+            prev_index = self.cur_index
+            await self.load(self._items)
+            if prev_index:
+                await self.play_index(prev_index)
+        else:
+            LOGGER.warning("resume queue requested for %s but queue is empty" % self._player.name)
+    
+    async def play_index(self, index):
+        ''' play item at index X in queue '''
+        if not len(self.items) > index:
+            return
+        if self.use_queue_stream:
+            self._cur_index = index -1
+            queue_stream_uri = 'http://%s:%s/stream/%s'% (
+                        self.mass.web.local_ip, self.mass.web.http_port, self._player.player_id)
+            return await self._player.cmd_play_uri(queue_stream_uri)
+        elif self._player.supports_queue:
+            return await self._player.cmd_queue_play_index(index)
+        else:
+            return await self._player.cmd_play_uri(self._items[index].uri)
+    
     async def load(self, queue_items:List[QueueItem]):
         ''' load (overwrite) queue with new items '''
         if self._shuffle_enabled:
             queue_items = await self.__shuffle_items(queue_items)
         self._items = queue_items
         self._cur_index = None
-        await self._player.play_queue()
+        if self.use_queue_stream or not self._player.supports_queue:
+            return await self.play_index(0)
+        else:
+            return await self._player.cmd_queue_load(queue_items)
 
     async def insert(self, queue_items:List[QueueItem], offset=0):
         ''' 
             insert new items at offset x from current position
             keeps remaining items in queue
             if offset 0 or None, will start playing newly added item(s)
+            :param queue_items: a list of QueueItem
+            :param offset: offset from current queue position
         '''
-        insert_at_index = self.cur_index + offset
+        if self.cur_index:
+            insert_at_index = self.cur_index + offset
+        else:
+            insert_at_index = 0
         if not self.items or insert_at_index >= len(self.items):
             return await self.load(queue_items)
         if self.shuffle_enabled:
             queue_items = await self.__shuffle_items(queue_items)
         self._items = self._items[:insert_at_index] + queue_items + self._items[insert_at_index:]
-        if not offset:
-            await self._player.stop()
-            await self._player.play_queue()
+        if self.use_queue_stream or not self._player.supports_queue:
+            if offset == 0:
+                return await self.play_index(0)
+        else:
+            return await self._player.cmd_queue_insert(queue_items, offset)
 
     async def append(self, queue_items:List[QueueItem]):
         ''' 
@@ -152,6 +220,8 @@ class PlayerQueue():
         if self.shuffle_enabled:
             queue_items = await self.__shuffle_items(queue_items)
         self._items = self._items + queue_items
+        if self._player.supports_queue:
+            return await self._player.cmd_queue_append(queue_items)
 
     async def __shuffle_items(self, queue_items):
         ''' shuffle a list of tracks '''
index d2868b01e98bc5b82ab383778a5a6f266e872317..2b45955f92291e29229256e85fa4a1dac0a73f97 100755 (executable)
@@ -1,14 +1,15 @@
 #!/usr/bin/env python3
 # -*- coding:utf-8 -*-
 
+import asyncio
 from enum import Enum
 from typing import List
 from ..utils import run_periodic, LOGGER, parse_track_title
 from ..constants import CONF_ENABLED
-from ..modules.cache import use_cache
-from player_queue import PlayerQueue
-from media_types import Track
-from player import Player
+from ..cache import use_cache
+from .player_queue import PlayerQueue
+from .media_types import Track
+from .player import Player
 
 
 class PlayerProvider():
@@ -26,22 +27,27 @@ class PlayerProvider():
 
     ### Common methods and properties ####
 
+    async def get_player_config_entries(self):
+        ''' [CAN OVERRIDE] get the player-specific config entries for this provider (list with key/value pairs)'''
+        return []
+
     @property
-    async def players(self):
+    def players(self):
         ''' return all players for this provider '''
-        return self.mass.player.get_provider_players(self.prov_id)
+        return self.mass.bg_executor.submit(asyncio.run, 
+                self.mass.player.get_provider_players(self.prov_id)).result()
     
     async def get_player(self, player_id:str):
         ''' return player by id '''
-        return self.mass.player.get_player(player_id)
+        return await self.mass.player.get_player(player_id)
 
     async def add_player(self, player:Player):
         ''' register a new player '''
-        return self.mass.player.add_player(player)
+        return await self.mass.player.add_player(player)
 
     async def remove_player(self, player_id:str):
         ''' remove a player '''
-        return self.mass.player.remove_player(player_id)
+        return await self.mass.player.remove_player(player_id)
 
     ### Provider specific implementation #####
 
diff --git a/music_assistant/modules/__init__.py b/music_assistant/modules/__init__.py
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/music_assistant/modules/cache.py b/music_assistant/modules/cache.py
deleted file mode 100644 (file)
index 85945da..0000000
+++ /dev/null
@@ -1,237 +0,0 @@
-#!/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, datapath):
-        '''Initialize our caching class'''
-        self._datapath = datapath
-        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 = os.path.join(self._datapath, "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/modules/homeassistant.py b/music_assistant/modules/homeassistant.py
deleted file mode 100644 (file)
index 32961b4..0000000
+++ /dev/null
@@ -1,289 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from typing import List
-import random
-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 modules.cache import use_cache
-import copy
-import slugify as slug
-
-'''
-    Homeassistant integration
-    allows publishing of our players to hass
-    allows using hass entities (like switches, media_players or gui inputs) to be triggered
-'''
-
-def setup(mass):
-    ''' setup the module and read/apply config'''
-    create_config_entries(mass.config)
-    conf = mass.config['base']['homeassistant']
-    enabled = conf.get(CONF_ENABLED)
-    token = conf.get('token')
-    url = conf.get('url')
-    if enabled and url and token:
-        return HomeAssistant(mass, url, token)
-    return None
-
-def create_config_entries(config):
-    ''' get the config entries for this module (list with key/value pairs)'''
-    config_entries = [
-        (CONF_ENABLED, False, 'enabled'),
-        ('url', 'localhost', 'hass_url'), 
-        ('token', '<password>', 'hass_token'),
-        ('publish_players', True, 'hass_publish')
-        ]
-    if not config['base'].get('homeassistant'):
-        config['base']['homeassistant'] = {}
-    config['base']['homeassistant']['__desc__'] = config_entries
-    for key, def_value, desc in config_entries:
-        if not key in config['base']['homeassistant']:
-            config['base']['homeassistant'][key] = def_value
-
-class HomeAssistant():
-    ''' HomeAssistant integration '''
-
-    def __init__(self, mass, url, token):
-        self.mass = mass
-        self._published_players = {}
-        self._tracked_states = {}
-        self._state_listeners = []
-        self._sources = []
-        self._token = token
-        if url.startswith('https://'):
-            self._use_ssl = True
-            self._host = url.replace('https://','').split('/')[0]
-        else:
-            self._use_ssl = False
-            self._host = url.replace('http://','').split('/')[0]
-        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
-        self.__send_ws = None
-        self.__last_id = 10
-        LOGGER.info('Homeassistant integration is enabled')
-        mass.event_loop.create_task(self.__hass_websocket())
-        self.mass.add_event_listener(self.mass_event, "player updated")
-        mass.event_loop.create_task(self.__get_sources())
-
-    async def get_state(self, entity_id, attribute='state', register_listener=None):
-        ''' get state of a hass entity'''
-        if entity_id in self._tracked_states:
-            state_obj = self._tracked_states[entity_id]
-        else:
-            # first request
-            state_obj = await self.__get_data('states/%s' % entity_id)
-            if register_listener:
-                # register state listener
-                self._state_listeners.append( (entity_id, register_listener) )
-            self._tracked_states[entity_id] = state_obj
-        if attribute == 'state':
-            return state_obj['state']
-        elif not attribute:
-            return state_obj
-        else:
-            return state_obj['attributes'].get(attribute)
-    
-    async def mass_event(self, msg, msg_details):
-        ''' received event from mass '''
-        if msg == "player updated":
-            await self.publish_player(msg_details)
-
-    async def hass_event(self, event_type, event_data):
-        ''' received event from hass '''
-        if event_type == 'state_changed':
-            if event_data['entity_id'] in self._tracked_states:
-                self._tracked_states[event_data['entity_id']] = event_data['new_state']
-                for entity_id, handler in self._state_listeners:
-                    if entity_id == event_data['entity_id']:
-                        asyncio.create_task(handler())
-        elif event_type == 'call_service' and event_data['domain'] == 'media_player':
-            await self.__handle_player_command(event_data['service'], event_data['service_data'])
-
-    async def __handle_player_command(self, service, service_data):
-        ''' handle forwarded service call for one of our players '''
-        if isinstance(service_data['entity_id'], list):
-            # can be a list of entity ids if action fired on multiple items
-            entity_ids = service_data['entity_id']
-        else:
-            entity_ids = [service_data['entity_id']]
-        for entity_id in entity_ids:
-            if entity_id in self._published_players:
-                # call is for one of our players so handle it
-                player_id = self._published_players[entity_id]
-                if service == 'turn_on':
-                    await self.mass.player.player_command(player_id, 'power', 'on')
-                elif service == 'turn_off':
-                    await self.mass.player.player_command(player_id, 'power', 'off')
-                elif service == 'toggle':
-                    await self.mass.player.player_command(player_id, 'power', 'toggle')
-                elif service == 'volume_mute':
-                    args = 'on' if service_data['is_volume_muted'] else 'off'
-                    await self.mass.player.player_command(player_id, 'mute', args)
-                elif service == 'volume_up':
-                    await self.mass.player.player_command(player_id, 'volume', 'up')
-                elif service == 'volume_down':
-                    await self.mass.player.player_command(player_id, 'volume', 'down')
-                elif service == 'volume_set':
-                    volume_level = service_data['volume_level']*100
-                    await self.mass.player.player_command(player_id, 'volume', volume_level)
-                elif service == 'media_play':
-                    await self.mass.player.player_command(player_id, 'play')
-                elif service == 'media_pause':
-                    await self.mass.player.player_command(player_id, 'pause')
-                elif service == 'media_stop':
-                    await self.mass.player.player_command(player_id, 'stop')
-                elif service == 'media_next_track':
-                    await self.mass.player.player_command(player_id, 'next')
-                elif service == 'media_play_pause':
-                    await self.mass.player.player_command(player_id, 'pause', 'toggle')
-                elif service == 'play_media':
-                    return await self.__handle_play_media(player_id, service_data)
-
-    async def __handle_play_media(self, player_id, service_data):
-        ''' handle play_media request from homeassistant'''
-        media_content_type = service_data['media_content_type'].lower()
-        media_content_id = service_data['media_content_id']
-        queue_opt = 'add' if service_data.get('enqueue') else 'play'
-        if media_content_type == 'playlist' and not '://' in media_content_id:
-            media_items = []
-            for playlist_str in media_content_id.split(','):
-                playlist_str = playlist_str.strip()
-                playlist = await self.mass.music.playlist_by_name(playlist_str)
-                if playlist:
-                    media_items.append(playlist)
-            return await self.mass.player.play_media(player_id, media_items, queue_opt)
-        elif media_content_type == 'playlist' and 'spotify://playlist' in media_content_id:
-            # TODO: handle parsing of other uri's here
-            playlist = self.mass.music.providers['spotify'].playlist(media_content_id.split(':')[-1])
-            return await self.mass.player.play_media(player_id, playlist, queue_opt)
-        elif media_content_id.startswith('http'):
-            track = Track()
-            track.uri = media_content_id
-            track.provider = 'http'
-            return await self.mass.player.play_media(player_id, track, queue_opt)
-    
-    async def publish_player(self, player):
-        ''' publish player details to hass'''
-        if not self.mass.config['base']['homeassistant']['publish_players']:
-            return False
-        player_id = player.player_id
-        entity_id = 'media_player.mass_' + slug.slugify(player.name, separator='_').lower()
-        state = player.state if player.powered else 'off'
-        state_attributes = {
-                "supported_features": 65471, 
-                "friendly_name": player.name,
-                "source_list": self._sources,
-                "source": 'unknown',
-                "volume_level": player.volume_level/100,
-                "is_volume_muted": player.muted,
-                "media_duration": player.cur_item.duration if player.cur_item else 0,
-                "media_position": player.cur_item_time,
-                "media_title": player.cur_item.name if player.cur_item else "",
-                "media_artist": player.cur_item.artists[0].name if player.cur_item and player.cur_item.artists else "",
-                "media_album_name": player.cur_item.album.name if player.cur_item and player.cur_item.album else "",
-                "entity_picture": player.cur_item.album.metadata.get('image') if player.cur_item and player.cur_item.album else ""
-                }
-        self._published_players[entity_id] = player_id
-        await self.__set_state(entity_id, state, state_attributes)
-
-    async def call_service(self, domain, service, service_data=None):
-        ''' call service on hass '''
-        if not self.__send_ws:
-            return False
-        msg = {
-            "type": "call_service",
-            "domain": domain,
-            "service": service,
-            }
-        if service_data:
-            msg['service_data'] = service_data
-        return await self.__send_ws(msg)
-
-    @run_periodic(120)
-    async def __get_sources(self):
-        ''' we build a list of all playlists to use as player sources '''
-        self._sources = [playlist.name for playlist in await self.mass.music.playlists()]
-
-    async def __set_state(self, entity_id, new_state, state_attributes={}):
-        ''' set state to hass entity '''
-        data = {
-            "state": new_state,
-            "entity_id": entity_id,
-            "attributes": state_attributes
-            }
-        return await self.__post_data('states/%s' % entity_id, data)
-    
-    async def __hass_websocket(self):
-        ''' Receive events from Hass through websockets '''
-        while self.mass.event_loop.is_running():
-            try:
-                protocol = 'wss' if self._use_ssl else 'ws'
-                async with self.http_session.ws_connect('%s://%s/api/websocket' % (protocol, self._host)) as ws:
-                    
-                    async def send_msg(msg):
-                        ''' callback to send message to the websockets client'''
-                        self.__last_id += 1
-                        msg['id'] = self.__last_id
-                        await ws.send_json(msg)
-
-                    async for msg in ws:
-                        if msg.type == aiohttp.WSMsgType.TEXT:
-                            if msg.data == 'close cmd':
-                                await ws.close()
-                                break
-                            else:
-                                data = msg.json()
-                                if data['type'] == 'auth_required':
-                                    # send auth token
-                                    auth_msg = {"type": "auth", "access_token": self._token}
-                                    await ws.send_json(auth_msg)
-                                elif data['type'] == 'auth_invalid':
-                                    raise Exception(data)
-                                elif data['type'] == 'auth_ok':
-                                    # register callback
-                                    self.__send_ws = send_msg
-                                    # subscribe to events
-                                    subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"}
-                                    await send_msg(subscribe_msg)
-                                    subscribe_msg = {"type": "subscribe_events", "event_type": "call_service"}
-                                    await send_msg(subscribe_msg)
-                                elif data['type'] == 'event':
-                                    asyncio.create_task(self.hass_event(data['event']['event_type'], data['event']['data']))
-                                elif data['type'] == 'result' and data.get('result'):
-                                    # reply to our get_states request
-                                    asyncio.create_task(self.hass_event('all_states', data['result']))
-                                else:
-                                    LOGGER.info(data)
-                        elif msg.type == aiohttp.WSMsgType.ERROR:
-                            raise Exception("error in websocket")
-            except Exception as exc:
-                LOGGER.exception(exc)
-                await asyncio.sleep(10)
-
-    async def __get_data(self, endpoint):
-        ''' get data from hass rest api'''
-        url = "http://%s/api/%s" % (self._host, endpoint)
-        if self._use_ssl:
-            url = "https://%s/api/%s" % (self._host, endpoint)
-        headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"}
-        async with self.http_session.get(url, headers=headers) as response:
-            return await response.json()
-
-    async def __post_data(self, endpoint, data):
-        ''' post data to hass rest api'''
-        url = "http://%s/api/%s" % (self._host, endpoint)
-        if self._use_ssl:
-            url = "https://%s/api/%s" % (self._host, endpoint)
-        headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"}
-        async with self.http_session.post(url, headers=headers, json=data) as response:
-            return await response.json()
\ No newline at end of file
diff --git a/music_assistant/modules/http_streamer.py b/music_assistant/modules/http_streamer.py
deleted file mode 100755 (executable)
index e4c0261..0000000
+++ /dev/null
@@ -1,449 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from utils import LOGGER, try_parse_int, get_ip, run_async_background_task, run_periodic, get_folder_size
-from models import TrackQuality, MediaType, PlayerState
-import operator
-from aiohttp import web
-import threading
-import urllib
-from memory_tempfile import MemoryTempfile
-import io
-import soundfile as sf
-import pyloudnorm as pyln
-import aiohttp
-
-
-class HTTPStreamer():
-    ''' Built-in streamer using sox and webserver '''
-    
-    def __init__(self, mass):
-        self.mass = mass
-        self.create_config_entries()
-        self.local_ip = get_ip()
-        self.analyze_jobs = {}
-    
-    async def stream_track(self, http_request):
-        ''' start streaming track from provider '''
-        player_id = http_request.query.get('player_id')
-        track_id = http_request.query.get('track_id')
-        provider = http_request.query.get('provider')
-        resp = web.StreamResponse(status=200,
-                                 reason='OK',
-                                 headers={'Content-Type': 'audio/flac'})
-        await resp.prepare(http_request)
-        if http_request.method.upper() != 'HEAD':
-            # stream audio
-            cancelled = threading.Event()
-            queue = asyncio.Queue()
-
-            async def fill_buffer():
-                ''' fill buffer runs in background process to prevent deadlocks of the sox executable '''
-                audio_stream = self.__get_audio_stream(track_id, provider, player_id, cancelled)
-                async for is_last_chunk, audio_chunk in audio_stream:
-                    if not cancelled.is_set():
-                        await queue.put(audio_chunk)
-                    # wait for the queue to consume the data
-                    # this prevents that the entire track is sitting in memory
-                    while queue.qsize() > 1 and not cancelled.is_set():
-                        await asyncio.sleep(1)
-                await queue.put(b'') # EOF
-            run_async_background_task(self.mass.bg_executor, fill_buffer)
-               
-            try:
-                while True:
-                    chunk = await queue.get()
-                    if not chunk:
-                        queue.task_done()
-                        break
-                    await resp.write(chunk)
-                    queue.task_done()
-            except (asyncio.CancelledError, asyncio.TimeoutError):
-                cancelled.set()
-                LOGGER.info("stream_track interrupted for %s" % track_id)
-                raise asyncio.CancelledError()
-            else:
-                LOGGER.info("stream_track fininished for %s" % track_id)
-                return resp        
-
-    async def stream_radio(self, http_request):
-        ''' start streaming radio from provider '''
-        player_id = http_request.query.get('player_id')
-        radio_id = http_request.query.get('radio_id')
-        provider = http_request.query.get('provider')
-        resp = web.StreamResponse(status=200,
-                                 reason='OK',
-                                 headers={'Content-Type': 'audio/flac'})
-        await resp.prepare(http_request)
-        if http_request.method.upper() != 'HEAD':
-            # stream audio with sox
-            sox_effects = await self.__get_player_sox_options(radio_id, provider, player_id, True)
-            if self.mass.config['base']['http_streamer']['volume_normalisation']:
-                gain_correct = await self.__get_track_gain_correct(radio_id, provider)
-                gain_correct = 'vol %s dB ' % gain_correct
-            else:
-                gain_correct = ''
-            media_item = await self.mass.music.item(radio_id, MediaType.Radio, provider)
-            stream = sorted(media_item.provider_ids, key=operator.itemgetter('quality'), reverse=True)[0]
-            stream_url = stream["details"]
-            if stream["quality"] == TrackQuality.LOSSY_AAC:
-                input_content_type = "aac"
-            elif stream["quality"] == TrackQuality.LOSSY_OGG:
-                input_content_type = "ogg"
-            else:
-                input_content_type = "mp3"
-            if input_content_type == "aac":
-                args = 'ffmpeg -i "%s" -f flac - | sox -t flac - -t flac -C 0 - %s %s' % (stream_url, gain_correct, sox_effects)
-            else:
-                args = 'sox -t %s "%s" -t flac -C 0 - %s %s' % (input_content_type, stream_url, gain_correct, sox_effects)
-            LOGGER.info("Running sox with args: %s" % args)
-            process = await asyncio.create_subprocess_shell(args, stdout=asyncio.subprocess.PIPE)
-            try:
-                while not process.stdout.at_eof():
-                    chunk = await process.stdout.read(128000)
-                    if not chunk:
-                        break
-                    await resp.write(chunk)
-                await process.wait()
-                LOGGER.info("streaming of radio_id %s completed" % radio_id)
-            except asyncio.CancelledError:
-                process.terminate()
-                await process.wait()
-                LOGGER.info("streaming of radio_id %s interrupted" % radio_id)
-                raise asyncio.CancelledError()
-        return resp
-    
-    async def stream(self, http_request):
-        ''' 
-            stream queue track(s) for player with http
-        '''
-        player_id = request.match_info.get('player_id','')
-        #startindex = int(http_request.query.get('startindex'))
-        cancelled = threading.Event()
-        resp = web.StreamResponse(status=200,
-                                 reason='OK',
-                                 headers={'Content-Type': 'audio/flac'})
-        await resp.prepare(http_request)
-        if http_request.method.upper() != 'HEAD':
-            # stream audio
-            queue = asyncio.Queue()
-            cancelled = threading.Event()
-            run_async_background_task(
-                self.mass.bg_executor, 
-                self.__stream_queue, player_id, startindex, queue, cancelled)
-            try:
-                while True:
-                    chunk = await queue.get()
-                    if not chunk:
-                        queue.task_done()
-                        break
-                    await resp.write(chunk)
-                    queue.task_done()
-                LOGGER.info("stream fininished for %s" % player_id)
-            except asyncio.CancelledError:
-                cancelled.set()
-                LOGGER.info("stream interrupted for %s" % player_id)
-                raise asyncio.CancelledError()
-        return resp
-
-    async def __stream_queue(self, player_id, startindex, buffer, cancelled):
-        ''' start streaming all queue tracks '''
-        sample_rate = self.mass.config['player_settings'][player_id]['max_sample_rate']
-        fade_length = self.mass.config['player_settings'][player_id]["crossfade_duration"]
-        fade_bytes = int(sample_rate * 4 * 2 * fade_length)
-        pcm_args = 'raw -b 32 -c 2 -e signed-integer -r %s' % sample_rate
-        args = 'sox -t %s - -t flac -C 0 -' % pcm_args
-        sox_proc = await asyncio.create_subprocess_shell(args, 
-                stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE)
-
-        async def fill_buffer():
-            while not sox_proc.stdout.at_eof():
-                chunk = await sox_proc.stdout.read(256000)
-                if not chunk:
-                    break
-                await buffer.put(chunk)
-            await buffer.put(b'') # indicate EOF
-        asyncio.create_task(fill_buffer())
-
-        # retrieve player object
-        player = await self.mass.player.player(player_id)
-        queue_index = startindex
-        LOGGER.info("Start Queue Stream for player %s at index %s" %(player.name, queue_index))
-        last_fadeout_data = b''
-        # report start of queue playback so we can calculate current track/duration etc.
-        # self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True))
-        while True:
-            # get the (next) track in queue
-            try:
-                queue_tracks = await self.mass.player.player_queue(player_id, queue_index, queue_index+1)
-                queue_track = queue_tracks[0]
-            except IndexError:
-                LOGGER.warning("queue index out of range or end reached")
-                break
-
-            params = urllib.parse.parse_qs(queue_track.uri.split('?')[1])
-            track_id = params['track_id'][0]
-            provider = params['provider'][0]
-            LOGGER.debug("Start Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name))
-            fade_in_part = b''
-            cur_chunk = 0
-            prev_chunk = None
-            bytes_written = 0
-            async for is_last_chunk, chunk in self.__get_audio_stream(
-                    track_id, provider, player_id, cancelled, chunksize=fade_bytes, resample=sample_rate):
-                cur_chunk += 1
-                if cur_chunk <= 2 and not last_fadeout_data:
-                    # fade-in part but no fadeout_part available so just pass it to the output directly
-                    sox_proc.stdin.write(chunk)
-                    await sox_proc.stdin.drain()
-                    bytes_written += len(chunk)
-                elif cur_chunk == 1 and last_fadeout_data:
-                    prev_chunk = chunk
-                elif cur_chunk == 2 and last_fadeout_data:
-                    # combine the first 2 chunks and strip off silence
-                    args = 'sox --ignore-length -t %s - -t %s - silence 1 0.1 1%%' % (pcm_args, pcm_args)
-                    process = await asyncio.create_subprocess_shell(args,
-                            stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
-                    first_part, stderr = await process.communicate(prev_chunk + chunk)
-                    fade_in_part = first_part[:fade_bytes]
-                    remaining_bytes = first_part[fade_bytes:]
-                    del first_part
-                    # do crossfade
-                    crossfade_part = await self.__crossfade_pcm_parts(fade_in_part, last_fadeout_data, pcm_args, fade_length) 
-                    sox_proc.stdin.write(crossfade_part)
-                    await sox_proc.stdin.drain()
-                    bytes_written += len(crossfade_part)
-                    del crossfade_part
-                    del fade_in_part
-                    last_fadeout_data = b''
-                    # also write the leftover bytes from the strip action
-                    sox_proc.stdin.write(remaining_bytes)
-                    await sox_proc.stdin.drain()
-                    bytes_written += len(remaining_bytes)
-                    del remaining_bytes
-                    prev_chunk = None # needed to prevent this chunk being sent again
-                elif prev_chunk and is_last_chunk:
-                    # last chunk received so create the fadeout_part with the previous chunk and this chunk
-                    # and strip off silence
-                    args = 'sox --ignore-length -t %s - -t %s - reverse silence 1 0.1 1%% reverse' % (pcm_args, pcm_args)
-                    process = await asyncio.create_subprocess_shell(args,
-                            stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
-                    last_part, stderr = await process.communicate(prev_chunk + chunk)
-                    if len(last_part) < fade_bytes:
-                        # not enough data for crossfade duration after the strip action...
-                        last_part = prev_chunk + chunk
-                    if len(last_part) < fade_bytes:
-                        # still not enough data so we'll skip the crossfading
-                        LOGGER.warning("not enough data for fadeout so skip crossfade... %s" % len(last_part))
-                        sox_proc.stdin.write(last_part)
-                        bytes_written += len(last_part)
-                        await sox_proc.stdin.drain()
-                        del last_part
-                    else:
-                        # store fade section to be picked up for next track
-                        last_fadeout_data = last_part[-fade_bytes:]
-                        remaining_bytes = last_part[:-fade_bytes]
-                        # write remaining bytes
-                        sox_proc.stdin.write(remaining_bytes)
-                        bytes_written += len(remaining_bytes)
-                        await sox_proc.stdin.drain()
-                        del last_part
-                        del remaining_bytes
-                else:
-                    # middle part of the track
-                    # keep previous chunk in memory so we have enough samples to perform the crossfade
-                    if prev_chunk:
-                        sox_proc.stdin.write(prev_chunk)
-                        await sox_proc.stdin.drain()
-                        bytes_written += len(prev_chunk)
-                        prev_chunk = chunk
-                    else:
-                        prev_chunk = chunk
-                # wait for the queue to consume the data
-                # this prevents that the entire track is sitting in memory
-                # and it helps a bit in the quest to follow where we are in the queue
-                while buffer.qsize() > 1 and not cancelled.is_set():
-                    await asyncio.sleep(1)
-            # end of the track reached
-            if cancelled.is_set():
-                # break out the loop if the http session is cancelled
-                break
-            else:
-                # WIP: update actual duration to the queue for more accurate now playing info
-                accurate_duration = bytes_written / int(sample_rate * 4 * 2)
-                queue_track.duration = accurate_duration
-                self.mass.player.providers[player.player_provider]._player_queue[player_id][queue_index] = queue_track
-                # move to next queue index
-                queue_index += 1
-                self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, False))
-                LOGGER.debug("Finished Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name))
-        # end of queue reached, pass last fadeout bits to final output
-        if last_fadeout_data and not cancelled.is_set():
-            sox_proc.stdin.write(last_fadeout_data)
-            await sox_proc.stdin.drain()
-        sox_proc.stdin.close()
-        await sox_proc.wait()
-        LOGGER.info("streaming of queue for player %s completed" % player.name)
-
-    async def __get_audio_stream(self, track_id, provider, player_id, cancelled,
-                chunksize=512000, resample=None):
-        ''' get audio stream from provider and apply additional effects/processing where/if needed'''
-        if self.mass.config['base']['http_streamer']['volume_normalisation']:
-            gain_correct = await self.__get_track_gain_correct(track_id, provider)
-            gain_correct = 'vol %s dB ' % gain_correct
-        else:
-            gain_correct = ''
-        sox_effects = await self.__get_player_sox_options(track_id, provider, player_id, False)
-        outputfmt = 'flac -C 0'
-        if resample:
-            outputfmt = 'raw -b 32 -c 2 -e signed-integer'
-            sox_effects += ' rate -v %s' % resample
-        # stream audio from provider
-        streamdetails = asyncio.run_coroutine_threadsafe(
-                self.mass.music.providers[provider].get_stream_details(track_id), 
-                self.mass.event_loop).result()
-        if not streamdetails:
-            yield (True, b'')
-            return
-        # TODO: add support for AAC streams (which sox doesn't natively support)
-        if streamdetails['type'] == 'url':
-            args = 'sox -t %s "%s" -t %s - %s %s' % (streamdetails["content_type"], 
-                    streamdetails["path"], outputfmt, gain_correct, sox_effects)
-        elif streamdetails['type'] == 'executable':
-            args = '%s | sox -t %s - -t %s - %s %s' % (streamdetails["path"], 
-                    streamdetails["content_type"], outputfmt, gain_correct, sox_effects)
-        LOGGER.debug("Running sox with args: %s" % args)
-        process = await asyncio.create_subprocess_shell(args,
-                stdout=asyncio.subprocess.PIPE)
-        # fire event that streaming has started for this track (needed by some streaming providers)
-        streamdetails["provider"] = provider
-        streamdetails["track_id"] = track_id
-        streamdetails["player_id"] = player_id
-        self.mass.signal_event('streaming_started', streamdetails)
-        # yield chunks from stdout
-        # we keep 1 chunk behind to detect end of stream properly
-        prev_chunk = b''
-        bytes_sent = 0
-        while not process.stdout.at_eof():
-            try:
-                chunk = await process.stdout.readexactly(chunksize)
-            except asyncio.streams.IncompleteReadError:
-                chunk = await process.stdout.read(chunksize)
-            if not chunk:
-                break
-            if prev_chunk and not cancelled.is_set():
-                yield (False, prev_chunk)
-                bytes_sent += len(prev_chunk)
-            prev_chunk = chunk
-        # yield last chunk
-        if not cancelled.is_set():
-            yield (True, prev_chunk)
-            bytes_sent += len(prev_chunk)
-        #await process.wait()
-        if cancelled.is_set():
-            LOGGER.warning("__get_audio_stream for track_id %s interrupted" % track_id)
-        else:
-            LOGGER.debug("__get_audio_stream for track_id %s completed" % track_id)
-        # fire event that streaming has ended for this track (needed by some streaming providers)
-        if resample:
-            bytes_per_second = resample * (32/8) * 2
-        else:
-            bytes_per_second = streamdetails["sample_rate"] * (streamdetails["bit_depth"]/8) * 2
-        seconds_streamed = int(bytes_sent/bytes_per_second)
-        streamdetails["seconds"] = seconds_streamed
-        self.mass.signal_event('streaming_ended', streamdetails)
-        # send task to background to analyse the audio
-        self.mass.event_loop.create_task(self.__analyze_audio(track_id, provider))
-
-    async def __get_player_sox_options(self, track_id, provider, player_id, is_radio):
-        ''' get player specific sox options '''
-        sox_effects = ''
-        if player_id and not is_radio and self.mass.config['player_settings'][player_id]['max_sample_rate']:
-            # downsample if needed
-            max_sample_rate = try_parse_int(self.mass.config['player_settings'][player_id]['max_sample_rate'])
-            if max_sample_rate:
-                quality = TrackQuality.LOSSY_MP3
-                track_future = asyncio.run_coroutine_threadsafe(
-                    self.mass.music.track(track_id, provider),
-                    self.mass.event_loop
-                )
-                track = track_future.result()
-                for item in track.provider_ids:
-                    if item['provider'] == provider and item['item_id'] == track_id:
-                        quality = item['quality']
-                        break
-                if quality > TrackQuality.FLAC_LOSSLESS_HI_RES_3 and max_sample_rate == 192000:
-                    sox_effects += 'rate -v 192000'
-                elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_2 and max_sample_rate == 96000:
-                    sox_effects += 'rate -v 96000'
-                elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_1 and max_sample_rate == 48000:
-                    sox_effects += 'rate -v 48000'
-        if player_id and self.mass.config['player_settings'][player_id]['sox_effects']:
-            sox_effects += ' ' + self.mass.config['player_settings'][player_id]['sox_effects']
-        return sox_effects
-        
-    async def __analyze_audio(self, track_id, provider):
-        ''' analyze track audio, for now we only calculate EBU R128 loudness '''
-        track_key = '%s%s' %(track_id, provider)
-        if track_key in self.analyze_jobs:
-            return # prevent multiple analyze jobs for same track
-        self.analyze_jobs[track_key] = True
-        streamdetails = await self.mass.music.providers[provider].get_stream_details(track_id)
-        track_loudness = await self.mass.db.get_track_loudness(track_id, provider)
-        if track_loudness == None:
-            # only when needed we do the analyze stuff
-            LOGGER.debug('Start analyzing track %s' % track_id)
-            if streamdetails['type'] == 'url':
-                async with aiohttp.ClientSession() as session:
-                    async with session.get(streamdetails["path"]) as resp:
-                        audio_data = await resp.read()
-            elif streamdetails['type'] == 'executable':
-                process = await asyncio.create_subprocess_shell(streamdetails["path"],
-                    stdout=asyncio.subprocess.PIPE)
-                audio_data, stderr = await process.communicate()
-            # calculate BS.1770 R128 integrated loudness
-            if track_loudness == None:
-                with io.BytesIO(audio_data) as tmpfile:
-                    data, rate = sf.read(tmpfile)
-                meter = pyln.Meter(rate) # create BS.1770 meter
-                loudness = meter.integrated_loudness(data) # measure loudness
-                del data
-                LOGGER.debug("Integrated loudness of track %s is: %s" %(track_id, loudness))
-                await self.mass.db.set_track_loudness(track_id, provider, loudness)
-            del audio_data
-            LOGGER.debug('Finished analyzing track %s' % track_id)
-        self.analyze_jobs.pop(track_key, None)
-    
-    async def __get_track_gain_correct(self, track_id, provider):
-        ''' get the gain correction that should be applied to a track '''
-        target_gain = int(self.mass.config['base']['http_streamer']['target_volume'])
-        fallback_gain = int(self.mass.config['base']['http_streamer']['fallback_gain_correct'])
-        track_loudness = await self.mass.db.get_track_loudness(track_id, provider)
-        if track_loudness == None:
-            return fallback_gain
-        gain_correct = target_gain - track_loudness
-        return round(gain_correct,2)
-
-    async def __crossfade_pcm_parts(self, fade_in_part, fade_out_part, pcm_args, fade_length):
-        ''' crossfade two chunks of audio using sox '''
-        # create fade-in part
-        fadeinfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0)
-        args = 'sox --ignore-length -t %s - -t %s %s fade t %s' % (pcm_args, pcm_args, fadeinfile.name, fade_length)
-        process = await asyncio.create_subprocess_shell(args, stdin=asyncio.subprocess.PIPE)
-        await process.communicate(fade_in_part)
-        # create fade-out part
-        fadeoutfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0)
-        args = 'sox --ignore-length -t %s - -t %s %s reverse fade t %s reverse' % (pcm_args, pcm_args, fadeoutfile.name, fade_length)
-        process = await asyncio.create_subprocess_shell(args,
-                stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
-        await process.communicate(fade_out_part)
-        # create crossfade using sox and some temp files
-        # TODO: figure out how to make this less complex and without the tempfiles
-        args = 'sox -m -v 1.0 -t %s %s -v 1.0 -t %s %s -t %s -' % (pcm_args, fadeoutfile.name, pcm_args, fadeinfile.name, pcm_args)
-        process = await asyncio.create_subprocess_shell(args,
-                stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
-        crossfade_part, stderr = await process.communicate()
-        LOGGER.debug("Got %s bytes in memory for crossfade_part after sox" % len(crossfade_part))
-        return crossfade_part
diff --git a/music_assistant/modules/metadata.py b/music_assistant/modules/metadata.py
deleted file mode 100755 (executable)
index 9765d66..0000000
+++ /dev/null
@@ -1,174 +0,0 @@
-#!/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 modules.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, connector=aiohttp.TCPConnector(verify_ssl=False))
-        self.throttler = Throttler(rate_limit=1, period=1)
-
-    async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
-        ''' 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]:
-                for item in result['releases']:
-                    if album_upc or Matcher(None, item['title'].lower(), albumname.lower()).ratio() >= strictness:
-                        for artist in item['artist-credit']:
-                            artist = artist['artist']
-                            if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
-                                return artist['id']
-                            for item in artist.get('aliases',[]):
-                                if item['name'].lower() == artistname.lower():
-                                    return artist['id']
-        return ''
-
-    async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
-        ''' 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]:
-                for item in result['recordings']:
-                    if track_isrc or Matcher(None, item['title'].lower(), trackname.lower()).ratio() >= strictness:
-                        for artist in item['artist-credit']:
-                            artist = artist['artist']
-                            if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
-                                return artist['id']
-                            for item in artist.get('aliases',[]):
-                                if item['name'].lower() == artistname.lower():
-                                    return artist['id']
-        return ''
-
-    @use_cache(30)
-    async def get_data(self, endpoint, params={}):
-        ''' get data from api'''
-        url = 'http://musicbrainz.org/ws/2/%s' % endpoint
-        headers = {'User-Agent': 'Music Assistant/1.0.0 https://github.com/marcelveldt'}
-        params['fmt'] = 'json'
-        async with self.throttler:
-            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, connector=aiohttp.TCPConnector(verify_ssl=False))
-        self.throttler = Throttler(rate_limit=1, period=1)
-
-    async def artist_images(self, mb_artist_id):
-        ''' retrieve images by musicbrainz artist id '''
-        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/modules/music_manager.py b/music_assistant/modules/music_manager.py
deleted file mode 100755 (executable)
index f068991..0000000
+++ /dev/null
@@ -1,414 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-from typing import List
-import toolz
-import operator
-import os
-from ..utils import run_periodic, LOGGER, try_supported
-from ..models.media_types import MediaType, Track, Artist, Album, Playlist, Radio
-
-
-BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-MODULES_PATH = os.path.join(BASE_DIR, "musicproviders" )
-
-class Music():
-    ''' several helpers around the musicproviders '''
-    
-    def __init__(self, mass):
-        self.sync_running = False
-        self.mass = mass
-        self.providers = {}
-        # dynamically load musicprovider modules
-        self.load_music_providers()
-        # schedule sync task
-        mass.event_loop.create_task(self.sync_music_providers())
-
-    async def item(self, item_id, media_type:MediaType, provider='database', lazy=True):
-        ''' get single music item by id and media type'''
-        if media_type == MediaType.Artist:
-            return await self.artist(item_id, provider, lazy=lazy)
-        elif media_type == MediaType.Album:
-            return await self.album(item_id, provider, lazy=lazy)
-        elif media_type == MediaType.Track:
-            return await self.track(item_id, provider, lazy=lazy)
-        elif media_type == MediaType.Playlist:
-            return await self.playlist(item_id, provider)
-        elif media_type == MediaType.Radio:
-            return await self.radio(item_id, provider)
-        else:
-            return None
-
-    async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Artist]:
-        ''' return all library artists, optionally filtered by provider '''
-        return await self.mass.db.library_artists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def library_albums(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Album]:
-        ''' return all library albums, optionally filtered by provider '''
-        return await self.mass.db.library_albums(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def library_tracks(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Track]:
-        ''' return all library tracks, optionally filtered by provider '''
-        return await self.mass.db.library_tracks(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def playlists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]:
-        ''' return all library playlists, optionally filtered by provider '''
-        return await self.mass.db.playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def radios(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]:
-        ''' return all library radios, optionally filtered by provider '''
-        return await self.mass.db.radios(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
-
-    async def library_items(self, media_type:MediaType, limit=0, offset=0, orderby='name', provider_filter=None) -> List[object]:
-        ''' get multiple music items in library'''
-        if media_type == MediaType.Artist:
-            return await self.library_artists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-        elif media_type == MediaType.Album:
-            return await self.library_albums(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-        elif media_type == MediaType.Track:
-            return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-        elif media_type == MediaType.Playlist:
-            return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-        elif media_type == MediaType.Radio:
-            return await self.radios(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
-
-    async def artist(self, item_id, provider='database', lazy=True) -> Artist:
-        ''' get artist by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.artist(item_id)
-        return await self.providers[provider].artist(item_id, lazy=lazy)
-
-    async def album(self, item_id, provider='database', lazy=True) -> Album:
-        ''' get album by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.album(item_id)
-        return await self.providers[provider].album(item_id, lazy=lazy)
-
-    async def track(self, item_id, provider='database', lazy=True) -> Track:
-        ''' get track by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.track(item_id)
-        return await self.providers[provider].track(item_id, lazy=lazy)
-
-    async def playlist(self, item_id, provider='database') -> Playlist:
-        ''' get playlist by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.playlist(item_id)
-        return await self.providers[provider].playlist(item_id)
-
-    async def radio(self, item_id, provider='database') -> Radio:
-        ''' get radio by id '''
-        if not provider or provider == 'database':
-            return await self.mass.db.radio(item_id)
-        return await self.providers[provider].radio(item_id)
-
-    async def playlist_by_name(self, name) -> Playlist:
-        ''' get playlist by name '''
-        for playlist in await self.playlists():
-            if playlist.name == name:
-                return playlist
-        return None
-
-    async def radio_by_name(self, name) -> Radio:
-        ''' get radio by name '''
-        for radio in await self.radios():
-            if radio.name == name:
-                return radio
-        return None
-    
-    async def artist_toptracks(self, artist_id, provider='database') -> List[Track]:
-        ''' get top tracks for given artist '''
-        artist = await self.artist(artist_id, provider)
-        # always append database tracks
-        items = await self.mass.db.artist_tracks(artist.item_id)
-        for prov_mapping in artist.provider_ids:
-            prov_id = prov_mapping['provider']
-            prov_item_id = prov_mapping['item_id']
-            prov_obj = self.providers[prov_id]
-            items += await prov_obj.artist_toptracks(prov_item_id)
-        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        items.sort(key=lambda x: x.name, reverse=False)
-        return items
-
-    async def artist_albums(self, artist_id, provider='database') -> List[Album]:
-        ''' get (all) albums for given artist '''
-        artist = await self.artist(artist_id, provider)
-        # always append database tracks
-        items = await self.mass.db.artist_albums(artist.item_id)
-        for prov_mapping in artist.provider_ids:
-            prov_id = prov_mapping['provider']
-            prov_item_id = prov_mapping['item_id']
-            prov_obj = self.providers[prov_id]
-            items += await prov_obj.artist_albums(prov_item_id)
-        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        items.sort(key=lambda x: x.name, reverse=False)
-        return items
-
-    async def album_tracks(self, album_id, provider='database') -> List[Track]:
-        ''' get the album tracks for given album '''
-        items = []
-        album = await self.album(album_id, provider)
-        for prov_mapping in album.provider_ids:
-            prov_id = prov_mapping['provider']
-            prov_item_id = prov_mapping['item_id']
-            prov_obj = self.providers[prov_id]
-            items += await prov_obj.album_tracks(prov_item_id)
-        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        items = sorted(items, key=operator.attrgetter('disc_number'), reverse=False)
-        items = sorted(items, key=operator.attrgetter('track_number'), reverse=False)
-        return items
-
-    async def playlist_tracks(self, playlist_id, provider='database', offset=0, limit=50) -> List[Track]:
-        ''' get the tracks for given playlist '''
-        playlist = None
-        if not provider or provider == 'database':
-            playlist = await self.mass.db.playlist(playlist_id)
-        if playlist and playlist.is_editable:
-            # database synced playlist, return tracks from db...
-            return await self.mass.db.playlist_tracks(
-                    playlist.item_id, offset=offset, limit=limit)
-        else:
-            # return playlist tracks from provider
-            playlist = await self.playlist(playlist_id, provider)
-            prov = playlist.provider_ids[0]
-            return await self.providers[prov['provider']].playlist_tracks(
-                    prov['item_id'], offset=offset, limit=limit)
-
-    async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False) -> dict:
-        ''' search database or providers '''
-        # get results from database
-        result = await self.mass.db.search(searchquery, media_types, limit)
-        if online:
-            # include results from music providers
-            for prov in self.providers.values():
-                prov_results = await prov.search(searchquery, media_types, limit)
-                for item_type, items in prov_results.items():
-                    if not item_type in result:
-                        result[item_type] = items
-                    else:
-                        result[item_type] += items
-            # filter out duplicates
-            for item_type, items in result.items():
-                items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
-        return result
-
-    async def item_action(self, item_id, media_type, provider, action, action_details=None):
-        ''' perform action on item (such as library add/remove) '''
-        result = None
-        item = await self.item(item_id, media_type, provider)
-        if item and action in ['library_add', 'library_remove']:
-            # remove or add item to the library
-            for prov_mapping in result.provider_ids:
-                prov_id = prov_mapping['provider']
-                prov_item_id = prov_mapping['item_id']
-                for prov in self.providers.values():
-                    if prov.prov_id == prov_id:
-                        if action == 'add':
-                            result = await prov.add_library(prov_item_id, media_type)
-                        elif action == 'remove':
-                            result = await prov.remove_library(prov_item_id, media_type)
-        return result
-    
-    async def add_playlist_tracks(self, playlist_id, tracks:List[Track]):
-        ''' add tracks to playlist - make sure we dont add dupes '''
-        # we can only edit playlists that are in the database (marked as editable)
-        playlist = await self.playlist(playlist_id, 'database')
-        if not playlist or not playlist.is_editable:
-            LOGGER.warning("Playlist %s is not editable - skip addition of tracks" %(playlist.name))
-            return False
-        playlist_prov = playlist.provider_ids[0] # playlist can only have one provider (for now)
-        cur_playlist_tracks = await self.mass.db.playlist_tracks(playlist_id, limit=0)
-        # grab all (database) track ids in the playlist so we can check for duplicates
-        cur_playlist_track_ids = [item.item_id for item in cur_playlist_tracks]
-        track_ids_to_add = []
-        for track in tracks:
-            if not track.provider == 'database':
-                # make sure we have a database track
-                track = await self.track(track.item_id, track.provider, lazy=False)
-            if track.item_id in cur_playlist_track_ids:
-                LOGGER.warning("Track %s already in playlist %s - skip addition" %(track.name, playlist.name))
-                continue
-            # we can only add a track to a provider playlist if the track is available on that provider
-            # exception is the file provider which does accept tracks from all providers in the m3u playlist
-            # this should all be handled in the frontend but these checks are here just to be safe
-            track_playlist_provs = [item['provider'] for item in track.provider_ids]
-            if playlist_prov['provider'] in track_playlist_provs:
-                # a track can contain multiple versions on the same provider
-                # # simply sort by quality and just add the first one (assuming the track is still available)
-                track_versions = sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True)
-                for track_version in track_versions:
-                    if track_version['provider'] == playlist_prov['provider']:
-                        track_ids_to_add.append(track_version['item_id'])
-                        break
-            elif playlist_prov['provider'] == 'file':
-                # the file provider can handle uri's from all providers in the file so simply add the db id
-                track_ids_to_add.append(track.item_id)
-            else:
-                LOGGER.warning("Track %s not available on provider %s - skip addition to playlist %s" %(track.name, playlist_prov['provider'], playlist.name))
-                continue
-        # actually add the tracks to the playlist on the provider
-        await self.providers[playlist_prov['provider']].add_playlist_tracks(playlist_prov['item_id'], track_ids_to_add)
-        # schedule sync
-        self.mass.event_loop.create_task(self.sync_playlist_tracks(playlist.item_id, playlist_prov['provider'], playlist_prov['item_id']))
-
-    @run_periodic(3600)
-    async def sync_music_providers(self):
-        ''' periodic sync of all music providers '''
-        if self.sync_running:
-            return
-        self.sync_running = True
-        for prov_id in self.providers.keys():
-            # sync library artists
-            await try_supported(self.sync_library_artists(prov_id))
-            await try_supported(self.sync_library_albums(prov_id))
-            await try_supported(self.sync_library_tracks(prov_id))
-            await try_supported(self.sync_playlists(prov_id))
-            await try_supported(self.sync_radios(prov_id))
-        self.sync_running = False
-        
-    async def sync_library_artists(self, prov_id):
-        ''' sync library artists for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.library_artists(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_library_artists()
-        cur_db_ids = []
-        for item in cur_items:
-            db_item = await music_provider.artist(item.item_id, lazy=False)
-            cur_db_ids.append(db_item.item_id)
-            if not db_item.item_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_item.item_id, MediaType.Artist, prov_id)
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id)
-        LOGGER.info("Finished syncing Artists for provider %s" % prov_id)
-
-    async def sync_library_albums(self, prov_id):
-        ''' sync library albums for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.library_albums(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_library_albums()
-        cur_db_ids = []
-        for item in cur_items:
-            db_item = await music_provider.album(item.item_id, lazy=False)
-            cur_db_ids.append(db_item.item_id)
-            # precache album tracks...
-            for album_track in await music_provider.get_album_tracks(item.item_id):
-                await music_provider.track(album_track.item_id)
-            if not db_item.item_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_item.item_id, MediaType.Album, prov_id)
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id)
-        LOGGER.info("Finished syncing Albums for provider %s" % prov_id)
-
-    async def sync_library_tracks(self, prov_id):
-        ''' sync library tracks for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.library_tracks(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_library_tracks()
-        cur_db_ids = []
-        for item in cur_items:
-            db_item = await music_provider.track(item.item_id, lazy=False)
-            cur_db_ids.append(db_item.item_id)
-            if not db_item.item_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_item.item_id, MediaType.Track, prov_id)
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id)
-        LOGGER.info("Finished syncing Tracks for provider %s" % prov_id)
-
-    async def sync_playlists(self, prov_id):
-        ''' sync library playlists for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.playlists(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_playlists()
-        cur_db_ids = []
-        for item in cur_items:
-            # always add to db because playlist attributes could have changed
-            db_id = await self.mass.db.add_playlist(item)
-            cur_db_ids.append(db_id)
-            if not db_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id)
-            if item.is_editable:
-                # precache/sync playlist tracks (user owned playlists only)
-                asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) )
-        # process playlist deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Playlist, prov_id)
-        LOGGER.info("Finished syncing Playlists for provider %s" % prov_id)
-
-    async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id):
-        ''' sync library playlists tracks for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.playlist_tracks(db_playlist_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_playlist_tracks(prov_playlist_id, limit=0)
-        cur_db_ids = []
-        pos = 0
-        for item in cur_items:
-            # we need to do this the complicated way because the file provider can return tracks from other providers
-            for prov_mapping in item.provider_ids:
-                item_provider = prov_mapping['provider']
-                prov_item_id = prov_mapping['item_id']
-                db_item = await self.providers[item_provider].track(prov_item_id, lazy=False)
-                cur_db_ids.append(db_item.item_id)
-                if not db_item.item_id in prev_db_ids:
-                    await self.mass.db.add_playlist_track(db_playlist_id, db_item.item_id, pos)
-            pos += 1
-        # process playlist track deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_playlist_track(db_playlist_id, db_id)
-        LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id))
-
-    async def sync_radios(self, prov_id):
-        ''' sync library radios for given provider'''
-        music_provider = self.providers[prov_id]
-        prev_items = await self.radios(provider_filter=prov_id)
-        prev_db_ids = [item.item_id for item in prev_items]
-        cur_items = await music_provider.get_radios()
-        cur_db_ids = []
-        for item in cur_items:
-            db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Radio)
-            if not db_id:
-                db_id = await self.mass.db.add_radio(item)
-            cur_db_ids.append(db_id)
-            if not db_id in prev_db_ids:
-                await self.mass.db.add_to_library(db_id, MediaType.Radio, prov_id)
-        # process deletions
-        for db_id in prev_db_ids:
-            if db_id not in cur_db_ids:
-                await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id)
-        LOGGER.info("Finished syncing Radios for provider %s" % prov_id)
-
-    def load_music_providers(self):
-        ''' dynamically load musicproviders '''
-        for item in os.listdir(MODULES_PATH):
-            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
-                    item.endswith('.py') and not item.startswith('.')):
-                module_name = item.replace(".py","")
-                LOGGER.debug("Loading musicprovider module %s" % module_name)
-                try:
-                    mod = __import__("modules.musicproviders." + module_name, fromlist=[''])
-                    if not self.mass.config['musicproviders'].get(module_name):
-                        self.mass.config['musicproviders'][module_name] = {}
-                    self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries()
-                    for key, def_value, desc in mod.config_entries():
-                        if not key in self.mass.config['musicproviders'][module_name]:
-                            self.mass.config['musicproviders'][module_name][key] = def_value
-                    mod = mod.setup(self.mass)
-                    if mod:
-                        self.providers[mod.prov_id] = mod
-                        cls_name = mod.__class__.__name__
-                        LOGGER.info("Successfully initialized module %s" % cls_name)
-                except Exception as exc:
-                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
diff --git a/music_assistant/modules/musicproviders/__init__.py b/music_assistant/modules/musicproviders/__init__.py
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/music_assistant/modules/musicproviders/file.py b/music_assistant/modules/musicproviders/file.py
deleted file mode 100644 (file)
index 8f7900a..0000000
+++ /dev/null
@@ -1,358 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from typing import List
-import sys
-import time
-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 modules.cache import use_cache
-import base64
-
-
-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", "", "file_prov_music_path"), 
-        ("playlists_dir", "", "file_prov_playlists_path")
-        ]
-
-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_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.sep in prov_item_id:
-            itempath = base64.b64decode(prov_item_id).decode('utf-8')
-        else:
-            itempath = prov_item_id
-            prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8')
-        if not os.path.isdir(itempath):
-            LOGGER.error("artist path does not exist: %s" % itempath)
-            return None
-        name = itempath.split(os.sep)[-1]
-        artist = Artist()
-        artist.item_id = prov_item_id
-        artist.provider = self.prov_id
-        artist.name = name
-        artist.provider_ids.append({
-            "provider": self.prov_id,
-            "item_id": artist.item_id
-        })
-        return artist
-        
-    async def get_album(self, prov_item_id) -> Album:
-        ''' get full album details by id '''
-        if not os.sep in prov_item_id:
-            itempath = base64.b64decode(prov_item_id).decode('utf-8')
-        else:
-            itempath = prov_item_id
-            prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8')
-        if not os.path.isdir(itempath):
-            LOGGER.error("album path does not exist: %s" % itempath)
-            return None
-        name = itempath.split(os.sep)[-1]
-        artistpath = itempath.rsplit(os.sep, 1)[0]
-        album = Album()
-        album.item_id = prov_item_id
-        album.provider = self.prov_id
-        album.name, album.version = parse_track_title(name)
-        album.artist = await self.get_artist(artistpath)
-        if not album.artist:
-            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.sep in prov_item_id:
-            itempath = base64.b64decode(prov_item_id).decode('utf-8')
-        else:
-            itempath = prov_item_id
-        if not os.path.isfile(itempath):
-            LOGGER.error("track path does not exist: %s" % itempath)
-            return None
-        return await self.__parse_track(itempath)
-
-    async def get_playlist(self, prov_item_id) -> Playlist:
-        ''' get full playlist details by id '''
-        if not os.sep in prov_item_id:
-            itempath = base64.b64decode(prov_item_id).decode('utf-8')
-        else:
-            itempath = prov_item_id
-            prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8')
-        if not os.path.isfile(itempath):
-            LOGGER.error("playlist path does not exist: %s" % itempath)
-            return None
-        playlist = Playlist()
-        playlist.item_id = prov_item_id
-        playlist.provider = self.prov_id
-        playlist.name = itempath.split(os.sep)[-1].replace('.m3u', '')
-        playlist.is_editable = True
-        playlist.provider_ids.append({
-            "provider": self.prov_id,
-            "item_id": prov_item_id
-        })
-        playlist.owner = 'disk'
-        return playlist
-    
-    async def get_album_tracks(self, prov_album_id) -> List[Track]:
-        ''' get album tracks for given album id '''
-        result = []
-        if not os.sep in prov_album_id:
-            albumpath = base64.b64decode(prov_album_id).decode('utf-8')
-        else:
-            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.sep in prov_playlist_id:
-            itempath = base64.b64decode(prov_playlist_id).decode('utf-8')
-        else:
-            itempath = prov_playlist_id
-        if not os.path.isfile(itempath):
-            LOGGER.error("playlist path does not exist: %s" % itempath)
-            return []
-        counter = 0
-        with open(itempath) 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 limit and 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 = []
-        if not os.sep in prov_artist_id:
-            artistpath = base64.b64decode(prov_artist_id).decode('utf-8')
-        else:
-            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_content_type(self, track_id):
-        ''' return the content type for the given track when it will be streamed'''
-        if not os.sep in track_id:
-            track_id = base64.b64decode(track_id).decode('utf-8')
-        return track_id.split('.')[-1]
-    
-    async def get_audio_stream(self, track_id):
-        ''' get audio stream for a track '''
-        if not os.sep in track_id:
-            track_id = base64.b64decode(track_id).decode('utf-8')
-        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 ?
-        prov_item_id = base64.b64encode(filename.encode('utf-8')).decode('utf-8')
-        track.duration = song.length
-        track.item_id = prov_item_id
-        track.provider = self.prov_id
-        name = song.tags['TITLE'][0]
-        track.name, track.version = parse_track_title(name)
-        albumpath = filename.rsplit(os.sep,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": base64.b64encode(fake_artistpath.encode('utf-8')).decode('utf-8')
-                    })
-            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])
-        quality_details = ""
-        if filename.endswith('.flac'):
-            # TODO: get bit depth
-            quality = TrackQuality.FLAC_LOSSLESS
-            if song.sampleRate > 192000:
-                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
-            elif song.sampleRate > 96000:
-                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
-            elif song.sampleRate > 48000:
-                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
-            quality_details = "%s Khz" % (song.sampleRate/1000)
-        elif filename.endswith('.ogg'):
-            quality = TrackQuality.LOSSY_OGG
-            quality_details = "%s kbps" % (song.bitrate)
-        elif filename.endswith('.m4a'):
-            quality = TrackQuality.LOSSY_AAC
-            quality_details = "%s kbps" % (song.bitrate)
-        else:
-            quality = TrackQuality.LOSSY_MP3
-            quality_details = "%s kbps" % (song.bitrate)
-        track.provider_ids.append({
-            "provider": self.prov_id,
-            "item_id": prov_item_id,
-            "quality": quality,
-            "details": quality_details
-        })
-        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
deleted file mode 100644 (file)
index a8e21c3..0000000
+++ /dev/null
@@ -1,559 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from typing import List
-from utils import run_periodic, LOGGER, parse_track_title
-from app_vars import get_app_var
-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 modules.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:
-        provider = QobuzProvider(mass, username, password)
-        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_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.mass = mass
-        self.cache = mass.cache
-        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
-        self.__username = username
-        self.__password = password
-        self.__user_auth_info = None
-        self.__logged_in = False
-        self.throttler = Throttler(rate_limit=2, period=1)
-        mass.add_event_listener(self.mass_event, 'streaming_started')
-        mass.add_event_listener(self.mass_event, 'streaming_ended')
-
-    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_playlists(self) -> List[Playlist]:
-        ''' retrieve playlists from the provider '''
-        result = []
-        for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists', cache_checksum=time.time()):
-            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)
-            # TODO: should we look for an alternative track version if the original is marked unavailable ?
-        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 str(item['artist']['id']) == str(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, so use search instead
-        items = []
-        artist = await self.get_artist(prov_artist_id)
-        params = {"query": artist.name, "limit": 10, "type": "tracks" }
-        searchresult = await self.__get_data("catalog/search", params)
-        for item in searchresult["tracks"]["items"]:
-            if "performer" in item and str(item["performer"]["id"]) == str(prov_artist_id):
-                track = await self.__parse_track(item)
-                items.append(track)
-        return items
-    
-    async def add_library(self, prov_item_id, media_type:MediaType):
-        ''' add item to library '''
-        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 add_playlist_tracks(self, prov_playlist_id, prov_track_ids):
-        ''' add track(s) to playlist '''
-        params = {
-            'playlist_id': prov_playlist_id,
-            'track_ids': ",".join(prov_track_ids)
-        }
-        return await self.__get_data('playlist/addTracks', params)
-
-    async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids):
-        ''' remove track(s) from playlist '''
-        playlist_track_ids = []
-        params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'}
-        for track in await self.__get_all_items("playlist/get", params, key='tracks', limit=0):
-            if track['id'] in prov_track_ids:
-                playlist_track_ids.append(track['playlist_track_id'])
-        params = {'playlist_id': prov_playlist_id, 'track_ids': ",".join(playlist_track_ids)}
-        return await self.__get_data('playlist/deleteTracks', params)
-    
-    async def get_stream_details(self, track_id):
-        ''' return the content details for the given track when it will be streamed'''
-        streamdetails = None
-        for format_id in [27, 7, 6, 5]:
-            # it seems that simply requesting for highest available quality does not work
-            # from time to time the api response is empty for this request ?!
-            params = {'format_id': format_id, 'track_id': track_id, 'intent': 'stream'}
-            streamdetails = await self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True)
-            if streamdetails and streamdetails.get('url'):
-                break
-        if not streamdetails or not streamdetails.get('url'):
-            LOGGER.error("Unable to retrieve stream url for track %s" % track_id)
-            return None
-        return {
-            "type": "url",
-            "path": streamdetails['url'],
-            "content_type": streamdetails['mime_type'].split('/')[1],
-            "sample_rate": int(streamdetails['sampling_rate']*1000),
-            "bit_depth": streamdetails['bit_depth'],
-            "details": streamdetails # we need these details for reporting playback
-        }
-
-    async def mass_event(self, msg, msg_details):
-        ''' received event from mass '''
-        # TODO: need to figure out if the streamed track is purchased
-        if msg == "streaming_started" and msg_details['provider'] == self.prov_id:
-            # report streaming started to qobuz
-            LOGGER.debug("streaming_started %s" % msg_details["track_id"])
-            device_id = self.__user_auth_info["user"]["device"]["id"]
-            credential_id = self.__user_auth_info["user"]["credential"]["id"]
-            user_id = self.__user_auth_info["user"]["id"]
-            format_id = msg_details["details"]["format_id"]
-            timestamp = int(time.time())
-            events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, 
-                "track_id": msg_details["track_id"], "purchase": False, "date": timestamp,
-                "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}]
-            await self.__post_data("track/reportStreamingStart", data=events)
-        elif msg == "streaming_ended" and msg_details['provider'] == self.prov_id:
-            # report streaming ended to qobuz
-            LOGGER.debug("streaming_ended %s - seconds played: %s" %(msg_details["track_id"], msg_details["seconds"]) )
-            device_id = self.__user_auth_info["user"]["device"]["id"]
-            credential_id = self.__user_auth_info["user"]["credential"]["id"]
-            user_id = self.__user_auth_info["user"]["id"]
-            format_id = msg_details["details"]["format_id"]
-            timestamp = int(time.time())
-            events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, 
-                "track_id": msg_details["track_id"], "purchase": False, "date": timestamp, "duration": msg_details["seconds"],
-                "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}]
-            await self.__post_data("track/reportStreamingStart", data=events)
-    
-    async def __parse_artist(self, artist_obj):
-        ''' parse qobuz artist object to generic layout '''
-        artist = Artist()
-        if not artist_obj.get('id'):
-            return None
-        artist.item_id = artist_obj['id']
-        artist.provider = self.prov_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 qobuz 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']
-        album.provider = self.prov_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 qobuz 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 - %s" % (track_obj.get('id'), track_obj.get('name')))
-            return None
-        track.item_id = track_obj['id']
-        track.provider = self.prov_id
-        if track_obj.get('performer') and not 'Various ' in track_obj['performer']:
-            artist = await self.__parse_artist(track_obj['performer'])
-            if not artist:
-                artist = self.get_artist(track_obj['performer']['id'])
-            if artist:
-                track.artists.append(artist)
-        if not track.artists:
-            # try to grab artist from album
-            if track_obj.get('album') and track_obj['album'].get('artist') and not 'Various ' in track_obj['album']['artist']:
-                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_LOSSLESS_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 qobuz playlist object to generic layout '''
-        playlist = Playlist()
-        if not playlist_obj.get('id'):
-            return None
-        playlist.item_id = playlist_obj['id']
-        playlist.provider = self.prov_id
-        playlist.provider_ids.append({
-            "provider": self.prov_id,
-            "item_id": playlist_obj['id']
-        })
-        playlist.name = playlist_obj['name']
-        playlist.owner = playlist_obj['owner']['name']
-        playlist.is_editable = playlist_obj['owner']['id'] == self.__user_auth_info["user"]["id"] or playlist_obj['is_collaborative']
-        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_info:
-            return self.__user_auth_info["user_auth_token"]
-        params = { "username": self.__username, "password": self.__password, "device_manufacturer_id": "music_assistant"}
-        details = await self.__get_data("user/login", params, ignore_cache=True)
-        self.__user_auth_info = details
-        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": get_app_var(0)}
-        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 + get_app_var(1)
-            request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
-            params["request_ts"] = request_ts
-            params["request_sig"] = request_sig
-            params["app_id"] = get_app_var(0)
-            params["user_auth_token"] = await self.__auth_token()
-        try:
-            async with self.throttler:
-                async with self.http_session.get(url, headers=headers, params=params) as response:
-                    result = await response.json()
-                    if not result or 'error' in result:
-                        LOGGER.error(url)
-                        LOGGER.debug(params)
-                        LOGGER.debug(result)
-                        return None
-                    return result
-        except Exception as exc:
-            LOGGER.exception(exc)
-            return None
-
-    async def __post_data(self, endpoint, params={}, data={}):
-        ''' post data to api'''
-        url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint
-        params["app_id"] = get_app_var(0)
-        params["user_auth_token"] = await self.__auth_token()
-        async with self.http_session.post(url, params=params, json=data) as response:
-            result = await response.json()
-            if not result or 'error' in result:
-                LOGGER.error(url)
-                LOGGER.debug(params)
-                LOGGER.debug(result)
-                result = None
-            return result
\ No newline at end of file
diff --git a/music_assistant/modules/musicproviders/spotify.py b/music_assistant/modules/musicproviders/spotify.py
deleted file mode 100644 (file)
index d2bcef7..0000000
+++ /dev/null
@@ -1,516 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from typing import List
-import sys
-import time
-from utils import run_periodic, LOGGER, parse_track_title
-from app_vars import get_app_var
-from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
-from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
-from asyncio_throttle import Throttler
-import json
-import aiohttp
-from modules.cache import use_cache
-import concurrent
-
-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, connector=aiohttp.TCPConnector(verify_ssl=False))
-        self.throttler = Throttler(rate_limit=1, period=1)
-        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_playlists(self) -> List[Playlist]:
-        ''' retrieve playlists from the provider '''
-        result = []
-        for item in await self.__get_all_items("me/playlists", cache_checksum=time.time()):
-            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 '''
-        artist = await self.get_artist(prov_artist_id)
-        items = await self.__get_data('artists/%s/top-tracks' % prov_artist_id)
-        tracks = []
-        for item in items['tracks']:
-            track = await self.__parse_track(item)
-            if track:
-                track.artists = [artist]
-                tracks.append(track)
-        return tracks
-
-    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):
-        ''' return the content details for the given track when it will be streamed'''
-        spotty = self.get_spotty_binary()
-        spotty_exec = "%s -n temp -u %s -p %s --pass-through --single-track %s" %(spotty, self._username, self._password, track_id)
-        return {
-            "type": "executable",
-            "path": spotty_exec,
-            "content_type": "ogg",
-            "sample_rate": 44100,
-            "bit_depth": 16
-        }
-        
-    async def __parse_artist(self, artist_obj):
-        ''' parse spotify artist object to generic layout '''
-        artist = Artist()
-        artist.item_id = artist_obj['id']
-        artist.provider = self.prov_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']
-        album.provider = self.prov_id
-        album.name, album.version = parse_track_title(album_obj['name'])
-        for artist in album_obj['artists']:
-            album.artist = await self.__parse_artist(artist)
-            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']:
-            LOGGER.warning("invalid/unavailable track found: %s - %s" % (track_obj.get('id'), track_obj.get('name')))
-            return None
-        track = Track()
-        track.item_id = track_obj['id']
-        track.provider = self.prov_id
-        for track_artist in track_obj['artists']:
-            artist = await self.__parse_artist(track_artist)
-            if artist:
-                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']
-        playlist.provider = self.prov_id
-        playlist.provider_ids.append({
-            "provider": self.prov_id,
-            "item_id": playlist_obj['id']
-        })
-        playlist.name = playlist_obj['name']
-        playlist.owner = playlist_obj['owner']['display_name']
-        playlist.is_editable = playlist_obj['owner']['id'] == self.sp_user["id"] or playlist_obj['collaborative']
-        if playlist_obj.get('images'):
-            playlist.metadata["image"] = playlist_obj['images'][0]['url']
-        if playlist_obj.get('external_urls'):
-            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)
-            args = [self.get_spotty_binary(), "-t", "--client-id", get_app_var(2), "--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.throttler:
-            async with self.http_session.get(url, headers=headers, params=params) as response:
-                result = await response.json()
-                if not result or 'error' in result:
-                    LOGGER.error(url)
-                    LOGGER.error(params)
-                    result = None
-                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
deleted file mode 100755 (executable)
index c928d8a..0000000
Binary files a/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf and /dev/null differ
diff --git a/music_assistant/modules/musicproviders/spotty/darwin/spotty b/music_assistant/modules/musicproviders/spotty/darwin/spotty
deleted file mode 100755 (executable)
index 44c6b60..0000000
Binary files a/music_assistant/modules/musicproviders/spotty/darwin/spotty and /dev/null differ
diff --git a/music_assistant/modules/musicproviders/spotty/windows/spotty.exe b/music_assistant/modules/musicproviders/spotty/windows/spotty.exe
deleted file mode 100755 (executable)
index 6ce9b19..0000000
Binary files a/music_assistant/modules/musicproviders/spotty/windows/spotty.exe and /dev/null differ
diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty
deleted file mode 100755 (executable)
index b2c3f34..0000000
Binary files a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty and /dev/null 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
deleted file mode 100755 (executable)
index 58911cf..0000000
Binary files a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 and /dev/null differ
diff --git a/music_assistant/modules/musicproviders/tunein.py b/music_assistant/modules/musicproviders/tunein.py
deleted file mode 100644 (file)
index 1350df4..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from typing import List
-import sys
-import time
-from utils import run_periodic, LOGGER, parse_track_title
-from models import MusicProvider, MediaType, TrackQuality, Radio
-from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
-from asyncio_throttle import Throttler
-import json
-import aiohttp
-from modules.cache import use_cache
-import concurrent
-
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["musicproviders"]['tunein'].get(CONF_ENABLED)
-    username = mass.config["musicproviders"]['tunein'].get(CONF_USERNAME)
-    password = mass.config["musicproviders"]['tunein'].get(CONF_PASSWORD)
-    if enabled and username and password:
-        provider = TuneInProvider(mass, username, password)
-        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_USERNAME, "", CONF_USERNAME), 
-        (CONF_PASSWORD, "<password>", CONF_PASSWORD)
-        ]
-
-class TuneInProvider(MusicProvider):
-    
-
-    def __init__(self, mass, username, password):
-        self.name = 'TuneIn Radio'
-        self.prov_id = 'tunein'
-        self.mass = mass
-        self.cache = mass.cache
-        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
-        self.throttler = Throttler(rate_limit=1, period=1)
-        self._username = username
-        self._password = password
-
-    async def search(self, searchstring, media_types=List[MediaType], limit=5):
-        ''' perform search on the provider '''
-        result = {
-            "artists": [],
-            "albums": [],
-            "tracks": [],
-            "playlists": [],
-            "radios": []
-        }
-        return result
-
-    async def get_radios(self):
-        ''' get favorited/library radio stations '''
-        items = []
-        params = {"c": "presets"}
-        result = await self.__get_data("Browse.ashx", params, ignore_cache=True)
-        if result and "body" in result:
-            for item in result["body"]:
-                # TODO: expand folders
-                if item["type"] == "audio":
-                    radio = await self.__parse_radio(item)
-                    items.append(radio)
-        return items
-
-    async def get_radio(self, radio_id):
-        ''' get radio station details '''
-        radio = None
-        params = {"c": "composite", "detail": "listing", "id": radio_id}
-        result = await self.__get_data("Describe.ashx", params, ignore_cache=True)
-        if result and result.get("body") and result["body"][0].get("children"):
-            item = result["body"][0]["children"][0]
-            radio = await self.__parse_radio(item)
-        return radio
-
-    async def __parse_radio(self, details):
-        ''' parse Radio object from json obj returned from api '''
-        radio = Radio()
-        radio.item_id = details['preset_id']
-        radio.provider = self.prov_id
-        if "name" in details:
-            radio.name = details["name"]
-        else:
-            # parse name from text attr
-            name = details["text"]
-            if " | " in name:
-                name = name.split(" | ")[1]
-            name = name.split(" (")[0]
-            radio.name = name
-        # parse stream urls and format
-        stream_info = await self.__get_stream_urls(radio.item_id)
-        for stream in stream_info["body"]:
-            if stream["media_type"] == 'aac':
-                quality = TrackQuality.LOSSY_AAC
-            elif stream["media_type"] == 'ogg':
-                quality = TrackQuality.LOSSY_OGG
-            else:
-                quality = TrackQuality.LOSSY_MP3
-            radio.provider_ids.append({
-                "provider": self.prov_id,
-                "item_id": details['preset_id'],
-                "quality": quality,
-                "details": stream['url']
-            })
-        # image
-        if "image" in details:
-            radio.metadata["image"] = details["image"]
-        elif "logo" in details:
-            radio.metadata["image"] = details["logo"]
-        return radio
-
-    async def __get_stream_urls(self, radio_id):
-        ''' get the stream urls for the given radio id '''
-        params = {"id": radio_id}
-        res = await self.__get_data("Tune.ashx", params)
-        return res
-
-    # async def get_stream_content_type(self, radio_id):
-    #     ''' return the content type for the given radio when it will be streamed'''
-    #     return 'flac' #TODO handle other file formats on qobuz?
-
-    # async def get_audio_stream(self, track_id):
-    #     ''' get audio stream for a track '''
-    #     params = {'format_id': 27, 'track_id': track_id, 'intent': 'stream'}
-    #     # we are called from other thread
-    #     streamdetails_future = asyncio.run_coroutine_threadsafe(
-    #         self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True),
-    #         self.mass.event_loop
-        
-    @use_cache(7)
-    async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None):
-        ''' get data from api'''
-        url = 'https://opml.radiotime.com/%s' % endpoint
-        params['render'] = 'json'
-        params['formats'] = 'ogg,aac,wma,mp3'
-        params['username'] = self._username
-        params['partnerId'] = '1'
-        async with self.throttler:
-            async with self.http_session.get(url, params=params) as response:
-                result = await response.json()
-                if not result or 'error' in result:
-                    LOGGER.error(url)
-                    LOGGER.error(params)
-                    result = None
-                return result
-
-    
\ No newline at end of file
diff --git a/music_assistant/modules/player_manager.py b/music_assistant/modules/player_manager.py
deleted file mode 100755 (executable)
index 438ce7b..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from enum import Enum
-from ..utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task
-from ..models.media_types import MediaType, TrackQuality
-from ..models.player_queue import QueueItem
-from ..models.player import PlayerState
-import operator
-import random
-import functools
-import urllib
-
-BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" )
-
-
-
-class PlayerManager():
-    ''' several helpers to handle playback through player providers '''
-    
-    def __init__(self, mass):
-        self.mass = mass
-        self.providers = {}
-        self._players = {}
-        self.local_ip = get_ip()
-        # dynamically load provider modules
-        self.load_providers()
-
-    @property
-    def players(self):
-        ''' all players as property '''
-        return self.mass.event_loop.run_until_complete(self.get_players())
-    
-    async def get_players(self):
-        ''' return all players as a list '''
-        items = list(self._players.values())
-        items.sort(key=lambda x: x.name, reverse=False)
-        return items
-
-    async def get_player(self, player_id):
-        ''' return player by id '''
-        return self._players.get(player_id, None)
-
-    async def get_provider_players(self, player_provider):
-        ''' return all players for given provider_id '''
-        return [item for item in self._players.values() if item.player_provider == player_provider] 
-
-    async def add_player(self, player):
-        ''' register a new player '''
-        self._players[player.player_id] = player
-        self.mass.signal_event('player added', player)
-        # TODO: turn on player if it was previously turned on ?
-        return player
-
-    async def remove_player(self, player_id):
-        ''' handle a player remove '''
-        self._players.pop(player_id, None)
-        self.mass.signal_event('player removed', player_id)
-
-    async def trigger_update(self, player_id):
-        ''' manually trigger update for a player '''
-        if player_id in self._players:
-            await self._players[player_id].update()
-    
-    def load_providers(self):
-        ''' dynamically load providers '''
-        for item in os.listdir(MODULES_PATH):
-            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
-                    item.endswith('.py') and not item.startswith('.')):
-                module_name = item.replace(".py","")
-                LOGGER.debug("Loading playerprovider module %s" % module_name)
-                try:
-                    mod = __import__("modules.playerproviders." + module_name, fromlist=[''])
-                    if not self.mass.config['playerproviders'].get(module_name):
-                        self.mass.config['playerproviders'][module_name] = {}
-                    self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries()
-                    for key, def_value, desc in mod.config_entries():
-                        if not key in self.mass.config['playerproviders'][module_name]:
-                            self.mass.config['playerproviders'][module_name][key] = def_value
-                    mod = mod.setup(self.mass)
-                    if mod:
-                        self.providers[mod.prov_id] = mod
-                        cls_name = mod.__class__.__name__
-                        LOGGER.info("Successfully initialized module %s" % cls_name)
-                except Exception as exc:
-                    LOGGER.exception("Error loading module %s: %s" %(module_name, exc))
diff --git a/music_assistant/modules/playerproviders/__init__.py b/music_assistant/modules/playerproviders/__init__.py
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/modules/playerproviders/chromecast.py
deleted file mode 100644 (file)
index 736528d..0000000
+++ /dev/null
@@ -1,402 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-# import os
-# from typing import List
-# import random
-# import sys
-# import json
-import aiohttp
-# import time
-# import datetime
-# import hashlib
-import pychromecast
-from pychromecast.controllers.multizone import MultizoneController
-from pychromecast.controllers import BaseController
-from pychromecast.controllers.media import MediaController
-import types
-# import urllib
-# import select
-from ...utils import run_periodic, LOGGER, try_parse_int
-from ...models.playerprovider import PlayerProvider
-from ...models.player import Player, PlayerState
-from ...constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
-
-def setup(mass):
-    ''' setup the provider'''
-    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 ChromecastPlayer(Player):
-    ''' Chromecast player object '''
-    cc = None
-
-    async def __stop(self):
-        ''' send stop command to player '''
-        self.cc.media_controller.stop()
-
-    async def __play(self):
-        ''' send play command to player '''
-        self.cc.media_controller.play()
-
-    async def __pause(self):
-        ''' send pause command to player '''
-        self.cc.media_controller.pause()
-
-    async def __power_on(self):
-        ''' send power ON command to player '''
-        self.powered = True
-
-    async def __power_off(self):
-        ''' send power OFF command to player '''
-        self.powered = False
-        # power is not supported so send quit_app instead
-        if not self.group_parent:
-            self.cc.quit_app()
-
-    async def __volume_set(self, volume_level):
-        ''' send new volume level command to player '''
-        self.cc.set_volume(volume_level/100)
-        self.volume_level = volume_level
-
-    async def __volume_mute(self, is_muted=False):
-        ''' send mute command to player '''
-        self.cc.set_volume_muted(is_muted)
-
-
-class ChromecastProvider(PlayerProvider):
-    ''' support for ChromeCast Audio '''
-    
-    def __init__(self, mass):
-        self.prov_id = 'chromecast'
-        self.name = 'Chromecast'
-        self.mass = mass
-        self._discovery_running = False
-        self.mass.event_loop.create_task(self.__periodic_chromecast_discovery())
-
-    async def __queue_load(self, player_id, new_tracks, startindex=None):
-        ''' load queue on player with given queue items '''
-        castplayer = self._chromecasts[player_id]
-        player = self._players[player_id]
-        queue_items = await self.__create_queue_items(new_tracks[:50])
-        self._player_queue_index[player_id] = 0
-        queuedata = { 
-                "type": 'QUEUE_LOAD',
-                "repeatMode":  "REPEAT_ALL" if player.repeat_enabled else "REPEAT_OFF",
-                "shuffle": player.shuffle_enabled,
-                "queueType": "PLAYLIST",
-                "startIndex":    startindex,    # Item index to play after this request or keep same item if undefined
-                "items": queue_items # only load 50 tracks at once or the socket will crash
-        }
-        await self.__send_player_queue(castplayer, queuedata)
-        await asyncio.sleep(0.2)
-        if len(new_tracks) > 50:
-            await self.__queue_insert(player_id, new_tracks[51:])
-            await asyncio.sleep(0.2)
-
-    async def __play_stream_queue(self, player_id, startindex=0):
-        ''' tell the cast player to stream our special queue (crossfaded) stream '''
-        castplayer = self._chromecasts[player_id]
-        uri = 'http://%s:%s/stream_queue?player_id=%s&startindex=%s'% (
-            self.mass.player.local_ip, self.mass.config['base']['web']['http_port'], player_id, startindex)
-        castplayer.play_media(uri, 'audio/flac')
-
-    async def __queue_insert(self, player_id, new_tracks, insert_before=None):
-        ''' insert item into the player queue '''
-        castplayer = self._chromecasts[player_id]
-        queue_items = await self.__create_queue_items(new_tracks)
-        for chunk in chunks(queue_items, 50):
-            queuedata = { 
-                        "type": 'QUEUE_INSERT',
-                        "insertBefore":     insert_before,
-                        "items":            chunk
-                }
-            await self.__send_player_queue(castplayer, queuedata)
-
-    async def __queue_update(self, player_id, queue_items_to_update):
-        ''' update the cast player queue '''
-        castplayer = self._chromecasts[player_id]
-        queuedata = { 
-                    "type": 'QUEUE_UPDATE',
-                    "items": queue_items_to_update
-            }
-        await self.__send_player_queue(castplayer, queuedata)
-
-    async def __queue_remove(self, player_id, queue_item_ids):
-        ''' remove items from the cast player queue '''
-        castplayer = self._chromecasts[player_id]
-        queuedata = { 
-                    "type": 'QUEUE_REMOVE',
-                    "items": queue_item_ids
-            }
-        await self.__send_player_queue(castplayer, queuedata)
-
-    async def __resume_queue(self, player_id):
-        ''' resume queue play after power off '''
-        LOGGER.info('resuming queue....')
-        tracks = self._player_queue[player_id]
-        await self.play_media(player_id, tracks)
-
-    async def __create_queue_items(self, tracks):
-        ''' create list of CC queue items from tracks '''
-        queue_items = []
-        for track in tracks:
-            queue_item = await self.__create_queue_item(track)
-            queue_items.append(queue_item)
-        return queue_items
-
-    async def __create_queue_item(self, track):
-        '''create queue item from track info '''
-        return {
-            'autoplay' : True,
-            'preloadTime' : 10,
-            'playbackDuration': int(track.duration),
-            'startTime' : 0,
-            'activeTrackIds' : [],
-            'media': {
-                'contentId':  track.uri,
-                'customData': {
-                    'provider': track.provider, 
-                    'uri': track.uri, 
-                    'item_id': track.item_id
-                },
-                'contentType': "audio/flac",
-                'streamType': 'BUFFERED',
-                'metadata': {
-                    'title': track.name,
-                    'artist': track.artists[0].name if track.artists else "",
-                },
-                'duration': int(track.duration)
-            }
-        }
-        
-    async def __send_player_queue(self, castplayer, queuedata):
-        '''send new data to the CC queue'''
-        media_controller = castplayer.media_controller
-        receiver_ctrl = media_controller._socket_client.receiver_controller
-        def send_queue():
-                """Plays media after chromecast has switched to requested app."""
-                queuedata['mediaSessionId'] = media_controller.status.media_session_id
-                media_controller.send_message(queuedata, inc_session_id=False)
-        if not media_controller.status.media_session_id:
-            receiver_ctrl.launch_app(media_controller.app_id, callback_function=send_queue)
-        else:
-            send_queue()
-        await asyncio.sleep(0.2)
-
-    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.get_player(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
-        if mediastatus:
-            # chromecast does not support power on/of so we only set state
-            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
-            if not mediastatus.content_id:
-                player.cur_item = None
-                player.cur_item_time = 0
-            elif not 'stream_queue' in mediastatus.content_id:
-                player.cur_item = await self.__parse_track(mediastatus)
-                player.cur_item_time =  mediastatus.adjusted_current_time
-                self._player_queue_index[player_id] = await self.__get_cur_queue_index(player_id, mediastatus.content_id)
-            elif 'stream_queue' in mediastatus.content_id:
-                # player is playing our special queue continuous stream
-                # try to work out the current time
-                # player is playing a constant stream of the queue so we need to do this the hard way
-                cur_time_queue = mediastatus.adjusted_current_time
-                total_time = 0
-                track_time = 0
-                queue_index = self._player_queue_stream_startindex[player_id]
-                queue_track = None
-                while True:
-                    queue_track = self._player_queue[player_id][queue_index]
-                    if cur_time_queue > (queue_track.duration + total_time):
-                        total_time += queue_track.duration
-                        queue_index += 1
-                    else:
-                        track_time = cur_time_queue - total_time
-                        break
-                player.cur_item = queue_track
-                player.cur_item_time = track_time
-                self._player_queue_index[player_id] = queue_index
-
-    async def __handle_group_members_update(self, mz, added_player=None, removed_player=None):
-        ''' callback when cast group members update '''
-        if added_player:
-            player = self.get_player(added_player)
-            group_player = self.get_player(str(mz._uuid))
-            if player and group_player:
-                player.group_parent = str(mz._uuid)
-                LOGGER.debug("player %s added to group %s" %(player.name, group_player.name))
-        elif removed_player:
-            player = self.get_player(added_player)
-            group_player = self.get_player(str(mz._uuid))
-            if player and group_player:
-                player.group_parent = None
-                LOGGER.debug("player %s removed from group %s" %(player.name, group_player.name))
-        else:
-            for member in mz.members:
-                player = self.get_player(member)
-                if player:
-                    player.group_parent = str(mz._uuid)
-    
-    @run_periodic(1800)
-    async def __periodic_chromecast_discovery(self):
-        ''' run chromecast discovery on interval '''
-        await self.__chromecast_discovery()
-
-    async def __chromecast_discovery(self):
-        ''' background non-blocking chromecast discovery and handler '''
-        if self._discovery_running:
-            return
-        self._discovery_running = True
-        LOGGER.info("Chromecast discovery started...")
-        # remove any disconnected players...
-        removed_players = []
-        for player in self.players:
-            if not player.cc.socket_client or not player.cc.socket_client.is_connected:
-                LOGGER.info("%s is disconnected" % player.name)
-                # cleanup cast object
-                del player.cc
-                removed_players.append(player.player_id)
-        # signal removed players
-        for player_id in removed_players:
-            await self.remove_player(player_id)
-        # search for available chromecasts
-        from pychromecast.discovery import start_discovery, stop_discovery
-        def discovered_callback(name):
-            """Called when zeroconf has discovered a (new) chromecast."""
-            discovery_info = listener.services[name]
-            ip_address, port, uuid, model_name, friendly_name = discovery_info
-            player_id = str(uuid)
-            if not self.get_player(player_id):
-                LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port))
-                asyncio.run_coroutine_threadsafe(
-                        self.__chromecast_discovered(player_id, discovery_info), self.mass.event_loop)
-        listener, browser = start_discovery(discovered_callback)
-        await asyncio.sleep(15) # run discovery for 15 seconds
-        stop_discovery(browser)
-        LOGGER.info("Chromecast discovery completed...")
-        self._discovery_running = False
-    
-    async def __chromecast_discovered(self, player_id, discovery_info):
-        ''' callback when a (new) chromecast device is discovered '''
-        from pychromecast import _get_chromecast_from_host, ChromecastConnectionError
-        try:
-            chromecast = _get_chromecast_from_host(discovery_info, tries=2, retry_wait=5)
-        except ChromecastConnectionError:
-            LOGGER.warning("Could not connect to device %s" % player_id)
-            return
-        # patch the receive message method for handling queue status updates
-        chromecast.media_controller.queue_items = []
-        chromecast.media_controller.queue_cur_id = None
-        chromecast.media_controller.receive_message = types.MethodType(receive_message, chromecast.media_controller)
-        listenerCast = StatusListener(chromecast, self.__handle_player_state, self.mass.event_loop)
-        chromecast.register_status_listener(listenerCast)
-        listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop)
-        chromecast.media_controller.register_status_listener(listenerMedia)
-        player = ChromecastPlayer(self.mass, player_id, self.prov_id)
-        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.mz = mz
-        player.cc = chromecast
-        player.cc.wait()
-        self.add_player(player)
-        self.update_all_group_members()
-
-    def update_all_group_members(self):
-        ''' force member update of all cast groups '''
-        for player in self.players:
-            if player.cc.cast_type == 'group':
-                player.cc.mz.update_members()
-
-def chunks(l, n):
-    """Yield successive n-sized chunks from l."""
-    for i in range(0, len(l), n):
-        yield l[i:i + n]
-
-
-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)
-
-def receive_message(self, message, data):
-    """ Called when a media message is received. """
-    #LOGGER.info('message: %s - data: %s'%(message, data))
-    if data['type'] == 'MEDIA_STATUS':
-        try:
-            self.queue_items = data['status'][0]['items']
-        except:
-            pass
-        try:
-            self.queue_cur_id = data['status'][0]['currentItemId']
-        except:
-            pass
-        self._process_media_status(data)
-        return True
-    return False
\ No newline at end of file
diff --git a/music_assistant/modules/playerproviders/lms.py b/music_assistant/modules/playerproviders/lms.py
deleted file mode 100644 (file)
index 13db3f7..0000000
+++ /dev/null
@@ -1,320 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from typing import List
-import random
-import sys
-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 modules.cache import use_cache
-import copy
-import urllib
-
-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, False, 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.last_msg_received = 0
-        self.supported_musicproviders = ['qobuz', 'file', 'spotify', 'http']
-        self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
-        # we use a combi of active polling and subscriptions because the cometd implementation of LMS is somewhat unreliable
-        asyncio.ensure_future(self.__lms_events())
-        asyncio.ensure_future(self.__get_players())            
-
-    ### Provider specific implementation #####
-
-    async def player_config_entries(self):
-        ''' get the player config entries for this provider (list with key/value pairs)'''
-        return []
-
-    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 == 'off':
-            lms_commands = ['power', '0']
-        elif cmd == 'power':
-            lms_commands = ['power', '1']
-        elif cmd == 'volume':
-            lms_commands = ['mixer', 'volume', cmd_args]
-        elif cmd == 'mute' and cmd_args == 'off':
-            lms_commands = ['mixer', 'muting', '0']
-        elif cmd == 'mute':
-            lms_commands = ['mixer', 'muting', '1']
-        return await self.__get_data(lms_commands, player_id=player_id)
-
-    async def play_media(self, player_id, media_items, queue_opt='play'):
-        ''' 
-            play media on a player
-        '''
-        if queue_opt == 'play':
-            cmd = ['playlist', 'insert', media_items[0].uri]
-            await self.__get_data(cmd, player_id=player_id)
-            cmd = ['playlist', 'index', '+1']
-            await self.__get_data(cmd, player_id=player_id)
-            for track in media_items[1:]:
-                cmd = ['playlist', 'insert', track.uri]
-                await self.__get_data(cmd, player_id=player_id)
-        elif queue_opt == 'replace':
-            cmd = ['playlist', 'play', media_items[0].uri]
-            await self.__get_data(cmd, player_id=player_id)
-            for track in media_items[1:]:
-                cmd = ['playlist', 'add', track.uri]
-                await self.__get_data(cmd, player_id=player_id)
-        elif queue_opt == 'next':
-            for track in media_items:
-                cmd = ['playlist', 'insert', track.uri]
-                await self.__get_data(cmd, player_id=player_id)
-        else:
-            for track in media_items:
-                cmd = ['playlist', 'add', track.uri]
-                await self.__get_data(cmd, player_id=player_id)
-    
-    async def player_queue(self, player_id, offset=0, limit=50):
-        ''' return the items in the player's queue '''
-        items = []
-        player_details = await self.__get_data(["status", offset, limit, "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id)
-        if 'playlist_loop' in player_details:
-            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
-        if 'power' in player_details:
-            player.powered = player_details['power'] == 1
-        else:
-            print(player_details) # DEBUG
-        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:
-                    if child_player_id in self._players:
-                        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)
-        elif track_url.startswith('http') and '/stream' in track_url:
-            params = urllib.parse.parse_qs(track_url.split('?')[1])
-            track_id = params['track_id'][0]
-            provider = params['provider'][0]
-            return await self.mass.music.providers[provider].track(track_id)
-        # fallback to a generic track
-        track = Track()
-        track.name = track_details['title']
-        track.duration = int(track_details['duration'])
-        if 'artwork_url' in track_details:
-            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 self.mass.event_loop.is_running():
-            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/modules/playerproviders/pylms.py b/music_assistant/modules/playerproviders/pylms.py
deleted file mode 100644 (file)
index 8c67b9b..0000000
+++ /dev/null
@@ -1,807 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-import struct
-from collections import OrderedDict
-import time
-import decimal
-from typing import List
-import random
-import sys
-import socket
-from utils import run_periodic, LOGGER, parse_track_title, try_parse_int, get_ip, get_hostname
-from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
-from constants import CONF_ENABLED
-
-
-def setup(mass):
-    ''' setup the provider'''
-    enabled = mass.config["playerproviders"]['pylms'].get(CONF_ENABLED)
-    if enabled:
-        provider = PyLMSServer(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 PyLMSServer(PlayerProvider):
-    ''' Python implementation of SlimProto server '''
-
-    def __init__(self, mass):
-        self.prov_id = 'pylms'
-        self.name = 'Logitech Media Server Emulation'
-        self.mass = mass
-        self._lmsplayers = {}
-        self.buffer = b''
-        self.last_msg_received = 0
-        self.supported_musicproviders = ['http']
-        
-        # start slimproto server
-        mass.event_loop.create_task(asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483))
-        # setup discovery
-        mass.event_loop.create_task(self.start_discovery())
-
-     ### Provider specific implementation #####
-
-    
-    async def start_discovery(self):
-        transport, protocol = await self.mass.event_loop.create_datagram_endpoint(
-            lambda: DiscoveryProtocol(self.mass.web._http_port),
-        local_addr=('0.0.0.0', 3483))
-        try:
-            while True:
-                await asyncio.sleep(60)  # serve forever
-        finally:
-            transport.close()
-
-    async def player_config_entries(self):
-        ''' get the player config entries for this provider (list with key/value pairs)'''
-        return [
-            ("crossfade_duration", 0, "crossfade_duration"),
-            ]
-
-    async def player_command(self, player_id, cmd:str, cmd_args=None):
-        ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) '''
-        if cmd == 'play':
-            if self._players[player_id].state == PlayerState.Stopped:
-                await self.__queue_play(player_id, None)
-            else:
-                self._lmsplayers[player_id].unpause()
-        elif cmd == 'pause':
-            self._lmsplayers[player_id].pause()
-        elif cmd == 'stop':
-            self._lmsplayers[player_id].stop()
-        elif cmd == 'next':
-            self._lmsplayers[player_id].next()
-        elif cmd == 'previous':
-             await self.__queue_previous(player_id)
-        elif cmd == 'power' and cmd_args == 'off':
-            self._lmsplayers[player_id].power_off()
-        elif cmd == 'power':
-            self._lmsplayers[player_id].power_on()
-        elif cmd == 'volume':
-            self._lmsplayers[player_id].volume_set(try_parse_int(cmd_args))
-        elif cmd == 'mute' and cmd_args == 'off':
-            self._lmsplayers[player_id].unmute()
-        elif cmd == 'mute':
-            self._lmsplayers[player_id].mute()
-    
-    async def play_media(self, player_id, media_items, queue_opt='play'):
-        ''' 
-            play media on a player
-        '''
-        player = self.get_player(player_id)
-        cur_index = player.cur_queue_index
-
-        if queue_opt == 'replace' or not player.queue:
-            # overwrite queue with new items
-            player.queue = media_items
-            await self.__queue_play(player_id, 0, send_flush=True)
-        elif queue_opt == 'play':
-            # replace current item with new item(s)
-            player.queue = player.queue[player_id][:cur_index] + media_items + player.queue[player_id][cur_index+1:]
-            await self.__queue_play(player_id, cur_index, send_flush=True)
-        elif queue_opt == 'next':
-            # insert new items at current index +1
-            player.queue[player_id] = player.queue[player_id][:cur_index+1] + media_items + player.queue[player_id][cur_index+1:]
-        elif queue_opt == 'add':
-            # add new items at end of queue
-            player.queue[player_id] = player.queue[player_id] + media_items
-
-    ### Provider specific (helper) methods #####
-
-    async def __queue_play(self, player_id, index, send_flush=False):
-        ''' send play command to player '''
-        if not player_id in player.queue or not player_id in player.queue_index:
-            return
-        if not player.queue[player_id]:
-            return
-        if index == None:
-            index = player.queue_index[player_id]
-        if len(player.queue[player_id]) >= index:
-            track = player.queue[player_id][index]
-            if send_flush:
-                self._lmsplayers[player_id].flush()
-            self._lmsplayers[player_id].play(track.uri)
-            player.queue_index[player_id] = index
-
-    async def __queue_next(self, player_id):
-        ''' request next track from queue '''
-        if not player_id in player.queue or not player_id in player.queue:
-            return
-        cur_queue_index = player.queue_index[player_id]
-        if len(player.queue[player_id]) > cur_queue_index:
-            new_queue_index = cur_queue_index + 1
-        elif self._players[player_id].repeat_enabled:
-            new_queue_index = 0
-        else:
-            LOGGER.warning("next track requested but no more tracks in queue")
-            return
-        return await self.__queue_play(player_id, new_queue_index)
-
-    async def __queue_previous(self, player_id):
-        ''' request previous track from queue '''
-        if not player_id in player.queue:
-            return
-        cur_queue_index = player.queue_index[player_id]
-        if cur_queue_index == 0 and len(player.queue[player_id]) > 1:
-            new_queue_index = len(player.queue[player_id]) -1
-        elif cur_queue_index == 0:
-            new_queue_index = cur_queue_index
-        else:
-            new_queue_index -= 1
-            player.queue_index[player_id] = new_queue_index
-        return await self.__queue_play(player_id, new_queue_index)
-
-    async def __handle_player_event(self, player_id, event, event_data=None):
-        ''' handle event from player '''
-        if not player_id:
-            return
-        LOGGER.debug("Event from player %s: %s - event_data: %s" %(player_id, event, str(event_data)))
-        lms_player = self._lmsplayers[player_id]
-        if event == "next_track":
-            return await self.__queue_next(player_id)
-        player 
-        if not player_id in self._players:
-            player = MusicPlayer()
-            player.player_id = player_id
-            player.player_provider = self.prov_id
-            self._players[player_id] = player
-            if not player_id in player.queue:
-                player.queue[player_id] = []
-            if not player_id in player.queue_index:
-                player.queue_index[player_id] = 0
-        else:
-            player = self._players[player_id]
-        # update player properties
-        player.name = lms_player.player_name
-        player.volume_level = lms_player.volume_level
-        player.cur_item_time = lms_player._elapsed_seconds
-        if event == "disconnected":
-            return await self.mass.player.remove_player(player_id)
-        elif event == "power":
-            player.powered = event_data
-        elif event == "state":
-            player.state = event_data
-        if player.queue[player_id]:
-            cur_queue_index = player.queue_index[player_id]
-            player.cur_item = player.queue[player_id][cur_queue_index]
-        # update player details
-        await self.mass.player.update_player(player)
-
-    async def __handle_socket_client(self, reader, writer):
-        ''' handle a client connection on the socket'''
-        LOGGER.debug("new socket client connected")
-        stream_host = get_ip()
-        stream_port = self.mass.config['base']['web']['http_port']
-        lms_player = PyLMSPlayer(stream_host, stream_port)
-
-        def send_frame(command, data):
-            ''' send command to lms player'''
-            packet = struct.pack('!H', len(data) + 4) + command + data
-            writer.write(packet)
-        
-        def handle_event(event, event_data=None):
-            ''' handle events from player'''
-            if event == "connected":
-                self._lmsplayers[lms_player.player_id] = lms_player
-                lms_player.player_settings = self.mass.config['player_settings'][lms_player.player_id]
-            asyncio.create_task(self.__handle_player_event(lms_player.player_id, event, event_data))
-
-        try:
-            @run_periodic(5)
-            async def send_heartbeat():
-                timestamp = int(time.time())
-                data = lms_player.pack_stream(b"t", replayGain=timestamp, flags=0)
-                lms_player.send_frame(b"strm", data)
-
-            lms_player.send_frame = send_frame
-            lms_player.send_event = handle_event
-            heartbeat_task = asyncio.create_task(send_heartbeat())
-            
-            # keep reading bytes from the socket
-            while True:
-                data = await reader.read(64)
-                if data:
-                    lms_player.dataReceived(data)
-                else:
-                    break
-        except Exception as exc:
-            # connection lost ?
-            LOGGER.warning(exc)
-        # disconnect
-        heartbeat_task.cancel()
-        asyncio.create_task(self.__handle_player_event(lms_player.player_id, 'disconnected'))
-
-
-class PyLMSPlayer(object):
-    ''' very basic Python implementation of SlimProto '''
-
-    def __init__(self, stream_host, stream_port):
-        self.buffer = b''
-        #self.display = Display()
-        self.send_frame = None
-        self.send_event = None
-        self.stream_host = stream_host
-        self.stream_port = stream_port
-        self.player_settings = {}
-        self.playback_millis = 0
-        self._volume = PyLMSVolume()
-        self._device_type = None
-        self._mac_address = None
-        self._player_name = None
-        self._last_volume = 0
-        self._last_heartbeat = 0
-        self._elapsed_seconds = 0
-        self._elapsed_milliseconds = 0
-
-    @property
-    def player_name(self):
-        if self._player_name:
-            return self._player_name
-        return "%s - %s" %(self._device_type, self._mac_address)
-
-    @property
-    def player_id(self):
-        return self._mac_address
-
-    @property
-    def volume_level(self):
-        return self._volume.volume
-    
-    def dataReceived(self, data):
-        self.buffer = self.buffer + data
-        if len(self.buffer) > 8:
-            operation, length = self.buffer[:4], self.buffer[4:8]
-            length = struct.unpack('!I', length)[0]
-            plen = length + 8
-            if len(self.buffer) >= plen:
-                packet, self.buffer = self.buffer[8:plen], self.buffer[plen:]
-                operation = operation.strip(b"!").strip().decode()
-                #LOGGER.info("operation: %s" % operation)
-                handler = getattr(self, "process_%s" % operation, None)
-                if handler is None:
-                    raise NotImplementedError
-                handler(packet)
-
-    def send_version(self):
-        self.send_frame(b'vers', b'7.8')
-
-    def pack_stream(self, command, autostart=b"1", formatbyte = b'o', pcmargs = (b'?',b'?',b'?',b'?'), threshold = 200,
-                    spdif = b'0', transDuration = 0, transType = b'0', flags = 0x40, outputThreshold = 0,
-                    replayGain=0, serverPort = 8095, serverIp = 0):
-        return struct.pack("!cccccccBcBcBBBLHL",
-                           command, autostart, formatbyte, *pcmargs,
-                           threshold, spdif, transDuration, transType,
-                           flags, outputThreshold, 0, replayGain, serverPort, serverIp)
-
-    def stop(self):
-        data = self.pack_stream(b"q", autostart=b"0", flags=0)
-        self.send_frame(b"strm", data)
-
-    def flush(self):
-        data = self.pack_stream(b"f", autostart=b"0", flags=0)
-        self.send_frame(b"strm", data)
-
-    def pause(self):
-        data = self.pack_stream(b"p", autostart=b"0", flags=0)
-        self.send_frame(b"strm", data)
-        LOGGER.info("Sending pause request")
-
-    def unpause(self):
-        data = self.pack_stream(b"u", autostart=b"0", flags=0)
-        self.send_frame(b"strm", data)
-        LOGGER.info("Sending unpause request")
-
-    def next(self):
-        data = self.pack_stream(b"f", autostart=b"0", flags=0)
-        self.send_frame(b"strm", data)
-        self.send_event("next_track")
-
-    def previous(self):
-        data = self.pack_stream(b"f", autostart=b"0", flags=0)
-        self.send_frame(b"strm", data)
-        self.send_event("previous_track")
-
-    def power_on(self):
-        self.send_frame(b"aude", struct.pack("2B", 1, 1))
-        self.send_event("power", True)
-
-    def power_off(self):
-        self.stop()
-        self.send_frame(b"aude", struct.pack("2B", 0, 0))
-        self.send_event("power", False)
-
-    def mute_on(self):
-        self.send_frame(b"aude", struct.pack("2B", 0, 0))
-        self.send_event("mute", True)
-
-    def mute_off(self):
-        self.send_frame(b"aude", struct.pack("2B", 1, 1))
-        self.send_event("mute", False)
-
-    def volume_up(self):
-        self._volume.increment()
-        self.send_volume()
-
-    def volume_down(self):
-        self._volume.decrement()
-        self.send_volume()
-
-    def volume_set(self, new_vol):
-        self._volume.volume = new_vol
-        self.send_volume()
-    
-    def play(self, uri):
-        enable_crossfade = self.player_settings["crossfade_duration"] > 0
-        command = b's'
-        autostart = b'3' # we use direct stream for now so let the player do the messy work with buffers
-        transType= b'1' if enable_crossfade else b'0'
-        transDuration = self.player_settings["crossfade_duration"]
-        formatbyte = b'f' # fixed to flac
-        uri = '/stream' + uri.split('/stream')[1]
-        data = self.pack_stream(command, autostart=autostart, flags=0x00, formatbyte=formatbyte, transType=transType, transDuration=transDuration)
-        headers = "Connection: close\r\nAccept: */*\r\nHost: %s:%s\r\n" %(self.stream_host, self.stream_port)
-        request = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers)
-        data = data + request.encode("utf-8")
-        self.send_frame(b'strm', data)
-        LOGGER.info("Requesting play from squeezebox" )
-
-    def displayTrack(self, track):
-        self.render("%s by %s" % (track.title, track.artist))
-
-    def process_HELO(self, data):
-        (devId, rev, mac) = struct.unpack('BB6s', data[:8])
-        device_mac = ':'.join("%02x" % x for x in mac)
-        self._device_type = devices.get(devId, 'unknown device')
-        self._mac_address = str(device_mac).lower()
-        LOGGER.debug("HELO received from %s %s" % (self._mac_address, self._device_type))
-        self.init_client()
-
-    def init_client(self):
-        ''' initialize a new connected client '''
-        self.send_event("connected")
-        self.send_version()
-        self.stop()
-        self.setBrightness()
-        #self.set_visualisation(SpectrumAnalyser())
-        self.send_frame(b"setd", struct.pack("B", 0))
-        self.send_frame(b"setd", struct.pack("B", 4))
-        self.power_on()
-        self.volume_set(40) # TODO: remember last volume
-        
-    def send_volume(self):
-        og = self._volume.old_gain()
-        ng = self._volume.new_gain()
-        LOGGER.info("Volume set to %d (%d/%d)" % (self._volume.volume, og, ng))
-        d = self.send_frame(b"audg", struct.pack("!LLBBLL", og, og, 1, 255, ng, ng))
-        self.send_event("volume", self._volume.volume)
-
-    def setBrightness(self, level=4):
-        assert 0 <= level <= 4
-        self.send_frame(b"grfb", struct.pack("!H", level))
-
-    def set_visualisation(self, visualisation):
-        self.send_frame(b"visu", visualisation.pack())
-
-    def render(self, text):
-        #self.display.clear()
-        #self.display.renderText(text, "DejaVu-Sans", 16, (0,0))
-        #self.updateDisplay(self.display.frame())
-        pass
-
-    def updateDisplay(self, bitmap, transition = 'c', offset=0, param=0):
-        frame = struct.pack("!Hcb", offset, transition, param) + bitmap
-        self.send_frame(b"grfe", frame)
-
-    def process_STAT(self, data):
-        ev = data[:4]
-        if ev == b'\x00\x00\x00\x00':
-            LOGGER.info("Presumed informational stat message")
-        else:
-            handler = getattr(self, 'stat_%s' % ev.decode(), None)
-            if handler is None:
-                raise NotImplementedError("Stat message %r not known" % ev)
-            handler(data[4:])
-
-    def stat_aude(self, data):
-        (spdif_enable, dac_enable) = struct.unpack("2B", data[:4])
-        powered = spdif_enable or dac_enable
-        self.send_event("power", powered)
-        LOGGER.debug("ACK aude - Received player power: %s" % powered)
-
-    def stat_audg(self, data):
-        LOGGER.info("Received volume_level from player %s" % data)
-        self.send_event("volume", self._volume.volume)
-
-    def stat_strm(self, data):
-        LOGGER.debug("ACK strm")
-        #self.send_frame(b"cont", b"0")
-
-    def stat_STMc(self, data):
-        LOGGER.debug("Status Message: Connect")
-
-    def stat_STMd(self, data):
-        LOGGER.debug("Decoder Ready for next track")
-        self.send_event("next_track")
-
-    def stat_STMe(self, data):
-        LOGGER.info("Connection established")
-
-    def stat_STMf(self, data):
-        LOGGER.info("Status Message: Connection closed")
-        self.send_event("state", PlayerState.Stopped)
-
-    def stat_STMh(self, data):
-        LOGGER.info("Status Message: End of headers")
-
-    def stat_STMn(self, data):
-        LOGGER.error("Decoder does not support file format")
-
-    def stat_STMo(self, data):
-        ''' No more decoded (uncompressed) data to play; triggers rebuffering. '''
-        LOGGER.debug("Output Underrun")
-        
-    def stat_STMp(self, data):
-        '''Pause confirmed'''
-        self.send_event("state", PlayerState.Paused)
-
-    def stat_STMr(self, data):
-        '''Resume confirmed'''
-        self.send_event("state", PlayerState.Playing)
-
-    def stat_STMs(self, data):
-        '''Playback of new track has started'''
-        self.send_event("state", PlayerState.Playing)
-
-    def stat_STMt(self, data):
-        """ heartbeat from client """
-        timestamp = time.time()
-        self._last_heartbeat = timestamp
-        (num_crlf, mas_initialized, mas_mode, rptr, wptr, 
-        bytes_received_h, bytes_received_l, signal_strength, 
-        jiffies, output_buffer_size, output_buffer_fullness, 
-        elapsed_seconds, voltage, elapsed_milliseconds, 
-        server_timestamp, error_code) = struct.unpack("!BBBLLLLHLLLLHLLH", data)
-        if elapsed_seconds != self._elapsed_seconds:
-            self.send_event("progress")
-        self._elapsed_seconds = elapsed_seconds
-        self._elapsed_milliseconds = elapsed_milliseconds
-
-    def stat_STMu(self, data):
-        '''Normal end of playback'''
-        LOGGER.info("End of playback - Underrun")
-        self.send_event("state", PlayerState.Stopped)
-
-    def process_BYE(self, data):
-        LOGGER.info("BYE received")
-        self.send_event("disconnected")
-
-    def process_RESP(self, data):
-        LOGGER.info("RESP received")
-        self.send_frame(b"cont", b"0")
-
-    def process_BODY(self, data):
-        LOGGER.info("BODY received")
-
-    def process_META(self, data):
-        LOGGER.info("META received")
-
-    def process_DSCO(self, data):
-        LOGGER.info("Data Stream Disconnected")
-
-    def process_DBUG(self, data):
-        LOGGER.info("DBUG received")
-
-    def process_IR(self, data):
-        """ Slightly involved codepath here. This raises an event, which may
-        be picked up by the service and then the process_remote_* function in
-        this player will be called. This is mostly relevant for volume changes
-        - most other button presses will require some context to operate. """
-        (time, code) = struct.unpack("!IxxI", data)
-        LOGGER.info("IR code %s" % code)
-        # command = Remote.codes.get(code, None)
-        # if command is not None:
-        #     LOGGER.info("IR received: %r, %r" % (code, command))
-        #     #self.service.evreactor.fireEvent(RemoteButtonPressed(self, command))
-        # else:
-        #     LOGGER.info("Unknown IR received: %r, %r" % (time, code))
-
-    def process_RAWI(self, data):
-        LOGGER.info("RAWI received")
-
-    def process_ANIC(self, data):
-        LOGGER.info("ANIC received")
-
-    def process_BUTN(self, data):
-        LOGGER.info("BUTN received")
-
-    def process_KNOB(self, data):
-        ''' Transporter only, knob-related '''
-        LOGGER.info("KNOB received")
-
-    def process_SETD(self, data):
-        ''' Get/set player firmware settings '''
-        LOGGER.debug("SETD received %s" % data)
-        cmd_id = data[0]
-        if cmd_id == 0:
-            # received player name
-            data = data[1:].decode()
-            self._player_name = data
-            self.send_event("name")
-
-    def process_UREQ(self, data):
-        LOGGER.info("UREQ received")
-
-
-
-# from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO
-devices = {
-    2: 'squeezebox',
-    3: 'softsqueeze',
-    4: 'squeezebox2',
-    5: 'transporter',
-    6: 'softsqueeze3',
-    7: 'receiver',
-    8: 'squeezeslave',
-    9: 'controller',
-    10: 'boom',
-    11: 'softboom',
-    12: 'squeezeplay',
-    }
-
-
-class PyLMSVolume(object):
-
-    """ Represents a sound volume. This is an awful lot more complex than it
-    sounds. """
-
-    minimum = 0
-    maximum = 100
-    step = 1
-
-    # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source
-    # i don't know how much magic it contains, or any way I can test it
-    old_map = [
-        0, 1, 1, 1, 2, 2, 2, 3,  3,  4,
-        5, 5, 6, 6, 7, 8, 9, 9, 10, 11,
-        12, 13, 14, 15, 16, 16, 17, 18, 19, 20,
-        22, 23, 24, 25, 26, 27, 28, 29, 30, 32,
-        33, 34, 35, 37, 38, 39, 40, 42, 43, 44,
-        46, 47, 48, 50, 51, 53, 54, 56, 57, 59,
-        60, 61, 63, 65, 66, 68, 69, 71, 72, 74,
-        75, 77, 79, 80, 82, 84, 85, 87, 89, 90,
-        92, 94, 96, 97, 99, 101, 103, 104, 106, 108, 110,
-        112, 113, 115, 117, 119, 121, 123, 125, 127, 128
-        ];
-
-    # new gain parameters, from the same place
-    total_volume_range = -50 # dB
-    step_point = -1           # Number of steps, up from the bottom, where a 2nd volume ramp kicks in.
-    step_fraction = 1         # fraction of totalVolumeRange where alternate volume ramp kicks in.
-
-    def __init__(self):
-        self.volume = 50
-
-    def increment(self):
-        """ Increment the volume """
-        self.volume += self.step
-        if self.volume > self.maximum:
-            self.volume = self.maximum
-
-    def decrement(self):
-        """ Decrement the volume """
-        self.volume -= self.step
-        if self.volume < self.minimum:
-            self.volume = self.minimum
-
-    def old_gain(self):
-        """ Return the "Old" gain value as required by the squeezebox """
-        return self.old_map[self.volume]
-
-    def decibels(self):
-        """ Return the "new" gain value. """
-
-        step_db = self.total_volume_range * self.step_fraction
-        max_volume_db = 0 # different on the boom?
-
-        # Equation for a line:
-        # y = mx+b
-        # y1 = mx1+b, y2 = mx2+b.
-        # y2-y1 = m(x2 - x1)
-        # y2 = m(x2 - x1) + y1
-        slope_high = max_volume_db - step_db / (100.0 - self.step_point)
-        slope_low = step_db - self.total_volume_range / (self.step_point - 0.0)
-        x2 = self.volume
-        if (x2 > self.step_point):
-            m = slope_high
-            x1 = 100
-            y1 = max_volume_db
-        else:
-            m = slope_low
-            x1 = 0
-            y1 = self.total_volume_range
-        return m * (x2 - x1) + y1
-
-    def new_gain(self):
-        db = self.decibels()
-        floatmult = 10 ** (db/20.0)
-        # avoid rounding errors somehow
-        if -30 <= db <= 0:
-            return int(floatmult * (1 << 8) + 0.5) * (1<<8)
-        else:
-            return int((floatmult * (1<<16)) + 0.5)
-
-
-##### UDP DISCOVERY STUFF #############
-
-class Datagram(object):
-
-    @classmethod
-    def decode(self, data):
-        if data[0] == 'e':
-            return TLVDiscoveryRequestDatagram(data)
-        elif data[0] == 'E':
-            return TLVDiscoveryResponseDatagram(data)
-        elif data[0] == 'd':
-            return ClientDiscoveryDatagram(data)
-        elif data[0] == 'h':
-            pass # Hello!
-        elif data[0] == 'i':
-            pass # IR
-        elif data[0] == '2':
-            pass # i2c?
-        elif data[0] == 'a':
-            pass # ack!
-
-class ClientDiscoveryDatagram(Datagram):
-
-    device = None
-    firmware = None
-    client = None
-
-    def __init__(self, data):
-        s = struct.unpack('!cxBB8x6B', data.encode())
-        assert  s[0] == 'd'
-        self.device = s[1]
-        self.firmware = hex(s[2])
-        self.client = ":".join(["%02x" % (x,) for x in s[3:]])
-
-    def __repr__(self):
-        return "<%s device=%r firmware=%r client=%r>" % (self.__class__.__name__, self.device, self.firmware, self.client)
-
-class DiscoveryResponseDatagram(Datagram):
-
-    def __init__(self, hostname, port):
-        hostname = hostname[:16].encode("UTF-8")
-        hostname += (16 - len(hostname)) * '\x00'
-        self.packet = struct.pack('!c16s', 'D', hostname).decode()
-
-class TLVDiscoveryRequestDatagram(Datagram):
-    
-    def __init__(self, data):
-        requestdata = OrderedDict()
-        assert data[0] == 'e'
-        idx = 1
-        length = len(data)-5
-        while idx <= length:
-            typ, l = struct.unpack_from("4sB", data.encode(), idx)
-            if l:
-                val = data[idx+5:idx+5+l]
-                idx += 5+l
-            else:
-                val = None
-                idx += 5
-            typ = typ.decode()
-            requestdata[typ] = val
-        self.data = requestdata
-            
-    def __repr__(self):
-        return "<%s data=%r>" % (self.__class__.__name__, self.data.items())
-
-class TLVDiscoveryResponseDatagram(Datagram):
-
-    def __init__(self, responsedata):
-        parts = ['E'] # new discovery format
-        for typ, value in responsedata.items():
-            if value is None:
-                value = ''
-            elif len(value) > 255:
-                LOGGER.warning("Response %s too long, truncating to 255 bytes" % typ)
-                value = value[:255]
-            parts.extend((typ, chr(len(value)), value))
-        self.packet = ''.join(parts)
-
-class DiscoveryProtocol():
-
-    def __init__(self, web_port):
-        self.web_port = web_port
-    
-    def connection_made(self, transport):
-        self.transport = transport
-        # Allow receiving multicast broadcasts
-        sock = self.transport.get_extra_info('socket')
-        group = socket.inet_aton('239.255.255.250')
-        mreq = struct.pack('4sL', group, socket.INADDR_ANY)
-        sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
-    
-    def build_TLV_response(self, requestdata):
-        responsedata = OrderedDict()
-        for typ, value in requestdata.items():
-            if typ == 'NAME':
-                # send full host name - no truncation
-                value = get_hostname()
-            elif typ == 'IPAD':
-                # send ipaddress as a string only if it is set
-                value = get_ip()
-                # :todo: IPv6
-                if value == '0.0.0.0':
-                    # do not send back an ip address
-                    typ = None
-            elif typ == 'JSON':
-                # send port as a string
-                json_port = self.web_port
-                value = str(json_port)
-            elif typ == 'VERS':
-                # send server version
-                 value = '7.9'
-            elif typ == 'UUID':
-                # send server uuid
-                value = 'musicassistant'
-            else:
-                LOGGER.debug('Unexpected information request: %r', typ)
-                typ = None
-            if typ:
-                responsedata[typ] = value
-        return responsedata
-
-    def datagram_received(self, data, addr):
-        try:
-            data = data.decode()
-            dgram = Datagram.decode(data)
-            LOGGER.debug("Data received from %s: %s" % (addr, dgram))
-            if isinstance(dgram, ClientDiscoveryDatagram):
-                self.sendDiscoveryResponse(addr)
-            elif isinstance(dgram, TLVDiscoveryRequestDatagram):
-                resonsedata = self.build_TLV_response(dgram.data)
-                self.sendTLVDiscoveryResponse(resonsedata, addr)
-        except Exception as exc:
-            LOGGER.exception(exc)
-
-    def sendDiscoveryResponse(self, addr):
-        dgram = DiscoveryResponseDatagram(get_hostname(), 3483)
-        LOGGER.debug("Sending discovery response %r" % (dgram.packet,))
-        self.transport.sendto(dgram.packet.encode(), addr)
-
-    def sendTLVDiscoveryResponse(self, resonsedata, addr):
-        dgram = TLVDiscoveryResponseDatagram(resonsedata)
-        LOGGER.debug("Sending discovery response %r" % (dgram.packet,))
-        self.transport.sendto(dgram.packet.encode(), addr)
-
diff --git a/music_assistant/modules/web.py b/music_assistant/modules/web.py
deleted file mode 100755 (executable)
index 6761d6c..0000000
+++ /dev/null
@@ -1,323 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from utils import run_periodic, LOGGER, run_async_background_task
-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__)
-import ssl
-import concurrent
-import threading
-
-def setup(mass):
-    ''' setup the module and read/apply config'''
-    create_config_entries(mass.config)
-    conf = mass.config['base']['web']
-    if conf['ssl_certificate'] and os.path.isfile(conf['ssl_certificate']):
-        ssl_cert = conf['ssl_certificate']
-    else:
-        ssl_cert = ''
-    if conf['ssl_key'] and os.path.isfile(conf['ssl_key']):
-        ssl_key = conf['ssl_key']
-    else:
-        ssl_key = ''
-    cert_fqdn_host = conf['cert_fqdn_host']
-    http_port = conf['http_port']
-    https_port = conf['https_port']
-    return Web(mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host)
-
-def create_config_entries(config):
-    ''' get the config entries for this module (list with key/value pairs)'''
-    config_entries = [
-        ('http_port', 8095, 'web_http_port'),
-        ('https_port', 8096, 'web_https_port'),
-        ('ssl_certificate', '', 'web_ssl_cert'), 
-        ('ssl_key', '', 'web_ssl_key'),
-        ('cert_fqdn_host', '', 'cert_fqdn_host')
-        ]
-    if not config['base'].get('web'):
-        config['base']['web'] = {}
-    config['base']['web']['__desc__'] = config_entries
-    for key, def_value, desc in config_entries:
-        if not key in config['base']['web']:
-            config['base']['web'][key] = def_value
-
-class Web():
-    ''' webserver and json/websocket api '''
-    
-    def __init__(self, mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host):
-        self.mass = mass
-        self._http_port = http_port
-        self._https_port = https_port
-        self._ssl_cert = ssl_cert
-        self._ssl_key = ssl_key
-        self._cert_fqdn_host = cert_fqdn_host
-        self.http_session = aiohttp.ClientSession()
-        mass.event_loop.create_task(self.setup_web())
-
-    def stop(self):
-        asyncio.create_task(self.runner.cleanup())
-        asyncio.create_task(self.http_session.close())
-
-    async def setup_web(self):
-        app = web.Application()
-        app.add_routes([web.get('/jsonrpc.js', self.json_rpc)])
-        app.add_routes([web.post('/jsonrpc.js', self.json_rpc)])
-        app.add_routes([web.get('/ws', self.websocket_handler)])
-        # app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)])
-        # app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)])
-        app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream_queue)])
-        app.add_routes([web.get('/api/search', self.search)])
-        app.add_routes([web.get('/api/config', self.get_config)])
-        app.add_routes([web.post('/api/config', self.save_config)])
-        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, access_log=None)
-        await self.runner.setup()
-        http_site = web.TCPSite(self.runner, '0.0.0.0', self._http_port)
-        await http_site.start()
-        if self._ssl_cert and self._ssl_key:
-            ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
-            ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key)
-            https_site = web.TCPSite(self.runner, '0.0.0.0', self._https_port, ssl_context=ssl_context)
-            await https_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','')
-        action_details = request.rel_url.query.get('action_details')
-        lazy = request.rel_url.query.get('lazy', '') != 'false'
-        provider = request.rel_url.query.get('provider')
-        if action:
-            result = await self.mass.music.item_action(media_id, media_type, provider, action, action_details)
-        else:
-            result = await self.mass.music.item(media_id, media_type, provider, lazy=lazy)
-        return web.json_response(result, dumps=json_serializer)
-
-    async def artist_toptracks(self, request):
-        ''' get top tracks for given artist '''
-        artist_id = request.match_info.get('artist_id')
-        provider = request.rel_url.query.get('provider')
-        result = await self.mass.music.artist_toptracks(artist_id, provider)
-        return web.json_response(result, dumps=json_serializer)
-
-    async def artist_albums(self, request):
-        ''' get (all) albums for given artist '''
-        artist_id = request.match_info.get('artist_id')
-        provider = request.rel_url.query.get('provider')
-        result = await self.mass.music.artist_albums(artist_id, provider)
-        return web.json_response(result, dumps=json_serializer)
-
-    async def playlist_tracks(self, request):
-        ''' 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))
-        provider = request.rel_url.query.get('provider')
-        result = await self.mass.music.playlist_tracks(playlist_id, provider, offset=offset, limit=limit)
-        return web.json_response(result, dumps=json_serializer)
-
-    async def album_tracks(self, request):
-        ''' get album tracks from provider'''
-        album_id = request.match_info.get('album_id')
-        provider = request.rel_url.query.get('provider')
-        result = await self.mass.music.album_tracks(album_id, provider)
-        return web.json_response(result, dumps=json_serializer)
-
-    async def search(self, request):
-        ''' 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)
-        if not media_types_query or "radios" in media_types_query:
-            media_types.append(MediaType.Radio)
-        # 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'''
-        result = False
-        player_id = request.match_info.get('player_id')
-        player = await self.mass.player.get_player(player_id)
-        if player:
-            cmd = request.match_info.get('cmd')
-            cmd_args = request.match_info.get('cmd_args')
-            player_cmd = getattr(player, cmd, None)
-            if player_cmd and cmd_args:
-                result = await player_cmd(player_id, cmd, cmd_args)
-            elif player_cmd and cmd_args:
-                result = await player_cmd(player_id, cmd, cmd_args)
-            else:
-                LOGGER.error("Received non-existing command %s for player %s" %(cmd, player.name))
-        else:
-            LOGGER.error("Received command dor non-existing player %s" %(player_id))
-        return web.json_response(result, dumps=json_serializer) 
-    
-    async def play_media(self, request):
-        ''' 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','')
-        provider = request.rel_url.query.get('provider')
-        media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True)
-        result = await self.mass.player.play_media(player_id, media_item, queue_opt)
-        return web.json_response(result, dumps=json_serializer) 
-    
-    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 '''
-        cb_id = None
-        ws = None
-        try:
-            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 }
-                await ws.send_json(ws_msg, dumps=json_serializer)
-            cb_id = self.mass.add_event_listener(send_event)
-            # process incoming messages
-            async for msg in ws:
-                if msg.type != aiohttp.WSMsgType.TEXT:
-                    continue
-                # for now we only use WS for (simple) 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 '/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)
-        finally:
-            self.mass.remove_event_listener(cb_id)
-        LOGGER.debug('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 (partial) config '''
-        LOGGER.debug('save config called from api')
-        new_config = await request.json()
-        config_changed = False
-        for key, value in self.mass.config.items():
-            if isinstance(value, dict):
-                for subkey, subvalue in value.items():
-                    if subkey in new_config[key]:
-                        if self.mass.config[key][subkey] != new_config[key][subkey]:
-                            config_changed = True
-                            self.mass.config[key][subkey] = new_config[key][subkey]
-            elif key in new_config:
-                if self.mass.config[key] != new_config[key]:
-                    config_changed = True
-                    self.mass.config[key] = new_config[key]
-        if config_changed:
-            self.mass.save_config()
-            self.mass.signal_event('config_changed')
-        return web.Response(text='success')
-
-    async def json_rpc(self, request):
-        ''' 
-            implement LMS jsonrpc interface 
-            for some compatability with tools that talk to lms
-            only support for basic commands
-        '''
-        data = await request.json()
-        LOGGER.info("jsonrpc: %s" % data)
-        params = data['params']
-        player_id = params[0]
-        cmds = params[1]
-        cmd_str = " ".join(cmds)
-        if cmd_str in ['play', 'pause', 'stop']:
-            await self.mass.player.player_command(player_id, cmd_str)
-        elif 'power' in cmd_str:
-            args = cmds[1] if len(cmds) > 1 else None
-            await self.mass.player.player_command(player_id, cmd_str, args)
-        elif cmd_str == 'playlist index +1':
-            await self.mass.player.player_command(player_id, 'next')
-        elif cmd_str == 'playlist index -1':
-            await self.mass.player.player_command(player_id, 'previous')
-        elif 'mixer volume' in cmd_str:
-            await self.mass.player.player_command(player_id, 'volume', cmds[2])
-        elif cmd_str == 'mixer muting 1':
-            await self.mass.player.player_command(player_id, 'mute', 'on')
-        elif cmd_str == 'mixer muting 0':
-            await self.mass.player.player_command(player_id, 'mute', 'off')
-        elif cmd_str == 'button volup':
-            await self.mass.player.player_command(player_id, 'volume', 'up')
-        elif cmd_str == 'button voldown':
-            await self.mass.player.player_command(player_id, 'volume', 'down')
-        elif cmd_str == 'button power':
-            await self.mass.player.player_command(player_id, 'power', 'toggle')
-        else:
-            return web.Response(text='command not supported')
-        return web.Response(text='success')
-        
\ No newline at end of file
diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py
new file mode 100755 (executable)
index 0000000..4c4a2f8
--- /dev/null
@@ -0,0 +1,416 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+from typing import List
+import toolz
+import operator
+import os
+import importlib
+
+from .utils import run_periodic, LOGGER, try_supported
+from .models.media_types import MediaType, Track, Artist, Album, Playlist, Radio
+
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MODULES_PATH = os.path.join(BASE_DIR, "musicproviders" )
+
+class Music():
+    ''' several helpers around the musicproviders '''
+    
+    def __init__(self, mass):
+        self.sync_running = False
+        self.mass = mass
+        self.providers = {}
+        # dynamically load musicprovider modules
+        self.load_music_providers()
+        # schedule sync task
+        mass.event_loop.create_task(self.sync_music_providers())
+
+    async def item(self, item_id, media_type:MediaType, provider='database', lazy=True):
+        ''' get single music item by id and media type'''
+        if media_type == MediaType.Artist:
+            return await self.artist(item_id, provider, lazy=lazy)
+        elif media_type == MediaType.Album:
+            return await self.album(item_id, provider, lazy=lazy)
+        elif media_type == MediaType.Track:
+            return await self.track(item_id, provider, lazy=lazy)
+        elif media_type == MediaType.Playlist:
+            return await self.playlist(item_id, provider)
+        elif media_type == MediaType.Radio:
+            return await self.radio(item_id, provider)
+        else:
+            return None
+
+    async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Artist]:
+        ''' return all library artists, optionally filtered by provider '''
+        return await self.mass.db.library_artists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_albums(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Album]:
+        ''' return all library albums, optionally filtered by provider '''
+        return await self.mass.db.library_albums(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_tracks(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Track]:
+        ''' return all library tracks, optionally filtered by provider '''
+        return await self.mass.db.library_tracks(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def playlists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]:
+        ''' return all library playlists, optionally filtered by provider '''
+        return await self.mass.db.playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def radios(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]:
+        ''' return all library radios, optionally filtered by provider '''
+        return await self.mass.db.radios(provider=provider_filter, limit=limit, offset=offset, orderby=orderby)
+
+    async def library_items(self, media_type:MediaType, limit=0, offset=0, orderby='name', provider_filter=None) -> List[object]:
+        ''' get multiple music items in library'''
+        if media_type == MediaType.Artist:
+            return await self.library_artists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Album:
+            return await self.library_albums(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Track:
+            return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Playlist:
+            return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+        elif media_type == MediaType.Radio:
+            return await self.radios(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+
+    async def artist(self, item_id, provider='database', lazy=True) -> Artist:
+        ''' get artist by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.artist(item_id)
+        return await self.providers[provider].artist(item_id, lazy=lazy)
+
+    async def album(self, item_id, provider='database', lazy=True) -> Album:
+        ''' get album by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.album(item_id)
+        return await self.providers[provider].album(item_id, lazy=lazy)
+
+    async def track(self, item_id, provider='database', lazy=True) -> Track:
+        ''' get track by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.track(item_id)
+        return await self.providers[provider].track(item_id, lazy=lazy)
+
+    async def playlist(self, item_id, provider='database') -> Playlist:
+        ''' get playlist by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.playlist(item_id)
+        return await self.providers[provider].playlist(item_id)
+
+    async def radio(self, item_id, provider='database') -> Radio:
+        ''' get radio by id '''
+        if not provider or provider == 'database':
+            return await self.mass.db.radio(item_id)
+        return await self.providers[provider].radio(item_id)
+
+    async def playlist_by_name(self, name) -> Playlist:
+        ''' get playlist by name '''
+        for playlist in await self.playlists():
+            if playlist.name == name:
+                return playlist
+        return None
+
+    async def radio_by_name(self, name) -> Radio:
+        ''' get radio by name '''
+        for radio in await self.radios():
+            if radio.name == name:
+                return radio
+        return None
+    
+    async def artist_toptracks(self, artist_id, provider='database') -> List[Track]:
+        ''' get top tracks for given artist '''
+        artist = await self.artist(artist_id, provider)
+        # always append database tracks
+        items = await self.mass.db.artist_tracks(artist.item_id)
+        for prov_mapping in artist.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.artist_toptracks(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def artist_albums(self, artist_id, provider='database') -> List[Album]:
+        ''' get (all) albums for given artist '''
+        artist = await self.artist(artist_id, provider)
+        # always append database tracks
+        items = await self.mass.db.artist_albums(artist.item_id)
+        for prov_mapping in artist.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.artist_albums(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def album_tracks(self, album_id, provider='database') -> List[Track]:
+        ''' get the album tracks for given album '''
+        items = []
+        album = await self.album(album_id, provider)
+        for prov_mapping in album.provider_ids:
+            prov_id = prov_mapping['provider']
+            prov_item_id = prov_mapping['item_id']
+            prov_obj = self.providers[prov_id]
+            items += await prov_obj.album_tracks(prov_item_id)
+        items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        items = sorted(items, key=operator.attrgetter('disc_number'), reverse=False)
+        items = sorted(items, key=operator.attrgetter('track_number'), reverse=False)
+        return items
+
+    async def playlist_tracks(self, playlist_id, provider='database', offset=0, limit=50) -> List[Track]:
+        ''' get the tracks for given playlist '''
+        playlist = None
+        if not provider or provider == 'database':
+            playlist = await self.mass.db.playlist(playlist_id)
+        if playlist and playlist.is_editable:
+            # database synced playlist, return tracks from db...
+            return await self.mass.db.playlist_tracks(
+                    playlist.item_id, offset=offset, limit=limit)
+        else:
+            # return playlist tracks from provider
+            playlist = await self.playlist(playlist_id, provider)
+            prov = playlist.provider_ids[0]
+            return await self.providers[prov['provider']].playlist_tracks(
+                    prov['item_id'], offset=offset, limit=limit)
+
+    async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False) -> dict:
+        ''' search database or providers '''
+        # get results from database
+        result = await self.mass.db.search(searchquery, media_types, limit)
+        if online:
+            # include results from music providers
+            for prov in self.providers.values():
+                prov_results = await prov.search(searchquery, media_types, limit)
+                for item_type, items in prov_results.items():
+                    if not item_type in result:
+                        result[item_type] = items
+                    else:
+                        result[item_type] += items
+            # filter out duplicates
+            for item_type, items in result.items():
+                items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+        return result
+
+    async def item_action(self, item_id, media_type, provider, action, action_details=None):
+        ''' perform action on item (such as library add/remove) '''
+        result = None
+        item = await self.item(item_id, media_type, provider)
+        if item and action in ['library_add', 'library_remove']:
+            # remove or add item to the library
+            for prov_mapping in result.provider_ids:
+                prov_id = prov_mapping['provider']
+                prov_item_id = prov_mapping['item_id']
+                for prov in self.providers.values():
+                    if prov.prov_id == prov_id:
+                        if action == 'add':
+                            result = await prov.add_library(prov_item_id, media_type)
+                        elif action == 'remove':
+                            result = await prov.remove_library(prov_item_id, media_type)
+        return result
+    
+    async def add_playlist_tracks(self, playlist_id, tracks:List[Track]):
+        ''' add tracks to playlist - make sure we dont add dupes '''
+        # we can only edit playlists that are in the database (marked as editable)
+        playlist = await self.playlist(playlist_id, 'database')
+        if not playlist or not playlist.is_editable:
+            LOGGER.warning("Playlist %s is not editable - skip addition of tracks" %(playlist.name))
+            return False
+        playlist_prov = playlist.provider_ids[0] # playlist can only have one provider (for now)
+        cur_playlist_tracks = await self.mass.db.playlist_tracks(playlist_id, limit=0)
+        # grab all (database) track ids in the playlist so we can check for duplicates
+        cur_playlist_track_ids = [item.item_id for item in cur_playlist_tracks]
+        track_ids_to_add = []
+        for track in tracks:
+            if not track.provider == 'database':
+                # make sure we have a database track
+                track = await self.track(track.item_id, track.provider, lazy=False)
+            if track.item_id in cur_playlist_track_ids:
+                LOGGER.warning("Track %s already in playlist %s - skip addition" %(track.name, playlist.name))
+                continue
+            # we can only add a track to a provider playlist if the track is available on that provider
+            # exception is the file provider which does accept tracks from all providers in the m3u playlist
+            # this should all be handled in the frontend but these checks are here just to be safe
+            track_playlist_provs = [item['provider'] for item in track.provider_ids]
+            if playlist_prov['provider'] in track_playlist_provs:
+                # a track can contain multiple versions on the same provider
+                # # simply sort by quality and just add the first one (assuming the track is still available)
+                track_versions = sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True)
+                for track_version in track_versions:
+                    if track_version['provider'] == playlist_prov['provider']:
+                        track_ids_to_add.append(track_version['item_id'])
+                        break
+            elif playlist_prov['provider'] == 'file':
+                # the file provider can handle uri's from all providers in the file so simply add the db id
+                track_ids_to_add.append(track.item_id)
+            else:
+                LOGGER.warning("Track %s not available on provider %s - skip addition to playlist %s" %(track.name, playlist_prov['provider'], playlist.name))
+                continue
+        # actually add the tracks to the playlist on the provider
+        await self.providers[playlist_prov['provider']].add_playlist_tracks(playlist_prov['item_id'], track_ids_to_add)
+        # schedule sync
+        self.mass.event_loop.create_task(self.sync_playlist_tracks(playlist.item_id, playlist_prov['provider'], playlist_prov['item_id']))
+
+    @run_periodic(3600)
+    async def sync_music_providers(self):
+        ''' periodic sync of all music providers '''
+        if self.sync_running:
+            return
+        self.sync_running = True
+        for prov_id in self.providers.keys():
+            # sync library artists
+            await try_supported(self.sync_library_artists(prov_id))
+            await try_supported(self.sync_library_albums(prov_id))
+            await try_supported(self.sync_library_tracks(prov_id))
+            await try_supported(self.sync_playlists(prov_id))
+            await try_supported(self.sync_radios(prov_id))
+        self.sync_running = False
+        
+    async def sync_library_artists(self, prov_id):
+        ''' sync library artists for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_artists(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_artists()
+        cur_db_ids = []
+        for item in cur_items:
+            db_item = await music_provider.artist(item.item_id, lazy=False)
+            cur_db_ids.append(db_item.item_id)
+            if not db_item.item_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_item.item_id, MediaType.Artist, prov_id)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id)
+        LOGGER.info("Finished syncing Artists for provider %s" % prov_id)
+
+    async def sync_library_albums(self, prov_id):
+        ''' sync library albums for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_albums(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_albums()
+        cur_db_ids = []
+        for item in cur_items:
+            db_item = await music_provider.album(item.item_id, lazy=False)
+            cur_db_ids.append(db_item.item_id)
+            # precache album tracks...
+            for album_track in await music_provider.get_album_tracks(item.item_id):
+                await music_provider.track(album_track.item_id)
+            if not db_item.item_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_item.item_id, MediaType.Album, prov_id)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id)
+        LOGGER.info("Finished syncing Albums for provider %s" % prov_id)
+
+    async def sync_library_tracks(self, prov_id):
+        ''' sync library tracks for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.library_tracks(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_library_tracks()
+        cur_db_ids = []
+        for item in cur_items:
+            db_item = await music_provider.track(item.item_id, lazy=False)
+            cur_db_ids.append(db_item.item_id)
+            if not db_item.item_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_item.item_id, MediaType.Track, prov_id)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id)
+        LOGGER.info("Finished syncing Tracks for provider %s" % prov_id)
+
+    async def sync_playlists(self, prov_id):
+        ''' sync library playlists for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.playlists(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_playlists()
+        cur_db_ids = []
+        for item in cur_items:
+            # always add to db because playlist attributes could have changed
+            db_id = await self.mass.db.add_playlist(item)
+            cur_db_ids.append(db_id)
+            if not db_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id)
+            if item.is_editable:
+                # precache/sync playlist tracks (user owned playlists only)
+                asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) )
+        # process playlist deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Playlist, prov_id)
+        LOGGER.info("Finished syncing Playlists for provider %s" % prov_id)
+
+    async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id):
+        ''' sync library playlists tracks for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.playlist_tracks(db_playlist_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_playlist_tracks(prov_playlist_id, limit=0)
+        cur_db_ids = []
+        pos = 0
+        for item in cur_items:
+            # we need to do this the complicated way because the file provider can return tracks from other providers
+            for prov_mapping in item.provider_ids:
+                item_provider = prov_mapping['provider']
+                prov_item_id = prov_mapping['item_id']
+                db_item = await self.providers[item_provider].track(prov_item_id, lazy=False)
+                cur_db_ids.append(db_item.item_id)
+                if not db_item.item_id in prev_db_ids:
+                    await self.mass.db.add_playlist_track(db_playlist_id, db_item.item_id, pos)
+            pos += 1
+        # process playlist track deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_playlist_track(db_playlist_id, db_id)
+        LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id))
+
+    async def sync_radios(self, prov_id):
+        ''' sync library radios for given provider'''
+        music_provider = self.providers[prov_id]
+        prev_items = await self.radios(provider_filter=prov_id)
+        prev_db_ids = [item.item_id for item in prev_items]
+        cur_items = await music_provider.get_radios()
+        cur_db_ids = []
+        for item in cur_items:
+            db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Radio)
+            if not db_id:
+                db_id = await self.mass.db.add_radio(item)
+            cur_db_ids.append(db_id)
+            if not db_id in prev_db_ids:
+                await self.mass.db.add_to_library(db_id, MediaType.Radio, prov_id)
+        # process deletions
+        for db_id in prev_db_ids:
+            if db_id not in cur_db_ids:
+                await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id)
+        LOGGER.info("Finished syncing Radios for provider %s" % prov_id)
+
+    def load_music_providers(self):
+        ''' dynamically load musicproviders '''
+        for item in os.listdir(MODULES_PATH):
+            if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and 
+                    item.endswith('.py') and not item.startswith('.')):
+                module_name = item.replace(".py","")
+                LOGGER.debug("Loading musicprovider module %s" % module_name)
+                try:
+                    mod = importlib.import_module("." + module_name, "music_assistant.musicproviders")
+                    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/musicproviders/__init__.py b/music_assistant/musicproviders/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/music_assistant/musicproviders/file.py b/music_assistant/musicproviders/file.py
new file mode 100644 (file)
index 0000000..a6ffabb
--- /dev/null
@@ -0,0 +1,360 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import sys
+import time
+import base64
+import taglib
+
+from ..cache import use_cache
+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
+
+
+
+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", "", "file_prov_music_path"), 
+        ("playlists_dir", "", "file_prov_playlists_path")
+        ]
+
+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_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.sep in prov_item_id:
+            itempath = base64.b64decode(prov_item_id).decode('utf-8')
+        else:
+            itempath = prov_item_id
+            prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8')
+        if not os.path.isdir(itempath):
+            LOGGER.error("artist path does not exist: %s" % itempath)
+            return None
+        name = itempath.split(os.sep)[-1]
+        artist = Artist()
+        artist.item_id = prov_item_id
+        artist.provider = self.prov_id
+        artist.name = name
+        artist.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": artist.item_id
+        })
+        return artist
+        
+    async def get_album(self, prov_item_id) -> Album:
+        ''' get full album details by id '''
+        if not os.sep in prov_item_id:
+            itempath = base64.b64decode(prov_item_id).decode('utf-8')
+        else:
+            itempath = prov_item_id
+            prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8')
+        if not os.path.isdir(itempath):
+            LOGGER.error("album path does not exist: %s" % itempath)
+            return None
+        name = itempath.split(os.sep)[-1]
+        artistpath = itempath.rsplit(os.sep, 1)[0]
+        album = Album()
+        album.item_id = prov_item_id
+        album.provider = self.prov_id
+        album.name, album.version = parse_track_title(name)
+        album.artist = await self.get_artist(artistpath)
+        if not album.artist:
+            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.sep in prov_item_id:
+            itempath = base64.b64decode(prov_item_id).decode('utf-8')
+        else:
+            itempath = prov_item_id
+        if not os.path.isfile(itempath):
+            LOGGER.error("track path does not exist: %s" % itempath)
+            return None
+        return await self.__parse_track(itempath)
+
+    async def get_playlist(self, prov_item_id) -> Playlist:
+        ''' get full playlist details by id '''
+        if not os.sep in prov_item_id:
+            itempath = base64.b64decode(prov_item_id).decode('utf-8')
+        else:
+            itempath = prov_item_id
+            prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8')
+        if not os.path.isfile(itempath):
+            LOGGER.error("playlist path does not exist: %s" % itempath)
+            return None
+        playlist = Playlist()
+        playlist.item_id = prov_item_id
+        playlist.provider = self.prov_id
+        playlist.name = itempath.split(os.sep)[-1].replace('.m3u', '')
+        playlist.is_editable = True
+        playlist.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": prov_item_id
+        })
+        playlist.owner = 'disk'
+        return playlist
+    
+    async def get_album_tracks(self, prov_album_id) -> List[Track]:
+        ''' get album tracks for given album id '''
+        result = []
+        if not os.sep in prov_album_id:
+            albumpath = base64.b64decode(prov_album_id).decode('utf-8')
+        else:
+            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.sep in prov_playlist_id:
+            itempath = base64.b64decode(prov_playlist_id).decode('utf-8')
+        else:
+            itempath = prov_playlist_id
+        if not os.path.isfile(itempath):
+            LOGGER.error("playlist path does not exist: %s" % itempath)
+            return []
+        counter = 0
+        with open(itempath) 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 limit and 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 = []
+        if not os.sep in prov_artist_id:
+            artistpath = base64.b64decode(prov_artist_id).decode('utf-8')
+        else:
+            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_content_type(self, track_id):
+        ''' return the content type for the given track when it will be streamed'''
+        if not os.sep in track_id:
+            track_id = base64.b64decode(track_id).decode('utf-8')
+        return track_id.split('.')[-1]
+    
+    async def get_audio_stream(self, track_id):
+        ''' get audio stream for a track '''
+        if not os.sep in track_id:
+            track_id = base64.b64decode(track_id).decode('utf-8')
+        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 ?
+        prov_item_id = base64.b64encode(filename.encode('utf-8')).decode('utf-8')
+        track.duration = song.length
+        track.item_id = prov_item_id
+        track.provider = self.prov_id
+        name = song.tags['TITLE'][0]
+        track.name, track.version = parse_track_title(name)
+        albumpath = filename.rsplit(os.sep,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": base64.b64encode(fake_artistpath.encode('utf-8')).decode('utf-8')
+                    })
+            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])
+        quality_details = ""
+        if filename.endswith('.flac'):
+            # TODO: get bit depth
+            quality = TrackQuality.FLAC_LOSSLESS
+            if song.sampleRate > 192000:
+                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
+            elif song.sampleRate > 96000:
+                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
+            elif song.sampleRate > 48000:
+                quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
+            quality_details = "%s Khz" % (song.sampleRate/1000)
+        elif filename.endswith('.ogg'):
+            quality = TrackQuality.LOSSY_OGG
+            quality_details = "%s kbps" % (song.bitrate)
+        elif filename.endswith('.m4a'):
+            quality = TrackQuality.LOSSY_AAC
+            quality_details = "%s kbps" % (song.bitrate)
+        else:
+            quality = TrackQuality.LOSSY_MP3
+            quality_details = "%s kbps" % (song.bitrate)
+        track.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": prov_item_id,
+            "quality": quality,
+            "details": quality_details
+        })
+        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/musicproviders/qobuz.py b/music_assistant/musicproviders/qobuz.py
new file mode 100644 (file)
index 0000000..40bab64
--- /dev/null
@@ -0,0 +1,560 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import json
+import aiohttp
+import time
+import datetime
+import hashlib
+from asyncio_throttle import Throttler
+
+from ..cache import use_cache
+from ..utils import run_periodic, LOGGER, parse_track_title
+from ..app_vars import get_app_var
+from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
+from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
+
+
+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:
+        provider = QobuzProvider(mass, username, password)
+        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_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.mass = mass
+        self.cache = mass.cache
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.__username = username
+        self.__password = password
+        self.__user_auth_info = None
+        self.__logged_in = False
+        self.throttler = Throttler(rate_limit=2, period=1)
+        mass.add_event_listener(self.mass_event, 'streaming_started')
+        mass.add_event_listener(self.mass_event, 'streaming_ended')
+
+    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_playlists(self) -> List[Playlist]:
+        ''' retrieve playlists from the provider '''
+        result = []
+        for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists', cache_checksum=time.time()):
+            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)
+            # TODO: should we look for an alternative track version if the original is marked unavailable ?
+        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 str(item['artist']['id']) == str(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, so use search instead
+        items = []
+        artist = await self.get_artist(prov_artist_id)
+        params = {"query": artist.name, "limit": 10, "type": "tracks" }
+        searchresult = await self.__get_data("catalog/search", params)
+        for item in searchresult["tracks"]["items"]:
+            if "performer" in item and str(item["performer"]["id"]) == str(prov_artist_id):
+                track = await self.__parse_track(item)
+                items.append(track)
+        return items
+    
+    async def add_library(self, prov_item_id, media_type:MediaType):
+        ''' add item to library '''
+        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 add_playlist_tracks(self, prov_playlist_id, prov_track_ids):
+        ''' add track(s) to playlist '''
+        params = {
+            'playlist_id': prov_playlist_id,
+            'track_ids': ",".join(prov_track_ids)
+        }
+        return await self.__get_data('playlist/addTracks', params)
+
+    async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids):
+        ''' remove track(s) from playlist '''
+        playlist_track_ids = []
+        params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'}
+        for track in await self.__get_all_items("playlist/get", params, key='tracks', limit=0):
+            if track['id'] in prov_track_ids:
+                playlist_track_ids.append(track['playlist_track_id'])
+        params = {'playlist_id': prov_playlist_id, 'track_ids': ",".join(playlist_track_ids)}
+        return await self.__get_data('playlist/deleteTracks', params)
+    
+    async def get_stream_details(self, track_id):
+        ''' return the content details for the given track when it will be streamed'''
+        streamdetails = None
+        for format_id in [27, 7, 6, 5]:
+            # it seems that simply requesting for highest available quality does not work
+            # from time to time the api response is empty for this request ?!
+            params = {'format_id': format_id, 'track_id': track_id, 'intent': 'stream'}
+            streamdetails = await self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True)
+            if streamdetails and streamdetails.get('url'):
+                break
+        if not streamdetails or not streamdetails.get('url'):
+            LOGGER.error("Unable to retrieve stream url for track %s" % track_id)
+            return None
+        return {
+            "type": "url",
+            "path": streamdetails['url'],
+            "content_type": streamdetails['mime_type'].split('/')[1],
+            "sample_rate": int(streamdetails['sampling_rate']*1000),
+            "bit_depth": streamdetails['bit_depth'],
+            "details": streamdetails # we need these details for reporting playback
+        }
+
+    async def mass_event(self, msg, msg_details):
+        ''' received event from mass '''
+        # TODO: need to figure out if the streamed track is purchased
+        if msg == "streaming_started" and msg_details['provider'] == self.prov_id:
+            # report streaming started to qobuz
+            LOGGER.debug("streaming_started %s" % msg_details["track_id"])
+            device_id = self.__user_auth_info["user"]["device"]["id"]
+            credential_id = self.__user_auth_info["user"]["credential"]["id"]
+            user_id = self.__user_auth_info["user"]["id"]
+            format_id = msg_details["details"]["format_id"]
+            timestamp = int(time.time())
+            events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, 
+                "track_id": msg_details["track_id"], "purchase": False, "date": timestamp,
+                "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}]
+            await self.__post_data("track/reportStreamingStart", data=events)
+        elif msg == "streaming_ended" and msg_details['provider'] == self.prov_id:
+            # report streaming ended to qobuz
+            LOGGER.debug("streaming_ended %s - seconds played: %s" %(msg_details["track_id"], msg_details["seconds"]) )
+            device_id = self.__user_auth_info["user"]["device"]["id"]
+            credential_id = self.__user_auth_info["user"]["credential"]["id"]
+            user_id = self.__user_auth_info["user"]["id"]
+            format_id = msg_details["details"]["format_id"]
+            timestamp = int(time.time())
+            events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, 
+                "track_id": msg_details["track_id"], "purchase": False, "date": timestamp, "duration": msg_details["seconds"],
+                "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}]
+            await self.__post_data("track/reportStreamingStart", data=events)
+    
+    async def __parse_artist(self, artist_obj):
+        ''' parse qobuz artist object to generic layout '''
+        artist = Artist()
+        if not artist_obj.get('id'):
+            return None
+        artist.item_id = artist_obj['id']
+        artist.provider = self.prov_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 qobuz 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']
+        album.provider = self.prov_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 qobuz 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 - %s" % (track_obj.get('id'), track_obj.get('name')))
+            return None
+        track.item_id = track_obj['id']
+        track.provider = self.prov_id
+        if track_obj.get('performer') and not 'Various ' in track_obj['performer']:
+            artist = await self.__parse_artist(track_obj['performer'])
+            if not artist:
+                artist = self.get_artist(track_obj['performer']['id'])
+            if artist:
+                track.artists.append(artist)
+        if not track.artists:
+            # try to grab artist from album
+            if track_obj.get('album') and track_obj['album'].get('artist') and not 'Various ' in track_obj['album']['artist']:
+                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_LOSSLESS_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 qobuz playlist object to generic layout '''
+        playlist = Playlist()
+        if not playlist_obj.get('id'):
+            return None
+        playlist.item_id = playlist_obj['id']
+        playlist.provider = self.prov_id
+        playlist.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": playlist_obj['id']
+        })
+        playlist.name = playlist_obj['name']
+        playlist.owner = playlist_obj['owner']['name']
+        playlist.is_editable = playlist_obj['owner']['id'] == self.__user_auth_info["user"]["id"] or playlist_obj['is_collaborative']
+        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_info:
+            return self.__user_auth_info["user_auth_token"]
+        params = { "username": self.__username, "password": self.__password, "device_manufacturer_id": "music_assistant"}
+        details = await self.__get_data("user/login", params, ignore_cache=True)
+        self.__user_auth_info = details
+        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": get_app_var(0)}
+        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 + get_app_var(1)
+            request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
+            params["request_ts"] = request_ts
+            params["request_sig"] = request_sig
+            params["app_id"] = get_app_var(0)
+            params["user_auth_token"] = await self.__auth_token()
+        try:
+            async with self.throttler:
+                async with self.http_session.get(url, headers=headers, params=params) as response:
+                    result = await response.json()
+                    if not result or 'error' in result:
+                        LOGGER.error(url)
+                        LOGGER.debug(params)
+                        LOGGER.debug(result)
+                        return None
+                    return result
+        except Exception as exc:
+            LOGGER.exception(exc)
+            return None
+
+    async def __post_data(self, endpoint, params={}, data={}):
+        ''' post data to api'''
+        url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint
+        params["app_id"] = get_app_var(0)
+        params["user_auth_token"] = await self.__auth_token()
+        async with self.http_session.post(url, params=params, json=data) as response:
+            result = await response.json()
+            if not result or 'error' in result:
+                LOGGER.error(url)
+                LOGGER.debug(params)
+                LOGGER.debug(result)
+                result = None
+            return result
\ No newline at end of file
diff --git a/music_assistant/musicproviders/spotify.py b/music_assistant/musicproviders/spotify.py
new file mode 100644 (file)
index 0000000..43a56fc
--- /dev/null
@@ -0,0 +1,518 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import sys
+import time
+import concurrent
+from asyncio_throttle import Throttler
+import json
+import aiohttp
+
+from ..cache import use_cache
+from ..utils import run_periodic, LOGGER, parse_track_title
+from ..app_vars import get_app_var
+from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
+from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
+
+
+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, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.throttler = Throttler(rate_limit=1, period=1)
+        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_playlists(self) -> List[Playlist]:
+        ''' retrieve playlists from the provider '''
+        result = []
+        for item in await self.__get_all_items("me/playlists", cache_checksum=time.time()):
+            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 '''
+        artist = await self.get_artist(prov_artist_id)
+        items = await self.__get_data('artists/%s/top-tracks' % prov_artist_id)
+        tracks = []
+        for item in items['tracks']:
+            track = await self.__parse_track(item)
+            if track:
+                track.artists = [artist]
+                tracks.append(track)
+        return tracks
+
+    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):
+        ''' return the content details for the given track when it will be streamed'''
+        spotty = self.get_spotty_binary()
+        spotty_exec = "%s -n temp -u %s -p %s --pass-through --single-track %s" %(spotty, self._username, self._password, track_id)
+        return {
+            "type": "executable",
+            "path": spotty_exec,
+            "content_type": "ogg",
+            "sample_rate": 44100,
+            "bit_depth": 16
+        }
+        
+    async def __parse_artist(self, artist_obj):
+        ''' parse spotify artist object to generic layout '''
+        artist = Artist()
+        artist.item_id = artist_obj['id']
+        artist.provider = self.prov_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']
+        album.provider = self.prov_id
+        album.name, album.version = parse_track_title(album_obj['name'])
+        for artist in album_obj['artists']:
+            album.artist = await self.__parse_artist(artist)
+            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']:
+            LOGGER.warning("invalid/unavailable track found: %s - %s" % (track_obj.get('id'), track_obj.get('name')))
+            return None
+        track = Track()
+        track.item_id = track_obj['id']
+        track.provider = self.prov_id
+        for track_artist in track_obj['artists']:
+            artist = await self.__parse_artist(track_artist)
+            if artist:
+                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']
+        playlist.provider = self.prov_id
+        playlist.provider_ids.append({
+            "provider": self.prov_id,
+            "item_id": playlist_obj['id']
+        })
+        playlist.name = playlist_obj['name']
+        playlist.owner = playlist_obj['owner']['display_name']
+        playlist.is_editable = playlist_obj['owner']['id'] == self.sp_user["id"] or playlist_obj['collaborative']
+        if playlist_obj.get('images'):
+            playlist.metadata["image"] = playlist_obj['images'][0]['url']
+        if playlist_obj.get('external_urls'):
+            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.debug(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)
+            args = [self.get_spotty_binary(), "-t", "--client-id", get_app_var(2), "--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.throttler:
+            async with self.http_session.get(url, headers=headers, params=params) as response:
+                result = await response.json()
+                if not result or 'error' in result:
+                    LOGGER.error(url)
+                    LOGGER.error(params)
+                    result = None
+                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/musicproviders/spotty/arm-linux/spotty-hf b/music_assistant/musicproviders/spotty/arm-linux/spotty-hf
new file mode 100755 (executable)
index 0000000..c928d8a
Binary files /dev/null and b/music_assistant/musicproviders/spotty/arm-linux/spotty-hf differ
diff --git a/music_assistant/musicproviders/spotty/darwin/spotty b/music_assistant/musicproviders/spotty/darwin/spotty
new file mode 100755 (executable)
index 0000000..44c6b60
Binary files /dev/null and b/music_assistant/musicproviders/spotty/darwin/spotty differ
diff --git a/music_assistant/musicproviders/spotty/windows/spotty.exe b/music_assistant/musicproviders/spotty/windows/spotty.exe
new file mode 100755 (executable)
index 0000000..6ce9b19
Binary files /dev/null and b/music_assistant/musicproviders/spotty/windows/spotty.exe differ
diff --git a/music_assistant/musicproviders/spotty/x86-linux/spotty b/music_assistant/musicproviders/spotty/x86-linux/spotty
new file mode 100755 (executable)
index 0000000..b2c3f34
Binary files /dev/null and b/music_assistant/musicproviders/spotty/x86-linux/spotty differ
diff --git a/music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64 b/music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64
new file mode 100755 (executable)
index 0000000..58911cf
Binary files /dev/null and b/music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64 differ
diff --git a/music_assistant/musicproviders/tunein.py b/music_assistant/musicproviders/tunein.py
new file mode 100644 (file)
index 0000000..cd9c7ba
--- /dev/null
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import sys
+import time
+from asyncio_throttle import Throttler
+import json
+import aiohttp
+
+from ..cache import use_cache
+from ..utils import run_periodic, LOGGER, parse_track_title
+from ..models import MusicProvider, MediaType, TrackQuality, Radio
+from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
+
+
+def setup(mass):
+    ''' setup the provider'''
+    enabled = mass.config["musicproviders"]['tunein'].get(CONF_ENABLED)
+    username = mass.config["musicproviders"]['tunein'].get(CONF_USERNAME)
+    password = mass.config["musicproviders"]['tunein'].get(CONF_PASSWORD)
+    if enabled and username and password:
+        provider = TuneInProvider(mass, username, password)
+        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_USERNAME, "", CONF_USERNAME), 
+        (CONF_PASSWORD, "<password>", CONF_PASSWORD)
+        ]
+
+class TuneInProvider(MusicProvider):
+    
+
+    def __init__(self, mass, username, password):
+        self.name = 'TuneIn Radio'
+        self.prov_id = 'tunein'
+        self.mass = mass
+        self.cache = mass.cache
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+        self.throttler = Throttler(rate_limit=1, period=1)
+        self._username = username
+        self._password = password
+
+    async def search(self, searchstring, media_types=List[MediaType], limit=5):
+        ''' perform search on the provider '''
+        result = {
+            "artists": [],
+            "albums": [],
+            "tracks": [],
+            "playlists": [],
+            "radios": []
+        }
+        return result
+
+    async def get_radios(self):
+        ''' get favorited/library radio stations '''
+        items = []
+        params = {"c": "presets"}
+        result = await self.__get_data("Browse.ashx", params, ignore_cache=True)
+        if result and "body" in result:
+            for item in result["body"]:
+                # TODO: expand folders
+                if item["type"] == "audio":
+                    radio = await self.__parse_radio(item)
+                    items.append(radio)
+        return items
+
+    async def get_radio(self, radio_id):
+        ''' get radio station details '''
+        radio = None
+        params = {"c": "composite", "detail": "listing", "id": radio_id}
+        result = await self.__get_data("Describe.ashx", params, ignore_cache=True)
+        if result and result.get("body") and result["body"][0].get("children"):
+            item = result["body"][0]["children"][0]
+            radio = await self.__parse_radio(item)
+        return radio
+
+    async def __parse_radio(self, details):
+        ''' parse Radio object from json obj returned from api '''
+        radio = Radio()
+        radio.item_id = details['preset_id']
+        radio.provider = self.prov_id
+        if "name" in details:
+            radio.name = details["name"]
+        else:
+            # parse name from text attr
+            name = details["text"]
+            if " | " in name:
+                name = name.split(" | ")[1]
+            name = name.split(" (")[0]
+            radio.name = name
+        # parse stream urls and format
+        stream_info = await self.__get_stream_urls(radio.item_id)
+        for stream in stream_info["body"]:
+            if stream["media_type"] == 'aac':
+                quality = TrackQuality.LOSSY_AAC
+            elif stream["media_type"] == 'ogg':
+                quality = TrackQuality.LOSSY_OGG
+            else:
+                quality = TrackQuality.LOSSY_MP3
+            radio.provider_ids.append({
+                "provider": self.prov_id,
+                "item_id": "%s--%s" % (details['preset_id'], stream["media_type"]),
+                "quality": quality,
+                "details": stream['url']
+            })
+        # image
+        if "image" in details:
+            radio.metadata["image"] = details["image"]
+        elif "logo" in details:
+            radio.metadata["image"] = details["logo"]
+        return radio
+
+    async def __get_stream_urls(self, radio_id):
+        ''' get the stream urls for the given radio id '''
+        params = {"id": radio_id}
+        res = await self.__get_data("Tune.ashx", params)
+        return res
+
+    async def get_stream_details(self, stream_id):
+        ''' return the content details for the given track when it will be streamed'''
+        radio_id, media_type = stream_id.split('--')
+        stream_info = await self.__get_stream_urls(radio_id)
+        for stream in stream_info["body"]:
+            if stream['media_type'] == media_type:
+                return {
+                    "type": "url",
+                    "path": stream['url'],
+                    "content_type": media_type,
+                    "sample_rate": 44100,
+                    "bit_depth": 16
+                }
+        return {}
+        
+    @use_cache(7)
+    async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None):
+        ''' get data from api'''
+        url = 'https://opml.radiotime.com/%s' % endpoint
+        params['render'] = 'json'
+        params['formats'] = 'ogg,aac,wma,mp3'
+        params['username'] = self._username
+        params['partnerId'] = '1'
+        async with self.throttler:
+            async with self.http_session.get(url, params=params) as response:
+                result = await response.json()
+                if not result or 'error' in result:
+                    LOGGER.error(url)
+                    LOGGER.error(params)
+                    result = None
+                return result
+
+    
\ No newline at end of file
diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py
new file mode 100755 (executable)
index 0000000..de6026d
--- /dev/null
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from enum import Enum
+import operator
+import random
+import functools
+import urllib
+import importlib
+
+from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task
+from .models.media_types import MediaType, TrackQuality
+from .models.player_queue import QueueItem
+from .models.player import PlayerState
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" )
+
+
+
+class PlayerManager():
+    ''' several helpers to handle playback through player providers '''
+    
+    def __init__(self, mass):
+        self.mass = mass
+        self.providers = {}
+        self._players = {}
+        # dynamically load provider modules
+        self.load_providers()
+
+    @property
+    def players(self):
+        ''' all players as property '''
+        return self.mass.bg_executor.submit(asyncio.run, 
+                self.get_players()).result()
+    
+    async def get_players(self):
+        ''' return all players as a list '''
+        items = list(self._players.values())
+        items.sort(key=lambda x: x.name, reverse=False)
+        return items
+
+    async def get_player(self, player_id):
+        ''' return player by id '''
+        return self._players.get(player_id, None)
+
+    async def get_provider_players(self, player_provider):
+        ''' return all players for given provider_id '''
+        return [item for item in self._players.values() if item.player_provider == player_provider] 
+
+    async def add_player(self, player):
+        ''' register a new player '''
+        self._players[player.player_id] = player
+        self.mass.signal_event('player added', player)
+        # TODO: turn on player if it was previously turned on ?
+        return player
+
+    async def remove_player(self, player_id):
+        ''' handle a player remove '''
+        self._players.pop(player_id, None)
+        self.mass.signal_event('player removed', player_id)
+
+    async def trigger_update(self, player_id):
+        ''' manually trigger update for a player '''
+        if player_id in self._players:
+            await self._players[player_id].update()
+    
+    async def play_media(self, player_id, media_item, queue_opt='play'):
+        ''' 
+            play media item(s) on the given player 
+            :param media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio)
+                        single item or list of items
+            :param queue_opt: 
+                play -> insert new items in queue and start playing at the inserted position
+                replace -> replace queue contents with these items
+                next -> play item(s) after current playing item
+                add -> append new items at end of the queue
+        '''
+        player = await self.get_player(player_id)
+        if not player:
+            return
+        # a single item or list of items may be provided
+        media_items = media_item if isinstance(media_item, list) else [media_item]
+        queue_items = []
+        for media_item in media_items:
+            # collect tracks to play
+            if media_item.media_type == MediaType.Artist:
+                tracks = await self.mass.music.artist_toptracks(media_item.item_id, 
+                        provider=media_item.provider)
+            elif media_item.media_type == MediaType.Album:
+                tracks = await self.mass.music.album_tracks(media_item.item_id, 
+                        provider=media_item.provider)
+            elif media_item.media_type == MediaType.Playlist:
+                tracks = await self.mass.music.playlist_tracks(media_item.item_id, 
+                        provider=media_item.provider, offset=0, limit=0) 
+            else:
+                tracks = [media_item] # single track
+            for track in tracks:
+                queue_item = QueueItem(track)
+                # generate uri for this queue item
+                queue_item.uri = 'http://%s:%s/stream/%s?queue_item_id=%s'% (
+                        self.mass.web.local_ip, self.mass.web.http_port, player_id, queue_item.queue_item_id)
+                # sort by quality and check track availability
+                for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True):
+                    queue_item.provider = prov_media['provider']
+                    queue_item.item_id = prov_media['item_id']
+                    queue_item.quality = prov_media['quality']
+                    # TODO: check track availability
+                    # TODO: handle direct stream capability
+                    queue_items.append(queue_item)
+                    break
+        # load items into the queue
+        if queue_opt == 'replace' or (queue_opt in ['next', 'play'] and len(queue_items) > 50):
+            return await player.queue.load(queue_items)
+        elif queue_opt == 'next':
+            return await player.queue.insert(queue_items, 1)
+        elif queue_opt == 'play':
+            return await player.queue.insert(queue_items, 0)
+        elif queue_opt == 'add':
+            return await player.queue.append(queue_items)
+    
+    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 = importlib.import_module("." + module_name, "music_assistant.playerproviders")
+                    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/playerproviders/__init__.py b/music_assistant/playerproviders/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/music_assistant/playerproviders/chromecast.py b/music_assistant/playerproviders/chromecast.py
new file mode 100644 (file)
index 0000000..7441b6c
--- /dev/null
@@ -0,0 +1,374 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import aiohttp
+from typing import List
+import pychromecast
+from pychromecast.controllers.multizone import MultizoneController
+from pychromecast.controllers import BaseController
+from pychromecast.controllers.media import MediaController
+import types
+
+from ..utils import run_periodic, LOGGER, try_parse_int
+from ..models.playerprovider import PlayerProvider
+from ..models.player import Player, PlayerState
+from ..models.player_queue import QueueItem, PlayerQueue
+from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
+
+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 ChromecastPlayer(Player):
+    ''' Chromecast player object '''
+    
+    async def cmd_stop(self):
+        ''' send stop command to player '''
+        self.cc.media_controller.stop()
+
+    async def cmd_play(self):
+        ''' send play command to player '''
+        self.cc.media_controller.play()
+
+    async def cmd_pause(self):
+        ''' send pause command to player '''
+        self.cc.media_controller.pause()
+
+    async def cmd_next(self):
+        ''' send next track command to player '''
+        return await self.cc.media_controller.queue_next()
+
+    async def cmd_previous(self):
+        ''' [CAN OVERRIDE] send previous track command to player '''
+        return await self.cc.media_controller.queue_prev()
+    
+    async def cmd_power_on(self):
+        ''' send power ON command to player '''
+        self.powered = True
+
+    async def cmd_power_off(self):
+        ''' send power OFF command to player '''
+        self.powered = False
+        # power is not supported so send quit_app instead
+        if not self.group_parent:
+            self.cc.quit_app()
+
+    async def cmd_volume_set(self, volume_level):
+        ''' send new volume level command to player '''
+        self.cc.set_volume(volume_level/100)
+        self.volume_level = volume_level
+
+    async def cmd_volume_mute(self, is_muted=False):
+        ''' send mute command to player '''
+        self.cc.set_volume_muted(is_muted)
+
+    async def cmd_play_uri(self, uri:str):
+        ''' play single uri on player '''
+        self.cc.play_media(uri, 'audio/flac')
+
+    async def cmd_queue_load(self, queue_items:List[QueueItem]):
+        ''' load (overwrite) queue with new items '''
+        cc_queue_items = await self.__create_queue_items(queue_items[:50])
+        queuedata = { 
+                "type": 'QUEUE_LOAD',
+                "repeatMode":  "REPEAT_ALL" if self.queue.repeat_enabled else "REPEAT_OFF",
+                "shuffle": self.queue.shuffle_enabled,
+                "queueType": "PLAYLIST",
+                "startIndex":    0,    # Item index to play after this request or keep same item if undefined
+                "items": cc_queue_items # only load 50 tracks at once or the socket will crash
+        }
+        await self.__send_player_queue(queuedata)
+        await asyncio.sleep(0.2)
+        if len(queue_items) > 50:
+            await self.cmd_queue_append(queue_items[51:])
+            await asyncio.sleep(0.2)
+
+    async def cmd_queue_insert(self, queue_items:List[QueueItem], offset=0):
+        ''' 
+            insert new items at offset x from current position
+            keeps remaining items in queue
+            if offset 0 or None, will start playing newly added item(s)
+            :param queue_items: a list of QueueItem
+            :param offset: offset from current queue position
+        '''
+        insert_before = self.queue.cur_index + offset
+        cc_queue_items = await self.__create_queue_items(queue_items)
+        for chunk in chunks(cc_queue_items, 50):
+            queuedata = { 
+                        "type": 'QUEUE_INSERT',
+                        "insertBefore":     insert_before,
+                        "items":            chunk
+                }
+            await self.__send_player_queue(queuedata)
+
+    async def cmd_queue_append(self, queue_items:List[QueueItem]):
+        ''' 
+            append new items at the end of the queue
+        '''
+        cc_queue_items = await self.__create_queue_items(queue_items)
+        for chunk in chunks(cc_queue_items, 50):
+            queuedata = { 
+                        "type": 'QUEUE_INSERT',
+                        "insertBefore":     None,
+                        "items":            chunk
+                }
+            await self.__send_player_queue(queuedata)
+
+    async def __create_queue_items(self, tracks):
+        ''' create list of CC queue items from tracks '''
+        queue_items = []
+        for track in tracks:
+            queue_item = await self.__create_queue_item(track)
+            queue_items.append(queue_item)
+        return queue_items
+
+    async def __create_queue_item(self, track):
+        '''create CC queue item from track info '''
+        return {
+            'autoplay' : True,
+            'preloadTime' : 10,
+            'playbackDuration': int(track.duration),
+            'startTime' : 0,
+            'activeTrackIds' : [],
+            'media': {
+                'contentId':  track.uri,
+                'customData': {
+                    'provider': track.provider, 
+                    'uri': track.uri, 
+                    'item_id': track.item_id
+                },
+                'contentType': "audio/flac",
+                'streamType': 'BUFFERED',
+                'metadata': {
+                    'title': track.name,
+                    'artist': track.artists[0].name if track.artists else "",
+                },
+                'duration': int(track.duration)
+            }
+        }
+        
+    async def __send_player_queue(self, queuedata):
+        '''send new data to the CC queue'''
+        media_controller = self.cc.media_controller
+        receiver_ctrl = media_controller._socket_client.receiver_controller
+        def send_queue():
+                """Plays media after chromecast has switched to requested app."""
+                queuedata['mediaSessionId'] = media_controller.status.media_session_id
+                media_controller.send_message(queuedata, inc_session_id=False)
+        if not media_controller.status.media_session_id:
+            receiver_ctrl.launch_app(media_controller.app_id, callback_function=send_queue)
+        else:
+            send_queue()
+        await asyncio.sleep(0.2)
+
+class ChromecastProvider(PlayerProvider):
+    ''' support for ChromeCast Audio '''
+    
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.prov_id = 'chromecast'
+        self.name = 'Chromecast'
+        self._discovery_running = False
+        self.mass.event_loop.create_task(self.__periodic_chromecast_discovery())
+
+    async def get_player_config_entries(self):
+        ''' get the player config entries for this provider (list with key/value pairs)'''
+        return [
+            ("gapless_enabled", False, "gapless_enabled")
+            ]
+
+    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 = await self.get_player(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
+        if mediastatus:
+            # chromecast does not support power on/of so we only set state
+            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_uri = mediastatus.content_id
+            player.cur_time = mediastatus.adjusted_current_time
+
+    async def __handle_group_members_update(self, mz, added_player=None, removed_player=None):
+        ''' callback when cast group members update '''
+        if added_player:
+            player = await self.get_player(added_player)
+            group_player = await self.get_player(str(mz._uuid))
+            if player and group_player:
+                player.group_parent = str(mz._uuid)
+                LOGGER.debug("player %s added to group %s" %(player.name, group_player.name))
+        elif removed_player:
+            player = await self.get_player(added_player)
+            group_player = await self.get_player(str(mz._uuid))
+            if player and group_player:
+                player.group_parent = None
+                LOGGER.debug("player %s removed from group %s" %(player.name, group_player.name))
+        else:
+            for member in mz.members:
+                player = await self.get_player(member)
+                if player:
+                    player.group_parent = str(mz._uuid)
+    
+    @run_periodic(1800)
+    async def __periodic_chromecast_discovery(self):
+        ''' run chromecast discovery on interval '''
+        await self.__chromecast_discovery()
+
+    async def __chromecast_discovery(self):
+        ''' background non-blocking chromecast discovery and handler '''
+        if self._discovery_running:
+            return
+        self._discovery_running = True
+        LOGGER.info("Chromecast discovery started...")
+        # remove any disconnected players...
+        removed_players = []
+        for player in self.players:
+            if not player.cc.socket_client or not player.cc.socket_client.is_connected:
+                LOGGER.info("%s is disconnected" % player.name)
+                # cleanup cast object
+                del player.cc
+                removed_players.append(player.player_id)
+        # signal removed players
+        for player_id in removed_players:
+            await self.remove_player(player_id)
+        # search for available chromecasts
+        from pychromecast.discovery import start_discovery, stop_discovery
+        def discovered_callback(name):
+            """Called when zeroconf has discovered a (new) chromecast."""
+            discovery_info = listener.services[name]
+            ip_address, port, uuid, model_name, friendly_name = discovery_info
+            player_id = str(uuid)
+            player = self.mass.bg_executor.submit(asyncio.run, 
+                self.get_player(player_id)).result()
+            if not player:
+                LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port))
+                asyncio.run_coroutine_threadsafe(
+                        self.__chromecast_discovered(player_id, discovery_info), self.mass.event_loop)
+        listener, browser = start_discovery(discovered_callback)
+        await asyncio.sleep(15) # run discovery for 15 seconds
+        stop_discovery(browser)
+        LOGGER.info("Chromecast discovery completed...")
+        self._discovery_running = False
+    
+    async def __chromecast_discovered(self, player_id, discovery_info):
+        ''' callback when a (new) chromecast device is discovered '''
+        from pychromecast import _get_chromecast_from_host, ChromecastConnectionError
+        try:
+            chromecast = _get_chromecast_from_host(discovery_info, tries=2, retry_wait=5)
+        except ChromecastConnectionError:
+            LOGGER.warning("Could not connect to device %s" % player_id)
+            return
+        # patch the receive message method for handling queue status updates
+        chromecast.media_controller.queue_items = []
+        chromecast.media_controller.queue_cur_id = None
+        chromecast.media_controller.receive_message = types.MethodType(receive_message, chromecast.media_controller)
+        listenerCast = StatusListener(chromecast, self.__handle_player_state, self.mass.event_loop)
+        chromecast.register_status_listener(listenerCast)
+        listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop)
+        chromecast.media_controller.register_status_listener(listenerMedia)
+        player = ChromecastPlayer(self.mass, player_id, self.prov_id)
+        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.mz = mz
+        player.cc = chromecast
+        player.cc.wait()
+        await self.add_player(player)
+        await self.update_all_group_members()
+
+    async def update_all_group_members(self):
+        ''' force member update of all cast groups '''
+        for player in self.players:
+            if player.cc.cast_type == 'group':
+                player.cc.mz.update_members()
+
+
+def chunks(l, n):
+    """Yield successive n-sized chunks from l."""
+    for i in range(0, len(l), n):
+        yield l[i:i + n]
+
+
+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)
+
+def receive_message(self, message, data):
+    """ Called when a media message is received. """
+    #LOGGER.info('message: %s - data: %s'%(message, data))
+    if data['type'] == 'MEDIA_STATUS':
+        try:
+            self.queue_items = data['status'][0]['items']
+        except:
+            pass
+        try:
+            self.queue_cur_id = data['status'][0]['currentItemId']
+        except:
+            pass
+        self._process_media_status(data)
+        return True
+    return False
\ No newline at end of file
diff --git a/music_assistant/playerproviders/lms.py b/music_assistant/playerproviders/lms.py
new file mode 100644 (file)
index 0000000..ad5b5e3
--- /dev/null
@@ -0,0 +1,322 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import random
+import sys
+import json
+import aiohttp
+import time
+import datetime
+import hashlib
+from asyncio_throttle import Throttler
+from aiocometd import Client, ConnectionType, Extension
+import copy
+import urllib
+
+from ..cache import use_cache
+from ..utils import run_periodic, LOGGER, parse_track_title
+from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
+from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT
+
+
+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, False, 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.last_msg_received = 0
+        self.supported_musicproviders = ['qobuz', 'file', 'spotify', 'http']
+        self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+        # we use a combi of active polling and subscriptions because the cometd implementation of LMS is somewhat unreliable
+        asyncio.ensure_future(self.__lms_events())
+        asyncio.ensure_future(self.__get_players())            
+
+    ### Provider specific implementation #####
+
+    async def player_config_entries(self):
+        ''' get the player config entries for this provider (list with key/value pairs)'''
+        return []
+
+    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 == 'off':
+            lms_commands = ['power', '0']
+        elif cmd == 'power':
+            lms_commands = ['power', '1']
+        elif cmd == 'volume':
+            lms_commands = ['mixer', 'volume', cmd_args]
+        elif cmd == 'mute' and cmd_args == 'off':
+            lms_commands = ['mixer', 'muting', '0']
+        elif cmd == 'mute':
+            lms_commands = ['mixer', 'muting', '1']
+        return await self.__get_data(lms_commands, player_id=player_id)
+
+    async def play_media(self, player_id, media_items, queue_opt='play'):
+        ''' 
+            play media on a player
+        '''
+        if queue_opt == 'play':
+            cmd = ['playlist', 'insert', media_items[0].uri]
+            await self.__get_data(cmd, player_id=player_id)
+            cmd = ['playlist', 'index', '+1']
+            await self.__get_data(cmd, player_id=player_id)
+            for track in media_items[1:]:
+                cmd = ['playlist', 'insert', track.uri]
+                await self.__get_data(cmd, player_id=player_id)
+        elif queue_opt == 'replace':
+            cmd = ['playlist', 'play', media_items[0].uri]
+            await self.__get_data(cmd, player_id=player_id)
+            for track in media_items[1:]:
+                cmd = ['playlist', 'add', track.uri]
+                await self.__get_data(cmd, player_id=player_id)
+        elif queue_opt == 'next':
+            for track in media_items:
+                cmd = ['playlist', 'insert', track.uri]
+                await self.__get_data(cmd, player_id=player_id)
+        else:
+            for track in media_items:
+                cmd = ['playlist', 'add', track.uri]
+                await self.__get_data(cmd, player_id=player_id)
+    
+    async def player_queue(self, player_id, offset=0, limit=50):
+        ''' return the items in the player's queue '''
+        items = []
+        player_details = await self.__get_data(["status", offset, limit, "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id)
+        if 'playlist_loop' in player_details:
+            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
+        if 'power' in player_details:
+            player.powered = player_details['power'] == 1
+        else:
+            print(player_details) # DEBUG
+        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_time = player_details.get('time',0)
+        else:
+            player.cur_item = None
+            player.cur_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:
+                    if child_player_id in self._players:
+                        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)
+        elif track_url.startswith('http') and '/stream' in track_url:
+            params = urllib.parse.parse_qs(track_url.split('?')[1])
+            track_id = params['track_id'][0]
+            provider = params['provider'][0]
+            return await self.mass.music.providers[provider].track(track_id)
+        # fallback to a generic track
+        track = Track()
+        track.name = track_details['title']
+        track.duration = int(track_details['duration'])
+        if 'artwork_url' in track_details:
+            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 self.mass.event_loop.is_running():
+            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/playerproviders/pylms.py b/music_assistant/playerproviders/pylms.py
new file mode 100644 (file)
index 0000000..0a83f53
--- /dev/null
@@ -0,0 +1,800 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+import struct
+from collections import OrderedDict
+import time
+import decimal
+from typing import List
+import random
+import sys
+import socket
+from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, get_ip, get_hostname
+from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
+from ..constants import CONF_ENABLED
+
+
+def setup(mass):
+    ''' setup the provider'''
+    enabled = mass.config["playerproviders"]['pylms'].get(CONF_ENABLED)
+    if enabled:
+        provider = PyLMSServer(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 PyLMSServer(PlayerProvider):
+    ''' Python implementation of SlimProto server '''
+
+    def __init__(self, mass):
+        self.prov_id = 'pylms'
+        self.name = 'Logitech Media Server Emulation'
+        self.mass = mass
+        self._lmsplayers = {}
+        self.buffer = b''
+        self.last_msg_received = 0
+        
+        # start slimproto server
+        mass.event_loop.create_task(asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483))
+        # setup discovery
+        mass.event_loop.create_task(self.start_discovery())
+
+     ### Provider specific implementation #####
+
+    
+    async def start_discovery(self):
+        transport, protocol = await self.mass.event_loop.create_datagram_endpoint(
+            lambda: DiscoveryProtocol(self.mass.web._http_port),
+        local_addr=('0.0.0.0', 3483))
+        try:
+            while True:
+                await asyncio.sleep(60)  # serve forever
+        finally:
+            transport.close()
+
+    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':
+            if self._players[player_id].state == PlayerState.Stopped:
+                await self.__queue_play(player_id, None)
+            else:
+                self._lmsplayers[player_id].unpause()
+        elif cmd == 'pause':
+            self._lmsplayers[player_id].pause()
+        elif cmd == 'stop':
+            self._lmsplayers[player_id].stop()
+        elif cmd == 'next':
+            self._lmsplayers[player_id].next()
+        elif cmd == 'previous':
+             await self.__queue_previous(player_id)
+        elif cmd == 'power' and cmd_args == 'off':
+            self._lmsplayers[player_id].power_off()
+        elif cmd == 'power':
+            self._lmsplayers[player_id].power_on()
+        elif cmd == 'volume':
+            self._lmsplayers[player_id].volume_set(try_parse_int(cmd_args))
+        elif cmd == 'mute' and cmd_args == 'off':
+            self._lmsplayers[player_id].unmute()
+        elif cmd == 'mute':
+            self._lmsplayers[player_id].mute()
+    
+    async def play_media(self, player_id, media_items, queue_opt='play'):
+        ''' 
+            play media on a player
+        '''
+        player = await self.get_player(player_id)
+        cur_index = player.cur_queue_index
+
+        if queue_opt == 'replace' or not player.queue:
+            # overwrite queue with new items
+            player.queue = media_items
+            await self.__queue_play(player_id, 0, send_flush=True)
+        elif queue_opt == 'play':
+            # replace current item with new item(s)
+            player.queue = player.queue[player_id][:cur_index] + media_items + player.queue[player_id][cur_index+1:]
+            await self.__queue_play(player_id, cur_index, send_flush=True)
+        elif queue_opt == 'next':
+            # insert new items at current index +1
+            player.queue[player_id] = player.queue[player_id][:cur_index+1] + media_items + player.queue[player_id][cur_index+1:]
+        elif queue_opt == 'add':
+            # add new items at end of queue
+            player.queue[player_id] = player.queue[player_id] + media_items
+
+    ### Provider specific (helper) methods #####
+
+    async def __queue_play(self, player_id, index, send_flush=False):
+        ''' send play command to player '''
+        if not player_id in player.queue or not player_id in player.queue_index:
+            return
+        if not player.queue[player_id]:
+            return
+        if index == None:
+            index = player.queue_index[player_id]
+        if len(player.queue[player_id]) >= index:
+            track = player.queue[player_id][index]
+            if send_flush:
+                self._lmsplayers[player_id].flush()
+            self._lmsplayers[player_id].play(track.uri)
+            player.queue_index[player_id] = index
+
+    async def __queue_next(self, player_id):
+        ''' request next track from queue '''
+        if not player_id in player.queue or not player_id in player.queue:
+            return
+        cur_queue_index = player.queue_index[player_id]
+        if len(player.queue[player_id]) > cur_queue_index:
+            new_queue_index = cur_queue_index + 1
+        elif self._players[player_id].repeat_enabled:
+            new_queue_index = 0
+        else:
+            LOGGER.warning("next track requested but no more tracks in queue")
+            return
+        return await self.__queue_play(player_id, new_queue_index)
+
+    async def __queue_previous(self, player_id):
+        ''' request previous track from queue '''
+        if not player_id in player.queue:
+            return
+        cur_queue_index = player.queue_index[player_id]
+        if cur_queue_index == 0 and len(player.queue[player_id]) > 1:
+            new_queue_index = len(player.queue[player_id]) -1
+        elif cur_queue_index == 0:
+            new_queue_index = cur_queue_index
+        else:
+            new_queue_index -= 1
+            player.queue_index[player_id] = new_queue_index
+        return await self.__queue_play(player_id, new_queue_index)
+
+    async def __handle_player_event(self, player_id, event, event_data=None):
+        ''' handle event from player '''
+        if not player_id:
+            return
+        LOGGER.debug("Event from player %s: %s - event_data: %s" %(player_id, event, str(event_data)))
+        lms_player = self._lmsplayers[player_id]
+        if event == "next_track":
+            return await self.__queue_next(player_id)
+        player 
+        if not player_id in self._players:
+            player = MusicPlayer()
+            player.player_id = player_id
+            player.player_provider = self.prov_id
+            self._players[player_id] = player
+            if not player_id in player.queue:
+                player.queue[player_id] = []
+            if not player_id in player.queue_index:
+                player.queue_index[player_id] = 0
+        else:
+            player = self._players[player_id]
+        # update player properties
+        player.name = lms_player.player_name
+        player.volume_level = lms_player.volume_level
+        player.cur_time = lms_player._elapsed_seconds
+        if event == "disconnected":
+            return await self.mass.player.remove_player(player_id)
+        elif event == "power":
+            player.powered = event_data
+        elif event == "state":
+            player.state = event_data
+        if player.queue[player_id]:
+            cur_queue_index = player.queue_index[player_id]
+            player.cur_item = player.queue[player_id][cur_queue_index]
+        # update player details
+        await self.mass.player.update_player(player)
+
+    async def __handle_socket_client(self, reader, writer):
+        ''' handle a client connection on the socket'''
+        LOGGER.debug("new socket client connected")
+        stream_host = get_ip()
+        stream_port = self.mass.config['base']['web']['http_port']
+        lms_player = PyLMSPlayer(stream_host, stream_port)
+
+        def send_frame(command, data):
+            ''' send command to lms player'''
+            packet = struct.pack('!H', len(data) + 4) + command + data
+            writer.write(packet)
+        
+        def handle_event(event, event_data=None):
+            ''' handle events from player'''
+            if event == "connected":
+                self._lmsplayers[lms_player.player_id] = lms_player
+                lms_player.player_settings = self.mass.config['player_settings'][lms_player.player_id]
+            asyncio.create_task(self.__handle_player_event(lms_player.player_id, event, event_data))
+
+        try:
+            @run_periodic(5)
+            async def send_heartbeat():
+                timestamp = int(time.time())
+                data = lms_player.pack_stream(b"t", replayGain=timestamp, flags=0)
+                lms_player.send_frame(b"strm", data)
+
+            lms_player.send_frame = send_frame
+            lms_player.send_event = handle_event
+            heartbeat_task = asyncio.create_task(send_heartbeat())
+            
+            # keep reading bytes from the socket
+            while True:
+                data = await reader.read(64)
+                if data:
+                    lms_player.dataReceived(data)
+                else:
+                    break
+        except Exception as exc:
+            # connection lost ?
+            LOGGER.warning(exc)
+        # disconnect
+        heartbeat_task.cancel()
+        asyncio.create_task(self.__handle_player_event(lms_player.player_id, 'disconnected'))
+
+
+class PyLMSPlayer(object):
+    ''' very basic Python implementation of SlimProto '''
+
+    def __init__(self, stream_host, stream_port):
+        self.buffer = b''
+        #self.display = Display()
+        self.send_frame = None
+        self.send_event = None
+        self.stream_host = stream_host
+        self.stream_port = stream_port
+        self.player_settings = {}
+        self.playback_millis = 0
+        self._volume = PyLMSVolume()
+        self._device_type = None
+        self._mac_address = None
+        self._player_name = None
+        self._last_volume = 0
+        self._last_heartbeat = 0
+        self._elapsed_seconds = 0
+        self._elapsed_milliseconds = 0
+
+    @property
+    def player_name(self):
+        if self._player_name:
+            return self._player_name
+        return "%s - %s" %(self._device_type, self._mac_address)
+
+    @property
+    def player_id(self):
+        return self._mac_address
+
+    @property
+    def volume_level(self):
+        return self._volume.volume
+    
+    def dataReceived(self, data):
+        self.buffer = self.buffer + data
+        if len(self.buffer) > 8:
+            operation, length = self.buffer[:4], self.buffer[4:8]
+            length = struct.unpack('!I', length)[0]
+            plen = length + 8
+            if len(self.buffer) >= plen:
+                packet, self.buffer = self.buffer[8:plen], self.buffer[plen:]
+                operation = operation.strip(b"!").strip().decode()
+                #LOGGER.info("operation: %s" % operation)
+                handler = getattr(self, "process_%s" % operation, None)
+                if handler is None:
+                    raise NotImplementedError
+                handler(packet)
+
+    def send_version(self):
+        self.send_frame(b'vers', b'7.8')
+
+    def pack_stream(self, command, autostart=b"1", formatbyte = b'o', pcmargs = (b'?',b'?',b'?',b'?'), threshold = 200,
+                    spdif = b'0', transDuration = 0, transType = b'0', flags = 0x40, outputThreshold = 0,
+                    replayGain=0, serverPort = 8095, serverIp = 0):
+        return struct.pack("!cccccccBcBcBBBLHL",
+                           command, autostart, formatbyte, *pcmargs,
+                           threshold, spdif, transDuration, transType,
+                           flags, outputThreshold, 0, replayGain, serverPort, serverIp)
+
+    def stop(self):
+        data = self.pack_stream(b"q", autostart=b"0", flags=0)
+        self.send_frame(b"strm", data)
+
+    def flush(self):
+        data = self.pack_stream(b"f", autostart=b"0", flags=0)
+        self.send_frame(b"strm", data)
+
+    def pause(self):
+        data = self.pack_stream(b"p", autostart=b"0", flags=0)
+        self.send_frame(b"strm", data)
+        LOGGER.info("Sending pause request")
+
+    def unpause(self):
+        data = self.pack_stream(b"u", autostart=b"0", flags=0)
+        self.send_frame(b"strm", data)
+        LOGGER.info("Sending unpause request")
+
+    def next(self):
+        data = self.pack_stream(b"f", autostart=b"0", flags=0)
+        self.send_frame(b"strm", data)
+        self.send_event("next_track")
+
+    def previous(self):
+        data = self.pack_stream(b"f", autostart=b"0", flags=0)
+        self.send_frame(b"strm", data)
+        self.send_event("previous_track")
+
+    def power_on(self):
+        self.send_frame(b"aude", struct.pack("2B", 1, 1))
+        self.send_event("power", True)
+
+    def power_off(self):
+        self.stop()
+        self.send_frame(b"aude", struct.pack("2B", 0, 0))
+        self.send_event("power", False)
+
+    def mute_on(self):
+        self.send_frame(b"aude", struct.pack("2B", 0, 0))
+        self.send_event("mute", True)
+
+    def mute_off(self):
+        self.send_frame(b"aude", struct.pack("2B", 1, 1))
+        self.send_event("mute", False)
+
+    def volume_up(self):
+        self._volume.increment()
+        self.send_volume()
+
+    def volume_down(self):
+        self._volume.decrement()
+        self.send_volume()
+
+    def volume_set(self, new_vol):
+        self._volume.volume = new_vol
+        self.send_volume()
+    
+    def play(self, uri):
+        enable_crossfade = self.player_settings["crossfade_duration"] > 0
+        command = b's'
+        autostart = b'3' # we use direct stream for now so let the player do the messy work with buffers
+        transType= b'1' if enable_crossfade else b'0'
+        transDuration = self.player_settings["crossfade_duration"]
+        formatbyte = b'f' # fixed to flac
+        uri = '/stream' + uri.split('/stream')[1]
+        data = self.pack_stream(command, autostart=autostart, flags=0x00, formatbyte=formatbyte, transType=transType, transDuration=transDuration)
+        headers = "Connection: close\r\nAccept: */*\r\nHost: %s:%s\r\n" %(self.stream_host, self.stream_port)
+        request = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers)
+        data = data + request.encode("utf-8")
+        self.send_frame(b'strm', data)
+        LOGGER.info("Requesting play from squeezebox" )
+
+    def displayTrack(self, track):
+        self.render("%s by %s" % (track.title, track.artist))
+
+    def process_HELO(self, data):
+        (devId, rev, mac) = struct.unpack('BB6s', data[:8])
+        device_mac = ':'.join("%02x" % x for x in mac)
+        self._device_type = devices.get(devId, 'unknown device')
+        self._mac_address = str(device_mac).lower()
+        LOGGER.debug("HELO received from %s %s" % (self._mac_address, self._device_type))
+        self.init_client()
+
+    def init_client(self):
+        ''' initialize a new connected client '''
+        self.send_event("connected")
+        self.send_version()
+        self.stop()
+        self.setBrightness()
+        #self.set_visualisation(SpectrumAnalyser())
+        self.send_frame(b"setd", struct.pack("B", 0))
+        self.send_frame(b"setd", struct.pack("B", 4))
+        self.power_on()
+        self.volume_set(40) # TODO: remember last volume
+        
+    def send_volume(self):
+        og = self._volume.old_gain()
+        ng = self._volume.new_gain()
+        LOGGER.info("Volume set to %d (%d/%d)" % (self._volume.volume, og, ng))
+        d = self.send_frame(b"audg", struct.pack("!LLBBLL", og, og, 1, 255, ng, ng))
+        self.send_event("volume", self._volume.volume)
+
+    def setBrightness(self, level=4):
+        assert 0 <= level <= 4
+        self.send_frame(b"grfb", struct.pack("!H", level))
+
+    def set_visualisation(self, visualisation):
+        self.send_frame(b"visu", visualisation.pack())
+
+    def render(self, text):
+        #self.display.clear()
+        #self.display.renderText(text, "DejaVu-Sans", 16, (0,0))
+        #self.updateDisplay(self.display.frame())
+        pass
+
+    def updateDisplay(self, bitmap, transition = 'c', offset=0, param=0):
+        frame = struct.pack("!Hcb", offset, transition, param) + bitmap
+        self.send_frame(b"grfe", frame)
+
+    def process_STAT(self, data):
+        ev = data[:4]
+        if ev == b'\x00\x00\x00\x00':
+            LOGGER.info("Presumed informational stat message")
+        else:
+            handler = getattr(self, 'stat_%s' % ev.decode(), None)
+            if handler is None:
+                raise NotImplementedError("Stat message %r not known" % ev)
+            handler(data[4:])
+
+    def stat_aude(self, data):
+        (spdif_enable, dac_enable) = struct.unpack("2B", data[:4])
+        powered = spdif_enable or dac_enable
+        self.send_event("power", powered)
+        LOGGER.debug("ACK aude - Received player power: %s" % powered)
+
+    def stat_audg(self, data):
+        LOGGER.info("Received volume_level from player %s" % data)
+        self.send_event("volume", self._volume.volume)
+
+    def stat_strm(self, data):
+        LOGGER.debug("ACK strm")
+        #self.send_frame(b"cont", b"0")
+
+    def stat_STMc(self, data):
+        LOGGER.debug("Status Message: Connect")
+
+    def stat_STMd(self, data):
+        LOGGER.debug("Decoder Ready for next track")
+        self.send_event("next_track")
+
+    def stat_STMe(self, data):
+        LOGGER.info("Connection established")
+
+    def stat_STMf(self, data):
+        LOGGER.info("Status Message: Connection closed")
+        self.send_event("state", PlayerState.Stopped)
+
+    def stat_STMh(self, data):
+        LOGGER.info("Status Message: End of headers")
+
+    def stat_STMn(self, data):
+        LOGGER.error("Decoder does not support file format")
+
+    def stat_STMo(self, data):
+        ''' No more decoded (uncompressed) data to play; triggers rebuffering. '''
+        LOGGER.debug("Output Underrun")
+        
+    def stat_STMp(self, data):
+        '''Pause confirmed'''
+        self.send_event("state", PlayerState.Paused)
+
+    def stat_STMr(self, data):
+        '''Resume confirmed'''
+        self.send_event("state", PlayerState.Playing)
+
+    def stat_STMs(self, data):
+        '''Playback of new track has started'''
+        self.send_event("state", PlayerState.Playing)
+
+    def stat_STMt(self, data):
+        """ heartbeat from client """
+        timestamp = time.time()
+        self._last_heartbeat = timestamp
+        (num_crlf, mas_initialized, mas_mode, rptr, wptr, 
+        bytes_received_h, bytes_received_l, signal_strength, 
+        jiffies, output_buffer_size, output_buffer_fullness, 
+        elapsed_seconds, voltage, elapsed_milliseconds, 
+        server_timestamp, error_code) = struct.unpack("!BBBLLLLHLLLLHLLH", data)
+        if elapsed_seconds != self._elapsed_seconds:
+            self.send_event("progress")
+        self._elapsed_seconds = elapsed_seconds
+        self._elapsed_milliseconds = elapsed_milliseconds
+
+    def stat_STMu(self, data):
+        '''Normal end of playback'''
+        LOGGER.info("End of playback - Underrun")
+        self.send_event("state", PlayerState.Stopped)
+
+    def process_BYE(self, data):
+        LOGGER.info("BYE received")
+        self.send_event("disconnected")
+
+    def process_RESP(self, data):
+        LOGGER.info("RESP received")
+        self.send_frame(b"cont", b"0")
+
+    def process_BODY(self, data):
+        LOGGER.info("BODY received")
+
+    def process_META(self, data):
+        LOGGER.info("META received")
+
+    def process_DSCO(self, data):
+        LOGGER.info("Data Stream Disconnected")
+
+    def process_DBUG(self, data):
+        LOGGER.info("DBUG received")
+
+    def process_IR(self, data):
+        """ Slightly involved codepath here. This raises an event, which may
+        be picked up by the service and then the process_remote_* function in
+        this player will be called. This is mostly relevant for volume changes
+        - most other button presses will require some context to operate. """
+        (time, code) = struct.unpack("!IxxI", data)
+        LOGGER.info("IR code %s" % code)
+        # command = Remote.codes.get(code, None)
+        # if command is not None:
+        #     LOGGER.info("IR received: %r, %r" % (code, command))
+        #     #self.service.evreactor.fireEvent(RemoteButtonPressed(self, command))
+        # else:
+        #     LOGGER.info("Unknown IR received: %r, %r" % (time, code))
+
+    def process_RAWI(self, data):
+        LOGGER.info("RAWI received")
+
+    def process_ANIC(self, data):
+        LOGGER.info("ANIC received")
+
+    def process_BUTN(self, data):
+        LOGGER.info("BUTN received")
+
+    def process_KNOB(self, data):
+        ''' Transporter only, knob-related '''
+        LOGGER.info("KNOB received")
+
+    def process_SETD(self, data):
+        ''' Get/set player firmware settings '''
+        LOGGER.debug("SETD received %s" % data)
+        cmd_id = data[0]
+        if cmd_id == 0:
+            # received player name
+            data = data[1:].decode()
+            self._player_name = data
+            self.send_event("name")
+
+    def process_UREQ(self, data):
+        LOGGER.info("UREQ received")
+
+
+
+# from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO
+devices = {
+    2: 'squeezebox',
+    3: 'softsqueeze',
+    4: 'squeezebox2',
+    5: 'transporter',
+    6: 'softsqueeze3',
+    7: 'receiver',
+    8: 'squeezeslave',
+    9: 'controller',
+    10: 'boom',
+    11: 'softboom',
+    12: 'squeezeplay',
+    }
+
+
+class PyLMSVolume(object):
+
+    """ Represents a sound volume. This is an awful lot more complex than it
+    sounds. """
+
+    minimum = 0
+    maximum = 100
+    step = 1
+
+    # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source
+    # i don't know how much magic it contains, or any way I can test it
+    old_map = [
+        0, 1, 1, 1, 2, 2, 2, 3,  3,  4,
+        5, 5, 6, 6, 7, 8, 9, 9, 10, 11,
+        12, 13, 14, 15, 16, 16, 17, 18, 19, 20,
+        22, 23, 24, 25, 26, 27, 28, 29, 30, 32,
+        33, 34, 35, 37, 38, 39, 40, 42, 43, 44,
+        46, 47, 48, 50, 51, 53, 54, 56, 57, 59,
+        60, 61, 63, 65, 66, 68, 69, 71, 72, 74,
+        75, 77, 79, 80, 82, 84, 85, 87, 89, 90,
+        92, 94, 96, 97, 99, 101, 103, 104, 106, 108, 110,
+        112, 113, 115, 117, 119, 121, 123, 125, 127, 128
+        ];
+
+    # new gain parameters, from the same place
+    total_volume_range = -50 # dB
+    step_point = -1           # Number of steps, up from the bottom, where a 2nd volume ramp kicks in.
+    step_fraction = 1         # fraction of totalVolumeRange where alternate volume ramp kicks in.
+
+    def __init__(self):
+        self.volume = 50
+
+    def increment(self):
+        """ Increment the volume """
+        self.volume += self.step
+        if self.volume > self.maximum:
+            self.volume = self.maximum
+
+    def decrement(self):
+        """ Decrement the volume """
+        self.volume -= self.step
+        if self.volume < self.minimum:
+            self.volume = self.minimum
+
+    def old_gain(self):
+        """ Return the "Old" gain value as required by the squeezebox """
+        return self.old_map[self.volume]
+
+    def decibels(self):
+        """ Return the "new" gain value. """
+
+        step_db = self.total_volume_range * self.step_fraction
+        max_volume_db = 0 # different on the boom?
+
+        # Equation for a line:
+        # y = mx+b
+        # y1 = mx1+b, y2 = mx2+b.
+        # y2-y1 = m(x2 - x1)
+        # y2 = m(x2 - x1) + y1
+        slope_high = max_volume_db - step_db / (100.0 - self.step_point)
+        slope_low = step_db - self.total_volume_range / (self.step_point - 0.0)
+        x2 = self.volume
+        if (x2 > self.step_point):
+            m = slope_high
+            x1 = 100
+            y1 = max_volume_db
+        else:
+            m = slope_low
+            x1 = 0
+            y1 = self.total_volume_range
+        return m * (x2 - x1) + y1
+
+    def new_gain(self):
+        db = self.decibels()
+        floatmult = 10 ** (db/20.0)
+        # avoid rounding errors somehow
+        if -30 <= db <= 0:
+            return int(floatmult * (1 << 8) + 0.5) * (1<<8)
+        else:
+            return int((floatmult * (1<<16)) + 0.5)
+
+
+##### UDP DISCOVERY STUFF #############
+
+class Datagram(object):
+
+    @classmethod
+    def decode(self, data):
+        if data[0] == 'e':
+            return TLVDiscoveryRequestDatagram(data)
+        elif data[0] == 'E':
+            return TLVDiscoveryResponseDatagram(data)
+        elif data[0] == 'd':
+            return ClientDiscoveryDatagram(data)
+        elif data[0] == 'h':
+            pass # Hello!
+        elif data[0] == 'i':
+            pass # IR
+        elif data[0] == '2':
+            pass # i2c?
+        elif data[0] == 'a':
+            pass # ack!
+
+class ClientDiscoveryDatagram(Datagram):
+
+    device = None
+    firmware = None
+    client = None
+
+    def __init__(self, data):
+        s = struct.unpack('!cxBB8x6B', data.encode())
+        assert  s[0] == 'd'
+        self.device = s[1]
+        self.firmware = hex(s[2])
+        self.client = ":".join(["%02x" % (x,) for x in s[3:]])
+
+    def __repr__(self):
+        return "<%s device=%r firmware=%r client=%r>" % (self.__class__.__name__, self.device, self.firmware, self.client)
+
+class DiscoveryResponseDatagram(Datagram):
+
+    def __init__(self, hostname, port):
+        hostname = hostname[:16].encode("UTF-8")
+        hostname += (16 - len(hostname)) * '\x00'
+        self.packet = struct.pack('!c16s', 'D', hostname).decode()
+
+class TLVDiscoveryRequestDatagram(Datagram):
+    
+    def __init__(self, data):
+        requestdata = OrderedDict()
+        assert data[0] == 'e'
+        idx = 1
+        length = len(data)-5
+        while idx <= length:
+            typ, l = struct.unpack_from("4sB", data.encode(), idx)
+            if l:
+                val = data[idx+5:idx+5+l]
+                idx += 5+l
+            else:
+                val = None
+                idx += 5
+            typ = typ.decode()
+            requestdata[typ] = val
+        self.data = requestdata
+            
+    def __repr__(self):
+        return "<%s data=%r>" % (self.__class__.__name__, self.data.items())
+
+class TLVDiscoveryResponseDatagram(Datagram):
+
+    def __init__(self, responsedata):
+        parts = ['E'] # new discovery format
+        for typ, value in responsedata.items():
+            if value is None:
+                value = ''
+            elif len(value) > 255:
+                LOGGER.warning("Response %s too long, truncating to 255 bytes" % typ)
+                value = value[:255]
+            parts.extend((typ, chr(len(value)), value))
+        self.packet = ''.join(parts)
+
+class DiscoveryProtocol():
+
+    def __init__(self, web_port):
+        self.web_port = web_port
+    
+    def connection_made(self, transport):
+        self.transport = transport
+        # Allow receiving multicast broadcasts
+        sock = self.transport.get_extra_info('socket')
+        group = socket.inet_aton('239.255.255.250')
+        mreq = struct.pack('4sL', group, socket.INADDR_ANY)
+        sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+    
+    def build_TLV_response(self, requestdata):
+        responsedata = OrderedDict()
+        for typ, value in requestdata.items():
+            if typ == 'NAME':
+                # send full host name - no truncation
+                value = get_hostname()
+            elif typ == 'IPAD':
+                # send ipaddress as a string only if it is set
+                value = get_ip()
+                # :todo: IPv6
+                if value == '0.0.0.0':
+                    # do not send back an ip address
+                    typ = None
+            elif typ == 'JSON':
+                # send port as a string
+                json_port = self.web_port
+                value = str(json_port)
+            elif typ == 'VERS':
+                # send server version
+                 value = '7.9'
+            elif typ == 'UUID':
+                # send server uuid
+                value = 'musicassistant'
+            else:
+                LOGGER.debug('Unexpected information request: %r', typ)
+                typ = None
+            if typ:
+                responsedata[typ] = value
+        return responsedata
+
+    def datagram_received(self, data, addr):
+        try:
+            data = data.decode()
+            dgram = Datagram.decode(data)
+            LOGGER.debug("Data received from %s: %s" % (addr, dgram))
+            if isinstance(dgram, ClientDiscoveryDatagram):
+                self.sendDiscoveryResponse(addr)
+            elif isinstance(dgram, TLVDiscoveryRequestDatagram):
+                resonsedata = self.build_TLV_response(dgram.data)
+                self.sendTLVDiscoveryResponse(resonsedata, addr)
+        except Exception as exc:
+            LOGGER.exception(exc)
+
+    def sendDiscoveryResponse(self, addr):
+        dgram = DiscoveryResponseDatagram(get_hostname(), 3483)
+        LOGGER.debug("Sending discovery response %r" % (dgram.packet,))
+        self.transport.sendto(dgram.packet.encode(), addr)
+
+    def sendTLVDiscoveryResponse(self, resonsedata, addr):
+        dgram = TLVDiscoveryResponseDatagram(resonsedata)
+        LOGGER.debug("Sending discovery response %r" % (dgram.packet,))
+        self.transport.sendto(dgram.packet.encode(), addr)
+
diff --git a/music_assistant/web.py b/music_assistant/web.py
new file mode 100755 (executable)
index 0000000..739e894
--- /dev/null
@@ -0,0 +1,345 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+import json
+import aiohttp
+from aiohttp import web
+from functools import partial
+import ssl
+import concurrent
+import threading
+from .models.media_types import MediaItem, MediaType, media_type_from_string
+from .models.player import Player
+from .utils import run_periodic, LOGGER, run_async_background_task, get_ip
+
+#json_serializer = partial(json.dumps, default=lambda x: x.__dict__)
+
+def json_serializer(obj):
+    # if isinstance(obj, list):
+    #     lst = []
+    #     for item in obj:
+    #         json_obj = json.dumps(item, skipkeys=True, default=lambda x: x.__dict__)
+    #         lst.append(json_obj)
+    #     return '[' + ','.join(lst) + ']'
+    return json.dumps(obj, skipkeys=True, default=lambda x: x.__dict__)
+
+
+def setup(mass):
+    ''' setup the module and read/apply config'''
+    create_config_entries(mass.config)
+    conf = mass.config['base']['web']
+    if conf['ssl_certificate'] and os.path.isfile(conf['ssl_certificate']):
+        ssl_cert = conf['ssl_certificate']
+    else:
+        ssl_cert = ''
+    if conf['ssl_key'] and os.path.isfile(conf['ssl_key']):
+        ssl_key = conf['ssl_key']
+    else:
+        ssl_key = ''
+    cert_fqdn_host = conf['cert_fqdn_host']
+    http_port = conf['http_port']
+    https_port = conf['https_port']
+    return Web(mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host)
+
+def create_config_entries(config):
+    ''' get the config entries for this module (list with key/value pairs)'''
+    config_entries = [
+        ('http_port', 8095, 'webhttp_port'),
+        ('https_port', 8096, 'web_https_port'),
+        ('ssl_certificate', '', 'web_ssl_cert'), 
+        ('ssl_key', '', 'web_ssl_key'),
+        ('cert_fqdn_host', '', 'cert_fqdn_host')
+        ]
+    if not config['base'].get('web'):
+        config['base']['web'] = {}
+    config['base']['web']['__desc__'] = config_entries
+    for key, def_value, desc in config_entries:
+        if not key in config['base']['web']:
+            config['base']['web'][key] = def_value
+
+class Web():
+    ''' webserver and json/websocket api '''
+    
+    def __init__(self, mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host):
+        self.mass = mass
+        self.local_ip = get_ip()
+        self.http_port = http_port
+        self._https_port = https_port
+        self._ssl_cert = ssl_cert
+        self._ssl_key = ssl_key
+        self._cert_fqdn_host = cert_fqdn_host
+        self.http_session = aiohttp.ClientSession()
+        mass.event_loop.create_task(self.setup_web())
+
+    def stop(self):
+        asyncio.create_task(self.runner.cleanup())
+        asyncio.create_task(self.http_session.close())
+
+    async def setup_web(self):
+        app = web.Application()
+        app.add_routes([web.get('/jsonrpc.js', self.json_rpc)])
+        app.add_routes([web.post('/jsonrpc.js', self.json_rpc)])
+        app.add_routes([web.get('/ws', self.websocket_handler)])
+        # app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)])
+        # app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)])
+        app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.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, access_log=None)
+        await self.runner.setup()
+        http_site = web.TCPSite(self.runner, '0.0.0.0', self.http_port)
+        await http_site.start()
+        if self._ssl_cert and self._ssl_key:
+            ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+            ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key)
+            https_site = web.TCPSite(self.runner, '0.0.0.0', self._https_port, ssl_context=ssl_context)
+            await https_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','')
+        action_details = request.rel_url.query.get('action_details')
+        lazy = request.rel_url.query.get('lazy', '') != 'false'
+        provider = request.rel_url.query.get('provider')
+        if action:
+            result = await self.mass.music.item_action(media_id, media_type, provider, action, action_details)
+        else:
+            result = await self.mass.music.item(media_id, media_type, provider, lazy=lazy)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def artist_toptracks(self, request):
+        ''' get top tracks for given artist '''
+        artist_id = request.match_info.get('artist_id')
+        provider = request.rel_url.query.get('provider')
+        result = await self.mass.music.artist_toptracks(artist_id, provider)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def artist_albums(self, request):
+        ''' get (all) albums for given artist '''
+        artist_id = request.match_info.get('artist_id')
+        provider = request.rel_url.query.get('provider')
+        result = await self.mass.music.artist_albums(artist_id, provider)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def playlist_tracks(self, request):
+        ''' 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))
+        provider = request.rel_url.query.get('provider')
+        result = await self.mass.music.playlist_tracks(playlist_id, provider, offset=offset, limit=limit)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def album_tracks(self, request):
+        ''' get album tracks from provider'''
+        album_id = request.match_info.get('album_id')
+        provider = request.rel_url.query.get('provider')
+        result = await self.mass.music.album_tracks(album_id, provider)
+        return web.json_response(result, dumps=json_serializer)
+
+    async def search(self, request):
+        ''' 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)
+        if not media_types_query or "radios" in media_types_query:
+            media_types.append(MediaType.Radio)
+        # 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 '''
+        return web.json_response(self.mass.player.players, dumps=json_serializer)
+
+    async def player_command(self, request):
+        ''' issue player command'''
+        result = False
+        player_id = request.match_info.get('player_id')
+        player = await self.mass.player.get_player(player_id)
+        if player:
+            cmd = request.match_info.get('cmd')
+            cmd_args = request.match_info.get('cmd_args')
+            player_cmd = getattr(player, cmd, None)
+            if player_cmd and cmd_args:
+                result = await player_cmd(cmd_args)
+            elif player_cmd:
+                result = await player_cmd()
+            else:
+                LOGGER.error("Received non-existing command %s for player %s" %(cmd, player.name))
+        else:
+            LOGGER.error("Received command for non-existing player %s" %(player_id))
+        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','')
+        provider = request.rel_url.query.get('provider')
+        media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True)
+        result = await self.mass.player.play_media(player_id, media_item, queue_opt)
+        return web.json_response(result, dumps=json_serializer) 
+    
+    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))
+        player = await self.mass.player.get_player(player_id)
+        # queue_items = player.queue.items
+        # queue_items = [item.__dict__ for item in queue_items]
+        # print(queue_items)
+        # result = queue_items[offset:limit]
+        return web.json_response(player.queue.items, dumps=json_serializer) 
+    
+    async def index(self, request):  
+        return web.FileResponse("./web/index.html")
+
+    async def websocket_handler(self, request):
+        ''' websockets handler '''
+        cb_id = None
+        ws = None
+        try:
+            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 }
+                await ws.send_json(ws_msg, dumps=json_serializer)
+            cb_id = self.mass.add_event_listener(send_event)
+            # process incoming messages
+            async for msg in ws:
+                if msg.type != aiohttp.WSMsgType.TEXT:
+                    continue
+                # for now we only use WS for (simple) player commands
+                if msg.data == 'players':
+                    ws_msg = {'message': 'players', 'message_details': self.mass.player.players}
+                    await ws.send_json(ws_msg, dumps=json_serializer)
+                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
+                    player = await self.mass.player.get_player(player_id)
+                    player_cmd = getattr(player, cmd, None)
+                    if player_cmd and cmd_args:
+                        result = await player_cmd(cmd_args)
+                    elif player_cmd:
+                        result = await player_cmd()
+        except Exception as exc:
+            LOGGER.exception(exc)
+        finally:
+            self.mass.remove_event_listener(cb_id)
+        LOGGER.debug('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 (partial) config '''
+        LOGGER.debug('save config called from api')
+        new_config = await request.json()
+        config_changed = False
+        for key, value in self.mass.config.items():
+            if isinstance(value, dict):
+                for subkey, subvalue in value.items():
+                    if subkey in new_config[key]:
+                        if self.mass.config[key][subkey] != new_config[key][subkey]:
+                            config_changed = True
+                            self.mass.config[key][subkey] = new_config[key][subkey]
+            elif key in new_config:
+                if self.mass.config[key] != new_config[key]:
+                    config_changed = True
+                    self.mass.config[key] = new_config[key]
+        if config_changed:
+            self.mass.save_config()
+            self.mass.signal_event('config_changed')
+        return web.Response(text='success')
+
+    async def json_rpc(self, request):
+        ''' 
+            implement LMS jsonrpc interface 
+            for some compatability with tools that talk to lms
+            only support for basic commands
+        '''
+        data = await request.json()
+        LOGGER.info("jsonrpc: %s" % data)
+        params = data['params']
+        player_id = params[0]
+        cmds = params[1]
+        cmd_str = " ".join(cmds)
+        if cmd_str in ['play', 'pause', 'stop']:
+            await self.mass.player.player_command(player_id, cmd_str)
+        elif 'power' in cmd_str:
+            args = cmds[1] if len(cmds) > 1 else None
+            await self.mass.player.player_command(player_id, cmd_str, args)
+        elif cmd_str == 'playlist index +1':
+            await self.mass.player.player_command(player_id, 'next')
+        elif cmd_str == 'playlist index -1':
+            await self.mass.player.player_command(player_id, 'previous')
+        elif 'mixer volume' in cmd_str:
+            await self.mass.player.player_command(player_id, 'volume', cmds[2])
+        elif cmd_str == 'mixer muting 1':
+            await self.mass.player.player_command(player_id, 'mute', 'on')
+        elif cmd_str == 'mixer muting 0':
+            await self.mass.player.player_command(player_id, 'mute', 'off')
+        elif cmd_str == 'button volup':
+            await self.mass.player.player_command(player_id, 'volume', 'up')
+        elif cmd_str == 'button voldown':
+            await self.mass.player.player_command(player_id, 'volume', 'down')
+        elif cmd_str == 'button power':
+            await self.mass.player.player_command(player_id, 'power', 'toggle')
+        else:
+            return web.Response(text='command not supported')
+        return web.Response(text='success')
+        
\ No newline at end of file
diff --git a/music_assistant/web/components/headermenu.vue.js b/music_assistant/web/components/headermenu.vue.js
deleted file mode 100755 (executable)
index f0e8566..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-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 app flat dense dark v-if="$globals.windowtitle" > 
-        <div class="title justify-center" style="text-align:center;position:absolute;width:100%;margin-left:-16px;margin-right:0">
-          {{ $globals.windowtitle }}
-        </div>
-        <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-if="$route.path != '/'">
-              <v-icon>arrow_back</v-icon>
-            </v-btn>
-        </v-layout>
-    </v-toolbar>
-    <v-toolbar flat fixed dense dark scroll-off-screen color="transparent" v-if="!$globals.windowtitle" > 
-        <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-on:click="$router.push({path: '/search'})">
-                <v-icon>search</v-icon>
-              </v-btn>
-        </v-layout>
-    </v-toolbar>
-</div>`,
-  props: [],
-  $_veeValidate: {
-    validator: "new"
-  },
-  data() {
-    return {
-      menu: false,
-      items: [
-        { title: this.$t('home'), icon: "home", path: "/" },
-        { title: this.$t('artists'), icon: "person", path: "/artists" },
-        { title: this.$t('albums'), icon: "album", path: "/albums" },
-        { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" },
-        { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" },
-        { title: this.$t('radios'), icon: "radio", path: "/radios" },
-        { title: this.$t('search'), icon: "search", path: "/search" },
-        { title: this.$t('settings'), icon: "settings", path: "/config" }
-      ]
-    }
-  },
-  mounted() { },
-  methods: { }
-})
diff --git a/music_assistant/web/components/infoheader.vue.js b/music_assistant/web/components/infoheader.vue.js
deleted file mode 100644 (file)
index 9d8bc8c..0000000
+++ /dev/null
@@ -1,135 +0,0 @@
-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="isMobile() ? '230' : '370'"\r
-              position="center top" \r
-              :src="getFanartImage()"\r
-              gradient="to bottom, rgba(0,0,0,.65), rgba(0,0,0,.35)"\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
-                                                                       <providericons v-bind:item="info" :height="30" :compact="false"/>\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(info.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>{{ $t('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>{{ $t('add_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>{{ $t('remove_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() ? 60 : 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)\r
-                               return ''\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
-                       else if (this.info.artist && this.info.artist.metadata.fanart)\r
-                               img = this.info.artist.metadata.fanart;\r
-                       return img;\r
-               },\r
-               getThumb() {\r
-                       var img = '';\r
-                       if (!this.info)\r
-                               return ''\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)\r
-                               return ''\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
deleted file mode 100755 (executable)
index 687c69c..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-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(item.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>
-
-          <providericons v-bind:item="item" :height="20" :compact="true" :dark="true" :hiresonly="hideproviders"/>
-
-          <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">{{ $t('remove_library') }}</span>
-                  <span v-if="item.in_library.length == 0">{{ $t('add_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 {}
-  },
-methods: {
-  }
-})
diff --git a/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js
deleted file mode 100755 (executable)
index 840b057..0000000
+++ /dev/null
@@ -1,314 +0,0 @@
-Vue.component("player", {
-  template: `
-  <div>
-
-    <!-- player bar in footer -->
-    <v-footer app light height="auto">
-      
-      <v-card class="flex" tile style="background-color:#e8eaed;">
-        <!-- divider -->
-        <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
-
-        <!-- now playing media -->
-        <v-list-tile avatar ripple>
-
-              <v-list-tile-avatar v-if="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 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>
-
-          </v-list-tile>
-
-          <!-- progress bar -->
-          <div style="color:rgba(0,0,0,.65); height:30px;width:100%; vertical-align: middle; left:15px; right:0; margin-bottom:5px; margin-top:5px">
-            <v-layout row style="vertical-align: middle" v-if="active_player.cur_item">
-              <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>
-
-        <!-- divider -->
-        <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
-
-          <!-- Control buttons -->
-          <v-list-tile light avatar ripple style="margin-bottom:5px;">
-              
-          <!-- player controls -->
-              <v-list-tile-content>
-                  <v-layout row style="content-align: left;vertical-align: middle; margin-top:10px;margin-left:-15px">
-                    <v-btn small icon style="padding:5px;" @click="playerCommand('previous')"><v-icon color="rgba(0,0,0,.54)">skip_previous</v-icon></v-btn>
-                    <v-btn small 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 small 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 small 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:20px;" v-if="active_player_id">
-                  <v-btn x-small flat icon @click="$router.push('/queue/' + active_player_id)">
-                      <v-flex xs12 class="vertical-btn">
-                      <v-icon>queue_music</v-icon>
-                      <span class="caption">{{ $t('queue') }}</span>
-                    </v-flex>    
-                  </v-btn>
-              </v-list-tile-action> 
-
-              <!-- active player volume -->
-              <v-list-tile-action style="padding:20px;" 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 x-small flat icon v-on="on">
-                            <v-flex xs12 class="vertical-btn">
-                            <v-icon>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 x-small flat icon @click="menu = !menu">
-                      <v-flex xs12 class="vertical-btn">
-                      <v-icon>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>
-
-          <!-- add some additional whitespace in standalone mode only -->
-          <v-list-tile avatar ripple style="height:14px" v-if="isInStandaloneMode()"/>
-
-          
-
-      </v-card>
-    </v-footer>
-
-    <!-- players side menu -->
-    <v-navigation-drawer right app clipped temporary v-model="menu">
-        <v-card-title class="headline">
-            <b>{{ $t('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">
-                          {{ $t('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" :active_player="active_player" />
-  </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);
-      this.$globals.loading = true;
-      var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt;
-      axios
-      .get(api_url, {
-        params: {
-          provider: item.provider
-        }
-      })
-      .then(result => {
-        console.log(result.data);
-        this.$globals.loading = false;
-      })
-      .catch(error => {
-        console.log("error", error);
-        this.$globals.loading = false;
-      });
-    },
-    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 == 'player removed')
-          this.players[msg.message_details].enabled = false;
-        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 || !this.players[this.active_player_id].enabled)
-          for (var player_id in this.players)
-            if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) {
-              // prefer the first playing player
-              this.active_player_id = player_id;
-              break; 
-            }
-            if (!this.active_player_id || !this.players[this.active_player_id].enabled)
-          for (var player_id in this.players) {
-            // fallback to just the first player
-            if (this.players[player_id].enabled && !this.players[player_id].group_parent)
-            {
-              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
deleted file mode 100644 (file)
index 611ecc3..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-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 class="title">{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }}</v-subheader>\r
-                       <v-subheader>{{ $t('play_on') }} {{ active_player.name }}</v-subheader>\r
-                       \r
-                       <v-list-tile avatar @click="itemClick('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>{{ $t('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="itemClick('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>{{ $t('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="itemClick('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>{{ $t('add_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="itemClick('info')" 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>{{ $t('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-tile avatar @click="itemClick('add_playlist')" v-if="$globals.playmenuitem.media_type == 3">\r
-                               <v-list-tile-avatar>\r
-                                       <v-icon>add_circle_outline</v-icon>\r
-                               </v-list-tile-avatar>\r
-                               <v-list-tile-content>\r
-                                       <v-list-tile-title>{{ $t('add_playlist') }}</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="itemClick('remove_playlist')" v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')">\r
-                               <v-list-tile-avatar>\r
-                                       <v-icon>remove_circle_outline</v-icon>\r
-                               </v-list-tile-avatar>\r
-                               <v-list-tile-content>\r
-                                       <v-list-tile-title>{{ $t('remove_playlist') }}</v-list-tile-title>\r
-                               </v-list-tile-content>\r
-                       </v-list-tile>\r
-                       <v-divider v-if="$globals.playmenuitem.media_type == 3  && this.$route.path.startsWith('/playlists/')"/>\r
-                       \r
-               </v-list>\r
-        </v-card>\r
-      </v-dialog>\r
-`,\r
-       props: ['value', 'active_player'],\r
-       data (){\r
-               return{\r
-                       fav: true,\r
-                       message: false,\r
-                       hints: true,\r
-                       }\r
-       },\r
-       mounted() { },\r
-       created() { },\r
-       methods: { \r
-               itemClick(cmd) {\r
-               if (cmd == 'info')\r
-                               this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}})\r
-                       else\r
-                               this.$emit('playItem', this.$globals.playmenuitem, cmd)\r
-                       // close dialog\r
-                       this.$globals.showplaymenu = false;\r
-       },\r
-       }\r
-  })\r
diff --git a/music_assistant/web/components/providericons.vue.js b/music_assistant/web/components/providericons.vue.js
deleted file mode 100644 (file)
index 0e0124d..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-Vue.component("providericons", {\r
-       template: `\r
-               <div :style="'height:' + height + 'px;'">\r
-                       <span v-for="provider in uniqueProviders" :key="provider.item_id" style="padding:5px;vertical-align: middle;" v-if="!hiresonly || provider.quality > 6">\r
-                               <v-tooltip bottom>\r
-                                       <template v-slot:activator="{ on }">\r
-                                               <img v-on="on" :height="height" src="images/icons/hires.png" v-if="provider.quality > 6" style="margin-right:9px"/>\r
-                                               <img v-on="on" :height="height" :src="'images/icons/' + provider.provider + '.png'" v-if="!hiresonly"/>\r
-                                       </template>\r
-                                       <div align="center" v-if="item.media_type == 3">\r
-                                               <img height="35px" :src="getFileFormatLogo(provider)"/>\r
-                                               <span><br>{{ getFileFormatDesc(provider) }}</span>\r
-                                       </div>\r
-                                       <span v-if="item.media_type != 3">{{ provider.provider }}</span>\r
-                               </v-tooltip> \r
-                       </span>         \r
-               </div>\r
-`,\r
-       props: ['item','height','compact', 'dark', 'hiresonly'],\r
-       data (){\r
-               return{}\r
-       },\r
-       mounted() { },\r
-       created() { },\r
-       computed: {\r
-               uniqueProviders() {\r
-                       var keys = [];\r
-                       var qualities = [];\r
-                       if (!this.item || !this.item.provider_ids)\r
-                               return []\r
-                       let sorted_item_ids = this.item.provider_ids.sort((a,b) => (a.quality < b.quality) ? 1 : ((b.quality < a.quality) ? -1 : 0));\r
-                       if (!this.compact)\r
-                               return sorted_item_ids;\r
-                       for (provider of sorted_item_ids) {\r
-                               if (!keys.includes(provider.provider)){\r
-                                       qualities.push(provider);\r
-                                       keys.push(provider.provider);\r
-                               }\r
-                       }\r
-                       return qualities;\r
-               }\r
-       },\r
-       methods: { \r
-\r
-               getFileFormatLogo(provider) {\r
-                       if (provider.quality == 0)\r
-                               return 'images/icons/mp3.png'\r
-                       else if (provider.quality == 1)\r
-                               return 'images/icons/vorbis.png'\r
-                       else if (provider.quality == 2)\r
-                               return 'images/icons/aac.png'\r
-                       else if (provider.quality > 2)\r
-                               return 'images/icons/flac.png'\r
-                       },\r
-               getFileFormatDesc(provider) {\r
-                       var desc = '';\r
-                       if (provider.details)\r
-                               desc += ' ' + provider.details;\r
-                       return desc;\r
-               },\r
-               getMaxQualityFormatDesc() {\r
-                       var desc = '';\r
-                       if (provider.details)\r
-                               desc += ' ' + provider.details;\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
deleted file mode 100644 (file)
index 6af2fd3..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-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/searchbox.vue.js b/music_assistant/web/components/searchbox.vue.js
deleted file mode 100644 (file)
index 1570ab6..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-Vue.component("searchbox", {
-  template: `
-  <v-dialog :value="$globals.showsearchbox" @input="$emit('input', $event)" max-width="500px">
-      <v-text-field
-            solo
-            clearable
-            :label="$t('type_to_search')"
-            prepend-inner-icon="search"
-            v-model="searchQuery">
-          </v-text-field>
-      </v-dialog>
-  `,
-  data () {
-    return {
-      searchQuery: "",
-    }
-  },
-  props: ['value'],
-  mounted () {
-    this.searchQuery = "" // TODO: set to last searchquery ?
-  },
-  watch: {
-    searchQuery: {
-      handler: _.debounce(function (val) {
-        this.onSearch();
-        // if (this.searchQuery)
-        //     this.$globals.showsearchbox = false;
-      }, 1000)
-    },
-    newSearchQuery (val) {
-      this.searchQuery = val
-    }
-  },
-  computed: {},
-  methods: {
-    onSearch () {
-      //this.$emit('clickSearch', this.searchQuery)
-      console.log(this.searchQuery);
-      router.push({ path: '/search', query: {searchQuery: this.searchQuery}});
-    },
-  }
-})
-/* <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
deleted file mode 100644 (file)
index 7ef20ab..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-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>{{ $t('state.' + 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
-                                                                               @click:append="$emit('setPlayerVolume', child_id, 'up')"\r
-                                                                       @click:prepend="$emit('setPlayerVolume', child_id, 'down')"\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
deleted file mode 100644 (file)
index e4cb811..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-/* 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
deleted file mode 100755 (executable)
index 2071f04..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-[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/css/vue-loading.css b/music_assistant/web/css/vue-loading.css
deleted file mode 100644 (file)
index 6d62f80..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-.vld-overlay {
-  bottom: 0;
-  left: 0;
-  position: absolute;
-  right: 0;
-  top: 0;
-  align-items: center;
-  display: none;
-  justify-content: center;
-  overflow: hidden;
-  z-index: 1
-}
-
-.vld-overlay.is-active {
-  display: flex
-}
-
-.vld-overlay.is-full-page {
-  z-index: 999;
-  position: fixed
-}
-
-.vld-overlay .vld-background {
-  bottom: 0;
-  left: 0;
-  position: absolute;
-  right: 0;
-  top: 0;
-  background: #000;
-  opacity: 0.7
-}
-
-.vld-overlay .vld-icon, .vld-parent {
-  position: relative
-}
-
diff --git a/music_assistant/web/images/default_artist.png b/music_assistant/web/images/default_artist.png
deleted file mode 100644 (file)
index a530d5b..0000000
Binary files a/music_assistant/web/images/default_artist.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/aac.png b/music_assistant/web/images/icons/aac.png
deleted file mode 100644 (file)
index 7dafab2..0000000
Binary files a/music_assistant/web/images/icons/aac.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/chromecast.png b/music_assistant/web/images/icons/chromecast.png
deleted file mode 100644 (file)
index f7d2a46..0000000
Binary files a/music_assistant/web/images/icons/chromecast.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/file.png b/music_assistant/web/images/icons/file.png
deleted file mode 100644 (file)
index bd2df04..0000000
Binary files a/music_assistant/web/images/icons/file.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/flac.png b/music_assistant/web/images/icons/flac.png
deleted file mode 100644 (file)
index 33e1f17..0000000
Binary files a/music_assistant/web/images/icons/flac.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/hires.png b/music_assistant/web/images/icons/hires.png
deleted file mode 100644 (file)
index a398c6e..0000000
Binary files a/music_assistant/web/images/icons/hires.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/homeassistant.png b/music_assistant/web/images/icons/homeassistant.png
deleted file mode 100644 (file)
index 5f28d69..0000000
Binary files a/music_assistant/web/images/icons/homeassistant.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/http_streamer.png b/music_assistant/web/images/icons/http_streamer.png
deleted file mode 100644 (file)
index c35c983..0000000
Binary files a/music_assistant/web/images/icons/http_streamer.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/icon-128x128.png b/music_assistant/web/images/icons/icon-128x128.png
deleted file mode 100644 (file)
index 01363c8..0000000
Binary files a/music_assistant/web/images/icons/icon-128x128.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/icon-256x256.png b/music_assistant/web/images/icons/icon-256x256.png
deleted file mode 100644 (file)
index 4c36796..0000000
Binary files a/music_assistant/web/images/icons/icon-256x256.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/icon-apple.png b/music_assistant/web/images/icons/icon-apple.png
deleted file mode 100644 (file)
index 67d26d5..0000000
Binary files a/music_assistant/web/images/icons/icon-apple.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/info_gradient.jpg b/music_assistant/web/images/icons/info_gradient.jpg
deleted file mode 100644 (file)
index 9d0c0e3..0000000
Binary files a/music_assistant/web/images/icons/info_gradient.jpg and /dev/null differ
diff --git a/music_assistant/web/images/icons/lms.png b/music_assistant/web/images/icons/lms.png
deleted file mode 100644 (file)
index 6dd9b06..0000000
Binary files a/music_assistant/web/images/icons/lms.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/mp3.png b/music_assistant/web/images/icons/mp3.png
deleted file mode 100644 (file)
index b894bda..0000000
Binary files a/music_assistant/web/images/icons/mp3.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/pylms.png b/music_assistant/web/images/icons/pylms.png
deleted file mode 100644 (file)
index 18531d7..0000000
Binary files a/music_assistant/web/images/icons/pylms.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/qobuz.png b/music_assistant/web/images/icons/qobuz.png
deleted file mode 100644 (file)
index 9d7b726..0000000
Binary files a/music_assistant/web/images/icons/qobuz.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/spotify.png b/music_assistant/web/images/icons/spotify.png
deleted file mode 100644 (file)
index 805f5c7..0000000
Binary files a/music_assistant/web/images/icons/spotify.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/tunein.png b/music_assistant/web/images/icons/tunein.png
deleted file mode 100644 (file)
index 3352c29..0000000
Binary files a/music_assistant/web/images/icons/tunein.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/vorbis.png b/music_assistant/web/images/icons/vorbis.png
deleted file mode 100644 (file)
index c6d6914..0000000
Binary files a/music_assistant/web/images/icons/vorbis.png and /dev/null differ
diff --git a/music_assistant/web/images/icons/web.png b/music_assistant/web/images/icons/web.png
deleted file mode 100644 (file)
index d3b5724..0000000
Binary files a/music_assistant/web/images/icons/web.png and /dev/null differ
diff --git a/music_assistant/web/images/info_gradient.jpg b/music_assistant/web/images/info_gradient.jpg
deleted file mode 100644 (file)
index 9d0c0e3..0000000
Binary files a/music_assistant/web/images/info_gradient.jpg and /dev/null differ
diff --git a/music_assistant/web/index.html b/music_assistant/web/index.html
deleted file mode 100755 (executable)
index dcef414..0000000
+++ /dev/null
@@ -1,249 +0,0 @@
-<!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@1.5.16/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-256x256.png">
-        <link rel="manifest" href="./manifest.json">
-        <link rel="apple-touch-icon" href="./images/icons/icon-apple.png">
-        <meta name="apple-mobile-web-app-capable" content="yes">
-        <link href="./css/site.css" rel="stylesheet">
-        <link href="./css/vue-loading.css" rel="stylesheet">
-    </head>
-
-    <body>
-
-        <div id="app">
-            <v-app light>
-                <v-content>
-                    <headermenu></headermenu>
-                    <player></player>
-                    <router-view app :key="$route.path"></router-view>      
-                    <searchbox/>             
-                </v-content>
-                <loading :active.sync="$globals.loading" :can-cancel="true" color="#2196f3" loader="dots"></loading>
-            </v-app>
-        </div>
-
-
-        <script src="https://unpkg.com/vue/dist/vue.js"></script>
-        <script src="https://unpkg.com/vue-i18n/dist/vue-i18n.js"></script>
-        <script src="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.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="./lib/vue-loading-overlay.js"></script>
-        <script src="https://unpkg.com/vue-toasted"></script>
-
-
-        <script>
-            const isMobile = () => (document.body.clientWidth < 800);
-            const isInStandaloneMode = () => ('standalone' in window.navigator) && (window.navigator.standalone);
-
-            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 || item.media_type == 5)
-                    {
-                    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, query: {provider: item.provider}});
-            }
-
-            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 = "/library_remove"
-                if (item.in_library.length == 0)
-                    action = "/library_add"
-                var url = endpoint + item_id + action;
-                console.log('loading ' + url);
-                axios
-                    .get(url, { params: { provider: item.provider }})
-                    .then(result => {
-                        data = result.data;
-                        console.log(data);
-                        if (action == "/library_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='./pages/config.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/providericons.vue.js'></script>
-        <script src='./components/searchbox.vue.js'></script>
-        
-        <script src='./strings.js'></script>
-        
-        <script>
-        Vue.use(VueRouter);
-        Vue.use(VeeValidate);
-        Vue.use(Vuetify);
-        Vue.use(VueI18n);
-        Vue.use(VueLoading);
-        Vue.use(Toasted, {duration: 5000, fullWidth: true});
-
-
-        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,
-                showsearchbox: false,
-                playmenuitem: null
-            }
-        })
-        Vue.prototype.$globals = globalStore;
-        Vue.prototype.isMobile = isMobile;
-        Vue.prototype.isInStandaloneMode = isInStandaloneMode;
-        Vue.prototype.toggleLibrary = toggleLibrary;
-        Vue.prototype.showPlayMenu = showPlayMenu;
-        Vue.prototype.clickItem= clickItem;
-        
-        const i18n = new VueI18n({
-            locale: navigator.language.split('-')[0],
-            fallbackLocale: 'en',
-            enableInSFC: true,
-            messages
-            })
-
-        var app = new Vue({
-            i18n,
-            el: '#app',
-            watch: {},
-            mounted() {
-            },
-            components: {
-                Loading: VueLoading
-            },
-            created() {
-                // little hack to force refresh PWA on iOS by simple reloading it every hour
-                var d = new Date();
-                var cur_update = d.getDay() + d.getHours();
-                if (localStorage.getItem('last_update') != cur_update)
-                {
-                    localStorage.setItem('last_update', cur_update);
-                    window.location.reload(true);
-                }
-            },
-            data: { },
-            methods: {},
-            router
-        })
-    </script>
-    </body>
-
-</html>
\ No newline at end of file
diff --git a/music_assistant/web/lib/vue-loading-overlay.js b/music_assistant/web/lib/vue-loading-overlay.js
deleted file mode 100644 (file)
index b3b9da1..0000000
+++ /dev/null
@@ -1 +0,0 @@
-!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("VueLoading",[],e):"object"==typeof exports?exports.VueLoading=e():t.VueLoading=e()}("undefined"!=typeof self?self:this,function(){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var r=e[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)i.d(n,r,function(e){return t[e]}.bind(null,r));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=1)}([function(t,e,i){},function(t,e,i){"use strict";i.r(e);var n="undefined"!=typeof window?window.HTMLElement:Object,r={mounted:function(){document.addEventListener("focusin",this.focusIn)},methods:{focusIn:function(t){if(this.isActive&&t.target!==this.$el&&!this.$el.contains(t.target)){var e=this.container?this.container:this.isFullPage?null:this.$el.parentElement;(this.isFullPage||e&&e.contains(t.target))&&(t.preventDefault(),this.$el.focus())}}},beforeDestroy:function(){document.removeEventListener("focusin",this.focusIn)}};function a(t,e,i,n,r,a,o,s){var u,l="function"==typeof t?t.options:t;if(e&&(l.render=e,l.staticRenderFns=i,l._compiled=!0),n&&(l.functional=!0),a&&(l._scopeId="data-v-"+a),o?(u=function(t){(t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),r&&r.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(o)},l._ssrRegister=u):r&&(u=s?function(){r.call(this,this.$root.$options.shadowRoot)}:r),u)if(l.functional){l._injectStyles=u;var c=l.render;l.render=function(t,e){return u.call(e),c(t,e)}}else{var d=l.beforeCreate;l.beforeCreate=d?[].concat(d,u):[u]}return{exports:t,options:l}}var o=a({name:"spinner",props:{color:{type:String,default:"#000"},height:{type:Number,default:64},width:{type:Number,default:64}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 38 38",xmlns:"http://www.w3.org/2000/svg",width:this.width,height:this.height,stroke:this.color}},[e("g",{attrs:{fill:"none","fill-rule":"evenodd"}},[e("g",{attrs:{transform:"translate(1 1)","stroke-width":"2"}},[e("circle",{attrs:{"stroke-opacity":".25",cx:"18",cy:"18",r:"18"}}),e("path",{attrs:{d:"M36 18c0-9.94-8.06-18-18-18"}},[e("animateTransform",{attrs:{attributeName:"transform",type:"rotate",from:"0 18 18",to:"360 18 18",dur:"0.8s",repeatCount:"indefinite"}})],1)])])])},[],!1,null,null,null).exports,s=a({name:"dots",props:{color:{type:String,default:"#000"},height:{type:Number,default:240},width:{type:Number,default:60}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 120 30",xmlns:"http://www.w3.org/2000/svg",fill:this.color,width:this.width,height:this.height}},[e("circle",{attrs:{cx:"15",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"60",cy:"15",r:"9","fill-opacity":"0.3"}},[e("animate",{attrs:{attributeName:"r",from:"9",to:"9",begin:"0s",dur:"0.8s",values:"9;15;9",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"0.5",to:"0.5",begin:"0s",dur:"0.8s",values:".5;1;.5",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"105",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,u=a({name:"bars",props:{color:{type:String,default:"#000"},height:{type:Number,default:40},width:{type:Number,default:40}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 30 30",height:this.height,width:this.width,fill:this.color}},[e("rect",{attrs:{x:"0",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"10",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"20",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,l=a({name:"vue-loading",mixins:[r],props:{active:Boolean,programmatic:Boolean,container:[Object,Function,n],isFullPage:{type:Boolean,default:!0},transition:{type:String,default:"fade"},canCancel:Boolean,onCancel:{type:Function,default:function(){}},color:String,backgroundColor:String,opacity:Number,width:Number,height:Number,zIndex:Number,loader:{type:String,default:"spinner"}},data:function(){return{isActive:this.active}},components:{Spinner:o,Dots:s,Bars:u},beforeMount:function(){this.programmatic&&(this.container?(this.isFullPage=!1,this.container.appendChild(this.$el)):document.body.appendChild(this.$el))},mounted:function(){this.programmatic&&(this.isActive=!0),document.addEventListener("keyup",this.keyPress)},methods:{cancel:function(){this.canCancel&&this.isActive&&(this.hide(),this.onCancel.apply(null,arguments))},hide:function(){var t=this;this.$emit("hide"),this.$emit("update:active",!1),this.programmatic&&(this.isActive=!1,setTimeout(function(){var e;t.$destroy(),void 0!==(e=t.$el).remove?e.remove():e.parentNode.removeChild(e)},150))},keyPress:function(t){27===t.keyCode&&this.cancel()}},watch:{active:function(t){this.isActive=t}},beforeDestroy:function(){document.removeEventListener("keyup",this.keyPress)}},function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("transition",{attrs:{name:t.transition}},[i("div",{directives:[{name:"show",rawName:"v-show",value:t.isActive,expression:"isActive"}],staticClass:"vld-overlay is-active",class:{"is-full-page":t.isFullPage},style:{zIndex:this.zIndex},attrs:{tabindex:"0","aria-busy":t.isActive,"aria-label":"Loading"}},[i("div",{staticClass:"vld-background",style:{background:this.backgroundColor,opacity:this.opacity},on:{click:function(e){return e.preventDefault(),t.cancel(e)}}}),i("div",{staticClass:"vld-icon"},[t._t("before"),t._t("default",[i(t.loader,{tag:"component",attrs:{color:t.color,width:t.width,height:t.height}})]),t._t("after")],2)])])},[],!1,null,null,null).exports,c=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return{show:function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:e,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:i,a=Object.assign({},e,n,{programmatic:!0}),o=new(t.extend(l))({el:document.createElement("div"),propsData:a}),s=Object.assign({},i,r);return Object.keys(s).map(function(t){o.$slots[t]=s[t]}),o}}};i(0);l.install=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},n=c(t,e,i);t.$loading=n,t.prototype.$loading=n};e.default=l}]).default});
\ No newline at end of file
diff --git a/music_assistant/web/manifest.json b/music_assistant/web/manifest.json
deleted file mode 100755 (executable)
index 6a3c4b9..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-{
-  "name": "Music Assistant",
-  "short_name": "MusicAssistant",
-  "theme_color": "#2196f3",
-  "background_color": "#2196f3",
-  "display": "standalone",
-  "Scope": "/",
-  "start_url": "/",
-  "icons": [
-    {
-      "src": "images/icons/icon-128x128.png",
-      "sizes": "128x128",
-      "type": "image/png"
-    },
-    {
-      "src": "images/icons/icon-256x256.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
deleted file mode 100755 (executable)
index 4f60a91..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-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 = ""
-    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
deleted file mode 100755 (executable)
index 4660030..0000000
+++ /dev/null
@@ -1,127 +0,0 @@
-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 = ""
-    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;
-      console.log(api_url + ' - ' + this.provider);
-      axios
-        .get(api_url, { params: { lazy: lazy, provider: this.provider }})
-        .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
deleted file mode 100755 (executable)
index 49bcdc8..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-var Browse = Vue.component('Browse', {
-  template: `
-    <section>
-      <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', 'provider'],
-  data() {
-    return {
-      selected: [2],
-      items: [],
-      offset: 0
-    }
-  },
-  created() {
-    this.showavatar = true;
-    mediatitle = 
-    this.$globals.windowtitle = this.$t(this.mediatype)
-    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, provider: this.provider }})
-        .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
deleted file mode 100755 (executable)
index c4164db..0000000
+++ /dev/null
@@ -1,152 +0,0 @@
-var Config = Vue.component('Config', {
-  template: `
-    <section>
-
-        <v-tabs v-model="active" color="transparent" light slider-color="black">
-            <v-tab ripple v-for="(conf_value, conf_key) in conf" :key="conf_key">{{ $t('conf.'+conf_key) }}</v-tab>
-                  <v-tab-item v-for="(conf_value, conf_key) in conf" :key="conf_key">
-
-                      <!-- generic and module settings -->
-                      <v-list two-line v-if="conf_key != 'player_settings'">
-                          <v-list-group no-action v-for="(conf_subvalue, conf_subkey) in conf[conf_key]" :key="conf_key+conf_subkey">
-                            <template v-slot:activator>
-                                <v-list-tile>
-                                  <v-list-tile-avatar>
-                                      <img :src="'images/icons/' + conf_subkey + '.png'"/>
-                                  </v-list-tile-avatar>
-                                  <v-list-tile-content>
-                                      <v-list-tile-title>{{ $t('conf.'+conf_subkey) }}</v-list-tile-title>
-                                  </v-list-tile-content>
-                                </v-list-tile>
-                              </template>
-                              <div v-for="conf_item_key in conf[conf_key][conf_subkey].__desc__">
-                                    <v-list-tile>
-                                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
-                                          <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
-                                          <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-select>
-                                          <v-text-field v-else v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
-                                    </v-list-tile>
-                                </div>
-                                <v-divider></v-divider>
-                            </v-list-group>
-                      </v-list two-line>
-
-                      <!-- player settings -->
-                      <v-list two-line v-if="conf_key == 'player_settings'">
-                          <v-list-group no-action v-for="(player, key) in players" v-if="key != '__desc__' && key in players" :key="key">
-                                <template v-slot:activator>
-                                    <v-list-tile>
-                                      <v-list-tile-avatar>
-                                          <img :src="'images/icons/' + players[key].player_provider + '.png'"/>
-                                      </v-list-tile-avatar>
-                                      <v-list-tile-content>
-                                        <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
-                                        <v-list-tile-sub-title class="title">{{ key }}</v-list-tile-sub-title>
-                                      </v-list-tile-content>
-                                  </v-list-tile>
-                              </template>
-                              <div v-for="conf_item_key in conf.player_settings[key].__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="$t('conf.'+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="$t('conf.'+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="$t('conf.'+conf_item_key[2])" 
-                                          :items="playersLst"
-                                          item-text="name"
-                                          item-value="id" box>
-                                        </v-select>
-                                        <v-select v-else-if="conf_item_key[0] == 'max_sample_rate'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" :items="sample_rates" box></v-select>
-                                        <v-slider v-else-if="conf_item_key[0] == 'crossfade_duration'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" min=0 max=10 box thumb-label></v-slider>
-                                        <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+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="$t('conf.'+'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="$t('conf.'+'enabled')"></v-switch>
-                                  </v-list-tile>
-                              </div>
-                                <v-divider></v-divider>
-                            </v-list-group>
-                      </v-list two-line>
-                  </v-tab-item>
-            </v-tab>
-        </v-tabs>
-
-
-    </section>
-  `,
-  props: [],
-  data() {
-    return {
-      conf: {},
-      players: {},
-      active: 0,
-      sample_rates: [44100, 48000, 88200, 96000, 192000, 384000]
-    }
-  },
-  computed: {
-    playersLst()
-    {
-      var playersLst = [];
-      playersLst.push({id: null, name: this.$t('conf.'+'not_grouped')})
-      for (player_id in this.players)
-        playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
-      return playersLst;
-    }
-  },
-  watch: {
-    'conf': {
-        handler: _.debounce(function (val, oldVal) {
-          if (oldVal.base) {
-            console.log("save config needed!");
-            this.saveConfig();
-            this.$toasted.show(this.$t('conf.conf_saved'))
-          }
-        }, 5000),
-        deep: true
-    }
-  },
-  created() {
-    this.$globals.windowtitle = this.$t('settings');
-    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
deleted file mode 100755 (executable)
index 91c0b33..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-var home = Vue.component("Home", {
-  template: `
-  <section>
-      <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>
-  </section>
-`,
-  props: ["title"],
-  $_veeValidate: {
-    validator: "new"
-  },
-  data() {
-    return {
-      result: null,
-      showProgress: false
-    };
-  },
-  created() {
-    this.$globals.windowtitle = this.$t('musicassistant');
-    this.items= [
-        { title: this.$t('artists'), icon: "person", path: "/artists" },
-        { title: this.$t('albums'), icon: "album", path: "/albums" },
-        { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" },
-        { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" },
-        { title: this.$t('search'), icon: "search", path: "/search" }
-    ]
-  },
-  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
deleted file mode 100755 (executable)
index b9c617d..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-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,
-      active: 0
-    }
-  },
-  created() {
-    this.$globals.windowtitle = ""
-    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
deleted file mode 100755 (executable)
index 9bc25a9..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-var Queue = Vue.component('Queue', {
-  template: `
-  <section>
-        <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>
-      </section>`,
-  props: ['player_id'],
-  data() {
-    return {
-      selected: [0],
-      info: {},
-      items: [],
-      offset: 0,
-    }
-  },
-  created() {
-    this.$globals.windowtitle = this.$t('queue')
-    this.getQueueTracks(0, 25);
-  },
-  methods: {
-
-    getQueueTracks (offset, limit) {
-      const api_url = '/api/players/' + this.player_id + '/queue'
-      return axios.get(api_url, { params: { offset: offset, limit: limit}})
-        .then(response => {
-            if (response.data.length < 1 )
-              return;
-            this.items.push(...response.data)
-            return this.getQueueTracks(offset+limit, 100)
-        })
-    }
-  }
-})
diff --git a/music_assistant/web/pages/search.vue.js b/music_assistant/web/pages/search.vue.js
deleted file mode 100755 (executable)
index 996c01e..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-var Search = Vue.component('Search', {
-  template: `
-  <section>
-
-    <v-text-field
-        solo
-        clearable
-        :label="$t('type_to_search')"
-        append-icon="search"
-        v-model="searchQuery" v-on:keyup.enter="Search" @click:append="Search" style="margin-left:30px; margin-right:30px; margin-top:10px">
-      </v-text-field>
-
-      <v-tabs
-          v-model="active"
-          color="transparent"
-          light
-          slider-color="black"
-        >
-
-        <v-tab ripple v-if="tracks.length">{{ $t('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">{{ $t('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">{{ $t('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">{{ $t('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,
-      active: 0,
-      searchQuery: ""
-    }
-  },
-  created() {
-    this.$globals.windowtitle = this.$t('search');
-  },
-  watch: {
-  },
-  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);
-      }
-    },
-    Search () {
-      this.artists = [];
-      this.albums = [];
-      this.tracks = [];
-      this.playlists = [];
-      if (this.searchQuery) {
-        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
deleted file mode 100755 (executable)
index e8f0896..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-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 = ""
-    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/music_assistant/web/strings.js b/music_assistant/web/strings.js
deleted file mode 100644 (file)
index 5c1df07..0000000
+++ /dev/null
@@ -1,179 +0,0 @@
-const messages = {
-    
-    
-    en: {
-        // generic strings
-        musicassistant: "Music Assistant",
-        home: "Home",
-        artists: "Artists",
-        albums: "Albums",
-        tracks: "Tracks",
-        playlists: "Playlists",
-        radios: "Radio",
-        search: "Search",
-        settings: "Settings",
-        queue: "Queue",
-        type_to_search: "Type here to search...",
-        add_library: "Add to library",
-        remove_library: "Remove from library",
-        add_playlist: "Add to playlist...",
-        remove_playlist: "Remove from playlist",
-        // settings strings
-        conf: {
-            enabled: "Enabled",
-            base: "Generic settings",
-            musicproviders: "Music providers",
-            playerproviders: "Player providers",
-            player_settings: "Player settings",
-            homeassistant: "Home Assistant integration",
-            web: "Webserver",
-            http_streamer: "Built-in (sox based) streamer",
-            qobuz: "Qobuz",
-            spotify: "Spotify",
-            tunein: "TuneIn",
-            file: "Filesystem",
-            chromecast: "Chromecast",
-            lms: "Logitech Media Server",
-            pylms: "Emulated (built-in) Squeezebox support",
-            username: "Username",
-            password: "Password",
-            hostname: "Hostname (or IP)",
-            port: "Port",
-            hass_url: "URL to homeassistant (e.g. https://homeassistant:8123)",
-            hass_token: "Long Lived Access Token",
-            hass_publish: "Publish players to Home Assistant",
-            hass_player_power: "Attach player power to homeassistant entity",
-            hass_player_source: "Source on the homeassistant entity (optional)",
-            hass_player_volume: "Attach player volume to homeassistant entity",
-            web_ssl_cert: "Path to ssl certificate file",
-            web_ssl_key: "Path to ssl keyfile",
-            player_enabled: "Enable player",
-            player_name: "Custom name for this player",
-            player_group_with: "Group this player to another (parent)player",
-            player_mute_power: "Use muting as power control",
-            player_disable_vol: "Disable volume controls",
-            player_group_vol: "Apply group volume to childs (for group players only)",
-            player_group_pow: "Apply group power based on childs (for group players only)",
-            player_power_play: "Issue play command on power on",
-            file_prov_music_path: "Path to music files",
-            file_prov_playlists_path: "Path to playlists (.m3u)",
-            web_http_port: "HTTP port",
-            web_https_port: "HTTPS port",
-            cert_fqdn_host: "FQDN of hostname in certificate",
-            enable_r128_volume_normalisation: "Enable R128 volume normalization",
-            target_volume_lufs: "Target volume (R128 default is -23 LUFS)",
-            fallback_gain_correct: "Fallback gain correction if R128 readings not (yet) available",
-            enable_audio_cache: "Allow caching of audio to temp files",
-            trim_silence: "Strip silence from beginning and end of audio (temp files only!)",
-            http_streamer_sox_effects: "Custom sox effects to apply to audio (built-in streamer only!) See http://sox.sourceforge.net/sox.html#EFFECTS",
-            max_sample_rate: "Maximum sample rate this player supports, higher will be downsampled",
-            force_http_streamer: "Force use of built-in streamer, even if the player can handle the music provider directly",
-            not_grouped: "Not grouped",
-            conf_saved: "Configuration saved, restart app to make effective",
-            audio_cache_folder: "Directory to use for cache files",
-            audio_cache_max_size_gb: "Maximum size of the cache folder (GB)"
-        },
-        // player strings
-        players: "Players",
-        play: "Play",
-        play_on: "Play on:",
-        play_now: "Play Now",
-        play_next: "Play Next",
-        add_queue: "Add to Queue",
-        show_info: "Show info",
-        state: {        
-            playing: "playing",
-            stopped: "stopped",
-            paused: "paused",
-            off: "off"
-        }
-    },
-
-    nl: {
-        // generic strings
-        musicassistant: "Music Assistant",
-        home: "Home",
-        artists: "Artiesten",
-        albums: "Albums",
-        tracks: "Nummers",
-        playlists: "Afspeellijsten",
-        radios: "Radio",
-        search: "Zoeken",
-        settings: "Instellingen",
-        queue: "Wachtrij",
-        type_to_search: "Type hier om te zoeken...",
-        add_library: "Voeg toe aan bibliotheek",
-        remove_library: "Verwijder uit bibliotheek",
-        add_playlist: "Aan playlist toevoegen...",
-        remove_playlist: "Verwijder uit playlist",
-        // settings strings
-        conf: {
-            enabled: "Ingeschakeld",
-            base: "Algemene instellingen",
-            musicproviders: "Muziek providers",
-            playerproviders: "Speler providers",
-            player_settings: "Speler instellingen",
-            homeassistant: "Home Assistant integratie",
-            web: "Webserver",
-            http_streamer: "Ingebouwde (sox gebaseerde) streamer",
-            qobuz: "Qobuz",
-            spotify: "Spotify",
-            tunein: "TuneIn",
-            file: "Bestandssysteem",
-            chromecast: "Chromecast",
-            lms: "Logitech Media Server",
-            pylms: "Geemuleerde (ingebouwde) Squeezebox ondersteuning",
-            username: "Gebruikersnaam",
-            password: "Wachtwoord",
-            hostname: "Hostnaam (of IP)",
-            port: "Poort",
-            hass_url: "URL naar homeassistant (b.v. https://homeassistant:8123)",
-            hass_token: "Token met lange levensduur",
-            hass_publish: "Publiceer spelers naar Home Assistant",
-            hass_player_power: "Verbind speler aan/uit met homeassistant entity",
-            hass_player_source: "Benodigde bron op de verbonden homeassistant entity (optioneel)",
-            hass_player_volume: "Verbind volume van speler aan een homeassistant entity",
-            web_ssl_cert: "Pad naar ssl certificaat bestand",
-            web_ssl_key: "Pad naar ssl certificaat key bestand",
-            player_enabled: "Speler inschakelen",
-            player_name: "Aangepaste naam voor deze speler",
-            player_group_with: "Groupeer deze speler met een andere (hoofd)speler",
-            player_mute_power: "Gebruik mute als aan/uit",
-            player_disable_vol: "Schakel volume bediening helemaal uit",
-            player_group_vol: "Pas groep volume toe op onderliggende spelers (alleen groep spelers)",
-            player_group_pow: "Pas groep aan/uit toe op onderliggende spelers (alleen groep spelers)",
-            player_power_play: "Automatisch afspelen bij inschakelen",
-            file_prov_music_path: "Pad naar muziek bestanden",
-            file_prov_playlists_path: "Pad naar playlist bestanden (.m3u)",
-            web_http_port: "HTTP poort",
-            web_https_port: "HTTPS poort",
-            cert_fqdn_host: "Hostname (FQDN van certificaat)",
-            enable_r128_volume_normalisation: "Schakel R128 volume normalisatie in",
-            target_volume_lufs: "Doelvolume (R128 standaard is -23 LUFS)",
-            fallback_gain_correct: "Fallback gain correctie indien R128 meting (nog) niet beschikbaar is",
-            enable_audio_cache: "Sta het cachen van audio toe naar temp map",
-            trim_silence: "Strip stilte van begin en eind van audio (in temp bestanden)",
-            http_streamer_sox_effects: "Eigen sox effects toepassen op audio (alleen voor ingebouwde streamer). Zie http://sox.sourceforge.net/sox.html#EFFECTS",
-            max_sample_rate: "Maximale sample rate welke deze speler ondersteund, hoger wordt gedownsampled.",
-            force_http_streamer: "Forceer het gebruik van de ingebouwde streamer, ook al heeft de speler directe ondersteuning voor de muziek provider",
-            not_grouped: "Niet gegroepeerd",
-            conf_saved: "Configuratie is opgeslagen, herstart om actief te maken",
-            audio_cache_folder: "Map om te gebruiken voor cache bestanden",
-            audio_cache_max_size_gb: "Maximale grootte van de cache map in GB."
-        },
-        // player strings
-        players: "Spelers",
-        play: "Afspelen",
-        play_on: "Afspelen op:",
-        play_now: "Nu afspelen",
-        play_next: "Speel als volgende af",
-        add_queue: "Voeg toe aan wachtrij",
-        show_info: "Bekijk informatie",
-        state: {        
-            playing: "afspelen",
-            stopped: "gestopt",
-            paused: "gepauzeerd",
-            off: "uitgeschakeld"
-        }
-    }
-}
\ No newline at end of file
diff --git a/run.sh b/run.sh
index 3e73292e5bc3122f4cd87314588c3712581e13a1..92cbdc4cf0e291537fd2c67b3606fc4ee6bf3f7d 100755 (executable)
--- a/run.sh
+++ b/run.sh
@@ -7,7 +7,8 @@ if [ "$autoupdate" == "true" ]; then
     cd /tmp
     curl -LOks "https://github.com/marcelveldt/musicassistant/archive/master.zip"
     unzip -q master.zip
-    cp -rf musicassistant-master/music_assistant/. /usr/src/app
+    cp -rf musicassistant-master/music_assistant /usr/src/app
+    cp -f musicassistant-master/main.py /usr/src/app
     rm -R /tmp/musicassistant-master
 fi
 
diff --git a/web/components/headermenu.vue.js b/web/components/headermenu.vue.js
new file mode 100755 (executable)
index 0000000..f0e8566
--- /dev/null
@@ -0,0 +1,68 @@
+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 app flat dense dark v-if="$globals.windowtitle" > 
+        <div class="title justify-center" style="text-align:center;position:absolute;width:100%;margin-left:-16px;margin-right:0">
+          {{ $globals.windowtitle }}
+        </div>
+        <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-if="$route.path != '/'">
+              <v-icon>arrow_back</v-icon>
+            </v-btn>
+        </v-layout>
+    </v-toolbar>
+    <v-toolbar flat fixed dense dark scroll-off-screen color="transparent" v-if="!$globals.windowtitle" > 
+        <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-on:click="$router.push({path: '/search'})">
+                <v-icon>search</v-icon>
+              </v-btn>
+        </v-layout>
+    </v-toolbar>
+</div>`,
+  props: [],
+  $_veeValidate: {
+    validator: "new"
+  },
+  data() {
+    return {
+      menu: false,
+      items: [
+        { title: this.$t('home'), icon: "home", path: "/" },
+        { title: this.$t('artists'), icon: "person", path: "/artists" },
+        { title: this.$t('albums'), icon: "album", path: "/albums" },
+        { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" },
+        { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" },
+        { title: this.$t('radios'), icon: "radio", path: "/radios" },
+        { title: this.$t('search'), icon: "search", path: "/search" },
+        { title: this.$t('settings'), icon: "settings", path: "/config" }
+      ]
+    }
+  },
+  mounted() { },
+  methods: { }
+})
diff --git a/web/components/infoheader.vue.js b/web/components/infoheader.vue.js
new file mode 100644 (file)
index 0000000..9d8bc8c
--- /dev/null
@@ -0,0 +1,135 @@
+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="isMobile() ? '230' : '370'"\r
+              position="center top" \r
+              :src="getFanartImage()"\r
+              gradient="to bottom, rgba(0,0,0,.65), rgba(0,0,0,.35)"\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
+                                                                       <providericons v-bind:item="info" :height="30" :compact="false"/>\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(info.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>{{ $t('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>{{ $t('add_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>{{ $t('remove_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() ? 60 : 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)\r
+                               return ''\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
+                       else if (this.info.artist && this.info.artist.metadata.fanart)\r
+                               img = this.info.artist.metadata.fanart;\r
+                       return img;\r
+               },\r
+               getThumb() {\r
+                       var img = '';\r
+                       if (!this.info)\r
+                               return ''\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)\r
+                               return ''\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/web/components/listviewItem.vue.js b/web/components/listviewItem.vue.js
new file mode 100755 (executable)
index 0000000..687c69c
--- /dev/null
@@ -0,0 +1,75 @@
+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(item.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>
+
+          <providericons v-bind:item="item" :height="20" :compact="true" :dark="true" :hiresonly="hideproviders"/>
+
+          <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">{{ $t('remove_library') }}</span>
+                  <span v-if="item.in_library.length == 0">{{ $t('add_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 {}
+  },
+methods: {
+  }
+})
diff --git a/web/components/player.vue.js b/web/components/player.vue.js
new file mode 100755 (executable)
index 0000000..33247fe
--- /dev/null
@@ -0,0 +1,315 @@
+Vue.component("player", {
+  template: `
+  <div>
+
+    <!-- player bar in footer -->
+    <v-footer app light height="auto">
+      
+      <v-card class="flex" tile style="background-color:#e8eaed;">
+        <!-- divider -->
+        <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
+
+        <!-- now playing media -->
+        <v-list-tile avatar ripple>
+
+              <v-list-tile-avatar v-if="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 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>
+
+          </v-list-tile>
+
+          <!-- progress bar -->
+          <div style="color:rgba(0,0,0,.65); height:30px;width:100%; vertical-align: middle; left:15px; right:0; margin-bottom:5px; margin-top:5px">
+            <v-layout row style="vertical-align: middle" v-if="active_player.cur_item">
+              <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>
+
+        <!-- divider -->
+        <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
+
+          <!-- Control buttons -->
+          <v-list-tile light avatar ripple style="margin-bottom:5px;">
+              
+          <!-- player controls -->
+              <v-list-tile-content>
+                  <v-layout row style="content-align: left;vertical-align: middle; margin-top:10px;margin-left:-15px">
+                    <v-btn small icon style="padding:5px;" @click="playerCommand('previous')"><v-icon color="rgba(0,0,0,.54)">skip_previous</v-icon></v-btn>
+                    <v-btn small 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 small 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 small 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:20px;" v-if="active_player_id">
+                  <v-btn x-small flat icon @click="$router.push('/queue/' + active_player_id)">
+                      <v-flex xs12 class="vertical-btn">
+                      <v-icon>queue_music</v-icon>
+                      <span class="caption">{{ $t('queue') }}</span>
+                    </v-flex>    
+                  </v-btn>
+              </v-list-tile-action> 
+
+              <!-- active player volume -->
+              <v-list-tile-action style="padding:20px;" 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 x-small flat icon v-on="on">
+                            <v-flex xs12 class="vertical-btn">
+                            <v-icon>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 x-small flat icon @click="menu = !menu">
+                      <v-flex xs12 class="vertical-btn">
+                      <v-icon>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>
+
+          <!-- add some additional whitespace in standalone mode only -->
+          <v-list-tile avatar ripple style="height:14px" v-if="isInStandaloneMode()"/>
+
+          
+
+      </v-card>
+    </v-footer>
+
+    <!-- players side menu -->
+    <v-navigation-drawer right app clipped temporary v-model="menu">
+        <v-card-title class="headline">
+            <b>{{ $t('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">
+                          {{ $t('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" :active_player="active_player" />
+  </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_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_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_time)
+        return "0:00";
+      var cur_sec = this.active_player.cur_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);
+      this.$globals.loading = true;
+      var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt;
+      axios
+      .get(api_url, {
+        params: {
+          provider: item.provider
+        }
+      })
+      .then(result => {
+        console.log(result.data);
+        this.$globals.loading = false;
+      })
+      .catch(error => {
+        console.log("error", error);
+        this.$globals.loading = false;
+      });
+    },
+    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_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 = [];
+        console.log(msg);
+        if (msg.message == 'player updated')
+          players = [msg.message_details];
+        else if (msg.message == 'player removed')
+          this.players[msg.message_details].enabled = false;
+        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 || !this.players[this.active_player_id].enabled)
+          for (var player_id in this.players)
+            if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) {
+              // prefer the first playing player
+              this.active_player_id = player_id;
+              break; 
+            }
+            if (!this.active_player_id || !this.players[this.active_player_id].enabled)
+          for (var player_id in this.players) {
+            // fallback to just the first player
+            if (this.players[player_id].enabled && !this.players[player_id].group_parent)
+            {
+              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/web/components/playmenu.vue.js b/web/components/playmenu.vue.js
new file mode 100644 (file)
index 0000000..611ecc3
--- /dev/null
@@ -0,0 +1,93 @@
+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 class="title">{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }}</v-subheader>\r
+                       <v-subheader>{{ $t('play_on') }} {{ active_player.name }}</v-subheader>\r
+                       \r
+                       <v-list-tile avatar @click="itemClick('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>{{ $t('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="itemClick('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>{{ $t('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="itemClick('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>{{ $t('add_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="itemClick('info')" 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>{{ $t('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-tile avatar @click="itemClick('add_playlist')" v-if="$globals.playmenuitem.media_type == 3">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>add_circle_outline</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>{{ $t('add_playlist') }}</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="itemClick('remove_playlist')" v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')">\r
+                               <v-list-tile-avatar>\r
+                                       <v-icon>remove_circle_outline</v-icon>\r
+                               </v-list-tile-avatar>\r
+                               <v-list-tile-content>\r
+                                       <v-list-tile-title>{{ $t('remove_playlist') }}</v-list-tile-title>\r
+                               </v-list-tile-content>\r
+                       </v-list-tile>\r
+                       <v-divider v-if="$globals.playmenuitem.media_type == 3  && this.$route.path.startsWith('/playlists/')"/>\r
+                       \r
+               </v-list>\r
+        </v-card>\r
+      </v-dialog>\r
+`,\r
+       props: ['value', 'active_player'],\r
+       data (){\r
+               return{\r
+                       fav: true,\r
+                       message: false,\r
+                       hints: true,\r
+                       }\r
+       },\r
+       mounted() { },\r
+       created() { },\r
+       methods: { \r
+               itemClick(cmd) {\r
+               if (cmd == 'info')\r
+                               this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}})\r
+                       else\r
+                               this.$emit('playItem', this.$globals.playmenuitem, cmd)\r
+                       // close dialog\r
+                       this.$globals.showplaymenu = false;\r
+       },\r
+       }\r
+  })\r
diff --git a/web/components/providericons.vue.js b/web/components/providericons.vue.js
new file mode 100644 (file)
index 0000000..0e0124d
--- /dev/null
@@ -0,0 +1,68 @@
+Vue.component("providericons", {\r
+       template: `\r
+               <div :style="'height:' + height + 'px;'">\r
+                       <span v-for="provider in uniqueProviders" :key="provider.item_id" style="padding:5px;vertical-align: middle;" v-if="!hiresonly || provider.quality > 6">\r
+                               <v-tooltip bottom>\r
+                                       <template v-slot:activator="{ on }">\r
+                                               <img v-on="on" :height="height" src="images/icons/hires.png" v-if="provider.quality > 6" style="margin-right:9px"/>\r
+                                               <img v-on="on" :height="height" :src="'images/icons/' + provider.provider + '.png'" v-if="!hiresonly"/>\r
+                                       </template>\r
+                                       <div align="center" v-if="item.media_type == 3">\r
+                                               <img height="35px" :src="getFileFormatLogo(provider)"/>\r
+                                               <span><br>{{ getFileFormatDesc(provider) }}</span>\r
+                                       </div>\r
+                                       <span v-if="item.media_type != 3">{{ provider.provider }}</span>\r
+                               </v-tooltip> \r
+                       </span>         \r
+               </div>\r
+`,\r
+       props: ['item','height','compact', 'dark', 'hiresonly'],\r
+       data (){\r
+               return{}\r
+       },\r
+       mounted() { },\r
+       created() { },\r
+       computed: {\r
+               uniqueProviders() {\r
+                       var keys = [];\r
+                       var qualities = [];\r
+                       if (!this.item || !this.item.provider_ids)\r
+                               return []\r
+                       let sorted_item_ids = this.item.provider_ids.sort((a,b) => (a.quality < b.quality) ? 1 : ((b.quality < a.quality) ? -1 : 0));\r
+                       if (!this.compact)\r
+                               return sorted_item_ids;\r
+                       for (provider of sorted_item_ids) {\r
+                               if (!keys.includes(provider.provider)){\r
+                                       qualities.push(provider);\r
+                                       keys.push(provider.provider);\r
+                               }\r
+                       }\r
+                       return qualities;\r
+               }\r
+       },\r
+       methods: { \r
+\r
+               getFileFormatLogo(provider) {\r
+                       if (provider.quality == 0)\r
+                               return 'images/icons/mp3.png'\r
+                       else if (provider.quality == 1)\r
+                               return 'images/icons/vorbis.png'\r
+                       else if (provider.quality == 2)\r
+                               return 'images/icons/aac.png'\r
+                       else if (provider.quality > 2)\r
+                               return 'images/icons/flac.png'\r
+                       },\r
+               getFileFormatDesc(provider) {\r
+                       var desc = '';\r
+                       if (provider.details)\r
+                               desc += ' ' + provider.details;\r
+                       return desc;\r
+               },\r
+               getMaxQualityFormatDesc() {\r
+                       var desc = '';\r
+                       if (provider.details)\r
+                               desc += ' ' + provider.details;\r
+                       return desc;\r
+               }\r
+    }\r
+  })\r
diff --git a/web/components/readmore.vue.js b/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/web/components/searchbox.vue.js b/web/components/searchbox.vue.js
new file mode 100644 (file)
index 0000000..1570ab6
--- /dev/null
@@ -0,0 +1,50 @@
+Vue.component("searchbox", {
+  template: `
+  <v-dialog :value="$globals.showsearchbox" @input="$emit('input', $event)" max-width="500px">
+      <v-text-field
+            solo
+            clearable
+            :label="$t('type_to_search')"
+            prepend-inner-icon="search"
+            v-model="searchQuery">
+          </v-text-field>
+      </v-dialog>
+  `,
+  data () {
+    return {
+      searchQuery: "",
+    }
+  },
+  props: ['value'],
+  mounted () {
+    this.searchQuery = "" // TODO: set to last searchquery ?
+  },
+  watch: {
+    searchQuery: {
+      handler: _.debounce(function (val) {
+        this.onSearch();
+        // if (this.searchQuery)
+        //     this.$globals.showsearchbox = false;
+      }, 1000)
+    },
+    newSearchQuery (val) {
+      this.searchQuery = val
+    }
+  },
+  computed: {},
+  methods: {
+    onSearch () {
+      //this.$emit('clickSearch', this.searchQuery)
+      console.log(this.searchQuery);
+      router.push({ path: '/search', query: {searchQuery: this.searchQuery}});
+    },
+  }
+})
+/* <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/web/components/volumecontrol.vue.js b/web/components/volumecontrol.vue.js
new file mode 100644 (file)
index 0000000..7ef20ab
--- /dev/null
@@ -0,0 +1,76 @@
+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>{{ $t('state.' + 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
+                                                                               @click:append="$emit('setPlayerVolume', child_id, 'up')"\r
+                                                                       @click:prepend="$emit('setPlayerVolume', child_id, 'down')"\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/web/css/nprogress.css b/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/web/css/site.css b/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/web/css/vue-loading.css b/web/css/vue-loading.css
new file mode 100644 (file)
index 0000000..6d62f80
--- /dev/null
@@ -0,0 +1,36 @@
+.vld-overlay {
+  bottom: 0;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  align-items: center;
+  display: none;
+  justify-content: center;
+  overflow: hidden;
+  z-index: 1
+}
+
+.vld-overlay.is-active {
+  display: flex
+}
+
+.vld-overlay.is-full-page {
+  z-index: 999;
+  position: fixed
+}
+
+.vld-overlay .vld-background {
+  bottom: 0;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  background: #000;
+  opacity: 0.7
+}
+
+.vld-overlay .vld-icon, .vld-parent {
+  position: relative
+}
+
diff --git a/web/images/default_artist.png b/web/images/default_artist.png
new file mode 100644 (file)
index 0000000..a530d5b
Binary files /dev/null and b/web/images/default_artist.png differ
diff --git a/web/images/icons/aac.png b/web/images/icons/aac.png
new file mode 100644 (file)
index 0000000..7dafab2
Binary files /dev/null and b/web/images/icons/aac.png differ
diff --git a/web/images/icons/chromecast.png b/web/images/icons/chromecast.png
new file mode 100644 (file)
index 0000000..f7d2a46
Binary files /dev/null and b/web/images/icons/chromecast.png differ
diff --git a/web/images/icons/file.png b/web/images/icons/file.png
new file mode 100644 (file)
index 0000000..bd2df04
Binary files /dev/null and b/web/images/icons/file.png differ
diff --git a/web/images/icons/flac.png b/web/images/icons/flac.png
new file mode 100644 (file)
index 0000000..33e1f17
Binary files /dev/null and b/web/images/icons/flac.png differ
diff --git a/web/images/icons/hires.png b/web/images/icons/hires.png
new file mode 100644 (file)
index 0000000..a398c6e
Binary files /dev/null and b/web/images/icons/hires.png differ
diff --git a/web/images/icons/homeassistant.png b/web/images/icons/homeassistant.png
new file mode 100644 (file)
index 0000000..5f28d69
Binary files /dev/null and b/web/images/icons/homeassistant.png differ
diff --git a/web/images/icons/http_streamer.png b/web/images/icons/http_streamer.png
new file mode 100644 (file)
index 0000000..c35c983
Binary files /dev/null and b/web/images/icons/http_streamer.png differ
diff --git a/web/images/icons/icon-128x128.png b/web/images/icons/icon-128x128.png
new file mode 100644 (file)
index 0000000..01363c8
Binary files /dev/null and b/web/images/icons/icon-128x128.png differ
diff --git a/web/images/icons/icon-256x256.png b/web/images/icons/icon-256x256.png
new file mode 100644 (file)
index 0000000..4c36796
Binary files /dev/null and b/web/images/icons/icon-256x256.png differ
diff --git a/web/images/icons/icon-apple.png b/web/images/icons/icon-apple.png
new file mode 100644 (file)
index 0000000..67d26d5
Binary files /dev/null and b/web/images/icons/icon-apple.png differ
diff --git a/web/images/icons/info_gradient.jpg b/web/images/icons/info_gradient.jpg
new file mode 100644 (file)
index 0000000..9d0c0e3
Binary files /dev/null and b/web/images/icons/info_gradient.jpg differ
diff --git a/web/images/icons/lms.png b/web/images/icons/lms.png
new file mode 100644 (file)
index 0000000..6dd9b06
Binary files /dev/null and b/web/images/icons/lms.png differ
diff --git a/web/images/icons/mp3.png b/web/images/icons/mp3.png
new file mode 100644 (file)
index 0000000..b894bda
Binary files /dev/null and b/web/images/icons/mp3.png differ
diff --git a/web/images/icons/pylms.png b/web/images/icons/pylms.png
new file mode 100644 (file)
index 0000000..18531d7
Binary files /dev/null and b/web/images/icons/pylms.png differ
diff --git a/web/images/icons/qobuz.png b/web/images/icons/qobuz.png
new file mode 100644 (file)
index 0000000..9d7b726
Binary files /dev/null and b/web/images/icons/qobuz.png differ
diff --git a/web/images/icons/spotify.png b/web/images/icons/spotify.png
new file mode 100644 (file)
index 0000000..805f5c7
Binary files /dev/null and b/web/images/icons/spotify.png differ
diff --git a/web/images/icons/tunein.png b/web/images/icons/tunein.png
new file mode 100644 (file)
index 0000000..3352c29
Binary files /dev/null and b/web/images/icons/tunein.png differ
diff --git a/web/images/icons/vorbis.png b/web/images/icons/vorbis.png
new file mode 100644 (file)
index 0000000..c6d6914
Binary files /dev/null and b/web/images/icons/vorbis.png differ
diff --git a/web/images/icons/web.png b/web/images/icons/web.png
new file mode 100644 (file)
index 0000000..d3b5724
Binary files /dev/null and b/web/images/icons/web.png differ
diff --git a/web/images/info_gradient.jpg b/web/images/info_gradient.jpg
new file mode 100644 (file)
index 0000000..9d0c0e3
Binary files /dev/null and b/web/images/info_gradient.jpg differ
diff --git a/web/index.html b/web/index.html
new file mode 100755 (executable)
index 0000000..dcef414
--- /dev/null
@@ -0,0 +1,249 @@
+<!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@1.5.16/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-256x256.png">
+        <link rel="manifest" href="./manifest.json">
+        <link rel="apple-touch-icon" href="./images/icons/icon-apple.png">
+        <meta name="apple-mobile-web-app-capable" content="yes">
+        <link href="./css/site.css" rel="stylesheet">
+        <link href="./css/vue-loading.css" rel="stylesheet">
+    </head>
+
+    <body>
+
+        <div id="app">
+            <v-app light>
+                <v-content>
+                    <headermenu></headermenu>
+                    <player></player>
+                    <router-view app :key="$route.path"></router-view>      
+                    <searchbox/>             
+                </v-content>
+                <loading :active.sync="$globals.loading" :can-cancel="true" color="#2196f3" loader="dots"></loading>
+            </v-app>
+        </div>
+
+
+        <script src="https://unpkg.com/vue/dist/vue.js"></script>
+        <script src="https://unpkg.com/vue-i18n/dist/vue-i18n.js"></script>
+        <script src="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.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="./lib/vue-loading-overlay.js"></script>
+        <script src="https://unpkg.com/vue-toasted"></script>
+
+
+        <script>
+            const isMobile = () => (document.body.clientWidth < 800);
+            const isInStandaloneMode = () => ('standalone' in window.navigator) && (window.navigator.standalone);
+
+            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 || item.media_type == 5)
+                    {
+                    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, query: {provider: item.provider}});
+            }
+
+            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 = "/library_remove"
+                if (item.in_library.length == 0)
+                    action = "/library_add"
+                var url = endpoint + item_id + action;
+                console.log('loading ' + url);
+                axios
+                    .get(url, { params: { provider: item.provider }})
+                    .then(result => {
+                        data = result.data;
+                        console.log(data);
+                        if (action == "/library_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='./pages/config.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/providericons.vue.js'></script>
+        <script src='./components/searchbox.vue.js'></script>
+        
+        <script src='./strings.js'></script>
+        
+        <script>
+        Vue.use(VueRouter);
+        Vue.use(VeeValidate);
+        Vue.use(Vuetify);
+        Vue.use(VueI18n);
+        Vue.use(VueLoading);
+        Vue.use(Toasted, {duration: 5000, fullWidth: true});
+
+
+        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,
+                showsearchbox: false,
+                playmenuitem: null
+            }
+        })
+        Vue.prototype.$globals = globalStore;
+        Vue.prototype.isMobile = isMobile;
+        Vue.prototype.isInStandaloneMode = isInStandaloneMode;
+        Vue.prototype.toggleLibrary = toggleLibrary;
+        Vue.prototype.showPlayMenu = showPlayMenu;
+        Vue.prototype.clickItem= clickItem;
+        
+        const i18n = new VueI18n({
+            locale: navigator.language.split('-')[0],
+            fallbackLocale: 'en',
+            enableInSFC: true,
+            messages
+            })
+
+        var app = new Vue({
+            i18n,
+            el: '#app',
+            watch: {},
+            mounted() {
+            },
+            components: {
+                Loading: VueLoading
+            },
+            created() {
+                // little hack to force refresh PWA on iOS by simple reloading it every hour
+                var d = new Date();
+                var cur_update = d.getDay() + d.getHours();
+                if (localStorage.getItem('last_update') != cur_update)
+                {
+                    localStorage.setItem('last_update', cur_update);
+                    window.location.reload(true);
+                }
+            },
+            data: { },
+            methods: {},
+            router
+        })
+    </script>
+    </body>
+
+</html>
\ No newline at end of file
diff --git a/web/lib/vue-loading-overlay.js b/web/lib/vue-loading-overlay.js
new file mode 100644 (file)
index 0000000..b3b9da1
--- /dev/null
@@ -0,0 +1 @@
+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("VueLoading",[],e):"object"==typeof exports?exports.VueLoading=e():t.VueLoading=e()}("undefined"!=typeof self?self:this,function(){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var r=e[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)i.d(n,r,function(e){return t[e]}.bind(null,r));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=1)}([function(t,e,i){},function(t,e,i){"use strict";i.r(e);var n="undefined"!=typeof window?window.HTMLElement:Object,r={mounted:function(){document.addEventListener("focusin",this.focusIn)},methods:{focusIn:function(t){if(this.isActive&&t.target!==this.$el&&!this.$el.contains(t.target)){var e=this.container?this.container:this.isFullPage?null:this.$el.parentElement;(this.isFullPage||e&&e.contains(t.target))&&(t.preventDefault(),this.$el.focus())}}},beforeDestroy:function(){document.removeEventListener("focusin",this.focusIn)}};function a(t,e,i,n,r,a,o,s){var u,l="function"==typeof t?t.options:t;if(e&&(l.render=e,l.staticRenderFns=i,l._compiled=!0),n&&(l.functional=!0),a&&(l._scopeId="data-v-"+a),o?(u=function(t){(t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),r&&r.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(o)},l._ssrRegister=u):r&&(u=s?function(){r.call(this,this.$root.$options.shadowRoot)}:r),u)if(l.functional){l._injectStyles=u;var c=l.render;l.render=function(t,e){return u.call(e),c(t,e)}}else{var d=l.beforeCreate;l.beforeCreate=d?[].concat(d,u):[u]}return{exports:t,options:l}}var o=a({name:"spinner",props:{color:{type:String,default:"#000"},height:{type:Number,default:64},width:{type:Number,default:64}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 38 38",xmlns:"http://www.w3.org/2000/svg",width:this.width,height:this.height,stroke:this.color}},[e("g",{attrs:{fill:"none","fill-rule":"evenodd"}},[e("g",{attrs:{transform:"translate(1 1)","stroke-width":"2"}},[e("circle",{attrs:{"stroke-opacity":".25",cx:"18",cy:"18",r:"18"}}),e("path",{attrs:{d:"M36 18c0-9.94-8.06-18-18-18"}},[e("animateTransform",{attrs:{attributeName:"transform",type:"rotate",from:"0 18 18",to:"360 18 18",dur:"0.8s",repeatCount:"indefinite"}})],1)])])])},[],!1,null,null,null).exports,s=a({name:"dots",props:{color:{type:String,default:"#000"},height:{type:Number,default:240},width:{type:Number,default:60}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 120 30",xmlns:"http://www.w3.org/2000/svg",fill:this.color,width:this.width,height:this.height}},[e("circle",{attrs:{cx:"15",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"60",cy:"15",r:"9","fill-opacity":"0.3"}},[e("animate",{attrs:{attributeName:"r",from:"9",to:"9",begin:"0s",dur:"0.8s",values:"9;15;9",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"0.5",to:"0.5",begin:"0s",dur:"0.8s",values:".5;1;.5",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"105",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,u=a({name:"bars",props:{color:{type:String,default:"#000"},height:{type:Number,default:40},width:{type:Number,default:40}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 30 30",height:this.height,width:this.width,fill:this.color}},[e("rect",{attrs:{x:"0",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"10",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"20",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,l=a({name:"vue-loading",mixins:[r],props:{active:Boolean,programmatic:Boolean,container:[Object,Function,n],isFullPage:{type:Boolean,default:!0},transition:{type:String,default:"fade"},canCancel:Boolean,onCancel:{type:Function,default:function(){}},color:String,backgroundColor:String,opacity:Number,width:Number,height:Number,zIndex:Number,loader:{type:String,default:"spinner"}},data:function(){return{isActive:this.active}},components:{Spinner:o,Dots:s,Bars:u},beforeMount:function(){this.programmatic&&(this.container?(this.isFullPage=!1,this.container.appendChild(this.$el)):document.body.appendChild(this.$el))},mounted:function(){this.programmatic&&(this.isActive=!0),document.addEventListener("keyup",this.keyPress)},methods:{cancel:function(){this.canCancel&&this.isActive&&(this.hide(),this.onCancel.apply(null,arguments))},hide:function(){var t=this;this.$emit("hide"),this.$emit("update:active",!1),this.programmatic&&(this.isActive=!1,setTimeout(function(){var e;t.$destroy(),void 0!==(e=t.$el).remove?e.remove():e.parentNode.removeChild(e)},150))},keyPress:function(t){27===t.keyCode&&this.cancel()}},watch:{active:function(t){this.isActive=t}},beforeDestroy:function(){document.removeEventListener("keyup",this.keyPress)}},function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("transition",{attrs:{name:t.transition}},[i("div",{directives:[{name:"show",rawName:"v-show",value:t.isActive,expression:"isActive"}],staticClass:"vld-overlay is-active",class:{"is-full-page":t.isFullPage},style:{zIndex:this.zIndex},attrs:{tabindex:"0","aria-busy":t.isActive,"aria-label":"Loading"}},[i("div",{staticClass:"vld-background",style:{background:this.backgroundColor,opacity:this.opacity},on:{click:function(e){return e.preventDefault(),t.cancel(e)}}}),i("div",{staticClass:"vld-icon"},[t._t("before"),t._t("default",[i(t.loader,{tag:"component",attrs:{color:t.color,width:t.width,height:t.height}})]),t._t("after")],2)])])},[],!1,null,null,null).exports,c=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return{show:function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:e,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:i,a=Object.assign({},e,n,{programmatic:!0}),o=new(t.extend(l))({el:document.createElement("div"),propsData:a}),s=Object.assign({},i,r);return Object.keys(s).map(function(t){o.$slots[t]=s[t]}),o}}};i(0);l.install=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},n=c(t,e,i);t.$loading=n,t.prototype.$loading=n};e.default=l}]).default});
\ No newline at end of file
diff --git a/web/manifest.json b/web/manifest.json
new file mode 100755 (executable)
index 0000000..6a3c4b9
--- /dev/null
@@ -0,0 +1,22 @@
+{
+  "name": "Music Assistant",
+  "short_name": "MusicAssistant",
+  "theme_color": "#2196f3",
+  "background_color": "#2196f3",
+  "display": "standalone",
+  "Scope": "/",
+  "start_url": "/",
+  "icons": [
+    {
+      "src": "images/icons/icon-128x128.png",
+      "sizes": "128x128",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-256x256.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ],
+  "splash_pages": null
+}
\ No newline at end of file
diff --git a/web/pages/albumdetails.vue.js b/web/pages/albumdetails.vue.js
new file mode 100755 (executable)
index 0000000..4f60a91
--- /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 = ""
+    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/web/pages/artistdetails.vue.js b/web/pages/artistdetails.vue.js
new file mode 100755 (executable)
index 0000000..4660030
--- /dev/null
@@ -0,0 +1,127 @@
+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 = ""
+    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;
+      console.log(api_url + ' - ' + this.provider);
+      axios
+        .get(api_url, { params: { lazy: lazy, provider: this.provider }})
+        .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/web/pages/browse.vue.js b/web/pages/browse.vue.js
new file mode 100755 (executable)
index 0000000..49bcdc8
--- /dev/null
@@ -0,0 +1,61 @@
+var Browse = Vue.component('Browse', {
+  template: `
+    <section>
+      <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', 'provider'],
+  data() {
+    return {
+      selected: [2],
+      items: [],
+      offset: 0
+    }
+  },
+  created() {
+    this.showavatar = true;
+    mediatitle = 
+    this.$globals.windowtitle = this.$t(this.mediatype)
+    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, provider: this.provider }})
+        .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/web/pages/config.vue.js b/web/pages/config.vue.js
new file mode 100755 (executable)
index 0000000..c4164db
--- /dev/null
@@ -0,0 +1,152 @@
+var Config = Vue.component('Config', {
+  template: `
+    <section>
+
+        <v-tabs v-model="active" color="transparent" light slider-color="black">
+            <v-tab ripple v-for="(conf_value, conf_key) in conf" :key="conf_key">{{ $t('conf.'+conf_key) }}</v-tab>
+                  <v-tab-item v-for="(conf_value, conf_key) in conf" :key="conf_key">
+
+                      <!-- generic and module settings -->
+                      <v-list two-line v-if="conf_key != 'player_settings'">
+                          <v-list-group no-action v-for="(conf_subvalue, conf_subkey) in conf[conf_key]" :key="conf_key+conf_subkey">
+                            <template v-slot:activator>
+                                <v-list-tile>
+                                  <v-list-tile-avatar>
+                                      <img :src="'images/icons/' + conf_subkey + '.png'"/>
+                                  </v-list-tile-avatar>
+                                  <v-list-tile-content>
+                                      <v-list-tile-title>{{ $t('conf.'+conf_subkey) }}</v-list-tile-title>
+                                  </v-list-tile-content>
+                                </v-list-tile>
+                              </template>
+                              <div v-for="conf_item_key in conf[conf_key][conf_subkey].__desc__">
+                                    <v-list-tile>
+                                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
+                                          <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
+                                          <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-select>
+                                          <v-text-field v-else v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
+                                    </v-list-tile>
+                                </div>
+                                <v-divider></v-divider>
+                            </v-list-group>
+                      </v-list two-line>
+
+                      <!-- player settings -->
+                      <v-list two-line v-if="conf_key == 'player_settings'">
+                          <v-list-group no-action v-for="(player, key) in players" v-if="key != '__desc__' && key in players" :key="key">
+                                <template v-slot:activator>
+                                    <v-list-tile>
+                                      <v-list-tile-avatar>
+                                          <img :src="'images/icons/' + players[key].player_provider + '.png'"/>
+                                      </v-list-tile-avatar>
+                                      <v-list-tile-content>
+                                        <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
+                                        <v-list-tile-sub-title class="title">{{ key }}</v-list-tile-sub-title>
+                                      </v-list-tile-content>
+                                  </v-list-tile>
+                              </template>
+                              <div v-for="conf_item_key in conf.player_settings[key].__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="$t('conf.'+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="$t('conf.'+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="$t('conf.'+conf_item_key[2])" 
+                                          :items="playersLst"
+                                          item-text="name"
+                                          item-value="id" box>
+                                        </v-select>
+                                        <v-select v-else-if="conf_item_key[0] == 'max_sample_rate'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" :items="sample_rates" box></v-select>
+                                        <v-slider v-else-if="conf_item_key[0] == 'crossfade_duration'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" min=0 max=10 box thumb-label></v-slider>
+                                        <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+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="$t('conf.'+'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="$t('conf.'+'enabled')"></v-switch>
+                                  </v-list-tile>
+                              </div>
+                                <v-divider></v-divider>
+                            </v-list-group>
+                      </v-list two-line>
+                  </v-tab-item>
+            </v-tab>
+        </v-tabs>
+
+
+    </section>
+  `,
+  props: [],
+  data() {
+    return {
+      conf: {},
+      players: {},
+      active: 0,
+      sample_rates: [44100, 48000, 88200, 96000, 192000, 384000]
+    }
+  },
+  computed: {
+    playersLst()
+    {
+      var playersLst = [];
+      playersLst.push({id: null, name: this.$t('conf.'+'not_grouped')})
+      for (player_id in this.players)
+        playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
+      return playersLst;
+    }
+  },
+  watch: {
+    'conf': {
+        handler: _.debounce(function (val, oldVal) {
+          if (oldVal.base) {
+            console.log("save config needed!");
+            this.saveConfig();
+            this.$toasted.show(this.$t('conf.conf_saved'))
+          }
+        }, 5000),
+        deep: true
+    }
+  },
+  created() {
+    this.$globals.windowtitle = this.$t('settings');
+    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/web/pages/home.vue.js b/web/pages/home.vue.js
new file mode 100755 (executable)
index 0000000..91c0b33
--- /dev/null
@@ -0,0 +1,43 @@
+var home = Vue.component("Home", {
+  template: `
+  <section>
+      <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>
+  </section>
+`,
+  props: ["title"],
+  $_veeValidate: {
+    validator: "new"
+  },
+  data() {
+    return {
+      result: null,
+      showProgress: false
+    };
+  },
+  created() {
+    this.$globals.windowtitle = this.$t('musicassistant');
+    this.items= [
+        { title: this.$t('artists'), icon: "person", path: "/artists" },
+        { title: this.$t('albums'), icon: "album", path: "/albums" },
+        { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" },
+        { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" },
+        { title: this.$t('search'), icon: "search", path: "/search" }
+    ]
+  },
+  methods: {
+    click (item) {
+      console.log("selected: "+ item.path);
+      router.push({path: item.path})
+    }
+  }
+});
diff --git a/web/pages/playlistdetails.vue.js b/web/pages/playlistdetails.vue.js
new file mode 100755 (executable)
index 0000000..b9c617d
--- /dev/null
@@ -0,0 +1,83 @@
+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,
+      active: 0
+    }
+  },
+  created() {
+    this.$globals.windowtitle = ""
+    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/web/pages/queue.vue.js b/web/pages/queue.vue.js
new file mode 100755 (executable)
index 0000000..9bc25a9
--- /dev/null
@@ -0,0 +1,42 @@
+var Queue = Vue.component('Queue', {
+  template: `
+  <section>
+        <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>
+      </section>`,
+  props: ['player_id'],
+  data() {
+    return {
+      selected: [0],
+      info: {},
+      items: [],
+      offset: 0,
+    }
+  },
+  created() {
+    this.$globals.windowtitle = this.$t('queue')
+    this.getQueueTracks(0, 25);
+  },
+  methods: {
+
+    getQueueTracks (offset, limit) {
+      const api_url = '/api/players/' + this.player_id + '/queue'
+      return axios.get(api_url, { params: { offset: offset, limit: limit}})
+        .then(response => {
+            if (response.data.length < 1 )
+              return;
+            this.items.push(...response.data)
+            return this.getQueueTracks(offset+limit, 100)
+        })
+    }
+  }
+})
diff --git a/web/pages/search.vue.js b/web/pages/search.vue.js
new file mode 100755 (executable)
index 0000000..996c01e
--- /dev/null
@@ -0,0 +1,154 @@
+var Search = Vue.component('Search', {
+  template: `
+  <section>
+
+    <v-text-field
+        solo
+        clearable
+        :label="$t('type_to_search')"
+        append-icon="search"
+        v-model="searchQuery" v-on:keyup.enter="Search" @click:append="Search" style="margin-left:30px; margin-right:30px; margin-top:10px">
+      </v-text-field>
+
+      <v-tabs
+          v-model="active"
+          color="transparent"
+          light
+          slider-color="black"
+        >
+
+        <v-tab ripple v-if="tracks.length">{{ $t('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">{{ $t('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">{{ $t('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">{{ $t('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,
+      active: 0,
+      searchQuery: ""
+    }
+  },
+  created() {
+    this.$globals.windowtitle = this.$t('search');
+  },
+  watch: {
+  },
+  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);
+      }
+    },
+    Search () {
+      this.artists = [];
+      this.albums = [];
+      this.tracks = [];
+      this.playlists = [];
+      if (this.searchQuery) {
+        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/web/pages/trackdetails.vue.js b/web/pages/trackdetails.vue.js
new file mode 100755 (executable)
index 0000000..e8f0896
--- /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 = ""
+    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/web/strings.js b/web/strings.js
new file mode 100644 (file)
index 0000000..5c1df07
--- /dev/null
@@ -0,0 +1,179 @@
+const messages = {
+    
+    
+    en: {
+        // generic strings
+        musicassistant: "Music Assistant",
+        home: "Home",
+        artists: "Artists",
+        albums: "Albums",
+        tracks: "Tracks",
+        playlists: "Playlists",
+        radios: "Radio",
+        search: "Search",
+        settings: "Settings",
+        queue: "Queue",
+        type_to_search: "Type here to search...",
+        add_library: "Add to library",
+        remove_library: "Remove from library",
+        add_playlist: "Add to playlist...",
+        remove_playlist: "Remove from playlist",
+        // settings strings
+        conf: {
+            enabled: "Enabled",
+            base: "Generic settings",
+            musicproviders: "Music providers",
+            playerproviders: "Player providers",
+            player_settings: "Player settings",
+            homeassistant: "Home Assistant integration",
+            web: "Webserver",
+            http_streamer: "Built-in (sox based) streamer",
+            qobuz: "Qobuz",
+            spotify: "Spotify",
+            tunein: "TuneIn",
+            file: "Filesystem",
+            chromecast: "Chromecast",
+            lms: "Logitech Media Server",
+            pylms: "Emulated (built-in) Squeezebox support",
+            username: "Username",
+            password: "Password",
+            hostname: "Hostname (or IP)",
+            port: "Port",
+            hass_url: "URL to homeassistant (e.g. https://homeassistant:8123)",
+            hass_token: "Long Lived Access Token",
+            hass_publish: "Publish players to Home Assistant",
+            hass_player_power: "Attach player power to homeassistant entity",
+            hass_player_source: "Source on the homeassistant entity (optional)",
+            hass_player_volume: "Attach player volume to homeassistant entity",
+            web_ssl_cert: "Path to ssl certificate file",
+            web_ssl_key: "Path to ssl keyfile",
+            player_enabled: "Enable player",
+            player_name: "Custom name for this player",
+            player_group_with: "Group this player to another (parent)player",
+            player_mute_power: "Use muting as power control",
+            player_disable_vol: "Disable volume controls",
+            player_group_vol: "Apply group volume to childs (for group players only)",
+            player_group_pow: "Apply group power based on childs (for group players only)",
+            player_power_play: "Issue play command on power on",
+            file_prov_music_path: "Path to music files",
+            file_prov_playlists_path: "Path to playlists (.m3u)",
+            web_http_port: "HTTP port",
+            web_https_port: "HTTPS port",
+            cert_fqdn_host: "FQDN of hostname in certificate",
+            enable_r128_volume_normalisation: "Enable R128 volume normalization",
+            target_volume_lufs: "Target volume (R128 default is -23 LUFS)",
+            fallback_gain_correct: "Fallback gain correction if R128 readings not (yet) available",
+            enable_audio_cache: "Allow caching of audio to temp files",
+            trim_silence: "Strip silence from beginning and end of audio (temp files only!)",
+            http_streamer_sox_effects: "Custom sox effects to apply to audio (built-in streamer only!) See http://sox.sourceforge.net/sox.html#EFFECTS",
+            max_sample_rate: "Maximum sample rate this player supports, higher will be downsampled",
+            force_http_streamer: "Force use of built-in streamer, even if the player can handle the music provider directly",
+            not_grouped: "Not grouped",
+            conf_saved: "Configuration saved, restart app to make effective",
+            audio_cache_folder: "Directory to use for cache files",
+            audio_cache_max_size_gb: "Maximum size of the cache folder (GB)"
+        },
+        // player strings
+        players: "Players",
+        play: "Play",
+        play_on: "Play on:",
+        play_now: "Play Now",
+        play_next: "Play Next",
+        add_queue: "Add to Queue",
+        show_info: "Show info",
+        state: {        
+            playing: "playing",
+            stopped: "stopped",
+            paused: "paused",
+            off: "off"
+        }
+    },
+
+    nl: {
+        // generic strings
+        musicassistant: "Music Assistant",
+        home: "Home",
+        artists: "Artiesten",
+        albums: "Albums",
+        tracks: "Nummers",
+        playlists: "Afspeellijsten",
+        radios: "Radio",
+        search: "Zoeken",
+        settings: "Instellingen",
+        queue: "Wachtrij",
+        type_to_search: "Type hier om te zoeken...",
+        add_library: "Voeg toe aan bibliotheek",
+        remove_library: "Verwijder uit bibliotheek",
+        add_playlist: "Aan playlist toevoegen...",
+        remove_playlist: "Verwijder uit playlist",
+        // settings strings
+        conf: {
+            enabled: "Ingeschakeld",
+            base: "Algemene instellingen",
+            musicproviders: "Muziek providers",
+            playerproviders: "Speler providers",
+            player_settings: "Speler instellingen",
+            homeassistant: "Home Assistant integratie",
+            web: "Webserver",
+            http_streamer: "Ingebouwde (sox gebaseerde) streamer",
+            qobuz: "Qobuz",
+            spotify: "Spotify",
+            tunein: "TuneIn",
+            file: "Bestandssysteem",
+            chromecast: "Chromecast",
+            lms: "Logitech Media Server",
+            pylms: "Geemuleerde (ingebouwde) Squeezebox ondersteuning",
+            username: "Gebruikersnaam",
+            password: "Wachtwoord",
+            hostname: "Hostnaam (of IP)",
+            port: "Poort",
+            hass_url: "URL naar homeassistant (b.v. https://homeassistant:8123)",
+            hass_token: "Token met lange levensduur",
+            hass_publish: "Publiceer spelers naar Home Assistant",
+            hass_player_power: "Verbind speler aan/uit met homeassistant entity",
+            hass_player_source: "Benodigde bron op de verbonden homeassistant entity (optioneel)",
+            hass_player_volume: "Verbind volume van speler aan een homeassistant entity",
+            web_ssl_cert: "Pad naar ssl certificaat bestand",
+            web_ssl_key: "Pad naar ssl certificaat key bestand",
+            player_enabled: "Speler inschakelen",
+            player_name: "Aangepaste naam voor deze speler",
+            player_group_with: "Groupeer deze speler met een andere (hoofd)speler",
+            player_mute_power: "Gebruik mute als aan/uit",
+            player_disable_vol: "Schakel volume bediening helemaal uit",
+            player_group_vol: "Pas groep volume toe op onderliggende spelers (alleen groep spelers)",
+            player_group_pow: "Pas groep aan/uit toe op onderliggende spelers (alleen groep spelers)",
+            player_power_play: "Automatisch afspelen bij inschakelen",
+            file_prov_music_path: "Pad naar muziek bestanden",
+            file_prov_playlists_path: "Pad naar playlist bestanden (.m3u)",
+            web_http_port: "HTTP poort",
+            web_https_port: "HTTPS poort",
+            cert_fqdn_host: "Hostname (FQDN van certificaat)",
+            enable_r128_volume_normalisation: "Schakel R128 volume normalisatie in",
+            target_volume_lufs: "Doelvolume (R128 standaard is -23 LUFS)",
+            fallback_gain_correct: "Fallback gain correctie indien R128 meting (nog) niet beschikbaar is",
+            enable_audio_cache: "Sta het cachen van audio toe naar temp map",
+            trim_silence: "Strip stilte van begin en eind van audio (in temp bestanden)",
+            http_streamer_sox_effects: "Eigen sox effects toepassen op audio (alleen voor ingebouwde streamer). Zie http://sox.sourceforge.net/sox.html#EFFECTS",
+            max_sample_rate: "Maximale sample rate welke deze speler ondersteund, hoger wordt gedownsampled.",
+            force_http_streamer: "Forceer het gebruik van de ingebouwde streamer, ook al heeft de speler directe ondersteuning voor de muziek provider",
+            not_grouped: "Niet gegroepeerd",
+            conf_saved: "Configuratie is opgeslagen, herstart om actief te maken",
+            audio_cache_folder: "Map om te gebruiken voor cache bestanden",
+            audio_cache_max_size_gb: "Maximale grootte van de cache map in GB."
+        },
+        // player strings
+        players: "Spelers",
+        play: "Afspelen",
+        play_on: "Afspelen op:",
+        play_now: "Nu afspelen",
+        play_next: "Speel als volgende af",
+        add_queue: "Voeg toe aan wachtrij",
+        show_info: "Bekijk informatie",
+        state: {        
+            playing: "afspelen",
+            stopped: "gestopt",
+            paused: "gepauzeerd",
+            off: "uitgeschakeld"
+        }
+    }
+}
\ No newline at end of file