From: marcelveldt Date: Tue, 15 Oct 2019 22:13:47 +0000 (+0200) Subject: more cleanup X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=764893178f08cf1462329c9055959d55953377bd;p=music-assistant-server.git more cleanup better structured --- 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/music_assistant/web/components/headermenu.vue.js b/music_assistant/web/components/headermenu.vue.js new file mode 100755 index 00000000..f0e8566f --- /dev/null +++ b/music_assistant/web/components/headermenu.vue.js @@ -0,0 +1,68 @@ +Vue.component("headermenu", { + template: `
+ + + + + {{ item.icon }} + + + {{ item.title }} + + + + + + + +
+ {{ $globals.windowtitle }} +
+ + + menu + + + arrow_back + + +
+ + + + menu + + + arrow_back + + + + + search + + + +
`, + props: [], + $_veeValidate: { + validator: "new" + }, + data() { + return { + menu: false, + items: [ + { title: this.$t('home'), icon: "home", path: "/" }, + { title: this.$t('artists'), icon: "person", path: "/artists" }, + { title: this.$t('albums'), icon: "album", path: "/albums" }, + { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" }, + { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" }, + { title: this.$t('radios'), icon: "radio", path: "/radios" }, + { title: this.$t('search'), icon: "search", path: "/search" }, + { title: this.$t('settings'), icon: "settings", path: "/config" } + ] + } + }, + mounted() { }, + methods: { } +}) diff --git a/music_assistant/web/components/infoheader.vue.js b/music_assistant/web/components/infoheader.vue.js new file mode 100644 index 00000000..9d8bc8c3 --- /dev/null +++ b/music_assistant/web/components/infoheader.vue.js @@ -0,0 +1,135 @@ +Vue.component("infoheader", { + template: ` + + + +
+ + + + + + + + +
+ +
+
+ + + + + {{ info.name }} + ({{ info.version }}) + + + + + + {{ artist.name }} + + + + {{ info.artist.name }} + + + {{ info.owner }} + + + + + {{ info.album.name }} + + + +
+ play_circle_outline{{ $t('play') }} + favorite_border{{ $t('add_library') }} + favorite{{ $t('remove_library') }} +
+ + + +
+ +
+
+ +
+
+ + +
+ {{ tag }} +
+ + + +`, + props: ['info'], + data (){ + return{} + }, + mounted() { }, + created() { }, + methods: { + getFanartImage() { + var img = ''; + if (!this.info) + return '' + if (this.info.metadata && this.info.metadata.fanart) + img = this.info.metadata.fanart; + else if (this.info.artists) + this.info.artists.forEach(function(artist) { + if (artist.metadata && artist.metadata.fanart) + img = artist.metadata.fanart; + }); + else if (this.info.artist && this.info.artist.metadata.fanart) + img = this.info.artist.metadata.fanart; + return img; + }, + getThumb() { + var img = ''; + if (!this.info) + return '' + if (this.info.metadata && this.info.metadata.image) + img = this.info.metadata.image; + else if (this.info.album && this.info.album.metadata && this.info.album.metadata.image) + img = this.info.album.metadata.image; + else if (this.info.artists) + this.info.artists.forEach(function(artist) { + if (artist.metadata && artist.metadata.image) + img = artist.metadata.image; + }); + return img; + }, + getDescription() { + var desc = ''; + if (!this.info) + return '' + if (this.info.metadata && this.info.metadata.description) + return this.info.metadata.description; + else if (this.info.metadata && this.info.metadata.biography) + return this.info.metadata.biography; + else if (this.info.metadata && this.info.metadata.copyright) + return this.info.metadata.copyright; + else if (this.info.artists) + { + this.info.artists.forEach(function(artist) { + console.log(artist.metadata.biography); + if (artist.metadata && artist.metadata.biography) + desc = artist.metadata.biography; + }); + } + return desc; + }, + } +}) diff --git a/music_assistant/web/components/listviewItem.vue.js b/music_assistant/web/components/listviewItem.vue.js new file mode 100755 index 00000000..687c69c9 --- /dev/null +++ b/music_assistant/web/components/listviewItem.vue.js @@ -0,0 +1,75 @@ +Vue.component("listviewItem", { + template: ` +
+ + + + + + audiotrack + album + person + audiotrack + + + + + + {{ item.name }} ({{ item.version }}) + + + + + {{ artist.name }} + + + - {{ item.album.name }} + + + + {{ item.artist.name }} + + + + {{ item.owner }} + + + + + + + + + + {{ $t('remove_library') }} + {{ $t('add_library') }} + + + + + {{ item.duration.toString().formatDuration() }} + + + + more_vert + + + + +
+ `, +props: ['item', 'index', 'totalitems', 'hideavatar', 'hidetracknum', 'hideproviders', 'hidemenu', 'hidelibrary', 'hideduration'], +data() { + return {} + }, +methods: { + } +}) diff --git a/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js new file mode 100755 index 00000000..d8b9859b --- /dev/null +++ b/music_assistant/web/components/player.vue.js @@ -0,0 +1,321 @@ +Vue.component("player", { + template: ` +
+ + + + + + + + + + + + + + + + + + {{ active_player.cur_item ? active_player.cur_item.name : active_player.name }} + + + {{ artist.name }} + + + + + + + + +
+ + {{ player_time_str_cur }} + + {{ player_time_str_total }} + +
+ + + + + + + + + + + skip_previous + pause + play_arrow + skip_next + + + + + + + + queue_music + {{ $t('queue') }} + + + + + + + + + + + + + + + + + speaker + {{ active_player_id ? players[active_player_id].name : '' }} + + + + + + + + + + +
+
+ + + + + {{ $t('players') }} + + + +
+ + + {{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }} + + + {{ player.name }} + + + {{ $t('state.' + player.state) }} + + + + + + + + + + + + +
+
+
+ +
+ + `, + props: [], + $_veeValidate: { + validator: "new" + }, + watch: {}, + data() { + return { + menu: false, + players: {}, + active_player_id: "", + ws: null + } + }, + mounted() { }, + created() { + this.connectWS(); + this.updateProgress(); + }, + computed: { + + active_player() { + if (this.players && this.active_player_id && this.active_player_id in this.players) + return this.players[this.active_player_id]; + else + return { + name: 'no player selected', + cur_item: null, + cur_time: 0, + player_id: '', + volume_level: 0, + state: 'stopped' + }; + }, + progress() { + if (!this.active_player.cur_item) + return 0; + var total_sec = this.active_player.cur_item.duration; + var cur_sec = this.active_player.cur_time; + var cur_percent = cur_sec/total_sec*100; + return cur_percent; + }, + player_time_str_cur() { + if (!this.active_player.cur_item || !this.active_player.cur_time) + return "0:00"; + var cur_sec = this.active_player.cur_time; + return cur_sec.toString().formatDuration(); + }, + player_time_str_total() { + if (!this.active_player.cur_item) + return "0:00"; + var total_sec = this.active_player.cur_item.duration; + return total_sec.toString().formatDuration(); + } + }, + methods: { + playerCommand (cmd, cmd_opt=null, player_id=this.active_player_id) { + if (cmd_opt) + cmd = cmd + '/' + cmd_opt + cmd = 'players/' + player_id + '/cmd/' + cmd; + this.ws.send(cmd); + }, + playItem(item, queueopt) { + console.log('playItem: ' + item); + this.$globals.loading = true; + var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt; + axios + .get(api_url, { + params: { + provider: item.provider + } + }) + .then(result => { + console.log(result.data); + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + this.$globals.loading = false; + }); + }, + switchPlayer (new_player_id) { + this.active_player_id = new_player_id; + }, + isGroup(player_id) { + for (var item in this.players) + if (this.players[item].group_parent == player_id && this.players[item].enabled) + return true; + return false; + }, + updateProgress: function(){ + this.intervalid2 = setInterval(function(){ + if (this.active_player.state == 'playing') + this.active_player.cur_time +=1; + }.bind(this), 1000); + }, + setPlayerVolume: function(player_id, new_volume) { + this.players[player_id].volume_level = new_volume; + if (new_volume == 'up') + this.playerCommand('volume_up', null, player_id); + else if (new_volume == 'down') + this.playerCommand('volume_down', null, player_id); + else + this.playerCommand('volume_set', new_volume, player_id); + }, + togglePlayerPower: function(player_id) { + if (this.players[player_id].powered) + this.playerCommand('power_off', null, player_id); + else + this.playerCommand('power_on', null, player_id); + }, + connectWS() { + var loc = window.location, new_uri; + if (loc.protocol === "https:") { + new_uri = "wss:"; + } else { + new_uri = "ws:"; + } + new_uri += "/" + loc.host; + new_uri += loc.pathname + "ws"; + this.ws = new WebSocket(new_uri); + + this.ws.onopen = function() { + console.log('websocket connected!'); + this.ws.send('players'); + }.bind(this); + + this.ws.onmessage = function(e) { + var msg = JSON.parse(e.data); + if (msg.message == 'player changed') + { + Vue.set(this.players, msg.message_details.player_id, msg.message_details); + } + else if (msg.message == 'player removed') { + this.players[msg.message_details.player_id].enabled = false; + } + else if (msg.message == 'players') { + for (var item of msg.message_details) { + console.log("new player: " + item.player_id); + Vue.set(this.players, item.player_id, item); + } + } + else + console.log(msg); + + // select new active player + // TODO: store previous player in local storage + if (!this.active_player_id || !this.players[this.active_player_id].enabled) + for (var player_id in this.players) + if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) { + // prefer the first playing player + this.active_player_id = player_id; + break; + } + if (!this.active_player_id || !this.players[this.active_player_id].enabled) + for (var player_id in this.players) { + // fallback to just the first player + if (this.players[player_id].enabled && !this.players[player_id].group_parent) + { + this.active_player_id = player_id; + break; + } + } + }.bind(this); + + this.ws.onclose = function(e) { + console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason); + setTimeout(function() { + this.connectWS(); + }.bind(this), 5000); + }.bind(this); + + this.ws.onerror = function(err) { + console.error('Socket encountered error: ', err.message, 'Closing socket'); + this.ws.close(); + }.bind(this); + } + } +}) diff --git a/music_assistant/web/components/playmenu.vue.js b/music_assistant/web/components/playmenu.vue.js new file mode 100644 index 00000000..611ecc36 --- /dev/null +++ b/music_assistant/web/components/playmenu.vue.js @@ -0,0 +1,93 @@ +Vue.component("playmenu", { + template: ` + + + + {{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }} + {{ $t('play_on') }} {{ active_player.name }} + + + + play_circle_outline + + + {{ $t('play_now') }} + + + + + + + queue_play_next + + + {{ $t('play_next') }} + + + + + + + playlist_add + + + {{ $t('add_queue') }} + + + + + + + info + + + {{ $t('show_info') }} + + + + + + + add_circle_outline + + + {{ $t('add_playlist') }} + + + + + + + remove_circle_outline + + + {{ $t('remove_playlist') }} + + + + + + + +`, + props: ['value', 'active_player'], + data (){ + return{ + fav: true, + message: false, + hints: true, + } + }, + mounted() { }, + created() { }, + methods: { + itemClick(cmd) { + if (cmd == 'info') + this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}}) + else + this.$emit('playItem', this.$globals.playmenuitem, cmd) + // close dialog + this.$globals.showplaymenu = false; + }, + } + }) diff --git a/music_assistant/web/components/providericons.vue.js b/music_assistant/web/components/providericons.vue.js new file mode 100644 index 00000000..0e0124d0 --- /dev/null +++ b/music_assistant/web/components/providericons.vue.js @@ -0,0 +1,68 @@ +Vue.component("providericons", { + template: ` +
+ + + +
+ +
{{ getFileFormatDesc(provider) }}
+
+ {{ provider.provider }} +
+
+
+`, + props: ['item','height','compact', 'dark', 'hiresonly'], + data (){ + return{} + }, + mounted() { }, + created() { }, + computed: { + uniqueProviders() { + var keys = []; + var qualities = []; + if (!this.item || !this.item.provider_ids) + return [] + let sorted_item_ids = this.item.provider_ids.sort((a,b) => (a.quality < b.quality) ? 1 : ((b.quality < a.quality) ? -1 : 0)); + if (!this.compact) + return sorted_item_ids; + for (provider of sorted_item_ids) { + if (!keys.includes(provider.provider)){ + qualities.push(provider); + keys.push(provider.provider); + } + } + return qualities; + } + }, + methods: { + + getFileFormatLogo(provider) { + if (provider.quality == 0) + return 'images/icons/mp3.png' + else if (provider.quality == 1) + return 'images/icons/vorbis.png' + else if (provider.quality == 2) + return 'images/icons/aac.png' + else if (provider.quality > 2) + return 'images/icons/flac.png' + }, + getFileFormatDesc(provider) { + var desc = ''; + if (provider.details) + desc += ' ' + provider.details; + return desc; + }, + getMaxQualityFormatDesc() { + var desc = ''; + if (provider.details) + desc += ' ' + provider.details; + return desc; + } + } + }) diff --git a/music_assistant/web/components/readmore.vue.js b/music_assistant/web/components/readmore.vue.js new file mode 100644 index 00000000..6af2fd3b --- /dev/null +++ b/music_assistant/web/components/readmore.vue.js @@ -0,0 +1,63 @@ +Vue.component("read-more", { + template: ` +
+ {{moreStr}}

+ + + + + +
`, + props: { + moreStr: { + type: String, + default: 'read more' + }, + lessStr: { + type: String, + default: '' + }, + text: { + type: String, + required: true + }, + link: { + type: String, + default: '#' + }, + maxChars: { + type: Number, + default: 100 + } + }, + $_veeValidate: { + validator: "new" + }, + data (){ + return{ + isReadMore: false + } + }, + mounted() { }, + computed: { + formattedString(){ + var val_container = this.text; + if(this.text.length > this.maxChars){ + val_container = val_container.substring(0,this.maxChars) + '...'; + } + return(val_container); + } + }, + + methods: { + triggerReadMore(e, b){ + if(this.link == '#'){ + e.preventDefault(); + } + if(this.lessStr !== null || this.lessStr !== '') + { + this.isReadMore = b; + } + } + } + }) diff --git a/music_assistant/web/components/searchbox.vue.js b/music_assistant/web/components/searchbox.vue.js new file mode 100644 index 00000000..1570ab6c --- /dev/null +++ b/music_assistant/web/components/searchbox.vue.js @@ -0,0 +1,50 @@ +Vue.component("searchbox", { + template: ` + + + + + `, + data () { + return { + searchQuery: "", + } + }, + props: ['value'], + mounted () { + this.searchQuery = "" // TODO: set to last searchquery ? + }, + watch: { + searchQuery: { + handler: _.debounce(function (val) { + this.onSearch(); + // if (this.searchQuery) + // this.$globals.showsearchbox = false; + }, 1000) + }, + newSearchQuery (val) { + this.searchQuery = val + } + }, + computed: {}, + methods: { + onSearch () { + //this.$emit('clickSearch', this.searchQuery) + console.log(this.searchQuery); + router.push({ path: '/search', query: {searchQuery: this.searchQuery}}); + }, + } +}) +/* */ \ No newline at end of file diff --git a/music_assistant/web/components/volumecontrol.vue.js b/music_assistant/web/components/volumecontrol.vue.js new file mode 100644 index 00000000..7ef20ab8 --- /dev/null +++ b/music_assistant/web/components/volumecontrol.vue.js @@ -0,0 +1,76 @@ +Vue.component("volumecontrol", { + template: ` + + + + + {{ isGroup ? 'speaker_group' : 'speaker' }} + + + {{ players[player_id].name }} + {{ $t('state.' + players[player_id].state) }} + + + + + + + + + +
+ + + + + + +
+ {{ players[child_id].name }} +
+
+ + power_settings_new + +
+ + + +
+
+ +
+ +
+ + +
+`, + props: ['value', 'players', 'player_id'], + data (){ + return{ + } + }, + computed: { + volumePlayerIds() { + var volume_ids = [this.player_id]; + for (var player_id in this.players) + if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled) + volume_ids.push(player_id); + return volume_ids; + }, + isGroup() { + return this.volumePlayerIds.length > 1; + } + }, + mounted() { }, + created() { }, + methods: {} + }) diff --git a/music_assistant/web/css/nprogress.css b/music_assistant/web/css/nprogress.css new file mode 100644 index 00000000..e4cb811e --- /dev/null +++ b/music_assistant/web/css/nprogress.css @@ -0,0 +1,74 @@ +/* Make clicks pass-through */ +#nprogress { + pointer-events: none; + } + + #nprogress .bar { + background: rgb(119, 205, 255); + + position: fixed; + z-index: 1031; + top: 0; + left: 0; + + width: 100%; + height: 10px; + } + + /* Fancy blur effect */ + #nprogress .peg { + display: block; + position: absolute; + right: 0px; + width: 100px; + height: 100%; + box-shadow: 0 0 10px #29d, 0 0 5px #29d; + opacity: 1.0; + + -webkit-transform: rotate(3deg) translate(0px, -4px); + -ms-transform: rotate(3deg) translate(0px, -4px); + transform: rotate(3deg) translate(0px, -4px); + } + + /* Remove these to get rid of the spinner */ + #nprogress .spinner { + display: block; + position: fixed; + z-index: 1031; + top: 15px; + right: 15px; + } + + #nprogress .spinner-icon { + width: 18px; + height: 18px; + box-sizing: border-box; + + border: solid 2px transparent; + border-top-color: #29d; + border-left-color: #29d; + border-radius: 50%; + + -webkit-animation: nprogress-spinner 400ms linear infinite; + animation: nprogress-spinner 400ms linear infinite; + } + + .nprogress-custom-parent { + overflow: hidden; + position: relative; + } + + .nprogress-custom-parent #nprogress .spinner, + .nprogress-custom-parent #nprogress .bar { + position: absolute; + } + + @-webkit-keyframes nprogress-spinner { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } + } + @keyframes nprogress-spinner { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + \ No newline at end of file diff --git a/music_assistant/web/css/site.css b/music_assistant/web/css/site.css new file mode 100755 index 00000000..2071f04a --- /dev/null +++ b/music_assistant/web/css/site.css @@ -0,0 +1,73 @@ +[v-cloak] { + display: none; +} + +.navbar { + margin-bottom: 20px; +} + +/*.body-content { + padding-left: 25px; + padding-right: 25px; +}*/ + +input, +select { + max-width: 30em; +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity .5s; +} + +.fade-enter, +.fade-leave-to +/* .fade-leave-active below version 2.1.8 */ + + { + opacity: 0; +} + +.bounce-enter-active { + animation: bounce-in .5s; +} + +.bounce-leave-active { + animation: bounce-in .5s reverse; +} + +@keyframes bounce-in { + 0% { + transform: scale(0); + } + 50% { + transform: scale(1.5); + } + 100% { + transform: scale(1); + } +} + +.slide-fade-enter-active { + transition: all .3s ease; +} + +.slide-fade-leave-active { + transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0); +} + +.slide-fade-enter, +.slide-fade-leave-to +/* .slide-fade-leave-active below version 2.1.8 */ + + { + transform: translateX(10px); + opacity: 0; +} + +.vertical-btn { + display: flex; + flex-direction: column; + align-items: center; + } \ No newline at end of file diff --git a/music_assistant/web/css/vue-loading.css b/music_assistant/web/css/vue-loading.css new file mode 100644 index 00000000..6d62f807 --- /dev/null +++ b/music_assistant/web/css/vue-loading.css @@ -0,0 +1,36 @@ +.vld-overlay { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + align-items: center; + display: none; + justify-content: center; + overflow: hidden; + z-index: 1 +} + +.vld-overlay.is-active { + display: flex +} + +.vld-overlay.is-full-page { + z-index: 999; + position: fixed +} + +.vld-overlay .vld-background { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + background: #000; + opacity: 0.7 +} + +.vld-overlay .vld-icon, .vld-parent { + position: relative +} + diff --git a/music_assistant/web/images/default_artist.png b/music_assistant/web/images/default_artist.png new file mode 100644 index 00000000..a530d5b4 Binary files /dev/null and b/music_assistant/web/images/default_artist.png differ diff --git a/music_assistant/web/images/icons/aac.png b/music_assistant/web/images/icons/aac.png new file mode 100644 index 00000000..7dafab27 Binary files /dev/null and b/music_assistant/web/images/icons/aac.png differ 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/file.png b/music_assistant/web/images/icons/file.png new file mode 100644 index 00000000..bd2df042 Binary files /dev/null and b/music_assistant/web/images/icons/file.png differ diff --git a/music_assistant/web/images/icons/flac.png b/music_assistant/web/images/icons/flac.png new file mode 100644 index 00000000..33e1f175 Binary files /dev/null and b/music_assistant/web/images/icons/flac.png differ diff --git a/music_assistant/web/images/icons/hires.png b/music_assistant/web/images/icons/hires.png new file mode 100644 index 00000000..a398c6e5 Binary files /dev/null and b/music_assistant/web/images/icons/hires.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/icon-128x128.png b/music_assistant/web/images/icons/icon-128x128.png new file mode 100644 index 00000000..01363c8b Binary files /dev/null and b/music_assistant/web/images/icons/icon-128x128.png differ diff --git a/music_assistant/web/images/icons/icon-256x256.png b/music_assistant/web/images/icons/icon-256x256.png new file mode 100644 index 00000000..4c36796d Binary files /dev/null and b/music_assistant/web/images/icons/icon-256x256.png differ diff --git a/music_assistant/web/images/icons/icon-apple.png b/music_assistant/web/images/icons/icon-apple.png new file mode 100644 index 00000000..67d26d53 Binary files /dev/null and b/music_assistant/web/images/icons/icon-apple.png differ diff --git a/music_assistant/web/images/icons/info_gradient.jpg b/music_assistant/web/images/icons/info_gradient.jpg new file mode 100644 index 00000000..9d0c0e3b Binary files /dev/null and b/music_assistant/web/images/icons/info_gradient.jpg 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/mp3.png b/music_assistant/web/images/icons/mp3.png new file mode 100644 index 00000000..b894bda2 Binary files /dev/null and b/music_assistant/web/images/icons/mp3.png differ diff --git a/music_assistant/web/images/icons/qobuz.png b/music_assistant/web/images/icons/qobuz.png new file mode 100644 index 00000000..9d7b726c Binary files /dev/null and b/music_assistant/web/images/icons/qobuz.png differ diff --git a/music_assistant/web/images/icons/spotify.png b/music_assistant/web/images/icons/spotify.png new file mode 100644 index 00000000..805f5c71 Binary files /dev/null and b/music_assistant/web/images/icons/spotify.png differ diff --git a/music_assistant/web/images/icons/squeezebox.png b/music_assistant/web/images/icons/squeezebox.png new file mode 100644 index 00000000..18531d79 Binary files /dev/null and b/music_assistant/web/images/icons/squeezebox.png differ diff --git a/music_assistant/web/images/icons/tunein.png b/music_assistant/web/images/icons/tunein.png new file mode 100644 index 00000000..3352c29c Binary files /dev/null and b/music_assistant/web/images/icons/tunein.png differ diff --git a/music_assistant/web/images/icons/vorbis.png b/music_assistant/web/images/icons/vorbis.png new file mode 100644 index 00000000..c6d69145 Binary files /dev/null and b/music_assistant/web/images/icons/vorbis.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/images/info_gradient.jpg b/music_assistant/web/images/info_gradient.jpg new file mode 100644 index 00000000..9d0c0e3b Binary files /dev/null and b/music_assistant/web/images/info_gradient.jpg differ diff --git a/music_assistant/web/index.html b/music_assistant/web/index.html new file mode 100755 index 00000000..dcef414f --- /dev/null +++ b/music_assistant/web/index.html @@ -0,0 +1,249 @@ + + + + + + Music Assistant + + + + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/music_assistant/web/lib/vue-loading-overlay.js b/music_assistant/web/lib/vue-loading-overlay.js new file mode 100644 index 00000000..b3b9da10 --- /dev/null +++ b/music_assistant/web/lib/vue-loading-overlay.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("VueLoading",[],e):"object"==typeof exports?exports.VueLoading=e():t.VueLoading=e()}("undefined"!=typeof self?self:this,function(){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var r=e[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)i.d(n,r,function(e){return t[e]}.bind(null,r));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=1)}([function(t,e,i){},function(t,e,i){"use strict";i.r(e);var n="undefined"!=typeof window?window.HTMLElement:Object,r={mounted:function(){document.addEventListener("focusin",this.focusIn)},methods:{focusIn:function(t){if(this.isActive&&t.target!==this.$el&&!this.$el.contains(t.target)){var e=this.container?this.container:this.isFullPage?null:this.$el.parentElement;(this.isFullPage||e&&e.contains(t.target))&&(t.preventDefault(),this.$el.focus())}}},beforeDestroy:function(){document.removeEventListener("focusin",this.focusIn)}};function a(t,e,i,n,r,a,o,s){var u,l="function"==typeof t?t.options:t;if(e&&(l.render=e,l.staticRenderFns=i,l._compiled=!0),n&&(l.functional=!0),a&&(l._scopeId="data-v-"+a),o?(u=function(t){(t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),r&&r.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(o)},l._ssrRegister=u):r&&(u=s?function(){r.call(this,this.$root.$options.shadowRoot)}:r),u)if(l.functional){l._injectStyles=u;var c=l.render;l.render=function(t,e){return u.call(e),c(t,e)}}else{var d=l.beforeCreate;l.beforeCreate=d?[].concat(d,u):[u]}return{exports:t,options:l}}var o=a({name:"spinner",props:{color:{type:String,default:"#000"},height:{type:Number,default:64},width:{type:Number,default:64}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 38 38",xmlns:"http://www.w3.org/2000/svg",width:this.width,height:this.height,stroke:this.color}},[e("g",{attrs:{fill:"none","fill-rule":"evenodd"}},[e("g",{attrs:{transform:"translate(1 1)","stroke-width":"2"}},[e("circle",{attrs:{"stroke-opacity":".25",cx:"18",cy:"18",r:"18"}}),e("path",{attrs:{d:"M36 18c0-9.94-8.06-18-18-18"}},[e("animateTransform",{attrs:{attributeName:"transform",type:"rotate",from:"0 18 18",to:"360 18 18",dur:"0.8s",repeatCount:"indefinite"}})],1)])])])},[],!1,null,null,null).exports,s=a({name:"dots",props:{color:{type:String,default:"#000"},height:{type:Number,default:240},width:{type:Number,default:60}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 120 30",xmlns:"http://www.w3.org/2000/svg",fill:this.color,width:this.width,height:this.height}},[e("circle",{attrs:{cx:"15",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"60",cy:"15",r:"9","fill-opacity":"0.3"}},[e("animate",{attrs:{attributeName:"r",from:"9",to:"9",begin:"0s",dur:"0.8s",values:"9;15;9",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"0.5",to:"0.5",begin:"0s",dur:"0.8s",values:".5;1;.5",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"105",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,u=a({name:"bars",props:{color:{type:String,default:"#000"},height:{type:Number,default:40},width:{type:Number,default:40}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 30 30",height:this.height,width:this.width,fill:this.color}},[e("rect",{attrs:{x:"0",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"10",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"20",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,l=a({name:"vue-loading",mixins:[r],props:{active:Boolean,programmatic:Boolean,container:[Object,Function,n],isFullPage:{type:Boolean,default:!0},transition:{type:String,default:"fade"},canCancel:Boolean,onCancel:{type:Function,default:function(){}},color:String,backgroundColor:String,opacity:Number,width:Number,height:Number,zIndex:Number,loader:{type:String,default:"spinner"}},data:function(){return{isActive:this.active}},components:{Spinner:o,Dots:s,Bars:u},beforeMount:function(){this.programmatic&&(this.container?(this.isFullPage=!1,this.container.appendChild(this.$el)):document.body.appendChild(this.$el))},mounted:function(){this.programmatic&&(this.isActive=!0),document.addEventListener("keyup",this.keyPress)},methods:{cancel:function(){this.canCancel&&this.isActive&&(this.hide(),this.onCancel.apply(null,arguments))},hide:function(){var t=this;this.$emit("hide"),this.$emit("update:active",!1),this.programmatic&&(this.isActive=!1,setTimeout(function(){var e;t.$destroy(),void 0!==(e=t.$el).remove?e.remove():e.parentNode.removeChild(e)},150))},keyPress:function(t){27===t.keyCode&&this.cancel()}},watch:{active:function(t){this.isActive=t}},beforeDestroy:function(){document.removeEventListener("keyup",this.keyPress)}},function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("transition",{attrs:{name:t.transition}},[i("div",{directives:[{name:"show",rawName:"v-show",value:t.isActive,expression:"isActive"}],staticClass:"vld-overlay is-active",class:{"is-full-page":t.isFullPage},style:{zIndex:this.zIndex},attrs:{tabindex:"0","aria-busy":t.isActive,"aria-label":"Loading"}},[i("div",{staticClass:"vld-background",style:{background:this.backgroundColor,opacity:this.opacity},on:{click:function(e){return e.preventDefault(),t.cancel(e)}}}),i("div",{staticClass:"vld-icon"},[t._t("before"),t._t("default",[i(t.loader,{tag:"component",attrs:{color:t.color,width:t.width,height:t.height}})]),t._t("after")],2)])])},[],!1,null,null,null).exports,c=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return{show:function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:e,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:i,a=Object.assign({},e,n,{programmatic:!0}),o=new(t.extend(l))({el:document.createElement("div"),propsData:a}),s=Object.assign({},i,r);return Object.keys(s).map(function(t){o.$slots[t]=s[t]}),o}}};i(0);l.install=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},n=c(t,e,i);t.$loading=n,t.prototype.$loading=n};e.default=l}]).default}); \ No newline at end of file diff --git a/music_assistant/web/manifest.json b/music_assistant/web/manifest.json new file mode 100755 index 00000000..6a3c4b97 --- /dev/null +++ b/music_assistant/web/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Music Assistant", + "short_name": "MusicAssistant", + "theme_color": "#2196f3", + "background_color": "#2196f3", + "display": "standalone", + "Scope": "/", + "start_url": "/", + "icons": [ + { + "src": "images/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "images/icons/icon-256x256.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "splash_pages": null +} \ No newline at end of file diff --git a/music_assistant/web/pages/albumdetails.vue.js b/music_assistant/web/pages/albumdetails.vue.js new file mode 100755 index 00000000..4f60a91e --- /dev/null +++ b/music_assistant/web/pages/albumdetails.vue.js @@ -0,0 +1,107 @@ +var AlbumDetails = Vue.component('AlbumDetails', { + template: ` +
+ + + Album tracks + + + + + + + + + + Versions + + + + + + + + + + +
`, + props: ['provider', 'media_id'], + data() { + return { + selected: [2], + info: {}, + albumtracks: [], + albumversions: [], + offset: 0, + active: null, + } + }, + created() { + this.$globals.windowtitle = "" + this.getInfo(); + this.getAlbumTracks(); + }, + methods: { + getInfo () { + this.$globals.loading = true; + const api_url = '/api/albums/' + this.media_id + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.info = data; + this.getAlbumVersions() + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + }, + getAlbumTracks () { + const api_url = '/api/albums/' + this.media_id + '/tracks' + axios + .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider}}) + .then(result => { + data = result.data; + this.albumtracks.push(...data); + this.offset += 50; + }) + .catch(error => { + console.log("error", error); + }); + }, + getAlbumVersions () { + const api_url = '/api/search'; + var searchstr = this.info.artist.name + " - " + this.info.name + axios + .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'albums', online: true}}) + .then(result => { + data = result.data; + this.albumversions.push(...data.albums); + this.offset += 50; + }) + .catch(error => { + console.log("error", error); + }); + }, + } +}) diff --git a/music_assistant/web/pages/artistdetails.vue.js b/music_assistant/web/pages/artistdetails.vue.js new file mode 100755 index 00000000..46600303 --- /dev/null +++ b/music_assistant/web/pages/artistdetails.vue.js @@ -0,0 +1,127 @@ +var ArtistDetails = Vue.component('ArtistDetails', { + template: ` +
+ + + Top tracks + + + + + + + + + + Albums + + + + + + + + + +
`, + props: ['media_id', 'provider'], + data() { + return { + selected: [2], + info: {}, + toptracks: [], + artistalbums: [], + bg_image: "../images/info_gradient.jpg", + active: null, + playmenu: false, + playmenuitem: null + } + }, + created() { + this.$globals.windowtitle = "" + this.getInfo(); + }, + methods: { + getFanartImage() { + if (this.info.metadata && this.info.metadata.fanart) + return this.info.metadata.fanart; + else if (this.info.artists) + for (artist in this.info.artists) + if (artist.info.metadata && artist.data.metadata.fanart) + return artist.metadata.fanart; + }, + getInfo (lazy=true) { + this.$globals.loading = true; + const api_url = '/api/artists/' + this.media_id; + console.log(api_url + ' - ' + this.provider); + axios + .get(api_url, { params: { lazy: lazy, provider: this.provider }}) + .then(result => { + data = result.data; + this.info = data; + this.$globals.loading = false; + if (data.is_lazy == true) + // refresh the info if we got a lazy object + this.timeout1 = setTimeout(function(){ + this.getInfo(false); + }.bind(this), 1000); + else { + this.getArtistTopTracks(); + this.getArtistAlbums(); + } + }) + .catch(error => { + console.log("error", error); + this.$globals.loading = false; + }); + }, + getArtistTopTracks () { + + const api_url = '/api/artists/' + this.media_id + '/toptracks' + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.toptracks = data; + }) + .catch(error => { + console.log("error", error); + }); + + }, + getArtistAlbums () { + const api_url = '/api/artists/' + this.media_id + '/albums' + console.log('loading ' + api_url); + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.artistalbums = data; + }) + .catch(error => { + console.log("error", error); + }); + }, + } +}) diff --git a/music_assistant/web/pages/browse.vue.js b/music_assistant/web/pages/browse.vue.js new file mode 100755 index 00000000..49bcdc8b --- /dev/null +++ b/music_assistant/web/pages/browse.vue.js @@ -0,0 +1,61 @@ +var Browse = Vue.component('Browse', { + template: ` +
+ + + + +
+ `, + props: ['mediatype', 'provider'], + data() { + return { + selected: [2], + items: [], + offset: 0 + } + }, + created() { + this.showavatar = true; + mediatitle = + this.$globals.windowtitle = this.$t(this.mediatype) + this.scroll(this.Browse); + this.getItems(); + }, + methods: { + getItems () { + this.$globals.loading = true + const api_url = '/api/' + this.mediatype; + axios + .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider }}) + .then(result => { + data = result.data; + this.items.push(...data); + this.offset += 50; + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + this.showProgress = false; + }); + }, + scroll (Browse) { + window.onscroll = () => { + let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight; + + if (bottomOfWindow) { + this.getItems(); + } + }; + } + } +}) diff --git a/music_assistant/web/pages/config.vue.js b/music_assistant/web/pages/config.vue.js new file mode 100755 index 00000000..c4164db2 --- /dev/null +++ b/music_assistant/web/pages/config.vue.js @@ -0,0 +1,152 @@ +var Config = Vue.component('Config', { + template: ` +
+ + + {{ $t('conf.'+conf_key) }} + + + + + + +
+ + + + + + +
+ +
+
+ + + + + +
+ + + + + + + + + + + + +
+
+ + + +
+ +
+
+
+ +
+ + +
+ `, + props: [], + data() { + return { + conf: {}, + players: {}, + active: 0, + sample_rates: [44100, 48000, 88200, 96000, 192000, 384000] + } + }, + computed: { + playersLst() + { + var playersLst = []; + playersLst.push({id: null, name: this.$t('conf.'+'not_grouped')}) + for (player_id in this.players) + playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name}) + return playersLst; + } + }, + watch: { + 'conf': { + handler: _.debounce(function (val, oldVal) { + if (oldVal.base) { + console.log("save config needed!"); + this.saveConfig(); + this.$toasted.show(this.$t('conf.conf_saved')) + } + }, 5000), + deep: true + } + }, + created() { + this.$globals.windowtitle = this.$t('settings'); + this.getPlayers(); + this.getConfig(); + console.log(this.$globals.all_players); + }, + methods: { + getConfig () { + axios + .get('/api/config') + .then(result => { + this.conf = result.data; + }) + .catch(error => { + console.log("error", error); + }); + }, + saveConfig () { + axios + .post('/api/config', this.conf) + .then(result => { + console.log(result); + }) + .catch(error => { + console.log("error", error); + }); + }, + getPlayers () { + const api_url = '/api/players'; + axios + .get(api_url) + .then(result => { + for (var item of result.data) + this.$set(this.players, item.player_id, item) + }) + .catch(error => { + console.log("error", error); + this.showProgress = false; + }); + }, + } +}) diff --git a/music_assistant/web/pages/home.vue.js b/music_assistant/web/pages/home.vue.js new file mode 100755 index 00000000..91c0b33d --- /dev/null +++ b/music_assistant/web/pages/home.vue.js @@ -0,0 +1,43 @@ +var home = Vue.component("Home", { + template: ` +
+ + + + {{ item.icon }} + + + {{ item.title }} + + + +
+`, + props: ["title"], + $_veeValidate: { + validator: "new" + }, + data() { + return { + result: null, + showProgress: false + }; + }, + created() { + this.$globals.windowtitle = this.$t('musicassistant'); + this.items= [ + { title: this.$t('artists'), icon: "person", path: "/artists" }, + { title: this.$t('albums'), icon: "album", path: "/albums" }, + { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" }, + { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" }, + { title: this.$t('search'), icon: "search", path: "/search" } + ] + }, + methods: { + click (item) { + console.log("selected: "+ item.path); + router.push({path: item.path}) + } + } +}); diff --git a/music_assistant/web/pages/playlistdetails.vue.js b/music_assistant/web/pages/playlistdetails.vue.js new file mode 100755 index 00000000..b9c617d4 --- /dev/null +++ b/music_assistant/web/pages/playlistdetails.vue.js @@ -0,0 +1,83 @@ +var PlaylistDetails = Vue.component('PlaylistDetails', { + template: ` +
+ + + Playlist tracks + + + + + + + + + +
`, + props: ['provider', 'media_id'], + data() { + return { + selected: [2], + info: {}, + items: [], + offset: 0, + active: 0 + } + }, + created() { + this.$globals.windowtitle = "" + this.getInfo(); + this.getPlaylistTracks(); + this.scroll(this.Browse); + }, + methods: { + getInfo () { + const api_url = '/api/playlists/' + this.media_id + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.info = data; + }) + .catch(error => { + console.log("error", error); + }); + }, + getPlaylistTracks () { + this.$globals.loading = true + const api_url = '/api/playlists/' + this.media_id + '/tracks' + axios + .get(api_url, { params: { offset: this.offset, limit: 25, provider: this.provider}}) + .then(result => { + data = result.data; + this.items.push(...data); + this.offset += 25; + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + + }, + scroll (Browse) { + window.onscroll = () => { + let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight; + if (bottomOfWindow) { + this.getPlaylistTracks(); + } + }; + } + } +}) diff --git a/music_assistant/web/pages/queue.vue.js b/music_assistant/web/pages/queue.vue.js new file mode 100755 index 00000000..9bc25a94 --- /dev/null +++ b/music_assistant/web/pages/queue.vue.js @@ -0,0 +1,42 @@ +var Queue = Vue.component('Queue', { + template: ` +
+ + + + +
`, + props: ['player_id'], + data() { + return { + selected: [0], + info: {}, + items: [], + offset: 0, + } + }, + created() { + this.$globals.windowtitle = this.$t('queue') + this.getQueueTracks(0, 25); + }, + methods: { + + getQueueTracks (offset, limit) { + const api_url = '/api/players/' + this.player_id + '/queue' + return axios.get(api_url, { params: { offset: offset, limit: limit}}) + .then(response => { + if (response.data.length < 1 ) + return; + this.items.push(...response.data) + return this.getQueueTracks(offset+limit, 100) + }) + } + } +}) diff --git a/music_assistant/web/pages/search.vue.js b/music_assistant/web/pages/search.vue.js new file mode 100755 index 00000000..996c01ef --- /dev/null +++ b/music_assistant/web/pages/search.vue.js @@ -0,0 +1,154 @@ +var Search = Vue.component('Search', { + template: ` +
+ + + + + + + {{ $t('tracks') }} + + + + + + + + + + {{ $t('artists') }} + + + + + + + + + + {{ $t('albums') }} + + + + + + + + + + {{ $t('playlists') }} + + + + + + + + + + + +
`, + props: [], + data() { + return { + selected: [2], + artists: [], + albums: [], + tracks: [], + playlists: [], + timeout: null, + active: 0, + searchQuery: "" + } + }, + created() { + this.$globals.windowtitle = this.$t('search'); + }, + watch: { + }, + methods: { + toggle (index) { + const i = this.selected.indexOf(index) + if (i > -1) { + this.selected.splice(i, 1) + } else { + this.selected.push(index) + console.log("selected: "+ this.items[index].name); + } + }, + Search () { + this.artists = []; + this.albums = []; + this.tracks = []; + this.playlists = []; + if (this.searchQuery) { + this.$globals.loading = true; + console.log(this.searchQuery); + const api_url = '/api/search' + console.log('loading ' + api_url); + axios + .get(api_url, { + params: { + query: this.searchQuery, + online: true, + limit: 3 + } + }) + .then(result => { + data = result.data; + this.artists = data.artists; + this.albums = data.albums; + this.tracks = data.tracks; + this.playlists = data.playlists; + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + } + + }, + } +}) diff --git a/music_assistant/web/pages/trackdetails.vue.js b/music_assistant/web/pages/trackdetails.vue.js new file mode 100755 index 00000000..e8f08963 --- /dev/null +++ b/music_assistant/web/pages/trackdetails.vue.js @@ -0,0 +1,77 @@ +var TrackDetails = Vue.component('TrackDetails', { + template: ` +
+ + + Other versions + + + + + + + + + + +
`, + props: ['provider', 'media_id'], + data() { + return { + selected: [2], + info: {}, + trackversions: [], + offset: 0, + active: null, + } + }, + created() { + this.$globals.windowtitle = "" + this.getInfo(); + }, + methods: { + getInfo () { + this.$globals.loading = true; + const api_url = '/api/tracks/' + this.media_id + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.info = data; + this.getTrackVersions() + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + }, + getTrackVersions () { + const api_url = '/api/search'; + var searchstr = this.info.artists[0].name + " - " + this.info.name + axios + .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'tracks', online: true}}) + .then(result => { + data = result.data; + this.trackversions.push(...data.tracks); + this.offset += 50; + }) + .catch(error => { + console.log("error", error); + }); + }, + } +}) diff --git a/music_assistant/web/strings.js b/music_assistant/web/strings.js new file mode 100644 index 00000000..5c1df076 --- /dev/null +++ b/music_assistant/web/strings.js @@ -0,0 +1,179 @@ +const messages = { + + + en: { + // generic strings + musicassistant: "Music Assistant", + home: "Home", + artists: "Artists", + albums: "Albums", + tracks: "Tracks", + playlists: "Playlists", + radios: "Radio", + search: "Search", + settings: "Settings", + queue: "Queue", + type_to_search: "Type here to search...", + add_library: "Add to library", + remove_library: "Remove from library", + add_playlist: "Add to playlist...", + remove_playlist: "Remove from playlist", + // settings strings + conf: { + enabled: "Enabled", + base: "Generic settings", + musicproviders: "Music providers", + playerproviders: "Player providers", + player_settings: "Player settings", + homeassistant: "Home Assistant integration", + web: "Webserver", + http_streamer: "Built-in (sox based) streamer", + qobuz: "Qobuz", + spotify: "Spotify", + tunein: "TuneIn", + file: "Filesystem", + chromecast: "Chromecast", + lms: "Logitech Media Server", + pylms: "Emulated (built-in) Squeezebox support", + username: "Username", + password: "Password", + hostname: "Hostname (or IP)", + port: "Port", + hass_url: "URL to homeassistant (e.g. https://homeassistant:8123)", + hass_token: "Long Lived Access Token", + hass_publish: "Publish players to Home Assistant", + hass_player_power: "Attach player power to homeassistant entity", + hass_player_source: "Source on the homeassistant entity (optional)", + hass_player_volume: "Attach player volume to homeassistant entity", + web_ssl_cert: "Path to ssl certificate file", + web_ssl_key: "Path to ssl keyfile", + player_enabled: "Enable player", + player_name: "Custom name for this player", + player_group_with: "Group this player to another (parent)player", + player_mute_power: "Use muting as power control", + player_disable_vol: "Disable volume controls", + player_group_vol: "Apply group volume to childs (for group players only)", + player_group_pow: "Apply group power based on childs (for group players only)", + player_power_play: "Issue play command on power on", + file_prov_music_path: "Path to music files", + file_prov_playlists_path: "Path to playlists (.m3u)", + web_http_port: "HTTP port", + web_https_port: "HTTPS port", + cert_fqdn_host: "FQDN of hostname in certificate", + enable_r128_volume_normalisation: "Enable R128 volume normalization", + target_volume_lufs: "Target volume (R128 default is -23 LUFS)", + fallback_gain_correct: "Fallback gain correction if R128 readings not (yet) available", + enable_audio_cache: "Allow caching of audio to temp files", + trim_silence: "Strip silence from beginning and end of audio (temp files only!)", + http_streamer_sox_effects: "Custom sox effects to apply to audio (built-in streamer only!) See http://sox.sourceforge.net/sox.html#EFFECTS", + max_sample_rate: "Maximum sample rate this player supports, higher will be downsampled", + force_http_streamer: "Force use of built-in streamer, even if the player can handle the music provider directly", + not_grouped: "Not grouped", + conf_saved: "Configuration saved, restart app to make effective", + audio_cache_folder: "Directory to use for cache files", + audio_cache_max_size_gb: "Maximum size of the cache folder (GB)" + }, + // player strings + players: "Players", + play: "Play", + play_on: "Play on:", + play_now: "Play Now", + play_next: "Play Next", + add_queue: "Add to Queue", + show_info: "Show info", + state: { + playing: "playing", + stopped: "stopped", + paused: "paused", + off: "off" + } + }, + + nl: { + // generic strings + musicassistant: "Music Assistant", + home: "Home", + artists: "Artiesten", + albums: "Albums", + tracks: "Nummers", + playlists: "Afspeellijsten", + radios: "Radio", + search: "Zoeken", + settings: "Instellingen", + queue: "Wachtrij", + type_to_search: "Type hier om te zoeken...", + add_library: "Voeg toe aan bibliotheek", + remove_library: "Verwijder uit bibliotheek", + add_playlist: "Aan playlist toevoegen...", + remove_playlist: "Verwijder uit playlist", + // settings strings + conf: { + enabled: "Ingeschakeld", + base: "Algemene instellingen", + musicproviders: "Muziek providers", + playerproviders: "Speler providers", + player_settings: "Speler instellingen", + homeassistant: "Home Assistant integratie", + web: "Webserver", + http_streamer: "Ingebouwde (sox gebaseerde) streamer", + qobuz: "Qobuz", + spotify: "Spotify", + tunein: "TuneIn", + file: "Bestandssysteem", + chromecast: "Chromecast", + lms: "Logitech Media Server", + pylms: "Geemuleerde (ingebouwde) Squeezebox ondersteuning", + username: "Gebruikersnaam", + password: "Wachtwoord", + hostname: "Hostnaam (of IP)", + port: "Poort", + hass_url: "URL naar homeassistant (b.v. https://homeassistant:8123)", + hass_token: "Token met lange levensduur", + hass_publish: "Publiceer spelers naar Home Assistant", + hass_player_power: "Verbind speler aan/uit met homeassistant entity", + hass_player_source: "Benodigde bron op de verbonden homeassistant entity (optioneel)", + hass_player_volume: "Verbind volume van speler aan een homeassistant entity", + web_ssl_cert: "Pad naar ssl certificaat bestand", + web_ssl_key: "Pad naar ssl certificaat key bestand", + player_enabled: "Speler inschakelen", + player_name: "Aangepaste naam voor deze speler", + player_group_with: "Groupeer deze speler met een andere (hoofd)speler", + player_mute_power: "Gebruik mute als aan/uit", + player_disable_vol: "Schakel volume bediening helemaal uit", + player_group_vol: "Pas groep volume toe op onderliggende spelers (alleen groep spelers)", + player_group_pow: "Pas groep aan/uit toe op onderliggende spelers (alleen groep spelers)", + player_power_play: "Automatisch afspelen bij inschakelen", + file_prov_music_path: "Pad naar muziek bestanden", + file_prov_playlists_path: "Pad naar playlist bestanden (.m3u)", + web_http_port: "HTTP poort", + web_https_port: "HTTPS poort", + cert_fqdn_host: "Hostname (FQDN van certificaat)", + enable_r128_volume_normalisation: "Schakel R128 volume normalisatie in", + target_volume_lufs: "Doelvolume (R128 standaard is -23 LUFS)", + fallback_gain_correct: "Fallback gain correctie indien R128 meting (nog) niet beschikbaar is", + enable_audio_cache: "Sta het cachen van audio toe naar temp map", + trim_silence: "Strip stilte van begin en eind van audio (in temp bestanden)", + http_streamer_sox_effects: "Eigen sox effects toepassen op audio (alleen voor ingebouwde streamer). Zie http://sox.sourceforge.net/sox.html#EFFECTS", + max_sample_rate: "Maximale sample rate welke deze speler ondersteund, hoger wordt gedownsampled.", + force_http_streamer: "Forceer het gebruik van de ingebouwde streamer, ook al heeft de speler directe ondersteuning voor de muziek provider", + not_grouped: "Niet gegroepeerd", + conf_saved: "Configuratie is opgeslagen, herstart om actief te maken", + audio_cache_folder: "Map om te gebruiken voor cache bestanden", + audio_cache_max_size_gb: "Maximale grootte van de cache map in GB." + }, + // player strings + players: "Spelers", + play: "Afspelen", + play_on: "Afspelen op:", + play_now: "Nu afspelen", + play_next: "Speel als volgende af", + add_queue: "Voeg toe aan wachtrij", + show_info: "Bekijk informatie", + state: { + playing: "afspelen", + stopped: "gestopt", + paused: "gepauzeerd", + off: "uitgeschakeld" + } + } +} \ No newline at end of file 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 diff --git a/web/components/headermenu.vue.js b/web/components/headermenu.vue.js deleted file mode 100755 index f0e8566f..00000000 --- a/web/components/headermenu.vue.js +++ /dev/null @@ -1,68 +0,0 @@ -Vue.component("headermenu", { - template: `
- - - - - {{ item.icon }} - - - {{ item.title }} - - - - - - - -
- {{ $globals.windowtitle }} -
- - - menu - - - arrow_back - - -
- - - - menu - - - arrow_back - - - - - search - - - -
`, - props: [], - $_veeValidate: { - validator: "new" - }, - data() { - return { - menu: false, - items: [ - { title: this.$t('home'), icon: "home", path: "/" }, - { title: this.$t('artists'), icon: "person", path: "/artists" }, - { title: this.$t('albums'), icon: "album", path: "/albums" }, - { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" }, - { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" }, - { title: this.$t('radios'), icon: "radio", path: "/radios" }, - { title: this.$t('search'), icon: "search", path: "/search" }, - { title: this.$t('settings'), icon: "settings", path: "/config" } - ] - } - }, - mounted() { }, - methods: { } -}) diff --git a/web/components/infoheader.vue.js b/web/components/infoheader.vue.js deleted file mode 100644 index 9d8bc8c3..00000000 --- a/web/components/infoheader.vue.js +++ /dev/null @@ -1,135 +0,0 @@ -Vue.component("infoheader", { - template: ` - - - -
- - - - - - - - -
- -
-
- - - - - {{ info.name }} - ({{ info.version }}) - - - - - - {{ artist.name }} - - - - {{ info.artist.name }} - - - {{ info.owner }} - - - - - {{ info.album.name }} - - - -
- play_circle_outline{{ $t('play') }} - favorite_border{{ $t('add_library') }} - favorite{{ $t('remove_library') }} -
- - - -
- -
-
- -
-
- - -
- {{ tag }} -
- - - -`, - props: ['info'], - data (){ - return{} - }, - mounted() { }, - created() { }, - methods: { - getFanartImage() { - var img = ''; - if (!this.info) - return '' - if (this.info.metadata && this.info.metadata.fanart) - img = this.info.metadata.fanart; - else if (this.info.artists) - this.info.artists.forEach(function(artist) { - if (artist.metadata && artist.metadata.fanart) - img = artist.metadata.fanart; - }); - else if (this.info.artist && this.info.artist.metadata.fanart) - img = this.info.artist.metadata.fanart; - return img; - }, - getThumb() { - var img = ''; - if (!this.info) - return '' - if (this.info.metadata && this.info.metadata.image) - img = this.info.metadata.image; - else if (this.info.album && this.info.album.metadata && this.info.album.metadata.image) - img = this.info.album.metadata.image; - else if (this.info.artists) - this.info.artists.forEach(function(artist) { - if (artist.metadata && artist.metadata.image) - img = artist.metadata.image; - }); - return img; - }, - getDescription() { - var desc = ''; - if (!this.info) - return '' - if (this.info.metadata && this.info.metadata.description) - return this.info.metadata.description; - else if (this.info.metadata && this.info.metadata.biography) - return this.info.metadata.biography; - else if (this.info.metadata && this.info.metadata.copyright) - return this.info.metadata.copyright; - else if (this.info.artists) - { - this.info.artists.forEach(function(artist) { - console.log(artist.metadata.biography); - if (artist.metadata && artist.metadata.biography) - desc = artist.metadata.biography; - }); - } - return desc; - }, - } -}) diff --git a/web/components/listviewItem.vue.js b/web/components/listviewItem.vue.js deleted file mode 100755 index 687c69c9..00000000 --- a/web/components/listviewItem.vue.js +++ /dev/null @@ -1,75 +0,0 @@ -Vue.component("listviewItem", { - template: ` -
- - - - - - audiotrack - album - person - audiotrack - - - - - - {{ item.name }} ({{ item.version }}) - - - - - {{ artist.name }} - - - - {{ item.album.name }} - - - - {{ item.artist.name }} - - - - {{ item.owner }} - - - - - - - - - - {{ $t('remove_library') }} - {{ $t('add_library') }} - - - - - {{ item.duration.toString().formatDuration() }} - - - - more_vert - - - - -
- `, -props: ['item', 'index', 'totalitems', 'hideavatar', 'hidetracknum', 'hideproviders', 'hidemenu', 'hidelibrary', 'hideduration'], -data() { - return {} - }, -methods: { - } -}) diff --git a/web/components/player.vue.js b/web/components/player.vue.js deleted file mode 100755 index d8b9859b..00000000 --- a/web/components/player.vue.js +++ /dev/null @@ -1,321 +0,0 @@ -Vue.component("player", { - template: ` -
- - - - - - - - - - - - - - - - - - {{ active_player.cur_item ? active_player.cur_item.name : active_player.name }} - - - {{ artist.name }} - - - - - - - - -
- - {{ player_time_str_cur }} - - {{ player_time_str_total }} - -
- - - - - - - - - - - skip_previous - pause - play_arrow - skip_next - - - - - - - - queue_music - {{ $t('queue') }} - - - - - - - - - - - - - - - - - speaker - {{ active_player_id ? players[active_player_id].name : '' }} - - - - - - - - - - -
-
- - - - - {{ $t('players') }} - - - -
- - - {{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }} - - - {{ player.name }} - - - {{ $t('state.' + player.state) }} - - - - - - - - - - - - -
-
-
- -
- - `, - props: [], - $_veeValidate: { - validator: "new" - }, - watch: {}, - data() { - return { - menu: false, - players: {}, - active_player_id: "", - ws: null - } - }, - mounted() { }, - created() { - this.connectWS(); - this.updateProgress(); - }, - computed: { - - active_player() { - if (this.players && this.active_player_id && this.active_player_id in this.players) - return this.players[this.active_player_id]; - else - return { - name: 'no player selected', - cur_item: null, - cur_time: 0, - player_id: '', - volume_level: 0, - state: 'stopped' - }; - }, - progress() { - if (!this.active_player.cur_item) - return 0; - var total_sec = this.active_player.cur_item.duration; - var cur_sec = this.active_player.cur_time; - var cur_percent = cur_sec/total_sec*100; - return cur_percent; - }, - player_time_str_cur() { - if (!this.active_player.cur_item || !this.active_player.cur_time) - return "0:00"; - var cur_sec = this.active_player.cur_time; - return cur_sec.toString().formatDuration(); - }, - player_time_str_total() { - if (!this.active_player.cur_item) - return "0:00"; - var total_sec = this.active_player.cur_item.duration; - return total_sec.toString().formatDuration(); - } - }, - methods: { - playerCommand (cmd, cmd_opt=null, player_id=this.active_player_id) { - if (cmd_opt) - cmd = cmd + '/' + cmd_opt - cmd = 'players/' + player_id + '/cmd/' + cmd; - this.ws.send(cmd); - }, - playItem(item, queueopt) { - console.log('playItem: ' + item); - this.$globals.loading = true; - var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt; - axios - .get(api_url, { - params: { - provider: item.provider - } - }) - .then(result => { - console.log(result.data); - this.$globals.loading = false; - }) - .catch(error => { - console.log("error", error); - this.$globals.loading = false; - }); - }, - switchPlayer (new_player_id) { - this.active_player_id = new_player_id; - }, - isGroup(player_id) { - for (var item in this.players) - if (this.players[item].group_parent == player_id && this.players[item].enabled) - return true; - return false; - }, - updateProgress: function(){ - this.intervalid2 = setInterval(function(){ - if (this.active_player.state == 'playing') - this.active_player.cur_time +=1; - }.bind(this), 1000); - }, - setPlayerVolume: function(player_id, new_volume) { - this.players[player_id].volume_level = new_volume; - if (new_volume == 'up') - this.playerCommand('volume_up', null, player_id); - else if (new_volume == 'down') - this.playerCommand('volume_down', null, player_id); - else - this.playerCommand('volume_set', new_volume, player_id); - }, - togglePlayerPower: function(player_id) { - if (this.players[player_id].powered) - this.playerCommand('power_off', null, player_id); - else - this.playerCommand('power_on', null, player_id); - }, - connectWS() { - var loc = window.location, new_uri; - if (loc.protocol === "https:") { - new_uri = "wss:"; - } else { - new_uri = "ws:"; - } - new_uri += "/" + loc.host; - new_uri += loc.pathname + "ws"; - this.ws = new WebSocket(new_uri); - - this.ws.onopen = function() { - console.log('websocket connected!'); - this.ws.send('players'); - }.bind(this); - - this.ws.onmessage = function(e) { - var msg = JSON.parse(e.data); - if (msg.message == 'player changed') - { - Vue.set(this.players, msg.message_details.player_id, msg.message_details); - } - else if (msg.message == 'player removed') { - this.players[msg.message_details.player_id].enabled = false; - } - else if (msg.message == 'players') { - for (var item of msg.message_details) { - console.log("new player: " + item.player_id); - Vue.set(this.players, item.player_id, item); - } - } - else - console.log(msg); - - // select new active player - // TODO: store previous player in local storage - if (!this.active_player_id || !this.players[this.active_player_id].enabled) - for (var player_id in this.players) - if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) { - // prefer the first playing player - this.active_player_id = player_id; - break; - } - if (!this.active_player_id || !this.players[this.active_player_id].enabled) - for (var player_id in this.players) { - // fallback to just the first player - if (this.players[player_id].enabled && !this.players[player_id].group_parent) - { - this.active_player_id = player_id; - break; - } - } - }.bind(this); - - this.ws.onclose = function(e) { - console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason); - setTimeout(function() { - this.connectWS(); - }.bind(this), 5000); - }.bind(this); - - this.ws.onerror = function(err) { - console.error('Socket encountered error: ', err.message, 'Closing socket'); - this.ws.close(); - }.bind(this); - } - } -}) diff --git a/web/components/playmenu.vue.js b/web/components/playmenu.vue.js deleted file mode 100644 index 611ecc36..00000000 --- a/web/components/playmenu.vue.js +++ /dev/null @@ -1,93 +0,0 @@ -Vue.component("playmenu", { - template: ` - - - - {{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }} - {{ $t('play_on') }} {{ active_player.name }} - - - - play_circle_outline - - - {{ $t('play_now') }} - - - - - - - queue_play_next - - - {{ $t('play_next') }} - - - - - - - playlist_add - - - {{ $t('add_queue') }} - - - - - - - info - - - {{ $t('show_info') }} - - - - - - - add_circle_outline - - - {{ $t('add_playlist') }} - - - - - - - remove_circle_outline - - - {{ $t('remove_playlist') }} - - - - - - - -`, - props: ['value', 'active_player'], - data (){ - return{ - fav: true, - message: false, - hints: true, - } - }, - mounted() { }, - created() { }, - methods: { - itemClick(cmd) { - if (cmd == 'info') - this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}}) - else - this.$emit('playItem', this.$globals.playmenuitem, cmd) - // close dialog - this.$globals.showplaymenu = false; - }, - } - }) diff --git a/web/components/providericons.vue.js b/web/components/providericons.vue.js deleted file mode 100644 index 0e0124d0..00000000 --- a/web/components/providericons.vue.js +++ /dev/null @@ -1,68 +0,0 @@ -Vue.component("providericons", { - template: ` -
- - - -
- -
{{ getFileFormatDesc(provider) }}
-
- {{ provider.provider }} -
-
-
-`, - props: ['item','height','compact', 'dark', 'hiresonly'], - data (){ - return{} - }, - mounted() { }, - created() { }, - computed: { - uniqueProviders() { - var keys = []; - var qualities = []; - if (!this.item || !this.item.provider_ids) - return [] - let sorted_item_ids = this.item.provider_ids.sort((a,b) => (a.quality < b.quality) ? 1 : ((b.quality < a.quality) ? -1 : 0)); - if (!this.compact) - return sorted_item_ids; - for (provider of sorted_item_ids) { - if (!keys.includes(provider.provider)){ - qualities.push(provider); - keys.push(provider.provider); - } - } - return qualities; - } - }, - methods: { - - getFileFormatLogo(provider) { - if (provider.quality == 0) - return 'images/icons/mp3.png' - else if (provider.quality == 1) - return 'images/icons/vorbis.png' - else if (provider.quality == 2) - return 'images/icons/aac.png' - else if (provider.quality > 2) - return 'images/icons/flac.png' - }, - getFileFormatDesc(provider) { - var desc = ''; - if (provider.details) - desc += ' ' + provider.details; - return desc; - }, - getMaxQualityFormatDesc() { - var desc = ''; - if (provider.details) - desc += ' ' + provider.details; - return desc; - } - } - }) diff --git a/web/components/readmore.vue.js b/web/components/readmore.vue.js deleted file mode 100644 index 6af2fd3b..00000000 --- a/web/components/readmore.vue.js +++ /dev/null @@ -1,63 +0,0 @@ -Vue.component("read-more", { - template: ` -
- {{moreStr}}

- - - - - -
`, - props: { - moreStr: { - type: String, - default: 'read more' - }, - lessStr: { - type: String, - default: '' - }, - text: { - type: String, - required: true - }, - link: { - type: String, - default: '#' - }, - maxChars: { - type: Number, - default: 100 - } - }, - $_veeValidate: { - validator: "new" - }, - data (){ - return{ - isReadMore: false - } - }, - mounted() { }, - computed: { - formattedString(){ - var val_container = this.text; - if(this.text.length > this.maxChars){ - val_container = val_container.substring(0,this.maxChars) + '...'; - } - return(val_container); - } - }, - - methods: { - triggerReadMore(e, b){ - if(this.link == '#'){ - e.preventDefault(); - } - if(this.lessStr !== null || this.lessStr !== '') - { - this.isReadMore = b; - } - } - } - }) diff --git a/web/components/searchbox.vue.js b/web/components/searchbox.vue.js deleted file mode 100644 index 1570ab6c..00000000 --- a/web/components/searchbox.vue.js +++ /dev/null @@ -1,50 +0,0 @@ -Vue.component("searchbox", { - template: ` - - - - - `, - data () { - return { - searchQuery: "", - } - }, - props: ['value'], - mounted () { - this.searchQuery = "" // TODO: set to last searchquery ? - }, - watch: { - searchQuery: { - handler: _.debounce(function (val) { - this.onSearch(); - // if (this.searchQuery) - // this.$globals.showsearchbox = false; - }, 1000) - }, - newSearchQuery (val) { - this.searchQuery = val - } - }, - computed: {}, - methods: { - onSearch () { - //this.$emit('clickSearch', this.searchQuery) - console.log(this.searchQuery); - router.push({ path: '/search', query: {searchQuery: this.searchQuery}}); - }, - } -}) -/* */ \ No newline at end of file diff --git a/web/components/volumecontrol.vue.js b/web/components/volumecontrol.vue.js deleted file mode 100644 index 7ef20ab8..00000000 --- a/web/components/volumecontrol.vue.js +++ /dev/null @@ -1,76 +0,0 @@ -Vue.component("volumecontrol", { - template: ` - - - - - {{ isGroup ? 'speaker_group' : 'speaker' }} - - - {{ players[player_id].name }} - {{ $t('state.' + players[player_id].state) }} - - - - - - - - - -
- - - - - - -
- {{ players[child_id].name }} -
-
- - power_settings_new - -
- - - -
-
- -
- -
- - -
-`, - props: ['value', 'players', 'player_id'], - data (){ - return{ - } - }, - computed: { - volumePlayerIds() { - var volume_ids = [this.player_id]; - for (var player_id in this.players) - if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled) - volume_ids.push(player_id); - return volume_ids; - }, - isGroup() { - return this.volumePlayerIds.length > 1; - } - }, - mounted() { }, - created() { }, - methods: {} - }) diff --git a/web/css/nprogress.css b/web/css/nprogress.css deleted file mode 100644 index e4cb811e..00000000 --- a/web/css/nprogress.css +++ /dev/null @@ -1,74 +0,0 @@ -/* Make clicks pass-through */ -#nprogress { - pointer-events: none; - } - - #nprogress .bar { - background: rgb(119, 205, 255); - - position: fixed; - z-index: 1031; - top: 0; - left: 0; - - width: 100%; - height: 10px; - } - - /* Fancy blur effect */ - #nprogress .peg { - display: block; - position: absolute; - right: 0px; - width: 100px; - height: 100%; - box-shadow: 0 0 10px #29d, 0 0 5px #29d; - opacity: 1.0; - - -webkit-transform: rotate(3deg) translate(0px, -4px); - -ms-transform: rotate(3deg) translate(0px, -4px); - transform: rotate(3deg) translate(0px, -4px); - } - - /* Remove these to get rid of the spinner */ - #nprogress .spinner { - display: block; - position: fixed; - z-index: 1031; - top: 15px; - right: 15px; - } - - #nprogress .spinner-icon { - width: 18px; - height: 18px; - box-sizing: border-box; - - border: solid 2px transparent; - border-top-color: #29d; - border-left-color: #29d; - border-radius: 50%; - - -webkit-animation: nprogress-spinner 400ms linear infinite; - animation: nprogress-spinner 400ms linear infinite; - } - - .nprogress-custom-parent { - overflow: hidden; - position: relative; - } - - .nprogress-custom-parent #nprogress .spinner, - .nprogress-custom-parent #nprogress .bar { - position: absolute; - } - - @-webkit-keyframes nprogress-spinner { - 0% { -webkit-transform: rotate(0deg); } - 100% { -webkit-transform: rotate(360deg); } - } - @keyframes nprogress-spinner { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - \ No newline at end of file diff --git a/web/css/site.css b/web/css/site.css deleted file mode 100755 index 2071f04a..00000000 --- a/web/css/site.css +++ /dev/null @@ -1,73 +0,0 @@ -[v-cloak] { - display: none; -} - -.navbar { - margin-bottom: 20px; -} - -/*.body-content { - padding-left: 25px; - padding-right: 25px; -}*/ - -input, -select { - max-width: 30em; -} - -.fade-enter-active, -.fade-leave-active { - transition: opacity .5s; -} - -.fade-enter, -.fade-leave-to -/* .fade-leave-active below version 2.1.8 */ - - { - opacity: 0; -} - -.bounce-enter-active { - animation: bounce-in .5s; -} - -.bounce-leave-active { - animation: bounce-in .5s reverse; -} - -@keyframes bounce-in { - 0% { - transform: scale(0); - } - 50% { - transform: scale(1.5); - } - 100% { - transform: scale(1); - } -} - -.slide-fade-enter-active { - transition: all .3s ease; -} - -.slide-fade-leave-active { - transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0); -} - -.slide-fade-enter, -.slide-fade-leave-to -/* .slide-fade-leave-active below version 2.1.8 */ - - { - transform: translateX(10px); - opacity: 0; -} - -.vertical-btn { - display: flex; - flex-direction: column; - align-items: center; - } \ No newline at end of file diff --git a/web/css/vue-loading.css b/web/css/vue-loading.css deleted file mode 100644 index 6d62f807..00000000 --- a/web/css/vue-loading.css +++ /dev/null @@ -1,36 +0,0 @@ -.vld-overlay { - bottom: 0; - left: 0; - position: absolute; - right: 0; - top: 0; - align-items: center; - display: none; - justify-content: center; - overflow: hidden; - z-index: 1 -} - -.vld-overlay.is-active { - display: flex -} - -.vld-overlay.is-full-page { - z-index: 999; - position: fixed -} - -.vld-overlay .vld-background { - bottom: 0; - left: 0; - position: absolute; - right: 0; - top: 0; - background: #000; - opacity: 0.7 -} - -.vld-overlay .vld-icon, .vld-parent { - position: relative -} - diff --git a/web/images/default_artist.png b/web/images/default_artist.png deleted file mode 100644 index a530d5b4..00000000 Binary files a/web/images/default_artist.png and /dev/null differ diff --git a/web/images/icons/aac.png b/web/images/icons/aac.png deleted file mode 100644 index 7dafab27..00000000 Binary files a/web/images/icons/aac.png and /dev/null differ diff --git a/web/images/icons/chromecast.png b/web/images/icons/chromecast.png deleted file mode 100644 index f7d2a46f..00000000 Binary files a/web/images/icons/chromecast.png and /dev/null differ diff --git a/web/images/icons/file.png b/web/images/icons/file.png deleted file mode 100644 index bd2df042..00000000 Binary files a/web/images/icons/file.png and /dev/null differ diff --git a/web/images/icons/flac.png b/web/images/icons/flac.png deleted file mode 100644 index 33e1f175..00000000 Binary files a/web/images/icons/flac.png and /dev/null differ diff --git a/web/images/icons/hires.png b/web/images/icons/hires.png deleted file mode 100644 index a398c6e5..00000000 Binary files a/web/images/icons/hires.png and /dev/null differ diff --git a/web/images/icons/homeassistant.png b/web/images/icons/homeassistant.png deleted file mode 100644 index 5f28d69e..00000000 Binary files a/web/images/icons/homeassistant.png and /dev/null differ diff --git a/web/images/icons/http_streamer.png b/web/images/icons/http_streamer.png deleted file mode 100644 index c35c9839..00000000 Binary files a/web/images/icons/http_streamer.png and /dev/null differ diff --git a/web/images/icons/icon-128x128.png b/web/images/icons/icon-128x128.png deleted file mode 100644 index 01363c8b..00000000 Binary files a/web/images/icons/icon-128x128.png and /dev/null differ diff --git a/web/images/icons/icon-256x256.png b/web/images/icons/icon-256x256.png deleted file mode 100644 index 4c36796d..00000000 Binary files a/web/images/icons/icon-256x256.png and /dev/null differ diff --git a/web/images/icons/icon-apple.png b/web/images/icons/icon-apple.png deleted file mode 100644 index 67d26d53..00000000 Binary files a/web/images/icons/icon-apple.png and /dev/null differ diff --git a/web/images/icons/info_gradient.jpg b/web/images/icons/info_gradient.jpg deleted file mode 100644 index 9d0c0e3b..00000000 Binary files a/web/images/icons/info_gradient.jpg and /dev/null differ diff --git a/web/images/icons/lms.png b/web/images/icons/lms.png deleted file mode 100644 index 6dd9b06a..00000000 Binary files a/web/images/icons/lms.png and /dev/null differ diff --git a/web/images/icons/mp3.png b/web/images/icons/mp3.png deleted file mode 100644 index b894bda2..00000000 Binary files a/web/images/icons/mp3.png and /dev/null differ diff --git a/web/images/icons/qobuz.png b/web/images/icons/qobuz.png deleted file mode 100644 index 9d7b726c..00000000 Binary files a/web/images/icons/qobuz.png and /dev/null differ diff --git a/web/images/icons/spotify.png b/web/images/icons/spotify.png deleted file mode 100644 index 805f5c71..00000000 Binary files a/web/images/icons/spotify.png and /dev/null differ diff --git a/web/images/icons/squeezebox.png b/web/images/icons/squeezebox.png deleted file mode 100644 index 18531d79..00000000 Binary files a/web/images/icons/squeezebox.png and /dev/null differ diff --git a/web/images/icons/tunein.png b/web/images/icons/tunein.png deleted file mode 100644 index 3352c29c..00000000 Binary files a/web/images/icons/tunein.png and /dev/null differ diff --git a/web/images/icons/vorbis.png b/web/images/icons/vorbis.png deleted file mode 100644 index c6d69145..00000000 Binary files a/web/images/icons/vorbis.png and /dev/null differ diff --git a/web/images/icons/web.png b/web/images/icons/web.png deleted file mode 100644 index d3b5724e..00000000 Binary files a/web/images/icons/web.png and /dev/null differ diff --git a/web/images/info_gradient.jpg b/web/images/info_gradient.jpg deleted file mode 100644 index 9d0c0e3b..00000000 Binary files a/web/images/info_gradient.jpg and /dev/null differ diff --git a/web/index.html b/web/index.html deleted file mode 100755 index dcef414f..00000000 --- a/web/index.html +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - Music Assistant - - - - - - - - - - - - - -
- - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/web/lib/vue-loading-overlay.js b/web/lib/vue-loading-overlay.js deleted file mode 100644 index b3b9da10..00000000 --- a/web/lib/vue-loading-overlay.js +++ /dev/null @@ -1 +0,0 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define("VueLoading",[],e):"object"==typeof exports?exports.VueLoading=e():t.VueLoading=e()}("undefined"!=typeof self?self:this,function(){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var r=e[n]={i:n,l:!1,exports:{}};return t[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)i.d(n,r,function(e){return t[e]}.bind(null,r));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=1)}([function(t,e,i){},function(t,e,i){"use strict";i.r(e);var n="undefined"!=typeof window?window.HTMLElement:Object,r={mounted:function(){document.addEventListener("focusin",this.focusIn)},methods:{focusIn:function(t){if(this.isActive&&t.target!==this.$el&&!this.$el.contains(t.target)){var e=this.container?this.container:this.isFullPage?null:this.$el.parentElement;(this.isFullPage||e&&e.contains(t.target))&&(t.preventDefault(),this.$el.focus())}}},beforeDestroy:function(){document.removeEventListener("focusin",this.focusIn)}};function a(t,e,i,n,r,a,o,s){var u,l="function"==typeof t?t.options:t;if(e&&(l.render=e,l.staticRenderFns=i,l._compiled=!0),n&&(l.functional=!0),a&&(l._scopeId="data-v-"+a),o?(u=function(t){(t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),r&&r.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(o)},l._ssrRegister=u):r&&(u=s?function(){r.call(this,this.$root.$options.shadowRoot)}:r),u)if(l.functional){l._injectStyles=u;var c=l.render;l.render=function(t,e){return u.call(e),c(t,e)}}else{var d=l.beforeCreate;l.beforeCreate=d?[].concat(d,u):[u]}return{exports:t,options:l}}var o=a({name:"spinner",props:{color:{type:String,default:"#000"},height:{type:Number,default:64},width:{type:Number,default:64}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 38 38",xmlns:"http://www.w3.org/2000/svg",width:this.width,height:this.height,stroke:this.color}},[e("g",{attrs:{fill:"none","fill-rule":"evenodd"}},[e("g",{attrs:{transform:"translate(1 1)","stroke-width":"2"}},[e("circle",{attrs:{"stroke-opacity":".25",cx:"18",cy:"18",r:"18"}}),e("path",{attrs:{d:"M36 18c0-9.94-8.06-18-18-18"}},[e("animateTransform",{attrs:{attributeName:"transform",type:"rotate",from:"0 18 18",to:"360 18 18",dur:"0.8s",repeatCount:"indefinite"}})],1)])])])},[],!1,null,null,null).exports,s=a({name:"dots",props:{color:{type:String,default:"#000"},height:{type:Number,default:240},width:{type:Number,default:60}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{viewBox:"0 0 120 30",xmlns:"http://www.w3.org/2000/svg",fill:this.color,width:this.width,height:this.height}},[e("circle",{attrs:{cx:"15",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"60",cy:"15",r:"9","fill-opacity":"0.3"}},[e("animate",{attrs:{attributeName:"r",from:"9",to:"9",begin:"0s",dur:"0.8s",values:"9;15;9",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"0.5",to:"0.5",begin:"0s",dur:"0.8s",values:".5;1;.5",calcMode:"linear",repeatCount:"indefinite"}})]),e("circle",{attrs:{cx:"105",cy:"15",r:"15"}},[e("animate",{attrs:{attributeName:"r",from:"15",to:"15",begin:"0s",dur:"0.8s",values:"15;9;15",calcMode:"linear",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"fill-opacity",from:"1",to:"1",begin:"0s",dur:"0.8s",values:"1;.5;1",calcMode:"linear",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,u=a({name:"bars",props:{color:{type:String,default:"#000"},height:{type:Number,default:40},width:{type:Number,default:40}}},function(){var t=this.$createElement,e=this._self._c||t;return e("svg",{attrs:{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 30 30",height:this.height,width:this.width,fill:this.color}},[e("rect",{attrs:{x:"0",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"10",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.15s",dur:"0.6s",repeatCount:"indefinite"}})]),e("rect",{attrs:{x:"20",y:"13",width:"4",height:"5"}},[e("animate",{attrs:{attributeName:"height",attributeType:"XML",values:"5;21;5",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}}),e("animate",{attrs:{attributeName:"y",attributeType:"XML",values:"13; 5; 13",begin:"0.3s",dur:"0.6s",repeatCount:"indefinite"}})])])},[],!1,null,null,null).exports,l=a({name:"vue-loading",mixins:[r],props:{active:Boolean,programmatic:Boolean,container:[Object,Function,n],isFullPage:{type:Boolean,default:!0},transition:{type:String,default:"fade"},canCancel:Boolean,onCancel:{type:Function,default:function(){}},color:String,backgroundColor:String,opacity:Number,width:Number,height:Number,zIndex:Number,loader:{type:String,default:"spinner"}},data:function(){return{isActive:this.active}},components:{Spinner:o,Dots:s,Bars:u},beforeMount:function(){this.programmatic&&(this.container?(this.isFullPage=!1,this.container.appendChild(this.$el)):document.body.appendChild(this.$el))},mounted:function(){this.programmatic&&(this.isActive=!0),document.addEventListener("keyup",this.keyPress)},methods:{cancel:function(){this.canCancel&&this.isActive&&(this.hide(),this.onCancel.apply(null,arguments))},hide:function(){var t=this;this.$emit("hide"),this.$emit("update:active",!1),this.programmatic&&(this.isActive=!1,setTimeout(function(){var e;t.$destroy(),void 0!==(e=t.$el).remove?e.remove():e.parentNode.removeChild(e)},150))},keyPress:function(t){27===t.keyCode&&this.cancel()}},watch:{active:function(t){this.isActive=t}},beforeDestroy:function(){document.removeEventListener("keyup",this.keyPress)}},function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("transition",{attrs:{name:t.transition}},[i("div",{directives:[{name:"show",rawName:"v-show",value:t.isActive,expression:"isActive"}],staticClass:"vld-overlay is-active",class:{"is-full-page":t.isFullPage},style:{zIndex:this.zIndex},attrs:{tabindex:"0","aria-busy":t.isActive,"aria-label":"Loading"}},[i("div",{staticClass:"vld-background",style:{background:this.backgroundColor,opacity:this.opacity},on:{click:function(e){return e.preventDefault(),t.cancel(e)}}}),i("div",{staticClass:"vld-icon"},[t._t("before"),t._t("default",[i(t.loader,{tag:"component",attrs:{color:t.color,width:t.width,height:t.height}})]),t._t("after")],2)])])},[],!1,null,null,null).exports,c=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return{show:function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:e,r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:i,a=Object.assign({},e,n,{programmatic:!0}),o=new(t.extend(l))({el:document.createElement("div"),propsData:a}),s=Object.assign({},i,r);return Object.keys(s).map(function(t){o.$slots[t]=s[t]}),o}}};i(0);l.install=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},n=c(t,e,i);t.$loading=n,t.prototype.$loading=n};e.default=l}]).default}); \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json deleted file mode 100755 index 6a3c4b97..00000000 --- a/web/manifest.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "Music Assistant", - "short_name": "MusicAssistant", - "theme_color": "#2196f3", - "background_color": "#2196f3", - "display": "standalone", - "Scope": "/", - "start_url": "/", - "icons": [ - { - "src": "images/icons/icon-128x128.png", - "sizes": "128x128", - "type": "image/png" - }, - { - "src": "images/icons/icon-256x256.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "splash_pages": null -} \ No newline at end of file diff --git a/web/pages/albumdetails.vue.js b/web/pages/albumdetails.vue.js deleted file mode 100755 index 4f60a91e..00000000 --- a/web/pages/albumdetails.vue.js +++ /dev/null @@ -1,107 +0,0 @@ -var AlbumDetails = Vue.component('AlbumDetails', { - template: ` -
- - - Album tracks - - - - - - - - - - Versions - - - - - - - - - - -
`, - props: ['provider', 'media_id'], - data() { - return { - selected: [2], - info: {}, - albumtracks: [], - albumversions: [], - offset: 0, - active: null, - } - }, - created() { - this.$globals.windowtitle = "" - this.getInfo(); - this.getAlbumTracks(); - }, - methods: { - getInfo () { - this.$globals.loading = true; - const api_url = '/api/albums/' + this.media_id - axios - .get(api_url, { params: { provider: this.provider }}) - .then(result => { - data = result.data; - this.info = data; - this.getAlbumVersions() - this.$globals.loading = false; - }) - .catch(error => { - console.log("error", error); - }); - }, - getAlbumTracks () { - const api_url = '/api/albums/' + this.media_id + '/tracks' - axios - .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider}}) - .then(result => { - data = result.data; - this.albumtracks.push(...data); - this.offset += 50; - }) - .catch(error => { - console.log("error", error); - }); - }, - getAlbumVersions () { - const api_url = '/api/search'; - var searchstr = this.info.artist.name + " - " + this.info.name - axios - .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'albums', online: true}}) - .then(result => { - data = result.data; - this.albumversions.push(...data.albums); - this.offset += 50; - }) - .catch(error => { - console.log("error", error); - }); - }, - } -}) diff --git a/web/pages/artistdetails.vue.js b/web/pages/artistdetails.vue.js deleted file mode 100755 index 46600303..00000000 --- a/web/pages/artistdetails.vue.js +++ /dev/null @@ -1,127 +0,0 @@ -var ArtistDetails = Vue.component('ArtistDetails', { - template: ` -
- - - Top tracks - - - - - - - - - - Albums - - - - - - - - - -
`, - props: ['media_id', 'provider'], - data() { - return { - selected: [2], - info: {}, - toptracks: [], - artistalbums: [], - bg_image: "../images/info_gradient.jpg", - active: null, - playmenu: false, - playmenuitem: null - } - }, - created() { - this.$globals.windowtitle = "" - this.getInfo(); - }, - methods: { - getFanartImage() { - if (this.info.metadata && this.info.metadata.fanart) - return this.info.metadata.fanart; - else if (this.info.artists) - for (artist in this.info.artists) - if (artist.info.metadata && artist.data.metadata.fanart) - return artist.metadata.fanart; - }, - getInfo (lazy=true) { - this.$globals.loading = true; - const api_url = '/api/artists/' + this.media_id; - console.log(api_url + ' - ' + this.provider); - axios - .get(api_url, { params: { lazy: lazy, provider: this.provider }}) - .then(result => { - data = result.data; - this.info = data; - this.$globals.loading = false; - if (data.is_lazy == true) - // refresh the info if we got a lazy object - this.timeout1 = setTimeout(function(){ - this.getInfo(false); - }.bind(this), 1000); - else { - this.getArtistTopTracks(); - this.getArtistAlbums(); - } - }) - .catch(error => { - console.log("error", error); - this.$globals.loading = false; - }); - }, - getArtistTopTracks () { - - const api_url = '/api/artists/' + this.media_id + '/toptracks' - axios - .get(api_url, { params: { provider: this.provider }}) - .then(result => { - data = result.data; - this.toptracks = data; - }) - .catch(error => { - console.log("error", error); - }); - - }, - getArtistAlbums () { - const api_url = '/api/artists/' + this.media_id + '/albums' - console.log('loading ' + api_url); - axios - .get(api_url, { params: { provider: this.provider }}) - .then(result => { - data = result.data; - this.artistalbums = data; - }) - .catch(error => { - console.log("error", error); - }); - }, - } -}) diff --git a/web/pages/browse.vue.js b/web/pages/browse.vue.js deleted file mode 100755 index 49bcdc8b..00000000 --- a/web/pages/browse.vue.js +++ /dev/null @@ -1,61 +0,0 @@ -var Browse = Vue.component('Browse', { - template: ` -
- - - - -
- `, - props: ['mediatype', 'provider'], - data() { - return { - selected: [2], - items: [], - offset: 0 - } - }, - created() { - this.showavatar = true; - mediatitle = - this.$globals.windowtitle = this.$t(this.mediatype) - this.scroll(this.Browse); - this.getItems(); - }, - methods: { - getItems () { - this.$globals.loading = true - const api_url = '/api/' + this.mediatype; - axios - .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider }}) - .then(result => { - data = result.data; - this.items.push(...data); - this.offset += 50; - this.$globals.loading = false; - }) - .catch(error => { - console.log("error", error); - this.showProgress = false; - }); - }, - scroll (Browse) { - window.onscroll = () => { - let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight; - - if (bottomOfWindow) { - this.getItems(); - } - }; - } - } -}) diff --git a/web/pages/config.vue.js b/web/pages/config.vue.js deleted file mode 100755 index c4164db2..00000000 --- a/web/pages/config.vue.js +++ /dev/null @@ -1,152 +0,0 @@ -var Config = Vue.component('Config', { - template: ` -
- - - {{ $t('conf.'+conf_key) }} - - - - - - -
- - - - - - -
- -
-
- - - - - -
- - - - - - - - - - - - -
-
- - - -
- -
-
-
- -
- - -
- `, - props: [], - data() { - return { - conf: {}, - players: {}, - active: 0, - sample_rates: [44100, 48000, 88200, 96000, 192000, 384000] - } - }, - computed: { - playersLst() - { - var playersLst = []; - playersLst.push({id: null, name: this.$t('conf.'+'not_grouped')}) - for (player_id in this.players) - playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name}) - return playersLst; - } - }, - watch: { - 'conf': { - handler: _.debounce(function (val, oldVal) { - if (oldVal.base) { - console.log("save config needed!"); - this.saveConfig(); - this.$toasted.show(this.$t('conf.conf_saved')) - } - }, 5000), - deep: true - } - }, - created() { - this.$globals.windowtitle = this.$t('settings'); - this.getPlayers(); - this.getConfig(); - console.log(this.$globals.all_players); - }, - methods: { - getConfig () { - axios - .get('/api/config') - .then(result => { - this.conf = result.data; - }) - .catch(error => { - console.log("error", error); - }); - }, - saveConfig () { - axios - .post('/api/config', this.conf) - .then(result => { - console.log(result); - }) - .catch(error => { - console.log("error", error); - }); - }, - getPlayers () { - const api_url = '/api/players'; - axios - .get(api_url) - .then(result => { - for (var item of result.data) - this.$set(this.players, item.player_id, item) - }) - .catch(error => { - console.log("error", error); - this.showProgress = false; - }); - }, - } -}) diff --git a/web/pages/home.vue.js b/web/pages/home.vue.js deleted file mode 100755 index 91c0b33d..00000000 --- a/web/pages/home.vue.js +++ /dev/null @@ -1,43 +0,0 @@ -var home = Vue.component("Home", { - template: ` -
- - - - {{ item.icon }} - - - {{ item.title }} - - - -
-`, - props: ["title"], - $_veeValidate: { - validator: "new" - }, - data() { - return { - result: null, - showProgress: false - }; - }, - created() { - this.$globals.windowtitle = this.$t('musicassistant'); - this.items= [ - { title: this.$t('artists'), icon: "person", path: "/artists" }, - { title: this.$t('albums'), icon: "album", path: "/albums" }, - { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" }, - { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" }, - { title: this.$t('search'), icon: "search", path: "/search" } - ] - }, - methods: { - click (item) { - console.log("selected: "+ item.path); - router.push({path: item.path}) - } - } -}); diff --git a/web/pages/playlistdetails.vue.js b/web/pages/playlistdetails.vue.js deleted file mode 100755 index b9c617d4..00000000 --- a/web/pages/playlistdetails.vue.js +++ /dev/null @@ -1,83 +0,0 @@ -var PlaylistDetails = Vue.component('PlaylistDetails', { - template: ` -
- - - Playlist tracks - - - - - - - - - -
`, - props: ['provider', 'media_id'], - data() { - return { - selected: [2], - info: {}, - items: [], - offset: 0, - active: 0 - } - }, - created() { - this.$globals.windowtitle = "" - this.getInfo(); - this.getPlaylistTracks(); - this.scroll(this.Browse); - }, - methods: { - getInfo () { - const api_url = '/api/playlists/' + this.media_id - axios - .get(api_url, { params: { provider: this.provider }}) - .then(result => { - data = result.data; - this.info = data; - }) - .catch(error => { - console.log("error", error); - }); - }, - getPlaylistTracks () { - this.$globals.loading = true - const api_url = '/api/playlists/' + this.media_id + '/tracks' - axios - .get(api_url, { params: { offset: this.offset, limit: 25, provider: this.provider}}) - .then(result => { - data = result.data; - this.items.push(...data); - this.offset += 25; - this.$globals.loading = false; - }) - .catch(error => { - console.log("error", error); - }); - - }, - scroll (Browse) { - window.onscroll = () => { - let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight; - if (bottomOfWindow) { - this.getPlaylistTracks(); - } - }; - } - } -}) diff --git a/web/pages/queue.vue.js b/web/pages/queue.vue.js deleted file mode 100755 index 9bc25a94..00000000 --- a/web/pages/queue.vue.js +++ /dev/null @@ -1,42 +0,0 @@ -var Queue = Vue.component('Queue', { - template: ` -
- - - - -
`, - props: ['player_id'], - data() { - return { - selected: [0], - info: {}, - items: [], - offset: 0, - } - }, - created() { - this.$globals.windowtitle = this.$t('queue') - this.getQueueTracks(0, 25); - }, - methods: { - - getQueueTracks (offset, limit) { - const api_url = '/api/players/' + this.player_id + '/queue' - return axios.get(api_url, { params: { offset: offset, limit: limit}}) - .then(response => { - if (response.data.length < 1 ) - return; - this.items.push(...response.data) - return this.getQueueTracks(offset+limit, 100) - }) - } - } -}) diff --git a/web/pages/search.vue.js b/web/pages/search.vue.js deleted file mode 100755 index 996c01ef..00000000 --- a/web/pages/search.vue.js +++ /dev/null @@ -1,154 +0,0 @@ -var Search = Vue.component('Search', { - template: ` -
- - - - - - - {{ $t('tracks') }} - - - - - - - - - - {{ $t('artists') }} - - - - - - - - - - {{ $t('albums') }} - - - - - - - - - - {{ $t('playlists') }} - - - - - - - - - - - -
`, - props: [], - data() { - return { - selected: [2], - artists: [], - albums: [], - tracks: [], - playlists: [], - timeout: null, - active: 0, - searchQuery: "" - } - }, - created() { - this.$globals.windowtitle = this.$t('search'); - }, - watch: { - }, - methods: { - toggle (index) { - const i = this.selected.indexOf(index) - if (i > -1) { - this.selected.splice(i, 1) - } else { - this.selected.push(index) - console.log("selected: "+ this.items[index].name); - } - }, - Search () { - this.artists = []; - this.albums = []; - this.tracks = []; - this.playlists = []; - if (this.searchQuery) { - this.$globals.loading = true; - console.log(this.searchQuery); - const api_url = '/api/search' - console.log('loading ' + api_url); - axios - .get(api_url, { - params: { - query: this.searchQuery, - online: true, - limit: 3 - } - }) - .then(result => { - data = result.data; - this.artists = data.artists; - this.albums = data.albums; - this.tracks = data.tracks; - this.playlists = data.playlists; - this.$globals.loading = false; - }) - .catch(error => { - console.log("error", error); - }); - } - - }, - } -}) diff --git a/web/pages/trackdetails.vue.js b/web/pages/trackdetails.vue.js deleted file mode 100755 index e8f08963..00000000 --- a/web/pages/trackdetails.vue.js +++ /dev/null @@ -1,77 +0,0 @@ -var TrackDetails = Vue.component('TrackDetails', { - template: ` -
- - - Other versions - - - - - - - - - - -
`, - props: ['provider', 'media_id'], - data() { - return { - selected: [2], - info: {}, - trackversions: [], - offset: 0, - active: null, - } - }, - created() { - this.$globals.windowtitle = "" - this.getInfo(); - }, - methods: { - getInfo () { - this.$globals.loading = true; - const api_url = '/api/tracks/' + this.media_id - axios - .get(api_url, { params: { provider: this.provider }}) - .then(result => { - data = result.data; - this.info = data; - this.getTrackVersions() - this.$globals.loading = false; - }) - .catch(error => { - console.log("error", error); - }); - }, - getTrackVersions () { - const api_url = '/api/search'; - var searchstr = this.info.artists[0].name + " - " + this.info.name - axios - .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'tracks', online: true}}) - .then(result => { - data = result.data; - this.trackversions.push(...data.tracks); - this.offset += 50; - }) - .catch(error => { - console.log("error", error); - }); - }, - } -}) diff --git a/web/strings.js b/web/strings.js deleted file mode 100644 index 5c1df076..00000000 --- a/web/strings.js +++ /dev/null @@ -1,179 +0,0 @@ -const messages = { - - - en: { - // generic strings - musicassistant: "Music Assistant", - home: "Home", - artists: "Artists", - albums: "Albums", - tracks: "Tracks", - playlists: "Playlists", - radios: "Radio", - search: "Search", - settings: "Settings", - queue: "Queue", - type_to_search: "Type here to search...", - add_library: "Add to library", - remove_library: "Remove from library", - add_playlist: "Add to playlist...", - remove_playlist: "Remove from playlist", - // settings strings - conf: { - enabled: "Enabled", - base: "Generic settings", - musicproviders: "Music providers", - playerproviders: "Player providers", - player_settings: "Player settings", - homeassistant: "Home Assistant integration", - web: "Webserver", - http_streamer: "Built-in (sox based) streamer", - qobuz: "Qobuz", - spotify: "Spotify", - tunein: "TuneIn", - file: "Filesystem", - chromecast: "Chromecast", - lms: "Logitech Media Server", - pylms: "Emulated (built-in) Squeezebox support", - username: "Username", - password: "Password", - hostname: "Hostname (or IP)", - port: "Port", - hass_url: "URL to homeassistant (e.g. https://homeassistant:8123)", - hass_token: "Long Lived Access Token", - hass_publish: "Publish players to Home Assistant", - hass_player_power: "Attach player power to homeassistant entity", - hass_player_source: "Source on the homeassistant entity (optional)", - hass_player_volume: "Attach player volume to homeassistant entity", - web_ssl_cert: "Path to ssl certificate file", - web_ssl_key: "Path to ssl keyfile", - player_enabled: "Enable player", - player_name: "Custom name for this player", - player_group_with: "Group this player to another (parent)player", - player_mute_power: "Use muting as power control", - player_disable_vol: "Disable volume controls", - player_group_vol: "Apply group volume to childs (for group players only)", - player_group_pow: "Apply group power based on childs (for group players only)", - player_power_play: "Issue play command on power on", - file_prov_music_path: "Path to music files", - file_prov_playlists_path: "Path to playlists (.m3u)", - web_http_port: "HTTP port", - web_https_port: "HTTPS port", - cert_fqdn_host: "FQDN of hostname in certificate", - enable_r128_volume_normalisation: "Enable R128 volume normalization", - target_volume_lufs: "Target volume (R128 default is -23 LUFS)", - fallback_gain_correct: "Fallback gain correction if R128 readings not (yet) available", - enable_audio_cache: "Allow caching of audio to temp files", - trim_silence: "Strip silence from beginning and end of audio (temp files only!)", - http_streamer_sox_effects: "Custom sox effects to apply to audio (built-in streamer only!) See http://sox.sourceforge.net/sox.html#EFFECTS", - max_sample_rate: "Maximum sample rate this player supports, higher will be downsampled", - force_http_streamer: "Force use of built-in streamer, even if the player can handle the music provider directly", - not_grouped: "Not grouped", - conf_saved: "Configuration saved, restart app to make effective", - audio_cache_folder: "Directory to use for cache files", - audio_cache_max_size_gb: "Maximum size of the cache folder (GB)" - }, - // player strings - players: "Players", - play: "Play", - play_on: "Play on:", - play_now: "Play Now", - play_next: "Play Next", - add_queue: "Add to Queue", - show_info: "Show info", - state: { - playing: "playing", - stopped: "stopped", - paused: "paused", - off: "off" - } - }, - - nl: { - // generic strings - musicassistant: "Music Assistant", - home: "Home", - artists: "Artiesten", - albums: "Albums", - tracks: "Nummers", - playlists: "Afspeellijsten", - radios: "Radio", - search: "Zoeken", - settings: "Instellingen", - queue: "Wachtrij", - type_to_search: "Type hier om te zoeken...", - add_library: "Voeg toe aan bibliotheek", - remove_library: "Verwijder uit bibliotheek", - add_playlist: "Aan playlist toevoegen...", - remove_playlist: "Verwijder uit playlist", - // settings strings - conf: { - enabled: "Ingeschakeld", - base: "Algemene instellingen", - musicproviders: "Muziek providers", - playerproviders: "Speler providers", - player_settings: "Speler instellingen", - homeassistant: "Home Assistant integratie", - web: "Webserver", - http_streamer: "Ingebouwde (sox gebaseerde) streamer", - qobuz: "Qobuz", - spotify: "Spotify", - tunein: "TuneIn", - file: "Bestandssysteem", - chromecast: "Chromecast", - lms: "Logitech Media Server", - pylms: "Geemuleerde (ingebouwde) Squeezebox ondersteuning", - username: "Gebruikersnaam", - password: "Wachtwoord", - hostname: "Hostnaam (of IP)", - port: "Poort", - hass_url: "URL naar homeassistant (b.v. https://homeassistant:8123)", - hass_token: "Token met lange levensduur", - hass_publish: "Publiceer spelers naar Home Assistant", - hass_player_power: "Verbind speler aan/uit met homeassistant entity", - hass_player_source: "Benodigde bron op de verbonden homeassistant entity (optioneel)", - hass_player_volume: "Verbind volume van speler aan een homeassistant entity", - web_ssl_cert: "Pad naar ssl certificaat bestand", - web_ssl_key: "Pad naar ssl certificaat key bestand", - player_enabled: "Speler inschakelen", - player_name: "Aangepaste naam voor deze speler", - player_group_with: "Groupeer deze speler met een andere (hoofd)speler", - player_mute_power: "Gebruik mute als aan/uit", - player_disable_vol: "Schakel volume bediening helemaal uit", - player_group_vol: "Pas groep volume toe op onderliggende spelers (alleen groep spelers)", - player_group_pow: "Pas groep aan/uit toe op onderliggende spelers (alleen groep spelers)", - player_power_play: "Automatisch afspelen bij inschakelen", - file_prov_music_path: "Pad naar muziek bestanden", - file_prov_playlists_path: "Pad naar playlist bestanden (.m3u)", - web_http_port: "HTTP poort", - web_https_port: "HTTPS poort", - cert_fqdn_host: "Hostname (FQDN van certificaat)", - enable_r128_volume_normalisation: "Schakel R128 volume normalisatie in", - target_volume_lufs: "Doelvolume (R128 standaard is -23 LUFS)", - fallback_gain_correct: "Fallback gain correctie indien R128 meting (nog) niet beschikbaar is", - enable_audio_cache: "Sta het cachen van audio toe naar temp map", - trim_silence: "Strip stilte van begin en eind van audio (in temp bestanden)", - http_streamer_sox_effects: "Eigen sox effects toepassen op audio (alleen voor ingebouwde streamer). Zie http://sox.sourceforge.net/sox.html#EFFECTS", - max_sample_rate: "Maximale sample rate welke deze speler ondersteund, hoger wordt gedownsampled.", - force_http_streamer: "Forceer het gebruik van de ingebouwde streamer, ook al heeft de speler directe ondersteuning voor de muziek provider", - not_grouped: "Niet gegroepeerd", - conf_saved: "Configuratie is opgeslagen, herstart om actief te maken", - audio_cache_folder: "Map om te gebruiken voor cache bestanden", - audio_cache_max_size_gb: "Maximale grootte van de cache map in GB." - }, - // player strings - players: "Spelers", - play: "Afspelen", - play_on: "Afspelen op:", - play_now: "Nu afspelen", - play_next: "Speel als volgende af", - add_queue: "Voeg toe aan wachtrij", - show_info: "Bekijk informatie", - state: { - playing: "afspelen", - stopped: "gestopt", - paused: "gepauzeerd", - off: "uitgeschakeld" - } - } -} \ No newline at end of file