some optimizations
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 24 May 2019 16:17:02 +0000 (18:17 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 24 May 2019 16:17:02 +0000 (18:17 +0200)
18 files changed:
music_assistant/main.py
music_assistant/models.py
music_assistant/modules/http_streamer.py [new file with mode: 0755]
music_assistant/modules/musicproviders/qobuz.py
music_assistant/modules/musicproviders/spotify.py
music_assistant/modules/player.py
music_assistant/modules/playerproviders/chromecast.py
music_assistant/modules/playerproviders/pylms.py
music_assistant/modules/web.py
music_assistant/web/config_old.vue.js [new file with mode: 0755]
music_assistant/web/images/icons/chromecast.png [new file with mode: 0644]
music_assistant/web/images/icons/homeassistant.png [new file with mode: 0644]
music_assistant/web/images/icons/http_streamer.png [new file with mode: 0644]
music_assistant/web/images/icons/lms.png [new file with mode: 0644]
music_assistant/web/images/icons/pylms.png [new file with mode: 0644]
music_assistant/web/images/icons/web.png [new file with mode: 0644]
music_assistant/web/pages/config.vue.js
music_assistant/web/strings.js

index fc09a5117eee1c527c44ec402635e1b4d08313c4..9f3692d3b3833b13fe9466c1055d817591f52d7e 100755 (executable)
@@ -20,6 +20,7 @@ from modules.metadata import MetaData
 from modules.cache import Cache
 from modules.music import Music
 from modules.player import Player
+from modules.http_streamer import HTTPStreamer
 from modules.homeassistant import setup as hass_setup
 from modules.web import setup as web_setup
 
@@ -47,6 +48,7 @@ class Main():
         self.hass = hass_setup(self)
         self.music = Music(self)
         self.player = Player(self)
+        self.http_streamer = HTTPStreamer(self)
 
         # start the event loop
         try:
@@ -71,18 +73,25 @@ class Main():
         self.event_listeners[cb_id] = cb
 
     async def remove_event_listener(self, cb_id):
-        ''' add callback to our event listeners '''
+        ''' 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')
+        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(self.config, indent=4))
+            f.write(json.dumps(final_conf, indent=4))
         
     def parse_config(self):
         '''get config from config file'''
index e88b513230514d19b5d12b28ae6e8baf42470eab..5c30f536aa3c8c44104793aa3ced4e958d526212 100755 (executable)
@@ -468,7 +468,7 @@ class MusicPlayer():
         self.name = ''
         self.state = PlayerState.Stopped
         self.powered = False
-        self.cur_item = Track()
+        self.cur_item = None
         self.cur_item_time = 0
         self.volume_level = 0
         self.shuffle_enabled = True
diff --git a/music_assistant/modules/http_streamer.py b/music_assistant/modules/http_streamer.py
new file mode 100755 (executable)
index 0000000..751d4ae
--- /dev/null
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from utils import LOGGER, try_parse_int, get_ip, run_async_background_task
+from models import TrackQuality
+import shutil
+import xml.etree.ElementTree as ET
+import random
+
+
+AUDIO_TEMP_DIR = "/tmp/audio_tmp"
+AUDIO_CACHE_DIR = "/tmp/audio_cache"
+
+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()
+        # create needed temp/cache dirs
+        if self.mass.config['base']['http_streamer']['enable_cache'] and not os.path.isdir(AUDIO_CACHE_DIR):
+            os.makedirs(AUDIO_CACHE_DIR)
+        if not os.path.isdir(AUDIO_TEMP_DIR):
+            os.makedirs(AUDIO_TEMP_DIR)
+
+    def create_config_entries(self):
+        ''' sets the config entries for this module (list with key/value pairs)'''
+        config_entries = [
+            ('volume_normalisation', True, 'enable_r128_volume_normalisation'), 
+            ('target_volume', '-23', 'target_volume_lufs'),
+            ('fallback_gain_correct', '-12', 'fallback_gain_correct'),
+            ('enable_cache', True, 'enable_audio_cache'),
+            ('trim_silence', True, 'trim_silence')
+            ]
+        if not self.mass.config['base'].get('http_streamer'):
+            self.mass.config['base']['http_streamer'] = {}
+        self.mass.config['base']['http_streamer']['__desc__'] = config_entries
+        for key, def_value, desc in config_entries:
+            if not key in self.mass.config['base']['http_streamer']:
+                self.mass.config['base']['http_streamer'][key] = def_value
+    
+    async def get_audio_stream(self, track_id, provider, player_id=None):
+        ''' get audio stream for a track '''
+        queue = asyncio.Queue()
+        run_async_background_task(
+            self.mass.bg_executor, self.__get_audio_stream, queue, track_id, provider, player_id)
+        while True:
+            chunk = await queue.get()
+            if not chunk:
+                queue.task_done()
+                break
+            yield chunk
+            queue.task_done()
+        await queue.join()
+        # TODO: handle disconnects ?
+        LOGGER.info("Finished streaming %s" % track_id)
+
+    async def __get_audio_stream(self, audioqueue, track_id, provider, player_id=None):
+        ''' get audio stream from provider and apply additional effects/processing where/if needed'''
+        input_content_type = await self.mass.music.providers[provider].get_stream_content_type(track_id)
+        cachefile = self.__get_track_cache_filename(track_id, provider)
+        sox_effects = ''
+         # sox settings
+        if self.mass.config['base']['http_streamer']['volume_normalisation']:
+            gain_correct = await self.__get_track_gain_correct(track_id, provider)
+            LOGGER.info("apply gain correction of %s" % gain_correct)
+            sox_effects += ' vol %s dB ' % gain_correct
+        sox_effects += await self.__get_player_sox_options(track_id, provider, player_id)
+        if os.path.isfile(cachefile):
+            # we have a cache file for this track which we can use
+            args = 'sox -t flac %s -t flac -C 0 - %s' % (cachefile, sox_effects)
+            LOGGER.info("Running sox with args: %s" % args)
+            process = await asyncio.create_subprocess_shell(args, 
+                    stdout=asyncio.subprocess.PIPE)
+            buffer_task = None
+        else:
+            # stream from provider
+            args = 'sox -t %s - -t flac -C 0 - %s' % (input_content_type, sox_effects)
+            LOGGER.info("Running sox with args: %s" % args)
+            process = await asyncio.create_subprocess_shell(args,
+                    stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
+            buffer_task = asyncio.get_event_loop().create_task(
+                     self.__fill_audio_buffer(process.stdin, track_id, provider, input_content_type))
+        # put chunks from stdout into queue
+        while not process.stdout.at_eof():
+            chunk = await process.stdout.read(10240000)
+            if not chunk:
+                break
+            await audioqueue.put(chunk)
+            # TODO: cooldown if the queue can't catch up to prevent memory being filled up with entire track
+        await process.wait()
+        await audioqueue.put('') # indicate EOF
+        LOGGER.info("streaming of track_id %s completed" % track_id)
+
+    async def __get_player_sox_options(self, track_id, provider, player_id):
+        ''' get player specific sox options '''
+        sox_effects = ' '
+        if not player_id:
+            return ''
+        if 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 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, tmpfile, track_id, provider, content_type):
+        ''' analyze track audio, for now we only calculate EBU R128 loudness '''
+        LOGGER.info('Start analyzing file %s' % tmpfile)
+        cachefile = self.__get_track_cache_filename(track_id, provider)
+        # not needed to do processing if there already is a cachedfile
+        bs1770_binary = self.__get_bs1770_binary()
+        if bs1770_binary:
+            # calculate integrated r128 loudness with bs1770
+            analyse_dir = os.path.join(self.mass.datapath, 'analyse_info')
+            analysis_file = os.path.join(analyse_dir, "%s_%s.xml" %(provider, track_id.split(os.sep)[-1]))
+            if not os.path.isfile(analysis_file):
+                if not os.path.isdir(analyse_dir):
+                    os.makedirs(analyse_dir)
+                cmd = '%s %s --xml --ebu -f %s' % (bs1770_binary, tmpfile, analysis_file)
+                process = await asyncio.create_subprocess_shell(cmd)
+                await process.wait()
+            if self.mass.config['base']['http_streamer']['enable_cache'] and not os.path.isfile(cachefile):
+                # use sox to store cache file (optionally strip silence from start and end)
+                if self.mass.config['base']['http_streamer']['trim_silence']:
+                    cmd = 'sox -t %s %s -t flac -C5 %s silence 1 0.1 1%% reverse silence 1 0.1 1%% reverse' %(content_type, tmpfile, cachefile)
+                else:
+                    # cachefile is always stored as flac 
+                    cmd = 'sox -t %s %s -t flac -C5 %s' %(content_type, tmpfile, cachefile)
+                process = await asyncio.create_subprocess_shell(cmd)
+                await process.wait()
+        # always clean up temp file
+        while os.path.isfile(tmpfile):
+            os.remove(tmpfile)
+            await asyncio.sleep(0.5)
+        LOGGER.info('Fininished analyzing file %s' % tmpfile)
+    
+    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'])
+        analysis_file = os.path.join(self.mass.datapath, 'analyse_info', "%s_%s.xml" %(provider, track_id.split(os.sep)[-1]))
+        if not os.path.isfile(analysis_file):
+            return fallback_gain
+        try: # read audio analysis if available
+            tree = ET.parse(analysis_file)
+            trackinfo = tree.getroot().find("album").find("track")
+            track_lufs = trackinfo.find('integrated').get('lufs')
+            gain_correct = target_gain - float(track_lufs)
+        except Exception as exc:
+            LOGGER.error('could not retrieve track gain - %s' % exc)
+            gain_correct = fallback_gain # fallback value
+            if os.path.isfile(analysis_file):
+                os.remove(analysis_file)
+                # reschedule analyze task to try again
+                cachefile = self.__get_track_cache_filename(track_id, provider)
+                self.mass.event_loop.create_task(self.__analyze_audio(cachefile, track_id, provider, 'flac'))
+        return round(gain_correct,2)
+
+    async def __fill_audio_buffer(self, buf, track_id, provider, content_type):
+        ''' get audio data from provider and write to buffer'''
+        # fill the buffer with audio data
+        # a tempfile is created so we can do audio analysis
+        tmpfile = os.path.join(AUDIO_TEMP_DIR, '%s%s%s.tmp' % (random.randint(0, 999), track_id, random.randint(0, 999)))
+        fd = open(tmpfile, 'wb')
+        async for chunk in self.mass.music.providers[provider].get_audio_stream(track_id):
+            buf.write(chunk)
+            await buf.drain()
+            fd.write(chunk)
+        await buf.drain()
+        buf.write_eof()
+        fd.close()
+        # successfull completion, send tmpfile to be processed in the background in main loop
+        self.mass.event_loop.create_task(self.__analyze_audio(tmpfile, track_id, provider, content_type))
+        LOGGER.info("fill_audio_buffer complete for track %s" % track_id)
+        return
+
+    @staticmethod
+    def __get_track_cache_filename(track_id, provider):
+        ''' get filename for a track to use as cache file '''
+        return os.path.join(AUDIO_CACHE_DIR, '%s_%s' %(provider, track_id.split(os.sep)[-1]))
+
+    @staticmethod
+    def __get_bs1770_binary():
+        ''' get the path to the bs1770 binary for the current OS '''
+        import platform
+        bs1770_binary = None
+        if platform.system() == "Windows":
+            bs1770_binary = os.path.join(os.path.dirname(__file__), "bs1770gain", "win64", "bs1770gain")
+        elif platform.system() == "Darwin":
+            # macos binary is x86_64 intel
+            bs1770_binary = os.path.join(os.path.dirname(__file__), "bs1770gain", "osx", "bs1770gain")
+        elif platform.system() == "Linux":
+            architecture = platform.machine()
+            if architecture.startswith('AMD64') or architecture.startswith('x86_64'):
+                bs1770_binary = os.path.join(os.path.dirname(__file__), "bs1770gain", "linux64", "bs1770gain")
+            # TODO: build armhf binary
+        return bs1770_binary
\ No newline at end of file
index 25f93659a9145f44e94cc1eea69b79a397ad9a25..7ba28a95d2c9b2a6cf8ff54a2dd7bda558609427 100644 (file)
@@ -268,11 +268,10 @@ class QobuzProvider(MusicProvider):
         async with aiohttp.ClientSession(loop=asyncio.get_event_loop(), connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
             async with session.get(streamdetails['url']) as resp:
                 while True:
-                    chunk = await resp.content.read(10240000)
+                    chunk = await resp.content.read(512000)
                     if not chunk:
                         break
                     yield chunk
-                    await asyncio.sleep(0.1)
         LOGGER.info("end of stream for track_id %s" % track_id)
     
     async def __parse_artist(self, artist_obj):
@@ -507,11 +506,10 @@ class QobuzProvider(MusicProvider):
         async with self.throttler:
             async with self.http_session.get(url, headers=headers, params=params) as response:
                 result = await response.json()
-                if 'error' in result:
+                if not result or 'error' in result:
                     LOGGER.error(url)
                     LOGGER.error(params)
                     LOGGER.error(result)
                     result = None
-                result = await response.json()
                 return result
 
index 3640e544ee51538689dc0b3e8d19329a48c2afd5..0fc19698a18447678fd17c6092b3f27704c868f9 100644 (file)
@@ -253,11 +253,11 @@ class SpotifyProvider(MusicProvider):
         args = ['-n', 'temp', '-u', self._username, '-p', self._password, '--pass-through', '--single-track', track_id]
         process = await asyncio.create_subprocess_exec(spotty, *args, stdout=asyncio.subprocess.PIPE)
         while not process.stdout.at_eof():
-            chunk = await process.stdout.read(10240000)
+            chunk = await process.stdout.read(128000)
             if not chunk:
                 break
             yield chunk
-            await asyncio.sleep(0.1)
+        await process.wait()
         LOGGER.info("end of stream for track_id %s" % track_id)
         
     async def __parse_artist(self, artist_obj):
@@ -477,10 +477,10 @@ class SpotifyProvider(MusicProvider):
         async with self.throttler:
             async with self.http_session.get(url, headers=headers, params=params) as response:
                 result = await response.json()
-                if 'error' in result:
+                if not result or 'error' in result:
                     LOGGER.error(url)
                     LOGGER.error(params)
-                    return None
+                    result = None
                 return result
 
     async def __delete_data(self, endpoint, params={}):
index a8163685469868d44534ee487bb4667d1b3e2fbe..d5496ba064e6294ef298f716e7d407bee9376870 100755 (executable)
@@ -4,28 +4,16 @@
 import asyncio
 import os
 from utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task
-import aiohttp
-from difflib import SequenceMatcher as Matcher
 from models import MediaType, PlayerState, MusicPlayer, TrackQuality
-from typing import List
-import toolz
 import operator
-import socket
 import random
 from copy import deepcopy
 import functools
-import time
-import shutil
-import xml.etree.ElementTree as ET
-import concurrent
-import aiohttp
-import random
 import urllib
 
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" )
-AUDIO_TEMP_DIR = "/tmp/audio_tmp"
-AUDIO_CACHE_DIR = "/tmp/audio_cache"
+
 
 class Player():
     ''' several helpers to handle playback through player providers '''
@@ -34,46 +22,9 @@ class Player():
         self.mass = mass
         self.providers = {}
         self._players = {}
-        self.create_config_entries()
         self.local_ip = get_ip()
-        # create needed temp/cache dirs
-        if self.mass.config['base']['http_streamer']['enable_cache'] and not os.path.isdir(AUDIO_CACHE_DIR):
-            os.makedirs(AUDIO_CACHE_DIR)
-        if not os.path.isdir(AUDIO_TEMP_DIR):
-            os.makedirs(AUDIO_TEMP_DIR)
         # dynamically load provider modules
         self.load_providers()
-
-    def create_config_entries(self):
-        ''' sets the config entries for this module (list with key/value pairs)'''
-        # player specific settings
-        self.mass.config['player_settings']['__desc__'] = [
-            ("enabled", False, "player_enabled"),
-            ("name", "", "player_name"),
-            ("group_parent", "<player>", "player_group_with"),
-            ("mute_as_power", False, "player_mute_power"),
-            ("disable_volume", False, "player_disable_vol"),
-            ("apply_group_volume", False, "player_group_vol"),
-            ("apply_group_power", False, "player_group_pow"),
-            ("play_power_on", False, "player_power_play"),
-            ("sox_effects", '', "http_streamer_sox_effects"),
-            ("max_sample_rate", '96000', "max_sample_rate"),
-            ("force_http_streamer", False, "force_http_streamer")
-        ]
-        # config for the http streamer
-        config_entries = [
-            ('volume_normalisation', True, 'enable_r128_volume_normalisation'), 
-            ('target_volume', '-23', 'target_volume_lufs'),
-            ('fallback_gain_correct', '-12', 'fallback_gain_correct'),
-            ('enable_cache', True, 'enable_audio_cache'),
-            ('trim_silence', True, 'trim_silence')
-            ]
-        if not self.mass.config['base'].get('http_streamer'):
-            self.mass.config['base']['http_streamer'] = {}
-        self.mass.config['base']['http_streamer']['__desc__'] = config_entries
-        for key, def_value, desc in config_entries:
-            if not key in self.mass.config['base']['http_streamer']:
-                self.mass.config['base']['http_streamer'][key] = def_value
     
     async def players(self):
         ''' return all players '''
@@ -114,7 +65,7 @@ class Player():
             return await self.__player_command_group_volume(player, player_childs, cmd_args)
         if player.is_group and cmd == 'power' and cmd_args == 'off':
             for item in player_childs:
-                asyncio.create_task(self.player_command(item.player_id, cmd, cmd_args))
+                await self.player_command(item.player_id, cmd, cmd_args)
         # normal execution of command on player
         prov_id = self._players[player_id].player_provider
         prov = self.providers[prov_id]
@@ -287,8 +238,21 @@ class Player():
     
     async def __get_player_settings(self, player_id):
         ''' get (or create) player config '''
+        config_entries = [ # default config entries for a player
+            ("enabled", False, "player_enabled"),
+            ("name", "", "player_name"),
+            ("group_parent", "<player>", "player_group_with"),
+            ("mute_as_power", False, "player_mute_power"),
+            ("disable_volume", False, "player_disable_vol"),
+            ("apply_group_volume", False, "player_group_vol"),
+            ("apply_group_power", False, "player_group_pow"),
+            ("play_power_on", False, "player_power_play"),
+            ("sox_effects", '', "http_streamer_sox_effects"),
+            ("max_sample_rate", '96000', "max_sample_rate"),
+            ("force_http_streamer", False, "force_http_streamer")
+        ]
         player_settings = self.mass.config['player_settings'].get(player_id,{})
-        for key, def_value, desc in self.mass.config['player_settings']['__desc__']:
+        for key, def_value, desc in config_entries:
             if not key in player_settings:
                 if (isinstance(def_value, str) and def_value.startswith('<')):
                     player_settings[key] = None
@@ -371,177 +335,6 @@ class Player():
         player_prov = self.providers[player.player_provider]
         return await player_prov.player_queue(player_id, offset=offset, limit=limit)
 
-    async def get_audio_stream(self, track_id, provider, player_id=None):
-        ''' get audio stream for a track '''
-        queue = asyncio.Queue()
-        run_async_background_task(
-            self.mass.bg_executor, self.__get_audio_stream, queue, track_id, provider, player_id)
-        while True:
-            chunk = await queue.get()
-            if not chunk:
-                break
-            yield chunk
-            queue.task_done()
-
-    async def __get_audio_stream(self, audioqueue, track_id, provider, player_id=None):
-        ''' get audio stream from provider and apply additional effects/processing where/if needed'''
-        input_content_type = await self.mass.music.providers[provider].get_stream_content_type(track_id)
-        cachefile = self.__get_track_cache_filename(track_id, provider)
-        sox_effects = ''
-         # sox settings
-        if self.mass.config['base']['http_streamer']['volume_normalisation']:
-            gain_correct = await self.__get_track_gain_correct(track_id, provider)
-            LOGGER.info("apply gain correction of %s" % gain_correct)
-            sox_effects += ' vol %s dB ' % gain_correct
-        sox_effects += await self.__get_player_sox_options(track_id, provider, player_id)
-        if os.path.isfile(cachefile):
-            # we have a cache file for this track which we can use
-            args = 'sox -t flac %s -t flac -C 0 - %s' % (cachefile, sox_effects)
-            LOGGER.info("Running sox with args: %s" % args)
-            process = await asyncio.create_subprocess_shell(args, 
-                    stdout=asyncio.subprocess.PIPE)
-            buffer_task = None
-        else:
-            # stream from provider
-            args = 'sox -t %s - -t flac -C 0 - %s' % (input_content_type, sox_effects)
-            LOGGER.info("Running sox with args: %s" % args)
-            process = await asyncio.create_subprocess_shell(args,
-                    stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE)
-            buffer_task = asyncio.get_event_loop().create_task(
-                     self.__fill_audio_buffer(process.stdin, track_id, provider, input_content_type))
-        # put chunks from stdout into queue
-        while not process.stdout.at_eof():
-            chunk = await process.stdout.read(10240000)
-            await audioqueue.put(chunk)
-            if not chunk:
-                break
-            await asyncio.sleep(0.1)
-        await process.wait()
-        await audioqueue.put('') # indicate EOF
-        LOGGER.info("streaming of track_id %s completed" % track_id)
-
-    async def __get_player_sox_options(self, track_id, provider, player_id):
-        ''' get player specific sox options '''
-        sox_effects = ' '
-        if not player_id:
-            return ''
-        if 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 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, tmpfile, track_id, provider, content_type):
-        ''' analyze track audio, for now we only calculate EBU R128 loudness '''
-        LOGGER.info('Start analyzing file %s' % tmpfile)
-        cachefile = self.__get_track_cache_filename(track_id, provider)
-        # not needed to do processing if there already is a cachedfile
-        bs1770_binary = self.__get_bs1770_binary()
-        if bs1770_binary:
-            # calculate integrated r128 loudness with bs1770
-            analyse_dir = os.path.join(self.mass.datapath, 'analyse_info')
-            analysis_file = os.path.join(analyse_dir, "%s_%s.xml" %(provider, track_id.split(os.sep)[-1]))
-            if not os.path.isfile(analysis_file):
-                if not os.path.isdir(analyse_dir):
-                    os.makedirs(analyse_dir)
-                cmd = '%s %s --xml --ebu -f %s' % (bs1770_binary, tmpfile, analysis_file)
-                process = await asyncio.create_subprocess_shell(cmd)
-                await process.wait()
-            if self.mass.config['base']['http_streamer']['enable_cache'] and not os.path.isfile(cachefile):
-                # use sox to store cache file (optionally strip silence from start and end)
-                if self.mass.config['base']['http_streamer']['trim_silence']:
-                    cmd = 'sox -t %s %s -t flac -C5 %s silence 1 0.1 1%% reverse silence 1 0.1 1%% reverse' %(content_type, tmpfile, cachefile)
-                else:
-                    # cachefile is always stored as flac 
-                    cmd = 'sox -t %s %s -t flac -C5 %s' %(content_type, tmpfile, cachefile)
-                process = await asyncio.create_subprocess_shell(cmd)
-                await process.wait()
-        # always clean up temp file
-        while os.path.isfile(tmpfile):
-            os.remove(tmpfile)
-            await asyncio.sleep(0.5)
-        LOGGER.info('Fininished analyzing file %s' % tmpfile)
-    
-    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'])
-        analysis_file = os.path.join(self.mass.datapath, 'analyse_info', "%s_%s.xml" %(provider, track_id.split(os.sep)[-1]))
-        if not os.path.isfile(analysis_file):
-            return fallback_gain
-        try: # read audio analysis if available
-            tree = ET.parse(analysis_file)
-            trackinfo = tree.getroot().find("album").find("track")
-            track_lufs = trackinfo.find('integrated').get('lufs')
-            gain_correct = target_gain - float(track_lufs)
-        except Exception as exc:
-            LOGGER.error('could not retrieve track gain - %s' % exc)
-            gain_correct = fallback_gain # fallback value
-            if os.path.isfile(analysis_file):
-                os.remove(analysis_file)
-                # reschedule analyze task to try again
-                cachefile = self.__get_track_cache_filename(track_id, provider)
-                self.mass.event_loop.create_task(self.__analyze_audio(cachefile, track_id, provider, 'flac'))
-        return round(gain_correct,2)
-
-    async def __fill_audio_buffer(self, buf, track_id, provider, content_type):
-        ''' get audio data from provider and write to buffer'''
-        # fill the buffer with audio data
-        # a tempfile is created so we can do audio analysis
-        tmpfile = os.path.join(AUDIO_TEMP_DIR, '%s%s%s.tmp' % (random.randint(0, 999), track_id, random.randint(0, 999)))
-        fd = open(tmpfile, 'wb')
-        async for chunk in self.mass.music.providers[provider].get_audio_stream(track_id):
-            buf.write(chunk)
-            await buf.drain()
-            fd.write(chunk)
-        await buf.drain()
-        buf.write_eof()
-        fd.close()
-        # successfull completion, send tmpfile to be processed in the background in main loop
-        self.mass.event_loop.create_task(self.__analyze_audio(tmpfile, track_id, provider, content_type))
-        LOGGER.info("fill_audio_buffer complete for track %s" % track_id)
-        return
-
-    @staticmethod
-    def __get_track_cache_filename(track_id, provider):
-        ''' get filename for a track to use as cache file '''
-        return os.path.join(AUDIO_CACHE_DIR, '%s_%s' %(provider, track_id.split(os.sep)[-1]))
-
-    @staticmethod
-    def __get_bs1770_binary():
-        ''' get the path to the bs1770 binary for the current OS '''
-        import platform
-        bs1770_binary = None
-        if platform.system() == "Windows":
-            bs1770_binary = os.path.join(os.path.dirname(__file__), "bs1770gain", "win64", "bs1770gain")
-        elif platform.system() == "Darwin":
-            # macos binary is x86_64 intel
-            bs1770_binary = os.path.join(os.path.dirname(__file__), "bs1770gain", "osx", "bs1770gain")
-        elif platform.system() == "Linux":
-            architecture = platform.machine()
-            if architecture.startswith('AMD64') or architecture.startswith('x86_64'):
-                bs1770_binary = os.path.join(os.path.dirname(__file__), "bs1770gain", "linux64", "bs1770gain")
-            # TODO: build armhf binary
-        return bs1770_binary
-
     def load_providers(self):
         ''' dynamically load providers '''
         for item in os.listdir(MODULES_PATH):
index 5592e9d384e28ff6f281b6e26b5839e0b01f9aea..69008c84a31607716eff558abc1fee19446b1976 100644 (file)
@@ -61,7 +61,9 @@ class ChromecastProvider(PlayerProvider):
     async def player_command(self, player_id, cmd:str, cmd_args=None):
         ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) '''
         if cmd == 'play':
-            if self._chromecasts[player_id].media_controller.status.player_is_paused:
+            if self._chromecasts[player_id].media_controller.status.player_is_playing:
+                pass
+            elif self._chromecasts[player_id].media_controller.status.player_is_paused:
                 self._chromecasts[player_id].media_controller.play()
             else:
                 await self.__resume_queue(player_id)
index 8583b1074daa7857f2232f432ba1020e4289faa9..fb29c7b26eda2362597234f86fd9f4e52c9b5149 100644 (file)
@@ -96,11 +96,11 @@ class PyLMSServer(PlayerProvider):
         if queue_opt == 'replace' or not self._player_queue[player_id]:
             # overwrite queue with new items
             self._player_queue[player_id] = media_items
-            await self.__queue_play(player_id, 0)
+            await self.__queue_play(player_id, 0, send_flush=True)
         elif queue_opt == 'play':
             # replace current item with new item(s)
             self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index] + media_items + self._player_queue[player_id][cur_queue_index+1:]
