From: Marcel van der Veldt Date: Fri, 24 May 2019 16:17:02 +0000 (+0200) Subject: some optimizations X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=3229729f1378c2f8152d8928a84010d8efb07463;p=music-assistant-server.git some optimizations --- diff --git a/music_assistant/main.py b/music_assistant/main.py index fc09a511..9f3692d3 100755 --- a/music_assistant/main.py +++ b/music_assistant/main.py @@ -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''' diff --git a/music_assistant/models.py b/music_assistant/models.py index e88b5132..5c30f536 100755 --- a/music_assistant/models.py +++ b/music_assistant/models.py @@ -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 index 00000000..751d4aed --- /dev/null +++ b/music_assistant/modules/http_streamer.py @@ -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 diff --git a/music_assistant/modules/musicproviders/qobuz.py b/music_assistant/modules/musicproviders/qobuz.py index 25f93659..7ba28a95 100644 --- a/music_assistant/modules/musicproviders/qobuz.py +++ b/music_assistant/modules/musicproviders/qobuz.py @@ -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 diff --git a/music_assistant/modules/musicproviders/spotify.py b/music_assistant/modules/musicproviders/spotify.py index 3640e544..0fc19698 100644 --- a/music_assistant/modules/musicproviders/spotify.py +++ b/music_assistant/modules/musicproviders/spotify.py @@ -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={}): diff --git a/music_assistant/modules/player.py b/music_assistant/modules/player.py index a8163685..d5496ba0 100755 --- a/music_assistant/modules/player.py +++ b/music_assistant/modules/player.py @@ -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_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_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): diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/modules/playerproviders/chromecast.py index 5592e9d3..69008c84 100644 --- a/music_assistant/modules/playerproviders/chromecast.py +++ b/music_assistant/modules/playerproviders/chromecast.py @@ -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) diff --git a/music_assistant/modules/playerproviders/pylms.py b/music_assistant/modules/playerproviders/pylms.py index 8583b107..fb29c7b2 100644 --- a/music_assistant/modules/playerproviders/pylms.py +++ b/music_assistant/modules/playerproviders/pylms.py @@ -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' diff --git a/music_assistant/modules/web.py b/music_assistant/modules/web.py index d7a31d9d..62099c5d 100755 --- a/music_assistant/modules/web.py +++ b/music_assistant/modules/web.py @@ -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 index 00000000..77299c23 --- /dev/null +++ b/music_assistant/web/config_old.vue.js @@ -0,0 +1,202 @@ +var Config = Vue.component('Config', { + template: ` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Save + +
+ `, + 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 index 00000000..f7d2a46f 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 index 00000000..5f28d69e 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 index 00000000..c35c9839 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 index 00000000..6dd9b06a 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 index 00000000..18531d79 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 index 00000000..d3b5724e Binary files /dev/null and b/music_assistant/web/images/icons/web.png differ diff --git a/music_assistant/web/pages/config.vue.js b/music_assistant/web/pages/config.vue.js index 77299c23..dfd27c34 100755 --- a/music_assistant/web/pages/config.vue.js +++ b/music_assistant/web/pages/config.vue.js @@ -2,162 +2,106 @@ var Config = Vue.component('Config', { template: `
- + + {{ $t('conf.'+conf_key) }} + - - - - - + + + + +
+ + + + + + +
+ +
+
+ + + + +
+ + + + + + + + + + +
+
+ + + +
+ +
+
+
+ +
- - - - - - - - - - - - - - - - - - Save -
`, 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(); diff --git a/music_assistant/web/strings.js b/music_assistant/web/strings.js index 0c2c759d..a9623c7c 100644 --- a/music_assistant/web/strings.js +++ b/music_assistant/web/strings.js @@ -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",