From 764893178f08cf1462329c9055959d55953377bd Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Wed, 16 Oct 2019 00:13:47 +0200 Subject: [PATCH] more cleanup better structured --- Dockerfile | 27 ++-- main.py | 21 --- mass.py | 94 +++++++++++++ music_assistant/__init__.py | 124 +++++------------- music_assistant/cache.py | 8 +- music_assistant/config.py | 101 ++++++++++++++ music_assistant/constants.py | 16 ++- music_assistant/database.py | 14 +- music_assistant/homeassistant.py | 80 +++++------ music_assistant/http_streamer.py | 20 ++- music_assistant/metadata.py | 31 +++-- music_assistant/models/musicprovider.py | 10 +- music_assistant/models/player.py | 50 ++++--- music_assistant/models/playerprovider.py | 10 +- music_assistant/music_manager.py | 42 ++---- music_assistant/musicproviders/file.py | 39 +++--- music_assistant/musicproviders/qobuz.py | 49 ++++--- music_assistant/musicproviders/spotify.py | 51 ++++--- music_assistant/musicproviders/tunein.py | 40 +++--- music_assistant/player_manager.py | 41 ++---- music_assistant/playerproviders/chromecast.py | 45 ++++--- music_assistant/playerproviders/squeezebox.py | 52 ++++---- music_assistant/utils.py | 83 +++++++++++- music_assistant/web.py | 117 +++++------------ .../web}/components/headermenu.vue.js | 0 .../web}/components/infoheader.vue.js | 0 .../web}/components/listviewItem.vue.js | 0 .../web}/components/player.vue.js | 0 .../web}/components/playmenu.vue.js | 0 .../web}/components/providericons.vue.js | 0 .../web}/components/readmore.vue.js | 0 .../web}/components/searchbox.vue.js | 0 .../web}/components/volumecontrol.vue.js | 0 .../web}/css/nprogress.css | 0 {web => music_assistant/web}/css/site.css | 0 .../web}/css/vue-loading.css | 0 .../web}/images/default_artist.png | Bin .../web}/images/icons/aac.png | Bin .../web}/images/icons/chromecast.png | Bin .../web}/images/icons/file.png | Bin .../web}/images/icons/flac.png | Bin .../web}/images/icons/hires.png | Bin .../web}/images/icons/homeassistant.png | Bin .../web}/images/icons/http_streamer.png | Bin .../web}/images/icons/icon-128x128.png | Bin .../web}/images/icons/icon-256x256.png | Bin .../web}/images/icons/icon-apple.png | Bin .../web}/images/icons/info_gradient.jpg | Bin .../web}/images/icons/lms.png | Bin .../web}/images/icons/mp3.png | Bin .../web}/images/icons/qobuz.png | Bin .../web}/images/icons/spotify.png | Bin .../web}/images/icons/squeezebox.png | Bin .../web}/images/icons/tunein.png | Bin .../web}/images/icons/vorbis.png | Bin .../web}/images/icons/web.png | Bin .../web}/images/info_gradient.jpg | Bin {web => music_assistant/web}/index.html | 0 .../web}/lib/vue-loading-overlay.js | 0 {web => music_assistant/web}/manifest.json | 0 .../web}/pages/albumdetails.vue.js | 0 .../web}/pages/artistdetails.vue.js | 0 .../web}/pages/browse.vue.js | 0 .../web}/pages/config.vue.js | 0 .../web}/pages/home.vue.js | 0 .../web}/pages/playlistdetails.vue.js | 0 .../web}/pages/queue.vue.js | 0 .../web}/pages/search.vue.js | 0 .../web}/pages/trackdetails.vue.js | 0 {web => music_assistant/web}/strings.js | 0 requirements.txt | 7 +- run.sh | 18 --- 72 files changed, 636 insertions(+), 554 deletions(-) delete mode 100755 main.py create mode 100755 mass.py create mode 100755 music_assistant/config.py rename {web => music_assistant/web}/components/headermenu.vue.js (100%) rename {web => music_assistant/web}/components/infoheader.vue.js (100%) rename {web => music_assistant/web}/components/listviewItem.vue.js (100%) rename {web => music_assistant/web}/components/player.vue.js (100%) rename {web => music_assistant/web}/components/playmenu.vue.js (100%) rename {web => music_assistant/web}/components/providericons.vue.js (100%) rename {web => music_assistant/web}/components/readmore.vue.js (100%) rename {web => music_assistant/web}/components/searchbox.vue.js (100%) rename {web => music_assistant/web}/components/volumecontrol.vue.js (100%) rename {web => music_assistant/web}/css/nprogress.css (100%) rename {web => music_assistant/web}/css/site.css (100%) rename {web => music_assistant/web}/css/vue-loading.css (100%) rename {web => music_assistant/web}/images/default_artist.png (100%) rename {web => music_assistant/web}/images/icons/aac.png (100%) rename {web => music_assistant/web}/images/icons/chromecast.png (100%) rename {web => music_assistant/web}/images/icons/file.png (100%) rename {web => music_assistant/web}/images/icons/flac.png (100%) rename {web => music_assistant/web}/images/icons/hires.png (100%) rename {web => music_assistant/web}/images/icons/homeassistant.png (100%) rename {web => music_assistant/web}/images/icons/http_streamer.png (100%) rename {web => music_assistant/web}/images/icons/icon-128x128.png (100%) rename {web => music_assistant/web}/images/icons/icon-256x256.png (100%) rename {web => music_assistant/web}/images/icons/icon-apple.png (100%) rename {web => music_assistant/web}/images/icons/info_gradient.jpg (100%) rename {web => music_assistant/web}/images/icons/lms.png (100%) rename {web => music_assistant/web}/images/icons/mp3.png (100%) rename {web => music_assistant/web}/images/icons/qobuz.png (100%) rename {web => music_assistant/web}/images/icons/spotify.png (100%) rename {web => music_assistant/web}/images/icons/squeezebox.png (100%) rename {web => music_assistant/web}/images/icons/tunein.png (100%) rename {web => music_assistant/web}/images/icons/vorbis.png (100%) rename {web => music_assistant/web}/images/icons/web.png (100%) rename {web => music_assistant/web}/images/info_gradient.jpg (100%) rename {web => music_assistant/web}/index.html (100%) rename {web => music_assistant/web}/lib/vue-loading-overlay.js (100%) rename {web => music_assistant/web}/manifest.json (100%) rename {web => music_assistant/web}/pages/albumdetails.vue.js (100%) rename {web => music_assistant/web}/pages/artistdetails.vue.js (100%) rename {web => music_assistant/web}/pages/browse.vue.js (100%) rename {web => music_assistant/web}/pages/config.vue.js (100%) rename {web => music_assistant/web}/pages/home.vue.js (100%) rename {web => music_assistant/web}/pages/playlistdetails.vue.js (100%) rename {web => music_assistant/web}/pages/queue.vue.js (100%) rename {web => music_assistant/web}/pages/search.vue.js (100%) rename {web => music_assistant/web}/pages/trackdetails.vue.js (100%) rename {web => music_assistant/web}/strings.js (100%) delete mode 100755 run.sh diff --git a/Dockerfile b/Dockerfile index 924172dd..d0b9aea1 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,27 @@ -FROM python:3.8.0rc1-alpine3.10 +FROM python:3.7-buster + +RUN apt-get update && apt-get install -y --no-install-recommends \ + flac sox zip curl wget ffmpeg libsndfile1 libtag1-dev build-essential \ + python3-numpy python3-scipy python3-matplotlib python3-taglib \ + && rm -rf /var/lib/apt/lists/* -# install deps -RUN apk add flac sox zip curl wget ffmpeg taglib -# RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/testing py3-numpy py3-scipy py3-matplotlib py3-aiohttp py3-cairocffi COPY requirements.txt requirements.txt -RUN apk --no-cache add --virtual .builddeps build-base taglib-dev && \ - python3 -m pip install -r requirements.txt && \ - apk del .builddeps && \ - rm -rf /root/.cache +RUN pip install -r requirements.txt -# copy files +# copy app files RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY music_assistant /usr/src/app/music_assistant -COPY main.py /usr/src/app/main.py -RUN chmod a+x /usr/src/app/main.py +COPY mass.py /usr/src/app/main.py +RUN chmod a+x /usr/src/app/mass.py VOLUME ["/data"] COPY run.sh /run.sh RUN chmod +x /run.sh -ENV autoupdate false +ENV mass_debug false +ENV mass_datadir /data +ENV mass_update false -CMD ["/run.sh"] \ No newline at end of file +CMD ["python3 /usr/src/app/mass.py"] \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100755 index 7cb98257..00000000 --- a/main.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import sys -import os - -from music_assistant import MusicAssistant - -if __name__ == "__main__": - - if len(sys.argv) > 1: - datapath = sys.argv[1] - else: - datapath = os.path.dirname(os.path.abspath(__file__)) - if len(sys.argv) > 2: - debug = sys.argv[2] == "debug" - else: - debug = False - - MusicAssistant(datapath, debug) - \ No newline at end of file diff --git a/mass.py b/mass.py new file mode 100755 index 00000000..5fd436df --- /dev/null +++ b/mass.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import sys +import os +import logging +from aiorun import run +import asyncio +import uvloop + +logger = logging.getLogger() +logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s') +consolehandler = logging.StreamHandler() +consolehandler.setFormatter(logformat) +logger.addHandler(consolehandler) + + +def get_config(): + ''' start config handling ''' + data_dir = '' + debug = False + update_latest = False + # prefer command line args + if len(sys.argv) > 1: + data_dir = sys.argv[1] + if len(sys.argv) > 2: + debug = sys.argv[2] == "debug" + if len(sys.argv) > 3: + update_latest = sys.argv[3] == "update" + # fall back to environment variables (for plain docker) + if os.environ.get('mass_datadir'): + data_dir = os.environ['mass_datadir'] + if os.environ.get('mass_debug'): + debug = os.environ['mass_datadir'].lower() != 'false' + if os.environ.get('mass_update'): + update_latest = os.environ['mass_update'].lower() != 'false' + # config file found + if os.path.isfile('options.json'): + try: + import json + with open('options.json') as f: + conf = json.loads(f.read()) + data_dir = conf['data_dir'] + debug = conf['debug_messages'] + update_latest = conf['auto_update'] + except: + logger.exception('could not load options.json') + return data_dir, debug, update_latest + +def do_update(): + ''' auto update to latest git version ''' + if os.path.isdir(".git"): + # dev environment + return + logger.info("Updating to latest Git version!") + import subprocess + # TODO: handle this properly + args = """ + cd /tmp + curl -LOks "https://github.com/marcelveldt/musicassistant/archive/master.zip" + unzip -q master.zip + rm -R music_assistant + cp -rf musicassistant-master/music_assistant . + cp -rf musicassistant-master/mass.py . + rm -R /tmp/musicassistant-master + """ + if subprocess.call(args, shell=True) == 0: + logger.info("Update succesfull") + else: + logger.error("Update failed - do you have curl and zip installed ?") + + +if __name__ == "__main__": + # get config + data_dir, debug, update_latest = get_config() + if update_latest: + update_latest() + # create event_loop with uvloop + event_loop = asyncio.get_event_loop() + uvloop.install() + # config debug settings if needed + if debug: + event_loop.set_debug(True) + logger.setLevel(logging.DEBUG) + logging.getLogger('aiosqlite').setLevel(logging.INFO) + logging.getLogger('asyncio').setLevel(logging.INFO) + else: + logger.setLevel(logging.INFO) + # start music assistant! + do_update() + from music_assistant import MusicAssistant + mass = MusicAssistant(data_dir, event_loop) + run(mass.start(), loop=event_loop) + \ No newline at end of file diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index 9d08c84b..fdfa84c6 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -1,10 +1,7 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -import sys import asyncio -from concurrent.futures import ThreadPoolExecutor -from contextlib import suppress import re import uvloop import os @@ -16,70 +13,54 @@ import time import logging from .database import Database +from .config import MassConfig from .utils import run_periodic, LOGGER, try_parse_bool from .metadata import MetaData from .cache import Cache -from .music_manager import Music +from .music_manager import MusicManager from .player_manager import PlayerManager from .http_streamer import HTTPStreamer -from .homeassistant import setup as hass_setup -from .web import setup as web_setup +from .homeassistant import HomeAssistant +from .web import Web -def handle_exception(loop, context): - # context["message"] will always be there; but context["exception"] may not - msg = context.get("exception", context["message"]) - LOGGER.exception(f"Caught exception: {msg}") class MusicAssistant(): - def __init__(self, datapath, debug=False): - debug = try_parse_bool(debug) - logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s') - consolehandler = logging.StreamHandler() - consolehandler.setFormatter(logformat) - LOGGER.addHandler(consolehandler) - if debug: - LOGGER.setLevel(logging.DEBUG) - logging.getLogger('aiosqlite').setLevel(logging.INFO) - logging.getLogger('asyncio').setLevel(logging.INFO) - else: - LOGGER.setLevel(logging.INFO) - uvloop.install() + def __init__(self, datapath, event_loop): + ''' + Create an instance of MusicAssistant + :param datapath: file location to store the data + :param event_loop: asyncio event_loop + ''' + self.event_loop = event_loop + self.event_loop.set_exception_handler(self.handle_exception) self.datapath = datapath - self.parse_config() - self.event_loop = asyncio.get_event_loop() - self.event_loop.set_debug(debug) - self.bg_executor = ThreadPoolExecutor() - self.event_loop.set_default_executor(self.bg_executor) - #self.event_loop.set_exception_handler(handle_exception) self.event_listeners = {} - - # init database and metadata modules - self.db = Database(datapath, self.event_loop) - # allow some time for the database to initialize - while not self.db.db_ready: - time.sleep(0.15) - self.cache = Cache(datapath) - self.metadata = MetaData(self.event_loop, self.db, self.cache) - + self.config = MassConfig(self) # init modules - self.web = web_setup(self) - self.hass = hass_setup(self) - self.music = Music(self) - self.player = PlayerManager(self) + self.db = Database(datapath) + self.cache = Cache(datapath) + self.metadata = MetaData(self) + self.web = Web(self) + self.hass = HomeAssistant(self) + self.music = MusicManager(self) + self.players = PlayerManager(self) self.http_streamer = HTTPStreamer(self) - # start the event loop - try: - self.event_loop.run_forever() - except (KeyboardInterrupt, SystemExit): - LOGGER.info('Exit requested!') - self.event_loop.create_task(self.signal_event("system_shutdown")) - self.event_loop.stop() - self.save_config() - time.sleep(5) - self.event_loop.close() - LOGGER.info('Shutdown complete.') + async def start(self): + ''' start running the music assistant server ''' + await self.db.setup() + await self.cache.setup() + await self.metadata.setup() + await self.music.setup() + await self.players.setup() + await self.web.setup() + await self.http_streamer.setup() + + def handle_exception(self, loop, context): + ''' global exception handler ''' + loop.default_exception_handler(context) + LOGGER.exception(f"Caught exception: {context}") async def signal_event(self, msg, msg_details=None): ''' signal (systemwide) event ''' @@ -98,40 +79,3 @@ class MusicAssistant(): async def remove_event_listener(self, cb_id): ''' remove callback from our event listeners ''' self.event_listeners.pop(cb_id, None) - - def save_config(self): - ''' save config to file ''' - # backup existing file - conf_file = os.path.join(self.datapath, 'config.json') - conf_file_backup = os.path.join(self.datapath, 'config.json.backup') - if os.path.isfile(conf_file): - shutil.move(conf_file, conf_file_backup) - # remove description keys from config - final_conf = {} - for key, value in self.config.items(): - final_conf[key] = {} - for subkey, subvalue in value.items(): - if subkey != "__desc__": - final_conf[key][subkey] = subvalue - with open(conf_file, 'w') as f: - f.write(json.dumps(final_conf, indent=4)) - - def parse_config(self): - '''get config from config file''' - config = { - "base": {}, - "musicproviders": {}, - "playerproviders": {}, - "player_settings": {} - } - conf_file = os.path.join(self.datapath, 'config.json') - if os.path.isfile(conf_file): - with open(conf_file) as f: - data = f.read() - if data: - data = json.loads(data) - for key, value in data.items(): - config[key] = value - self.config = config - - \ No newline at end of file diff --git a/music_assistant/cache.py b/music_assistant/cache.py index 583080f2..95d4bd21 100644 --- a/music_assistant/cache.py +++ b/music_assistant/cache.py @@ -22,9 +22,13 @@ class Cache(object): def __init__(self, datapath): '''Initialize our caching class''' + if not os.path.isdir(datapath): + raise FileNotFoundError(f"data directory {datapath} does not exist!") self._datapath = datapath - asyncio.ensure_future(self._do_cleanup()) - LOGGER.debug("Initialized") + + async def setup(self): + ''' async initialize of cache module ''' + asyncio.create_task(self._do_cleanup()) async def get(self, endpoint, checksum=""): ''' diff --git a/music_assistant/config.py b/music_assistant/config.py new file mode 100755 index 00000000..8de4febd --- /dev/null +++ b/music_assistant/config.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import os +import shutil + +from .utils import try_load_json_file, json, LOGGER +from .constants import CONF_KEY_BASE, CONF_KEY_PLAYERSETTINGS, \ + CONF_KEY_MUSICPROVIDERS, CONF_KEY_PLAYERPROVIDERS, EVENT_CONFIG_CHANGED + + +class MassConfig(dict): + ''' Class which holds our configuration ''' + + def __init__(self, mass): + self.mass = mass + self.loading = False + self[CONF_KEY_BASE] = {} + self[CONF_KEY_MUSICPROVIDERS] = {} + self[CONF_KEY_PLAYERPROVIDERS] = {} + self[CONF_KEY_PLAYERSETTINGS] = {} + self.__load() + + + @property + def base(self): + ''' return base config ''' + return self[CONF_KEY_BASE] + + @property + def players(self): + ''' return player settings ''' + return self[CONF_KEY_PLAYERSETTINGS] + + @property + def playerproviders(self): + ''' return playerprovider settings ''' + return self[CONF_KEY_PLAYERPROVIDERS] + + @property + def musicproviders(self): + ''' return musicprovider settings ''' + return self[CONF_KEY_MUSICPROVIDERS] + + def create_module_config(self, conf_key, conf_entries, base_key=CONF_KEY_BASE): + ''' create (or update) module configuration ''' + cur_conf = self[base_key].get(conf_key) + new_conf = {} + for key, def_value, desc in conf_entries: + if not cur_conf or not key in cur_conf: + new_conf[key] = def_value + else: + new_conf[key] = cur_conf[key] + new_conf['__desc__'] = conf_entries + self[base_key][conf_key] = new_conf + return self[base_key][conf_key] + + def __setitem__(self, key, new_value): + # optional processing here + if self[key] != new_value: + # value changed + self[key] = new_value + self.mass.event_loop.create_task( + self.mass.signal_event(EVENT_CONFIG_CHANGED, self.__dict__)) + self.__save() + + def __save(self): + ''' save config to file ''' + if self.loading: + LOGGER.warning("save already running") + return + self.loading = True + # backup existing file + conf_file = os.path.join(self.mass.datapath, 'config.json') + conf_file_backup = os.path.join(self.mass.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.items(): + final_conf[key] = {} + for subkey, subvalue in value.items(): + if subkey != "__desc__": + final_conf[key][subkey] = subvalue + with open(conf_file, 'w') as f: + f.write(json.dumps(final_conf, indent=4)) + self.loading = False + + def __load(self): + '''load config from file''' + self.loading = True + conf_file = os.path.join(self.mass.datapath, 'config.json') + data = try_load_json_file(conf_file) + if not data: + # might be a corrupt config file, retry with backup file + conf_file_backup = os.path.join(self.mass.datapath, 'config.json.backup') + data = try_load_json_file(conf_file_backup) + if data: + for key, value in data.items(): + self[key] = value + self.loading = False diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 93f1e06d..a83bf0f9 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -5,4 +5,18 @@ CONF_USERNAME = "username" CONF_PASSWORD = "password" CONF_ENABLED = "enabled" CONF_HOSTNAME = "hostname" -CONF_PORT = "port" \ No newline at end of file +CONF_PORT = "port" +CONF_TOKEN = "token" +CONF_URL = "url" + +CONF_TYPE_PASSWORD = '' + +CONF_KEY_BASE = "base" +CONF_KEY_PLAYERSETTINGS = "player_settings" +CONF_KEY_MUSICPROVIDERS = "musicproviders" +CONF_KEY_PLAYERPROVIDERS = "playerproviders" + +EVENT_PLAYER_CHANGED = "player changed" +EVENT_STREAM_STARTED = "streaming started" +EVENT_STREAM_ENDED = "streaming ended" +EVENT_CONFIG_CHANGED = "config changed" diff --git a/music_assistant/database.py b/music_assistant/database.py index 7503eb37..fa8a228d 100755 --- a/music_assistant/database.py +++ b/music_assistant/database.py @@ -6,20 +6,21 @@ import os from typing import List import aiosqlite import operator +import logging from .utils import run_periodic, LOGGER, get_sort_name, try_parse_int from .models.media_types import MediaType, Artist, Album, Track, Playlist, Radio class Database(): - def __init__(self, datapath, event_loop): - self.event_loop = event_loop + def __init__(self, datapath): + if not os.path.isdir(datapath): + raise FileNotFoundError(f"data directory {datapath} does not exist!") self.dbfile = os.path.join(datapath, "database.db") - self.db_ready = False - event_loop.run_until_complete(self.__init_database()) + logging.getLogger('aiosqlite').setLevel(logging.INFO) - async def __init_database(self): - ''' init database tables''' + async def setup(self): + ''' init database ''' async with aiosqlite.connect(self.dbfile) as db: await db.execute('CREATE TABLE IF NOT EXISTS library_items(item_id INTEGER NOT NULL, provider TEXT NOT NULL, media_type INTEGER NOT NULL, UNIQUE(item_id, provider, media_type));') @@ -49,7 +50,6 @@ class Database(): await db.commit() await db.execute('VACUUM;') - self.db_ready = True async def get_database_id(self, provider:str, prov_item_id:str, media_type:MediaType): ''' get the database id for the given prov_id ''' diff --git a/music_assistant/homeassistant.py b/music_assistant/homeassistant.py index 3b884425..de9f8d12 100644 --- a/music_assistant/homeassistant.py +++ b/music_assistant/homeassistant.py @@ -16,69 +16,57 @@ import slugify as slug import json from .utils import run_periodic, LOGGER, parse_track_title, try_parse_int from .models.media_types import Track -from .constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT +from .constants import CONF_ENABLED, CONF_URL, CONF_TOKEN, EVENT_PLAYER_CHANGED from .cache import use_cache - -''' - Homeassistant integration - allows publishing of our players to hass - allows using hass entities (like switches, media_players or gui inputs) to be triggered -''' - -def setup(mass): - ''' setup the module and read/apply config''' - create_config_entries(mass.config) - conf = mass.config['base']['homeassistant'] - enabled = conf.get(CONF_ENABLED) - token = conf.get('token') - url = conf.get('url') - if enabled and url and token: - return HomeAssistant(mass, url, token) - return None - -def create_config_entries(config): - ''' get the config entries for this module (list with key/value pairs)''' - config_entries = [ - (CONF_ENABLED, False, 'enabled'), - ('url', 'localhost', 'hass_url'), - ('token', '', 'hass_token'), - ('publish_players', True, 'hass_publish') +CONF_KEY = 'homeassistant' +CONF_PUBLISH_PLAYERS = "publish_players" +EVENT_HASS_CHANGED = "hass entity changed" +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_URL, 'localhost', 'hass_url'), + (CONF_TOKEN, '', 'hass_token'), + (CONF_PUBLISH_PLAYERS, True, 'hass_publish') ] - if not config['base'].get('homeassistant'): - config['base']['homeassistant'] = {} - config['base']['homeassistant']['__desc__'] = config_entries - for key, def_value, desc in config_entries: - if not key in config['base']['homeassistant']: - config['base']['homeassistant'][key] = def_value class HomeAssistant(): - ''' HomeAssistant integration ''' + ''' + Homeassistant integration + allows publishing of our players to hass + allows using hass entities (like switches, media_players or gui inputs) to be triggered + ''' - def __init__(self, mass, url, token): + def __init__(self, mass): self.mass = mass self._published_players = {} self._tracked_entities = {} self._state_listeners = {} self._sources = [] - self._token = token + self.__send_ws = None + self.__last_id = 10 + # load/create/update config + config = self.mass.config.create_module_config(CONF_KEY, CONFIG_ENTRIES) + self.enabled = config[CONF_ENABLED] + if self.enabled and (not config[CONF_URL] or + not config[CONF_TOKEN]): + LOGGER.warning("Invalid configuration for Home Assistant") + self.enabled = False + self._token = config[CONF_TOKEN] + url = config[CONF_URL] if url.startswith('https://'): self._use_ssl = True self._host = url.replace('https://','').split('/')[0] else: self._use_ssl = False self._host = url.replace('http://','').split('/')[0] - self.__send_ws = None - self.__last_id = 10 LOGGER.info('Homeassistant integration is enabled') - self.mass.event_loop.create_task(self.setup()) async def setup(self): ''' perform async setup ''' self.http_session = aiohttp.ClientSession( loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) self.mass.event_loop.create_task(self.__hass_websocket()) - await self.mass.add_event_listener(self.mass_event, "player changed") + await self.mass.add_event_listener(self.mass_event, EVENT_PLAYER_CHANGED) self.mass.event_loop.create_task(self.__get_sources()) async def get_state_async(self, entity_id, attribute='state'): @@ -108,11 +96,11 @@ class HomeAssistant(): state_obj = await self.__get_data('states/%s' % entity_id) self._tracked_entities[entity_id] = state_obj self.mass.event_loop.create_task( - self.mass.signal_event("hass entity changed", entity_id)) + self.mass.signal_event(EVENT_HASS_CHANGED, entity_id)) async def mass_event(self, msg, msg_details): ''' received event from mass ''' - if msg == "player changed": + if msg == EVENT_PLAYER_CHANGED: await self.publish_player(msg_details) async def hass_event(self, event_type, event_data): @@ -121,7 +109,7 @@ class HomeAssistant(): if event_data['entity_id'] in self._tracked_entities: self._tracked_entities[event_data['entity_id']] = event_data['new_state'] self.mass.event_loop.create_task( - self.mass.signal_event("hass entity changed", event_data['entity_id'])) + self.mass.signal_event(EVENT_HASS_CHANGED, event_data['entity_id'])) elif event_type == 'call_service' and event_data['domain'] == 'media_player': await self.__handle_player_command(event_data['service'], event_data['service_data']) @@ -136,7 +124,7 @@ class HomeAssistant(): if entity_id in self._published_players: # call is for one of our players so handle it player_id = self._published_players[entity_id] - player = await self.mass.player.get_player(player_id) + player = await self.mass.players.get_player(player_id) if service == 'turn_on': await player.power_on() elif service == 'turn_off': @@ -177,16 +165,16 @@ class HomeAssistant(): playlist = await self.mass.music.playlist_by_name(playlist_str) if playlist: media_items.append(playlist) - return await self.mass.player.play_media(player_id, media_items, queue_opt) + return await self.mass.players.play_media(player_id, media_items, queue_opt) elif media_content_type == 'playlist' and 'spotify://playlist' in media_content_id: # TODO: handle parsing of other uri's here playlist = self.mass.music.providers['spotify'].playlist(media_content_id.split(':')[-1]) - return await self.mass.player.play_media(player_id, playlist, queue_opt) + return await self.mass.players.play_media(player_id, playlist, queue_opt) elif media_content_id.startswith('http'): track = Track() track.uri = media_content_id track.provider = 'http' - return await self.mass.player.play_media(player_id, track, queue_opt) + return await self.mass.players.play_media(player_id, track, queue_opt) async def publish_player(self, player): ''' publish player details to hass''' diff --git a/music_assistant/http_streamer.py b/music_assistant/http_streamer.py index 3dacd242..72921b1c 100755 --- a/music_assistant/http_streamer.py +++ b/music_assistant/http_streamer.py @@ -25,6 +25,11 @@ class HTTPStreamer(): self.mass = mass self.local_ip = get_ip() self.analyze_jobs = {} + + async def setup(self): + ''' async initialize of module ''' + # TODO: cleanup of cache files etc. + pass async def stream(self, http_request): ''' @@ -32,7 +37,7 @@ class HTTPStreamer(): ''' # make sure we have a valid player player_id = http_request.match_info.get('player_id','') - player = await self.mass.player.get_player(player_id) + player = await self.mass.players.get_player(player_id) if not player: LOGGER.error("Received stream request for non-existing player %s" %(player_id)) return @@ -49,12 +54,12 @@ class HTTPStreamer(): if queue_item: # single stream requested, run stream in executor bg_task = run_async_background_task( - self.mass.bg_executor, + None, self.__stream_single, player, queue_item, buf_queue, cancelled) else: # no item is given, start queue stream, run stream in executor bg_task = run_async_background_task( - self.mass.bg_executor, + None, self.__stream_queue, player, buf_queue, cancelled) try: while True: @@ -70,8 +75,7 @@ class HTTPStreamer(): await asyncio.sleep(1) del buf_queue raise asyncio.CancelledError() - if not cancelled.is_set(): - return resp + return resp async def __stream_single(self, player, queue_item, buffer, cancelled): ''' start streaming single track from provider ''' @@ -424,9 +428,3 @@ class HTTPStreamer(): crossfade_part, stderr = process.communicate() LOGGER.debug("Got %s bytes in memory for crossfade_part after sox" % len(crossfade_part)) return crossfade_part - - # def readexactly(streamobj, chunksize): - # ''' read exactly n bytes from the stream object ''' - # buf = b'' - # while len(buf) < chunksize: - # new_data = streamobj.read(chunksize) diff --git a/music_assistant/metadata.py b/music_assistant/metadata.py index 103c73df..5971f35c 100755 --- a/music_assistant/metadata.py +++ b/music_assistant/metadata.py @@ -18,12 +18,15 @@ LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' class MetaData(): ''' several helpers to search and store mediadata for mediaitems ''' - def __init__(self, event_loop, db, cache): - self.event_loop = event_loop - self.db = db - self.cache = cache - self.musicbrainz = MusicBrainz(event_loop, cache) - self.fanarttv = FanartTv(event_loop, cache) + def __init__(self, mass): + self.mass = mass + self.musicbrainz = MusicBrainz(mass) + self.fanarttv = FanartTv(mass) + + async def setup(self): + ''' async initialize of metadata module ''' + await self.musicbrainz.setup() + await self.fanarttv.setup() async def get_artist_metadata(self, mb_artist_id, cur_metadata): ''' get/update rich metadata for an artist by providing the musicbrainz artist id ''' @@ -58,15 +61,13 @@ class MetaData(): class MusicBrainz(): - def __init__(self, event_loop, cache): - self.event_loop = event_loop - self.cache = cache - self.event_loop.create_task(self.setup()) + def __init__(self, mass): + self.mass = mass async def setup(self): ''' perform async setup ''' self.http_session = aiohttp.ClientSession( - loop=self.event_loop, connector=aiohttp.TCPConnector()) + loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) self.throttler = Throttler(rate_limit=1, period=1) async def search_artist_by_album(self, artistname, albumname=None, album_upc=None): @@ -138,15 +139,13 @@ class MusicBrainz(): class FanartTv(): - def __init__(self, event_loop, cache): - self.event_loop = event_loop - self.cache = cache - self.event_loop.create_task(self.setup()) + def __init__(self, mass): + self.mass = mass async def setup(self): ''' perform async setup ''' self.http_session = aiohttp.ClientSession( - loop=self.event_loop, connector=aiohttp.TCPConnector()) + loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) self.throttler = Throttler(rate_limit=1, period=1) async def artist_images(self, mb_artist_id): diff --git a/music_assistant/models/musicprovider.py b/music_assistant/models/musicprovider.py index 41c1ca2b..0ed1376b 100755 --- a/music_assistant/models/musicprovider.py +++ b/music_assistant/models/musicprovider.py @@ -21,10 +21,14 @@ class MusicProvider(): prov_id = 'my_provider' # used as id icon = '' - def __init__(self, mass): + def __init__(self, mass, conf): self.mass = mass self.cache = mass.cache + async def setup(self): + ''' async initialize of module ''' + pass + ### Common methods and properties #### async def artist(self, prov_item_id, lazy=True) -> Artist: @@ -382,12 +386,12 @@ class PlayerProvider(): async def add_player(self, player_id, name='', is_group=False): ''' register a new player ''' - return await self.mass.player.add_player(player_id, + return await self.mass.players.add_player(player_id, self.prov_id, name=name, is_group=is_group) async def remove_player(self, player_id): ''' remove a player ''' - return await self.mass.player.remove_player(player_id) + return await self.mass.players.remove_player(player_id) ### Provider specific implementation ##### diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 5c6d6c7e..6b8b2e43 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -6,7 +6,7 @@ from enum import Enum from typing import List import operator from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, try_parse_bool, try_parse_float -from ..constants import CONF_ENABLED +from ..constants import EVENT_PLAYER_CHANGED from ..cache import use_cache from .media_types import Track, MediaType from .player_queue import PlayerQueue, QueueItem @@ -118,7 +118,7 @@ class Player(): self.supports_crossfade = False # has native crossfading support self.supports_replay_gain = False # has native support for replaygain volume leveling # if home assistant support is enabled, register state listener - if self.mass.hass: + if self.mass.hass.enabled: self.mass.event_loop.create_task( self.mass.add_event_listener(self.hass_state_listener, "hass entity changed")) @@ -165,8 +165,7 @@ class Player(): if not self.powered: return PlayerState.Off if self.group_parent: - group_player = self.mass.bg_executor.submit(asyncio.run, - self.mass.player.get_player(self.group_parent)).result() + group_player = self.mass.players._players.get(self.group_parent) if group_player: return group_player.state return self._state @@ -182,13 +181,13 @@ class Player(): def powered(self): ''' [PROTECTED] return power state for this player ''' # homeassistant integration - if (self.mass.hass and self.settings.get('hass_power_entity') and + if (self.mass.hass.enabled and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source')): hass_state = self.mass.hass.get_state( self.settings['hass_power_entity'], attribute='source') return hass_state == self.settings['hass_power_entity_source'] - elif self.mass.hass and self.settings.get('hass_power_entity'): + elif self.mass.hass.enabled and self.settings.get('hass_power_entity'): hass_state = self.mass.hass.get_state( self.settings['hass_power_entity']) return hass_state != 'off' @@ -210,7 +209,7 @@ class Player(): ''' [PROTECTED] cur_time (player's elapsed time) property of this player ''' # handle group player if self.group_parent: - group_player = self.mass.player.get_player_sync(self.group_parent) + group_player = self.mass.players.get_player_sync(self.group_parent) if group_player: return group_player.cur_time return self.queue.cur_item_time @@ -227,7 +226,7 @@ class Player(): ''' [PROTECTED] cur_uri (uri loaded in player) property of this player ''' # handle group player if self.group_parent: - group_player = self.mass.player.get_player_sync(self.group_parent) + group_player = self.mass.players.get_player_sync(self.group_parent) if group_player: return group_player.cur_uri return self._cur_uri @@ -254,7 +253,7 @@ class Player(): group_volume = group_volume / active_players return group_volume # handle hass integration - elif self.mass.hass and self.settings.get('hass_volume_entity'): + elif self.mass.hass.enabled and self.settings.get('hass_volume_entity'): hass_state = self.mass.hass.get_state( self.settings['hass_volume_entity'], attribute='volume_level') @@ -300,7 +299,7 @@ class Player(): ''' [PROTECTED] return group childs ''' if not self.is_group: return [] - return [item for item in self.mass.player.players if item.group_parent == self.player_id] + return [item for item in self.mass.players.players if item.group_parent == self.player_id] @property def enabled(self): @@ -312,7 +311,7 @@ class Player(): ''' [PROTECTED] player's queue ''' # handle group player if self.group_parent: - group_player = self.mass.player.get_player_sync(self.group_parent) + group_player = self.mass.players.get_player_sync(self.group_parent) if group_player: return group_player.queue return self._queue @@ -326,7 +325,7 @@ class Player(): ''' [PROTECTED] send stop command to player ''' if self.group_parent: # redirect playback related commands to parent player - group_player = await self.mass.player.get_player(self.group_parent) + group_player = await self.mass.players.get_player(self.group_parent) if group_player: return await group_player.stop() else: @@ -336,7 +335,7 @@ class Player(): ''' [PROTECTED] send play (unpause) command to player ''' if self.group_parent: # redirect playback related commands to parent player - group_player = await self.mass.player.get_player(self.group_parent) + group_player = await self.mass.players.get_player(self.group_parent) if group_player: return await group_player.play() elif self.state == PlayerState.Paused: @@ -348,7 +347,7 @@ class Player(): ''' [PROTECTED] send pause command to player ''' if self.group_parent: # redirect playback related commands to parent player - group_player = await self.mass.player.get_player(self.group_parent) + group_player = await self.mass.players.get_player(self.group_parent) if group_player: return await group_player.pause() else: @@ -365,7 +364,7 @@ class Player(): ''' [PROTECTED] send next command to player ''' if self.group_parent: # redirect playback related commands to parent player - group_player = await self.mass.player.get_player(self.group_parent) + group_player = await self.mass.players.get_player(self.group_parent) if group_player: return await group_player.next() else: @@ -375,7 +374,7 @@ class Player(): ''' [PROTECTED] send previous command to player ''' if self.group_parent: # redirect playback related commands to parent player - group_player = await self.mass.player.get_player(self.group_parent) + group_player = await self.mass.players.get_player(self.group_parent) if group_player: return await group_player.previous() else: @@ -396,7 +395,7 @@ class Player(): if self.settings.get('mute_as_power'): await self.volume_mute(False) # handle hass integration - if (self.mass.hass and + if (self.mass.hass.enabled and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source')): cur_source = await self.mass.hass.get_state_async( @@ -407,7 +406,7 @@ class Player(): 'source': self.settings['hass_power_entity_source'] } await self.mass.hass.call_service('media_player', 'select_source', service_data) - elif self.settings.get('hass_power_entity'): + elif self.mass.hass.enabled and self.settings.get('hass_power_entity'): domain = self.settings['hass_power_entity'].split('.')[0] service_data = { 'entity_id': self.settings['hass_power_entity']} await self.mass.hass.call_service(domain, 'turn_on', service_data) @@ -417,7 +416,7 @@ class Player(): # handle group power if self.group_parent: # player has a group parent, check if it should be turned on - group_player = await self.mass.player.get_player(self.group_parent) + group_player = await self.mass.players.get_player(self.group_parent) if group_player and not group_player.powered: return await group_player.power_on() @@ -428,7 +427,7 @@ class Player(): if self.settings.get('mute_as_power'): await self.volume_mute(True) # handle hass integration - if (self.mass.hass and + if (self.mass.hass.enabled and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source')): cur_source = await self.mass.hass.get_state_async( @@ -436,7 +435,7 @@ class Player(): if cur_source == self.settings['hass_power_entity_source']: service_data = { 'entity_id': self.settings['hass_power_entity'] } await self.mass.hass.call_service('media_player', 'turn_off', service_data) - elif self.mass.hass and self.settings.get('hass_power_entity'): + elif self.mass.hass.enabled and self.settings.get('hass_power_entity'): domain = self.settings['hass_power_entity'].split('.')[0] service_data = { 'entity_id': self.settings['hass_power_entity']} await self.mass.hass.call_service(domain, 'turn_off', service_data) @@ -448,7 +447,7 @@ class Player(): await item.power_off() elif self.group_parent: # player has a group parent, check if it should be turned off - group_player = await self.mass.player.get_player(self.group_parent) + group_player = await self.mass.players.get_player(self.group_parent) if group_player.powered: needs_power = False for child_player in group_player.group_childs: @@ -483,7 +482,7 @@ class Player(): new_child_volume = cur_child_volume + (cur_child_volume * volume_dif_percent) await child_player.volume_set(new_child_volume) # handle hass integration - elif self.mass.hass and self.settings.get('hass_volume_entity'): + elif self.mass.hass.enabled and self.settings.get('hass_volume_entity'): service_data = { 'entity_id': self.settings['hass_volume_entity'], 'volume_level': volume_level/100 @@ -514,8 +513,7 @@ class Player(): async def update(self): ''' [PROTECTED] signal player updated ''' await self.queue.update() - LOGGER.debug("player changed: %s" % self.name) - await self.mass.signal_event('player changed', self) + await self.mass.signal_event(EVENT_PLAYER_CHANGED, self) self.get_player_settings() async def hass_state_listener(self, msg, msg_details=None): @@ -548,7 +546,7 @@ class Player(): ("play_power_on", False, "player_power_play"), ] # append player specific settings - config_entries += self.mass.player.providers[self._prov_id].player_config_entries + config_entries += self.mass.players.providers[self._prov_id].player_config_entries # hass integration if self.mass.config['base'].get('homeassistant',{}).get("enabled"): # append hass specific config entries diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py index 0dce5c33..a9357090 100755 --- a/music_assistant/models/playerprovider.py +++ b/music_assistant/models/playerprovider.py @@ -20,7 +20,7 @@ class PlayerProvider(): ''' - def __init__(self, mass): + def __init__(self, mass, conf): self.mass = mass self.name = 'My great Musicplayer provider' # display name self.prov_id = 'my_provider' # used as id @@ -32,19 +32,19 @@ class PlayerProvider(): @property def players(self): ''' return all players for this provider ''' - return [item for item in self.mass.player.players if item.player_provider == self.prov_id] + return [item for item in self.mass.players.players if item.player_provider == self.prov_id] async def get_player(self, player_id:str): ''' return player by id ''' - return await self.mass.player.get_player(player_id) + return await self.mass.players.get_player(player_id) async def add_player(self, player:Player): ''' register a new player ''' - return await self.mass.player.add_player(player) + return await self.mass.players.add_player(player) async def remove_player(self, player_id:str): ''' remove a player ''' - return await self.mass.player.remove_player(player_id) + return await self.mass.players.remove_player(player_id) ### Provider specific implementation ##### diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py index 4c4a2f85..fc8fa3fd 100755 --- a/music_assistant/music_manager.py +++ b/music_assistant/music_manager.py @@ -6,26 +6,28 @@ from typing import List import toolz import operator import os -import importlib -from .utils import run_periodic, LOGGER, try_supported +from .utils import run_periodic, LOGGER, try_supported, load_provider_modules from .models.media_types import MediaType, Track, Artist, Album, Playlist, Radio +from .constants import CONF_KEY_MUSICPROVIDERS -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -MODULES_PATH = os.path.join(BASE_DIR, "musicproviders" ) - -class Music(): +class MusicManager(): ''' several helpers around the musicproviders ''' def __init__(self, mass): self.sync_running = False self.mass = mass - self.providers = {} # dynamically load musicprovider modules - self.load_music_providers() + self.providers = load_provider_modules(mass, CONF_KEY_MUSICPROVIDERS) + + async def setup(self): + ''' async initialize of module ''' + # start providers + for prov in self.providers.values(): + await prov.setup() # schedule sync task - mass.event_loop.create_task(self.sync_music_providers()) + self.mass.event_loop.create_task(self.sync_music_providers()) async def item(self, item_id, media_type:MediaType, provider='database', lazy=True): ''' get single music item by id and media type''' @@ -392,25 +394,3 @@ class Music(): await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id) LOGGER.info("Finished syncing Radios for provider %s" % prov_id) - def load_music_providers(self): - ''' dynamically load musicproviders ''' - for item in os.listdir(MODULES_PATH): - if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and - item.endswith('.py') and not item.startswith('.')): - module_name = item.replace(".py","") - LOGGER.debug("Loading musicprovider module %s" % module_name) - try: - mod = importlib.import_module("." + module_name, "music_assistant.musicproviders") - if not self.mass.config['musicproviders'].get(module_name): - self.mass.config['musicproviders'][module_name] = {} - self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries() - for key, def_value, desc in mod.config_entries(): - if not key in self.mass.config['musicproviders'][module_name]: - self.mass.config['musicproviders'][module_name][key] = def_value - mod = mod.setup(self.mass) - if mod: - self.providers[mod.prov_id] = mod - cls_name = mod.__class__.__name__ - LOGGER.info("Successfully initialized module %s" % cls_name) - except Exception as exc: - LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/musicproviders/file.py b/music_assistant/musicproviders/file.py index a6ffabb4..2773ef74 100644 --- a/music_assistant/musicproviders/file.py +++ b/music_assistant/musicproviders/file.py @@ -14,25 +14,16 @@ from ..utils import run_periodic, LOGGER, parse_track_title from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist from ..constants import CONF_ENABLED +PROV_ID = 'file' +PROV_NAME = 'Local files and playlists' +PROV_CLASS = 'FileProvider' +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + ("music_dir", "", "file_prov_music_path"), + ("playlists_dir", "", "file_prov_playlists_path") + ] -def setup(mass): - ''' setup the provider''' - enabled = mass.config["musicproviders"]['file'].get(CONF_ENABLED) - music_dir = mass.config["musicproviders"]['file'].get('music_dir') - playlists_dir = mass.config["musicproviders"]['file'].get('playlists_dir') - if enabled and (music_dir or playlists_dir): - file_provider = FileProvider(mass, music_dir, playlists_dir) - return file_provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - ("music_dir", "", "file_prov_music_path"), - ("playlists_dir", "", "file_prov_playlists_path") - ] class FileProvider(MusicProvider): ''' @@ -45,13 +36,17 @@ class FileProvider(MusicProvider): ''' - def __init__(self, mass, music_dir, playlists_dir): - self.name = 'Local files and playlists' - self.prov_id = 'file' + def __init__(self, mass, conf): + self.name = PROV_NAME + self.prov_id = PROV_ID self.mass = mass self.cache = mass.cache - self._music_dir = music_dir - self._playlists_dir = playlists_dir + self._music_dir = conf["music_dir"] + self._playlists_dir = conf["playlists_dir"] + if not os.path.isdir(conf["music_dir"]): + raise FileNotFoundError(f"Directory {conf['music_dir']} does not exist") + if not os.path.isdir(conf["playlists_dir"]): + raise FileNotFoundError(f"Directory {conf['playlists_dir']} does not exist") async def search(self, searchstring, media_types=List[MediaType], limit=5): ''' perform search on the provider ''' diff --git a/music_assistant/musicproviders/qobuz.py b/music_assistant/musicproviders/qobuz.py index 085d3a32..8979a446 100644 --- a/music_assistant/musicproviders/qobuz.py +++ b/music_assistant/musicproviders/qobuz.py @@ -14,49 +14,44 @@ from asyncio_throttle import Throttler from ..cache import use_cache from ..utils import run_periodic, LOGGER, parse_track_title from ..app_vars import get_app_var -from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED +from ..models import MusicProvider, MediaType, TrackQuality, \ + AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, \ + CONF_TYPE_PASSWORD, EVENT_STREAM_STARTED, EVENT_STREAM_ENDED +PROV_ID = 'qobuz' +PROV_NAME = 'Qobuz' +PROV_CLASS = 'QobuzProvider' -def setup(mass): - ''' setup the provider''' - enabled = mass.config["musicproviders"]['qobuz'].get(CONF_ENABLED) - username = mass.config["musicproviders"]['qobuz'].get(CONF_USERNAME) - password = mass.config["musicproviders"]['qobuz'].get(CONF_PASSWORD) - if enabled and username and password: - provider = QobuzProvider(mass, username, password) - return provider - return False +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD) + ] -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, "", CONF_PASSWORD) - ] class QobuzProvider(MusicProvider): - - def __init__(self, mass, username, password): - self.name = 'Qobuz' - self.prov_id = 'qobuz' + def __init__(self, mass, conf): + ''' Support for streaming music provider Qobuz ''' + self.name = PROV_NAME + self.prov_id = PROV_ID self.mass = mass self.cache = mass.cache - self.__username = username - self.__password = password + self.__username = conf[CONF_USERNAME] + self.__password = conf[CONF_PASSWORD] + if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: + raise Exception("Username and password must not be empty") self.__user_auth_info = None self.__logged_in = False - self.mass.event_loop.create_task(self.setup()) async def setup(self): ''' perform async setup ''' self.http_session = aiohttp.ClientSession( loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) self.throttler = Throttler(rate_limit=2, period=1) - await self.mass.add_event_listener(self.mass_event, 'streaming_started') - await self.mass.add_event_listener(self.mass_event, 'streaming_ended') + await self.mass.add_event_listener(self.mass_event, EVENT_STREAM_STARTED) + await self.mass.add_event_listener(self.mass_event, EVENT_STREAM_ENDED) async def search(self, searchstring, media_types=List[MediaType], limit=5): ''' perform search on the provider ''' diff --git a/music_assistant/musicproviders/spotify.py b/music_assistant/musicproviders/spotify.py index 9bc99f76..866f0526 100644 --- a/music_assistant/musicproviders/spotify.py +++ b/music_assistant/musicproviders/spotify.py @@ -15,47 +15,40 @@ from ..cache import use_cache from ..utils import run_periodic, LOGGER, parse_track_title from ..app_vars import get_app_var from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED - - -def setup(mass): - ''' setup the provider''' - enabled = mass.config["musicproviders"]['spotify'].get(CONF_ENABLED) - username = mass.config["musicproviders"]['spotify'].get(CONF_USERNAME) - password = mass.config["musicproviders"]['spotify'].get(CONF_PASSWORD) - if enabled and username and password: - spotify_provider = SpotifyProvider(mass, username, password) - return spotify_provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, "", CONF_PASSWORD) - ] +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, CONF_TYPE_PASSWORD + + +PROV_ID = 'spotify' +PROV_NAME = 'Spotify' +PROV_CLASS = 'SpotifyProvider' + +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD) + ] class SpotifyProvider(MusicProvider): - - def __init__(self, mass, username, password): - self.name = 'Spotify' - self.prov_id = 'spotify' - self._cur_user = None + def __init__(self, mass, conf): + ''' Support for streaming provider Spotify ''' self.mass = mass self.cache = mass.cache - self._username = username - self._password = password + self.name = PROV_NAME + self.prov_id = PROV_ID + self._cur_user = None + if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: + raise Exception("Username and password must not be empty") + self._username = conf[CONF_USERNAME] + self._password = conf[CONF_PASSWORD] self.__auth_token = {} - self.mass.event_loop.create_task(self.setup()) + async def setup(self): ''' perform async setup ''' self.throttler = Throttler(rate_limit=1, period=1) self.http_session = aiohttp.ClientSession( loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) - async def search(self, searchstring, media_types=List[MediaType], limit=5): ''' perform search on the provider ''' diff --git a/music_assistant/musicproviders/tunein.py b/music_assistant/musicproviders/tunein.py index 86fddfcc..db6398da 100644 --- a/music_assistant/musicproviders/tunein.py +++ b/music_assistant/musicproviders/tunein.py @@ -13,38 +13,32 @@ import aiohttp from ..cache import use_cache from ..utils import run_periodic, LOGGER, parse_track_title from ..models import MusicProvider, MediaType, TrackQuality, Radio -from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, CONF_TYPE_PASSWORD -def setup(mass): - ''' setup the provider''' - enabled = mass.config["musicproviders"]['tunein'].get(CONF_ENABLED) - username = mass.config["musicproviders"]['tunein'].get(CONF_USERNAME) - password = mass.config["musicproviders"]['tunein'].get(CONF_PASSWORD) - if enabled and username and password: - provider = TuneInProvider(mass, username, password) - return provider - return False +PROV_ID = 'tunein' +PROV_NAME = 'TuneIn Radio' +PROV_CLASS = 'TuneInProvider' -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, "", CONF_PASSWORD) - ] +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD) + ] class TuneInProvider(MusicProvider): - def __init__(self, mass, username, password): - self.name = 'TuneIn Radio' - self.prov_id = 'tunein' + def __init__(self, mass, conf): + ''' Support for streaming radio provider TuneIn ''' + self.name = PROV_NAME + self.prov_id = PROV_ID self.mass = mass self.cache = mass.cache - self._username = username - self._password = password - self.mass.event_loop.create_task(self.setup()) + if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: + raise Exception("Username and password must not be empty") + self._username = conf[CONF_USERNAME] + self._password = conf[CONF_PASSWORD] async def setup(self): ''' perform async setup ''' diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py index c6ff40c5..d3e2e13d 100755 --- a/music_assistant/player_manager.py +++ b/music_assistant/player_manager.py @@ -8,9 +8,10 @@ import operator import random import functools import urllib -import importlib -from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task +from .constants import CONF_KEY_PLAYERPROVIDERS +from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, \ + get_ip, run_async_background_task, load_provider_modules from .models.media_types import MediaType, TrackQuality from .models.player_queue import QueueItem from .models.playerstate import PlayerState @@ -19,16 +20,20 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" ) - class PlayerManager(): ''' several helpers to handle playback through player providers ''' def __init__(self, mass): self.mass = mass - self.providers = {} self._players = {} - # dynamically load provider modules - self.load_providers() + # dynamically load musicprovider modules + self.providers = load_provider_modules(mass, CONF_KEY_PLAYERPROVIDERS) + + async def setup(self): + ''' async initialize of module ''' + # start providers + for prov in self.providers.values(): + await prov.setup() @property def players(self): @@ -106,26 +111,4 @@ class PlayerManager(): return await player.queue.insert(queue_items, 0) elif queue_opt == 'add': return await player.queue.append(queue_items) - - def load_providers(self): - ''' dynamically load providers ''' - for item in os.listdir(MODULES_PATH): - if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and - item.endswith('.py') and not item.startswith('.')): - module_name = item.replace(".py","") - LOGGER.debug("Loading playerprovider module %s" % module_name) - try: - mod = importlib.import_module("." + module_name, "music_assistant.playerproviders") - if not self.mass.config['playerproviders'].get(module_name): - self.mass.config['playerproviders'][module_name] = {} - self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries() - for key, def_value, desc in mod.config_entries(): - if not key in self.mass.config['playerproviders'][module_name]: - self.mass.config['playerproviders'][module_name][key] = def_value - mod = mod.setup(self.mass) - if mod: - self.providers[mod.prov_id] = mod - cls_name = mod.__class__.__name__ - LOGGER.info("Successfully initialized module %s" % cls_name) - except Exception as exc: - LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) + \ No newline at end of file diff --git a/music_assistant/playerproviders/chromecast.py b/music_assistant/playerproviders/chromecast.py index f301374b..203dce86 100644 --- a/music_assistant/playerproviders/chromecast.py +++ b/music_assistant/playerproviders/chromecast.py @@ -18,19 +18,17 @@ from ..models.playerstate import PlayerState from ..models.player_queue import QueueItem, PlayerQueue from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT -def setup(mass): - ''' setup the provider''' - enabled = mass.config["playerproviders"]['chromecast'].get(CONF_ENABLED) - if enabled: - provider = ChromecastProvider(mass) - return provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, True, CONF_ENABLED), - ] +PROV_ID = 'chromecast' +PROV_NAME = 'Chromecast' +PROV_CLASS = 'ChromecastProvider' + +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + ] + +PLAYER_CONFIG_ENTRIES = [ + ("gapless_enabled", False, "gapless_enabled"), + ] class ChromecastPlayer(Player): ''' Chromecast player object ''' @@ -177,14 +175,18 @@ class ChromecastPlayer(Player): class ChromecastProvider(PlayerProvider): ''' support for ChromeCast Audio ''' - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.prov_id = 'chromecast' - self.name = 'Chromecast' + def __init__(self, mass, conf): + super().__init__(mass, conf) + self.prov_id = PROV_ID + self.name = PROV_NAME self._discovery_running = False logging.getLogger('pychromecast').setLevel(logging.WARNING) - self.player_config_entries = [("gapless_enabled", False, "gapless_enabled")] - self.mass.event_loop.create_task(self.__periodic_chromecast_discovery()) + self.player_config_entries = PLAYER_CONFIG_ENTRIES + + async def setup(self): + ''' perform async setup ''' + self.mass.event_loop.create_task( + self.__periodic_chromecast_discovery()) async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None): ''' handle a player state message from the socket ''' @@ -265,8 +267,9 @@ class ChromecastProvider(PlayerProvider): discovery_info = listener.services[name] ip_address, port, uuid, model_name, friendly_name = discovery_info player_id = str(uuid) - player = self.mass.bg_executor.submit(asyncio.run, - self.get_player(player_id)).result() + player = asyncio.run_coroutine_threadsafe( + self.get_player(player_id), + self.mass.event_loop).result() if not player: LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port)) asyncio.run_coroutine_threadsafe( diff --git a/music_assistant/playerproviders/squeezebox.py b/music_assistant/playerproviders/squeezebox.py index 14bd9e2b..f9e5090e 100644 --- a/music_assistant/playerproviders/squeezebox.py +++ b/music_assistant/playerproviders/squeezebox.py @@ -16,35 +16,35 @@ from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQualit from ..constants import CONF_ENABLED -def setup(mass): - ''' setup the provider''' - enabled = mass.config["playerproviders"]['squeezebox'].get(CONF_ENABLED) - if enabled: - provider = PySqueezeServer(mass) - return provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, True, CONF_ENABLED) - ] - - -class PySqueezeServer(PlayerProvider): +PROV_ID = 'squeezebox' +PROV_NAME = 'Squeezebox' +PROV_CLASS = 'PySqueezeProvider' + +CONFIG_ENTRIES = [ + (CONF_ENABLED, True, CONF_ENABLED), + ] + +PLAYER_CONFIG_ENTRIES = [] + + +class PySqueezeProvider(PlayerProvider): ''' Python implementation of SlimProto server ''' - def __init__(self, mass): - super().__init__(mass) - self.prov_id = 'squeezebox' - self.name = 'Squeezebox' + def __init__(self, mass, conf): + super().__init__(mass, conf) + self.prov_id = PROV_ID + self.name = PROV_NAME + self.player_config_entries = PLAYER_CONFIG_ENTRIES + + ### Provider specific implementation ##### + + async def setup(self): + ''' async initialize of module ''' # start slimproto server - mass.event_loop.create_task( + self.mass.event_loop.create_task( asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483)) # setup discovery - mass.event_loop.create_task(self.start_discovery()) - - ### Provider specific implementation ##### + self.mass.event_loop.create_task(self.start_discovery()) async def start_discovery(self): transport, protocol = await self.mass.event_loop.create_datagram_endpoint( @@ -80,7 +80,7 @@ class PySqueezeServer(PlayerProvider): player_id = str(device_mac).lower() device_type = devices.get(dev_id, 'unknown device') player = PySqueezePlayer(self.mass, player_id, self.prov_id, device_type, writer) - self.mass.event_loop.create_task(self.mass.player.add_player(player)) + self.mass.event_loop.create_task(self.mass.players.add_player(player)) elif player != None: player.process_msg(operation, packet) @@ -88,7 +88,7 @@ class PySqueezeServer(PlayerProvider): # connection lost ? LOGGER.warning(exc) # disconnect - await self.mass.player.remove_player(player) + await self.mass.players.remove_player(player) class PySqueezePlayer(Player): ''' Squeezebox socket client ''' diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 3713253a..f77c2c36 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -5,8 +5,15 @@ import asyncio import logging from concurrent.futures import ThreadPoolExecutor import socket +import importlib import os -LOGGER = logging.getLogger() +try: + import simplejson as json +except ImportError: + import json +LOGGER = logging.getLogger('music_assistant') + +from .constants import CONF_KEY_MUSICPROVIDERS, CONF_ENABLED def run_periodic(period): @@ -127,4 +134,76 @@ def get_folder_size(folderpath): fp = os.path.join(dirpath, f) total_size += os.path.getsize(fp) total_size_gb = total_size/float(1<<30) - return total_size_gb \ No newline at end of file + return total_size_gb + + +def json_serializer(obj): + ''' json serializer to recursively create serializable values for custom data types ''' + def get_val(val): + if isinstance(val, (int, str, bool, float)): + return val + elif isinstance(val, list): + new_list = [] + for item in val: + new_list.append( get_val(item)) + return new_list + elif hasattr(val, 'to_dict'): + return get_val(val.to_dict()) + elif isinstance(val, dict): + new_dict = {} + for key, value in val.items(): + new_dict[key] = get_val(value) + return new_dict + elif hasattr(val, '__dict__'): + new_dict = {} + for key, value in val.__dict__.items(): + new_dict[key] = get_val(value) + return new_dict + obj = get_val(obj) + return json.dumps(obj, skipkeys=True) + + +def try_load_json_file(jsonfile): + ''' try to load json from file ''' + try: + with open(jsonfile) as f: + return json.loads(f.read()) + except Exception as exc: + LOGGER.debug("Could not load json from file %s - %s" % (jsonfile, str(exc))) + return None + +def load_provider_modules(mass, prov_type=CONF_KEY_MUSICPROVIDERS): + ''' dynamically load music/player providers ''' + provider_modules = {} + base_dir = os.path.dirname(os.path.abspath(__file__)) + modules_path = os.path.join(base_dir, prov_type ) + for item in os.listdir(modules_path): + if (os.path.isfile(os.path.join(modules_path, item)) and not item.startswith("_") and + item.endswith('.py') and not item.startswith('.')): + module_name = item.replace(".py","") + prov_mod = load_provider_module(mass, module_name, prov_type) + if prov_mod: + provider_modules[prov_mod.prov_id] = prov_mod + return provider_modules + + +def load_provider_module(mass, module_name, prov_type): + ''' dynamically load music/player provider ''' + LOGGER.debug("Loading provider module %s" % module_name) + try: + prov_mod = importlib.import_module(f".{module_name}", + f"music_assistant.{prov_type}") + prov_conf_entries = prov_mod.CONFIG_ENTRIES + prov_id = prov_mod.PROV_ID + # get/create config for the module + prov_config = mass.config.create_module_config( + prov_id, prov_conf_entries, prov_type) + if prov_config[CONF_ENABLED]: + prov_mod_cls = getattr(prov_mod, prov_mod.PROV_CLASS) + provider = prov_mod_cls(mass, prov_config) + LOGGER.info("Successfully initialized module %s" % provider.name) + return provider + else: + return None + except Exception as exc: + LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) \ No newline at end of file diff --git a/music_assistant/web.py b/music_assistant/web.py index b20d8024..4b849aec 100755 --- a/music_assistant/web.py +++ b/music_assistant/web.py @@ -3,7 +3,6 @@ import asyncio import os -import json import aiohttp from aiohttp import web from functools import partial @@ -12,85 +11,36 @@ import concurrent import threading from .models.media_types import MediaItem, MediaType, media_type_from_string from .models.player import Player -from .utils import run_periodic, LOGGER, run_async_background_task, get_ip +from .utils import run_periodic, LOGGER, run_async_background_task, get_ip, json_serializer -#json_serializer = partial(json.dumps, default=lambda x: x.__dict__) - -def json_serializer(obj): - - def get_val(val): - if isinstance(val, (int, str, bool, float)): - return val - elif isinstance(val, list): - new_list = [] - for item in val: - new_list.append( get_val(item)) - return new_list - elif hasattr(val, 'to_dict'): - return get_val(val.to_dict()) - elif isinstance(val, dict): - new_dict = {} - for key, value in val.items(): - new_dict[key] = get_val(value) - return new_dict - elif hasattr(val, '__dict__'): - new_dict = {} - for key, value in val.__dict__.items(): - new_dict[key] = get_val(value) - return new_dict - - obj = get_val(obj) - return json.dumps(obj, skipkeys=True) - -def setup(mass): - ''' setup the module and read/apply config''' - create_config_entries(mass.config) - conf = mass.config['base']['web'] - if conf['ssl_certificate'] and os.path.isfile(conf['ssl_certificate']): - ssl_cert = conf['ssl_certificate'] - else: - ssl_cert = '' - if conf['ssl_key'] and os.path.isfile(conf['ssl_key']): - ssl_key = conf['ssl_key'] - else: - ssl_key = '' - cert_fqdn_host = conf['cert_fqdn_host'] - http_port = conf['http_port'] - https_port = conf['https_port'] - return Web(mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host) - -def create_config_entries(config): - ''' get the config entries for this module (list with key/value pairs)''' - config_entries = [ +CONF_KEY = 'web' +CONFIG_ENTRIES = [ ('http_port', 8095, 'webhttp_port'), ('https_port', 8096, 'web_https_port'), ('ssl_certificate', '', 'web_ssl_cert'), ('ssl_key', '', 'web_ssl_key'), ('cert_fqdn_host', '', 'cert_fqdn_host') ] - if not config['base'].get('web'): - config['base']['web'] = {} - config['base']['web']['__desc__'] = config_entries - for key, def_value, desc in config_entries: - if not key in config['base']['web']: - config['base']['web'][key] = def_value class Web(): ''' webserver and json/websocket api ''' - def __init__(self, mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host): + def __init__(self, mass): self.mass = mass + # load/create/update config + config = self.mass.config.create_module_config(CONF_KEY, CONFIG_ENTRIES) + if config['ssl_certificate'] and not os.path.isfile( + self.mass.config['ssl_certificate']): + raise FileNotFoundError( + "SSL certificate file not found: %s" % config['ssl_certificate']) + if config['ssl_key'] and not os.path.isfile(config['ssl_key']): + raise FileNotFoundError( + "SSL certificate key file not found: %s" % config['ssl_key']) self.local_ip = get_ip() - self.http_port = http_port - self._https_port = https_port - self._ssl_cert = ssl_cert - self._ssl_key = ssl_key - self._cert_fqdn_host = cert_fqdn_host - self.mass.event_loop.create_task(self.setup()) - - def stop(self): - asyncio.create_task(self.runner.cleanup()) - asyncio.create_task(self.http_session.close()) + self.http_port = config['http_port'] + self.https_port = config['https_port'] + self._enable_ssl = config['ssl_certificate'] and config['ssl_key'] + self.config = config async def setup(self): ''' perform async setup ''' @@ -119,15 +69,16 @@ class Web(): app.add_routes([web.get('/api/{media_type}/{media_id}/{action}', self.get_item)]) app.add_routes([web.get('/api/{media_type}/{media_id}', self.get_item)]) app.add_routes([web.get('/', self.index)]) - app.router.add_static("/", "./web/") + webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web/') + app.router.add_static("/", webdir) self.runner = web.AppRunner(app, access_log=None) await self.runner.setup() http_site = web.TCPSite(self.runner, '0.0.0.0', self.http_port) await http_site.start() - if self._ssl_cert and self._ssl_key: + if self._enable_ssl: ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key) - https_site = web.TCPSite(self.runner, '0.0.0.0', self._https_port, ssl_context=ssl_context) + ssl_context.load_cert_chain(self.config['ssl_certificate'], self.config['ssl_key']) + https_site = web.TCPSite(self.runner, '0.0.0.0', self.https_port, ssl_context=ssl_context) await https_site.start() async def get_items(self, request): @@ -211,21 +162,21 @@ class Web(): async def players(self, request): ''' get all players ''' - players = list(self.mass.player.players) + players = list(self.mass.players.players) players.sort(key=lambda x: x.name, reverse=False) return web.json_response(players, dumps=json_serializer) async def player(self, request): ''' get single player ''' player_id = request.match_info.get('player_id') - player = await self.mass.player.get_player(player_id) + player = await self.mass.players.get_player(player_id) return web.json_response(player, dumps=json_serializer) async def player_command(self, request): ''' issue player command''' result = False player_id = request.match_info.get('player_id') - player = await self.mass.player.get_player(player_id) + player = await self.mass.players.get_player(player_id) if player: cmd = request.match_info.get('cmd') cmd_args = request.match_info.get('cmd_args') @@ -249,7 +200,7 @@ class Web(): queue_opt = request.match_info.get('queue_opt','') provider = request.rel_url.query.get('provider') media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True) - result = await self.mass.player.play_media(player_id, media_item, queue_opt) + result = await self.mass.players.play_media(player_id, media_item, queue_opt) return web.json_response(result, dumps=json_serializer) async def player_queue(self, request): @@ -257,15 +208,17 @@ class Web(): player_id = request.match_info.get('player_id') limit = int(request.query.get('limit', 50)) offset = int(request.query.get('offset', 0)) - player = await self.mass.player.get_player(player_id) + player = await self.mass.players.get_player(player_id) # queue_items = player.queue.items # queue_items = [item.__dict__ for item in queue_items] # print(queue_items) # result = queue_items[offset:limit] return web.json_response(player.queue.items[offset:limit], dumps=json_serializer) - async def index(self, request): - return web.FileResponse("./web/index.html") + async def index(self, request): + index_file = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'web/index.html') + return web.FileResponse(index_file) async def websocket_handler(self, request): ''' websockets handler ''' @@ -285,7 +238,7 @@ class Web(): continue # for now we only use WS for (simple) player commands if msg.data == 'players': - players = list(self.mass.player.players) + players = list(self.mass.players.players) players.sort(key=lambda x: x.name, reverse=False) ws_msg = {'message': 'players', 'message_details': players} await ws.send_json(ws_msg, dumps=json_serializer) @@ -295,14 +248,12 @@ class Web(): player_id = msg_data_parts[1] cmd = msg_data_parts[3] cmd_args = msg_data_parts[4] if len(msg_data_parts) == 5 else None - player = await self.mass.player.get_player(player_id) + player = await self.mass.players.get_player(player_id) player_cmd = getattr(player, cmd, None) if player_cmd and cmd_args: result = await player_cmd(cmd_args) elif player_cmd: result = await player_cmd() - except Exception as exc: - LOGGER.exception(exc) finally: await self.mass.remove_event_listener(cb_id) LOGGER.debug('websocket connection closed') @@ -345,7 +296,7 @@ class Web(): player_id = params[0] cmds = params[1] cmd_str = " ".join(cmds) - player = await self.mass.player.get_player(player_id) + player = await self.mass.players.get_player(player_id) if cmd_str == 'play': await player.play() elif cmd_str == 'pause': diff --git a/web/components/headermenu.vue.js b/music_assistant/web/components/headermenu.vue.js similarity index 100% rename from web/components/headermenu.vue.js rename to music_assistant/web/components/headermenu.vue.js diff --git a/web/components/infoheader.vue.js b/music_assistant/web/components/infoheader.vue.js similarity index 100% rename from web/components/infoheader.vue.js rename to music_assistant/web/components/infoheader.vue.js diff --git a/web/components/listviewItem.vue.js b/music_assistant/web/components/listviewItem.vue.js similarity index 100% rename from web/components/listviewItem.vue.js rename to music_assistant/web/components/listviewItem.vue.js diff --git a/web/components/player.vue.js b/music_assistant/web/components/player.vue.js similarity index 100% rename from web/components/player.vue.js rename to music_assistant/web/components/player.vue.js diff --git a/web/components/playmenu.vue.js b/music_assistant/web/components/playmenu.vue.js similarity index 100% rename from web/components/playmenu.vue.js rename to music_assistant/web/components/playmenu.vue.js diff --git a/web/components/providericons.vue.js b/music_assistant/web/components/providericons.vue.js similarity index 100% rename from web/components/providericons.vue.js rename to music_assistant/web/components/providericons.vue.js diff --git a/web/components/readmore.vue.js b/music_assistant/web/components/readmore.vue.js similarity index 100% rename from web/components/readmore.vue.js rename to music_assistant/web/components/readmore.vue.js diff --git a/web/components/searchbox.vue.js b/music_assistant/web/components/searchbox.vue.js similarity index 100% rename from web/components/searchbox.vue.js rename to music_assistant/web/components/searchbox.vue.js diff --git a/web/components/volumecontrol.vue.js b/music_assistant/web/components/volumecontrol.vue.js similarity index 100% rename from web/components/volumecontrol.vue.js rename to music_assistant/web/components/volumecontrol.vue.js diff --git a/web/css/nprogress.css b/music_assistant/web/css/nprogress.css similarity index 100% rename from web/css/nprogress.css rename to music_assistant/web/css/nprogress.css diff --git a/web/css/site.css b/music_assistant/web/css/site.css similarity index 100% rename from web/css/site.css rename to music_assistant/web/css/site.css diff --git a/web/css/vue-loading.css b/music_assistant/web/css/vue-loading.css similarity index 100% rename from web/css/vue-loading.css rename to music_assistant/web/css/vue-loading.css diff --git a/web/images/default_artist.png b/music_assistant/web/images/default_artist.png similarity index 100% rename from web/images/default_artist.png rename to music_assistant/web/images/default_artist.png diff --git a/web/images/icons/aac.png b/music_assistant/web/images/icons/aac.png similarity index 100% rename from web/images/icons/aac.png rename to music_assistant/web/images/icons/aac.png diff --git a/web/images/icons/chromecast.png b/music_assistant/web/images/icons/chromecast.png similarity index 100% rename from web/images/icons/chromecast.png rename to music_assistant/web/images/icons/chromecast.png diff --git a/web/images/icons/file.png b/music_assistant/web/images/icons/file.png similarity index 100% rename from web/images/icons/file.png rename to music_assistant/web/images/icons/file.png diff --git a/web/images/icons/flac.png b/music_assistant/web/images/icons/flac.png similarity index 100% rename from web/images/icons/flac.png rename to music_assistant/web/images/icons/flac.png diff --git a/web/images/icons/hires.png b/music_assistant/web/images/icons/hires.png similarity index 100% rename from web/images/icons/hires.png rename to music_assistant/web/images/icons/hires.png diff --git a/web/images/icons/homeassistant.png b/music_assistant/web/images/icons/homeassistant.png similarity index 100% rename from web/images/icons/homeassistant.png rename to music_assistant/web/images/icons/homeassistant.png diff --git a/web/images/icons/http_streamer.png b/music_assistant/web/images/icons/http_streamer.png similarity index 100% rename from web/images/icons/http_streamer.png rename to music_assistant/web/images/icons/http_streamer.png diff --git a/web/images/icons/icon-128x128.png b/music_assistant/web/images/icons/icon-128x128.png similarity index 100% rename from web/images/icons/icon-128x128.png rename to music_assistant/web/images/icons/icon-128x128.png diff --git a/web/images/icons/icon-256x256.png b/music_assistant/web/images/icons/icon-256x256.png similarity index 100% rename from web/images/icons/icon-256x256.png rename to music_assistant/web/images/icons/icon-256x256.png diff --git a/web/images/icons/icon-apple.png b/music_assistant/web/images/icons/icon-apple.png similarity index 100% rename from web/images/icons/icon-apple.png rename to music_assistant/web/images/icons/icon-apple.png diff --git a/web/images/icons/info_gradient.jpg b/music_assistant/web/images/icons/info_gradient.jpg similarity index 100% rename from web/images/icons/info_gradient.jpg rename to music_assistant/web/images/icons/info_gradient.jpg diff --git a/web/images/icons/lms.png b/music_assistant/web/images/icons/lms.png similarity index 100% rename from web/images/icons/lms.png rename to music_assistant/web/images/icons/lms.png diff --git a/web/images/icons/mp3.png b/music_assistant/web/images/icons/mp3.png similarity index 100% rename from web/images/icons/mp3.png rename to music_assistant/web/images/icons/mp3.png diff --git a/web/images/icons/qobuz.png b/music_assistant/web/images/icons/qobuz.png similarity index 100% rename from web/images/icons/qobuz.png rename to music_assistant/web/images/icons/qobuz.png diff --git a/web/images/icons/spotify.png b/music_assistant/web/images/icons/spotify.png similarity index 100% rename from web/images/icons/spotify.png rename to music_assistant/web/images/icons/spotify.png diff --git a/web/images/icons/squeezebox.png b/music_assistant/web/images/icons/squeezebox.png similarity index 100% rename from web/images/icons/squeezebox.png rename to music_assistant/web/images/icons/squeezebox.png diff --git a/web/images/icons/tunein.png b/music_assistant/web/images/icons/tunein.png similarity index 100% rename from web/images/icons/tunein.png rename to music_assistant/web/images/icons/tunein.png diff --git a/web/images/icons/vorbis.png b/music_assistant/web/images/icons/vorbis.png similarity index 100% rename from web/images/icons/vorbis.png rename to music_assistant/web/images/icons/vorbis.png diff --git a/web/images/icons/web.png b/music_assistant/web/images/icons/web.png similarity index 100% rename from web/images/icons/web.png rename to music_assistant/web/images/icons/web.png diff --git a/web/images/info_gradient.jpg b/music_assistant/web/images/info_gradient.jpg similarity index 100% rename from web/images/info_gradient.jpg rename to music_assistant/web/images/info_gradient.jpg diff --git a/web/index.html b/music_assistant/web/index.html similarity index 100% rename from web/index.html rename to music_assistant/web/index.html diff --git a/web/lib/vue-loading-overlay.js b/music_assistant/web/lib/vue-loading-overlay.js similarity index 100% rename from web/lib/vue-loading-overlay.js rename to music_assistant/web/lib/vue-loading-overlay.js diff --git a/web/manifest.json b/music_assistant/web/manifest.json similarity index 100% rename from web/manifest.json rename to music_assistant/web/manifest.json diff --git a/web/pages/albumdetails.vue.js b/music_assistant/web/pages/albumdetails.vue.js similarity index 100% rename from web/pages/albumdetails.vue.js rename to music_assistant/web/pages/albumdetails.vue.js diff --git a/web/pages/artistdetails.vue.js b/music_assistant/web/pages/artistdetails.vue.js similarity index 100% rename from web/pages/artistdetails.vue.js rename to music_assistant/web/pages/artistdetails.vue.js diff --git a/web/pages/browse.vue.js b/music_assistant/web/pages/browse.vue.js similarity index 100% rename from web/pages/browse.vue.js rename to music_assistant/web/pages/browse.vue.js diff --git a/web/pages/config.vue.js b/music_assistant/web/pages/config.vue.js similarity index 100% rename from web/pages/config.vue.js rename to music_assistant/web/pages/config.vue.js diff --git a/web/pages/home.vue.js b/music_assistant/web/pages/home.vue.js similarity index 100% rename from web/pages/home.vue.js rename to music_assistant/web/pages/home.vue.js diff --git a/web/pages/playlistdetails.vue.js b/music_assistant/web/pages/playlistdetails.vue.js similarity index 100% rename from web/pages/playlistdetails.vue.js rename to music_assistant/web/pages/playlistdetails.vue.js diff --git a/web/pages/queue.vue.js b/music_assistant/web/pages/queue.vue.js similarity index 100% rename from web/pages/queue.vue.js rename to music_assistant/web/pages/queue.vue.js diff --git a/web/pages/search.vue.js b/music_assistant/web/pages/search.vue.js similarity index 100% rename from web/pages/search.vue.js rename to music_assistant/web/pages/search.vue.js diff --git a/web/pages/trackdetails.vue.js b/music_assistant/web/pages/trackdetails.vue.js similarity index 100% rename from web/pages/trackdetails.vue.js rename to music_assistant/web/pages/trackdetails.vue.js diff --git a/web/strings.js b/music_assistant/web/strings.js similarity index 100% rename from web/strings.js rename to music_assistant/web/strings.js diff --git a/requirements.txt b/requirements.txt index 397d2107..d2b71b31 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ cytoolz aiohttp spotify_token +protobuf pychromecast uvloop asyncio_throttle @@ -10,5 +11,7 @@ pytaglib python-slugify netaddr memory-tempfile -soundfile -pyloudnorm \ No newline at end of file +aiohttp +pyloudnorm +SoundFile +aiorun \ No newline at end of file diff --git a/run.sh b/run.sh deleted file mode 100755 index 2248eb3b..00000000 --- a/run.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -set -e - -# auto update to latest git version if update environmental variable is set -if [ "$autoupdate" == "true" ]; then - echo "Auto updating to latest (unstable) git version!" - cd /tmp - curl -LOks "https://github.com/marcelveldt/musicassistant/archive/master.zip" - unzip -q master.zip - rm -R /usr/src/app/ - mkdir /usr/src/app/ - cp -rf musicassistant-master/. /usr/src/app/ - rm -R /tmp/musicassistant-master -fi - -# run program -cd /usr/src/app -exec python3 main.py /data > /proc/1/fd/1 2>/proc/1/fd/2 \ No newline at end of file -- 2.34.1