-            await self.__queue_play(player_id, cur_queue_index)
+            await self.__queue_play(player_id, cur_queue_index, send_flush=True)
         elif queue_opt == 'next':
             # insert new items at current index +1
             self._player_queue[player_id] = self._player_queue[player_id][:cur_queue_index+1] + media_items + self._player_queue[player_id][cur_queue_index+1:]
@@ -110,13 +110,14 @@ class PyLMSServer(PlayerProvider):
 
     ### Provider specific (helper) methods #####
 
-    async def __queue_play(self, player_id, index):
+    async def __queue_play(self, player_id, index, send_flush=False):
         ''' send play command to player '''
-        if not index:
+        if index == None:
             index = self._player_queue_index[player_id]
         if len(self._player_queue[player_id]) >= index-1:
             track = self._player_queue[player_id][index]
-            self._lmsplayers[player_id].stop()
+            if send_flush:
+                self._lmsplayers[player_id].flush()
             self._lmsplayers[player_id].play(track.uri)
             self._player_queue_index[player_id] = index
 
@@ -201,9 +202,18 @@ class PyLMSServer(PlayerProvider):
                 self._lmsplayers[lms_player.player_id] = lms_player
             asyncio.create_task(self.__handle_player_event(lms_player.player_id, event, event_data))
 
+        @run_periodic(5)
+        async def send_heartbeat():
+            try:
+                timestamp = int(time.time())
+                data = lms_player.pack_stream(b"t", replayGain=timestamp, flags=0)
+                lms_player.send_frame(b"strm", data)
+            except RuntimeError:
+                reader.close()
+
         lms_player.send_frame = send_frame
         lms_player.send_event = handle_event
-        heartbeat_task = asyncio.create_task(self.send_heartbeat(lms_player))
+        heartbeat_task = asyncio.create_task(send_heartbeat())
         
         # keep reading bytes from the socket
         while True:
@@ -216,12 +226,7 @@ class PyLMSServer(PlayerProvider):
         heartbeat_task.cancel()
         asyncio.create_task(self.__handle_player_event(lms_player.player_id, 'disconnected'))
 
-    @run_periodic(5)
-    async def send_heartbeat(self, lms_player):
-        timestamp = int(time.time())
-        data = lms_player.pack_stream(b"t", replayGain=timestamp, flags=0)
-        lms_player.send_frame(b"strm", data)
-
+    
     ### Provider specific implementation #####
 
 class PyLMSPlayer(object):
@@ -288,6 +293,10 @@ class PyLMSPlayer(object):
         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"1", 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)
@@ -337,7 +346,8 @@ class PyLMSPlayer(object):
         self._volume.volume = new_vol
         self.send_volume()
     
-    def play(self, uri, crossfade=False):
+    def play(self, uri, crossfade=True):
+        # TODO: attach crossfade to a config setting
         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 crossfade else b'0'
index d7a31d9dd31e87e88e330ad3e76849f3265e2034..62099c5d31f1d1753664c27eec43c233cbcb9027 100755 (executable)
@@ -277,6 +277,6 @@ class Web():
                                  headers={'Content-Type': 'audio/flac'})
         await resp.prepare(request)
         if request.method.upper() != 'HEAD':
-            async for chunk in self.mass.player.get_audio_stream(track_id, provider, player_id):
+            async for chunk in self.mass.http_streamer.get_audio_stream(track_id, provider, player_id):
                 await resp.write(chunk)
         return resp
\ No newline at end of file
diff --git a/music_assistant/web/config_old.vue.js b/music_assistant/web/config_old.vue.js
new file mode 100755 (executable)
index 0000000..77299c2
--- /dev/null
@@ -0,0 +1,202 @@
+var Config = Vue.component('Config', {
+  template: `
+    <section>
+
+      <v-list two-line>
+
+        <!-- base/generic config -->
+        <v-list-group prepend-icon="settings" no-action>
+            <template v-slot:activator>
+              <v-list-tile>
+                <v-list-tile-content>
+                  <v-list-tile-title>{{ $t('generic_settings') }}</v-list-tile-title>
+                </v-list-tile-content>
+              </v-list-tile>
+            </template>
+            <template v-for="(conf_value, conf_key) in conf.base">
+                <v-list-tile>
+                  <v-list-tile-content>
+                    <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+                
+                <div v-for="conf_item_key in conf.base[conf_key].__desc__">
+                  <v-list-tile>
+                        <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
+                        <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
+                        <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
+                        <v-text-field v-else v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
+                  </v-list-tile>
+              </div>
+              <v-divider></v-divider>
+            </template>
+          </v-list-group>
+
+
+          <!-- music providers -->
+          <v-list-group prepend-icon="library_music" no-action>
+              <template v-slot:activator>
+                <v-list-tile>
+                  <v-list-tile-content>
+                    <v-list-tile-title>{{ $t('music_providers') }}</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+              </template>
+              <template v-for="(conf_value, conf_key) in conf.musicproviders">
+                  <v-list-tile>
+                    <v-list-tile-avatar>
+                        <img :src="'images/icons/' + conf_key + '.png'"/>
+                    </v-list-tile-avatar>
+                    <v-list-tile-content>
+                      <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
+                    </v-list-tile-content>
+                  </v-list-tile>
+                  
+                  <div v-for="conf_item_key in conf.musicproviders[conf_key].__desc__">
+                    <v-list-tile>
+                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
+                          <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
+                          <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
+                          <v-text-field v-else v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
+                    </v-list-tile>
+                </div>
+                <v-divider></v-divider>
+              </template>
+            </v-list-group>
+
+          <!-- player providers -->
+          <v-list-group prepend-icon="speaker_group" no-action>
+              <template v-slot:activator>
+                <v-list-tile>
+                  <v-list-tile-content>
+                    <v-list-tile-title>{{ $t('player_providers') }}</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+              </template>
+              <template v-for="(conf_value, conf_key) in conf.playerproviders">
+                  <v-list-tile>
+                    <v-list-tile-avatar>
+                        <img :src="'images/icons/' + conf_key + '.png'"/>
+                    </v-list-tile-avatar>
+                    <v-list-tile-content>
+                      <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
+                    </v-list-tile-content>
+                  </v-list-tile>
+                  
+                  <div v-for="conf_item_key in conf.playerproviders[conf_key].__desc__">
+                    <v-list-tile>
+                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
+                          <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
+                          <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
+                          <v-text-field v-else v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
+                    </v-list-tile>
+                </div>
+                <v-divider></v-divider>
+              </template>
+            </v-list-group>
+
+          <!-- player settings -->
+          <v-list-group prepend-icon="speaker" no-action>
+              <template v-slot:activator>
+                <v-list-tile>
+                  <v-list-tile-content>
+                    <v-list-tile-title>{{ $t('player_settings') }}</v-list-tile-title>
+                  </v-list-tile-content>
+                </v-list-tile>
+              </template>
+              <template v-for="(player, key) in players" v-if="key != '__desc__' && key in players">
+                  <v-list-tile>
+                    <v-list-tile-content>
+                      <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
+                      <v-list-tile-sub-title class="title">ID: {{ key }} Provider: {{ players[key].player_provider }}</v-list-tile-sub-title>
+                    </v-list-tile-content>
+                  </v-list-tile>
+                  
+                  <div v-for="conf_item_key in conf.player_settings.__desc__" v-if="conf.player_settings[key].enabled">
+                    <v-list-tile>
+                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t(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_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_item_key[2])" 
+                            :items="playersLst"
+                            item-text="name"
+                            item-value="id" box>
+                          </v-select>
+                          <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t(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('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('enabled')"></v-switch>
+                    </v-list-tile>
+                </div>
+                <v-divider></v-divider>
+              </template>
+            </v-list-group>
+
+            <v-btn @click="saveConfig()">Save</v-btn>
+        </v-list>
+    </section>
+  `,
+  props: [],
+  data() {
+    return {
+      conf: {},
+      players: {}
+    }
+  },
+  computed: {
+    playersLst()
+    {
+      var playersLst = [];
+      for (player_id in this.conf.player_settings)
+        if (player_id != '__desc__')
+          playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
+      return playersLst;
+    }
+
+  },
+  created() {
+    this.$globals.windowtitle = 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/images/icons/chromecast.png b/music_assistant/web/images/icons/chromecast.png
new file mode 100644 (file)
index 0000000..f7d2a46
Binary files /dev/null and b/music_assistant/web/images/icons/chromecast.png differ
diff --git a/music_assistant/web/images/icons/homeassistant.png b/music_assistant/web/images/icons/homeassistant.png
new file mode 100644 (file)
index 0000000..5f28d69
Binary files /dev/null and b/music_assistant/web/images/icons/homeassistant.png differ
diff --git a/music_assistant/web/images/icons/http_streamer.png b/music_assistant/web/images/icons/http_streamer.png
new file mode 100644 (file)
index 0000000..c35c983
Binary files /dev/null and b/music_assistant/web/images/icons/http_streamer.png differ
diff --git a/music_assistant/web/images/icons/lms.png b/music_assistant/web/images/icons/lms.png
new file mode 100644 (file)
index 0000000..6dd9b06
Binary files /dev/null and b/music_assistant/web/images/icons/lms.png differ
diff --git a/music_assistant/web/images/icons/pylms.png b/music_assistant/web/images/icons/pylms.png
new file mode 100644 (file)
index 0000000..18531d7
Binary files /dev/null and b/music_assistant/web/images/icons/pylms.png differ
diff --git a/music_assistant/web/images/icons/web.png b/music_assistant/web/images/icons/web.png
new file mode 100644 (file)
index 0000000..d3b5724
Binary files /dev/null and b/music_assistant/web/images/icons/web.png differ
index 77299c23d0a280d7c7d4c8a10a296c4bb1194fca..dfd27c34dc209b05e424341c85c076bfa4de71ad 100755 (executable)
@@ -2,162 +2,106 @@ var Config = Vue.component('Config', {
   template: `
     <section>
 
-      <v-list two-line>
+        <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">
 
-        <!-- base/generic config -->
-        <v-list-group prepend-icon="settings" no-action>
-            <template v-slot:activator>
-              <v-list-tile>
-                <v-list-tile-content>
-                  <v-list-tile-title>{{ $t('generic_settings') }}</v-list-tile-title>
-                </v-list-tile-content>
-              </v-list-tile>
-            </template>
-            <template v-for="(conf_value, conf_key) in conf.base">
-                <v-list-tile>
-                  <v-list-tile-content>
-                    <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
-                  </v-list-tile-content>
-                </v-list-tile>
-                
-                <div v-for="conf_item_key in conf.base[conf_key].__desc__">
-                  <v-list-tile>
-                        <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
-                        <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
-                        <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
-                        <v-text-field v-else v-model="conf.base[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
-                  </v-list-tile>
-              </div>
-              <v-divider></v-divider>
-            </template>
-          </v-list-group>
+                      <!-- 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.__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-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>
 
-          <!-- music providers -->
-          <v-list-group prepend-icon="library_music" no-action>
-              <template v-slot:activator>
-                <v-list-tile>
-                  <v-list-tile-content>
-                    <v-list-tile-title>{{ $t('music_providers') }}</v-list-tile-title>
-                  </v-list-tile-content>
-                </v-list-tile>
-              </template>
-              <template v-for="(conf_value, conf_key) in conf.musicproviders">
-                  <v-list-tile>
-                    <v-list-tile-avatar>
-                        <img :src="'images/icons/' + conf_key + '.png'"/>
-                    </v-list-tile-avatar>
-                    <v-list-tile-content>
-                      <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
-                    </v-list-tile-content>
-                  </v-list-tile>
-                  
-                  <div v-for="conf_item_key in conf.musicproviders[conf_key].__desc__">
-                    <v-list-tile>
-                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
-                          <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
-                          <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
-                          <v-text-field v-else v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
-                    </v-list-tile>
-                </div>
-                <v-divider></v-divider>
-              </template>
-            </v-list-group>
 
-          <!-- player providers -->
-          <v-list-group prepend-icon="speaker_group" no-action>
-              <template v-slot:activator>
-                <v-list-tile>
-                  <v-list-tile-content>
-                    <v-list-tile-title>{{ $t('player_providers') }}</v-list-tile-title>
-                  </v-list-tile-content>
-                </v-list-tile>
-              </template>
-              <template v-for="(conf_value, conf_key) in conf.playerproviders">
-                  <v-list-tile>
-                    <v-list-tile-avatar>
-                        <img :src="'images/icons/' + conf_key + '.png'"/>
-                    </v-list-tile-avatar>
-                    <v-list-tile-content>
-                      <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
-                    </v-list-tile-content>
-                  </v-list-tile>
-                  
-                  <div v-for="conf_item_key in conf.playerproviders[conf_key].__desc__">
-                    <v-list-tile>
-                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])"></v-switch>
-                          <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-text-field>
-                          <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box type="password"></v-select>
-                          <v-text-field v-else v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="$t(conf_item_key[2])" box></v-text-field>
-                    </v-list-tile>
-                </div>
-                <v-divider></v-divider>
-              </template>
-            </v-list-group>
-
-          <!-- player settings -->
-          <v-list-group prepend-icon="speaker" no-action>
-              <template v-slot:activator>
-                <v-list-tile>
-                  <v-list-tile-content>
-                    <v-list-tile-title>{{ $t('player_settings') }}</v-list-tile-title>
-                  </v-list-tile-content>
-                </v-list-tile>
-              </template>
-              <template v-for="(player, key) in players" v-if="key != '__desc__' && key in players">
-                  <v-list-tile>
-                    <v-list-tile-content>
-                      <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
-                      <v-list-tile-sub-title class="title">ID: {{ key }} Provider: {{ players[key].player_provider }}</v-list-tile-sub-title>
-                    </v-list-tile-content>
-                  </v-list-tile>
-                  
-                  <div v-for="conf_item_key in conf.player_settings.__desc__" v-if="conf.player_settings[key].enabled">
-                    <v-list-tile>
-                          <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t(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_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_item_key[2])" 
-                            :items="playersLst"
-                            item-text="name"
-                            item-value="id" box>
-                          </v-select>
-                          <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t(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('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('enabled')"></v-switch>
-                    </v-list-tile>
-                </div>
-                <v-divider></v-divider>
-              </template>
-            </v-list-group>
-
-            <v-btn @click="saveConfig()">Save</v-btn>
-        </v-list>
     </section>
   `,
   props: [],
   data() {
     return {
       conf: {},
-      players: {}
+      players: {},
+      active: 0
     }
   },
   computed: {
     playersLst()
     {
       var playersLst = [];
-      for (player_id in this.conf.player_settings)
-        if (player_id != '__desc__')
-          playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
+      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) {
+        console.log("save config needed!");
+        this.saveConfig();
+      }, 1000)
+    }
+  },
   created() {
     this.$globals.windowtitle = this.$t('settings');
     this.getPlayers();
index 0c2c759d5b01aca2b2caab85bd7970d8ee5b940e..a9623c7cb2b1468bf4c717b477ef5711a2f266f0 100644 (file)
@@ -12,38 +12,60 @@ const messages = {
         search: "Search",
         settings: "Settings",
         queue: "Queue",
-        generic_settings: "Generic settings",
-        music_providers: "Music providers",
-        player_providers: "Player providers",
-        player_settings: "Player settings",
         type_to_search: "Type here to search...",
-        enabled: "Enabled",
         add_library: "Add to library",
         remove_library: "Remove from library",
         // settings strings
-        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",
-        web_ssl_host: "Hostname (FQDN used in the certificate)",
-        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)",
+        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",
+            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"
+        },
         // player strings
         players: "Players",
         play: "Play",
@@ -58,7 +80,6 @@ const messages = {
             paused: "paused",
             off: "off"
         }
-
     },
 
     nl: {
@@ -72,38 +93,60 @@ const messages = {
         search: "Zoeken",
         settings: "Instellingen",
         queue: "Wachtrij",
-        generic_settings: "Algemene instellingen",
-        music_providers: "Muziek providers",
-        player_providers: "Speler providers",
-        player_settings: "Speler instellingen",
-        enabled: "Ingeschakeld",
         type_to_search: "Type hier om te zoeken...",
         add_library: "Voeg toe aan bibliotheek",
         remove_library: "Verwijder uit bibliotheek",
         // settings strings
-        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",
-        web_ssl_host: "Hostname (FQDN van certificaat)",
-        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)",
+        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",
+            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"
+        },
         // player strings
         players: "Spelers",
         play: "Afspelen",