From: marcelveldt Date: Sat, 12 Oct 2019 00:28:58 +0000 (+0200) Subject: refactor in progress X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f42636b46add1a22aa94881d773b389655634fdf;p=music-assistant-server.git refactor in progress --- diff --git a/.vscode/.ropeproject/config.py b/.vscode/.ropeproject/config.py new file mode 100644 index 00000000..dee2d1ae --- /dev/null +++ b/.vscode/.ropeproject/config.py @@ -0,0 +1,114 @@ +# The default ``config.py`` +# flake8: noqa + + +def set_prefs(prefs): + """This function is called before opening the project""" + + # Specify which files and folders to ignore in the project. + # Changes to ignored resources are not added to the history and + # VCSs. Also they are not returned in `Project.get_files()`. + # Note that ``?`` and ``*`` match all characters but slashes. + # '*.pyc': matches 'test.pyc' and 'pkg/test.pyc' + # 'mod*.pyc': matches 'test/mod1.pyc' but not 'mod/1.pyc' + # '.svn': matches 'pkg/.svn' and all of its children + # 'build/*.o': matches 'build/lib.o' but not 'build/sub/lib.o' + # 'build//*.o': matches 'build/lib.o' and 'build/sub/lib.o' + prefs['ignored_resources'] = ['*.pyc', '*~', '.ropeproject', + '.hg', '.svn', '_svn', '.git', '.tox'] + + # Specifies which files should be considered python files. It is + # useful when you have scripts inside your project. Only files + # ending with ``.py`` are considered to be python files by + # default. + # prefs['python_files'] = ['*.py'] + + # Custom source folders: By default rope searches the project + # for finding source folders (folders that should be searched + # for finding modules). You can add paths to that list. Note + # that rope guesses project source folders correctly most of the + # time; use this if you have any problems. + # The folders should be relative to project root and use '/' for + # separating folders regardless of the platform rope is running on. + # 'src/my_source_folder' for instance. + # prefs.add('source_folders', 'src') + + # You can extend python path for looking up modules + # prefs.add('python_path', '~/python/') + + # Should rope save object information or not. + prefs['save_objectdb'] = True + prefs['compress_objectdb'] = False + + # If `True`, rope analyzes each module when it is being saved. + prefs['automatic_soa'] = True + # The depth of calls to follow in static object analysis + prefs['soa_followed_calls'] = 0 + + # If `False` when running modules or unit tests "dynamic object + # analysis" is turned off. This makes them much faster. + prefs['perform_doa'] = True + + # Rope can check the validity of its object DB when running. + prefs['validate_objectdb'] = True + + # How many undos to hold? + prefs['max_history_items'] = 32 + + # Shows whether to save history across sessions. + prefs['save_history'] = True + prefs['compress_history'] = False + + # Set the number spaces used for indenting. According to + # :PEP:`8`, it is best to use 4 spaces. Since most of rope's + # unit-tests use 4 spaces it is more reliable, too. + prefs['indent_size'] = 4 + + # Builtin and c-extension modules that are allowed to be imported + # and inspected by rope. + prefs['extension_modules'] = [] + + # Add all standard c-extensions to extension_modules list. + prefs['import_dynload_stdmods'] = True + + # If `True` modules with syntax errors are considered to be empty. + # The default value is `False`; When `False` syntax errors raise + # `rope.base.exceptions.ModuleSyntaxError` exception. + prefs['ignore_syntax_errors'] = False + + # If `True`, rope ignores unresolvable imports. Otherwise, they + # appear in the importing namespace. + prefs['ignore_bad_imports'] = False + + # If `True`, rope will insert new module imports as + # `from import ` by default. + prefs['prefer_module_from_imports'] = False + + # If `True`, rope will transform a comma list of imports into + # multiple separate import statements when organizing + # imports. + prefs['split_imports'] = False + + # If `True`, rope will remove all top-level import statements and + # reinsert them at the top of the module when making changes. + prefs['pull_imports_to_top'] = True + + # If `True`, rope will sort imports alphabetically by module name instead + # of alphabetically by import statement, with from imports after normal + # imports. + prefs['sort_imports_alphabetically'] = False + + # Location of implementation of + # rope.base.oi.type_hinting.interfaces.ITypeHintingFactory In general + # case, you don't have to change this value, unless you're an rope expert. + # Change this value to inject you own implementations of interfaces + # listed in module rope.base.oi.type_hinting.providers.interfaces + # For example, you can add you own providers for Django Models, or disable + # the search type-hinting in a class hierarchy, etc. + prefs['type_hinting_factory'] = ( + 'rope.base.oi.type_hinting.factory.default_type_hinting_factory') + + +def project_opened(project): + """This function is called after opening the project""" + # Do whatever you like here! diff --git a/Dockerfile b/Dockerfile index 5c1ed6b8..5a0d990a 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,8 @@ RUN apk --no-cache add --virtual .builddeps build-base python3-dev taglib-dev && # copy files RUN mkdir -p /usr/src/app WORKDIR /usr/src/app -COPY music_assistant /usr/src/app +COPY music_assistant /usr/src/app/music_assistant +COPY main.py /usr/src/app/main.py RUN chmod a+x /usr/src/app/main.py VOLUME ["/data"] diff --git a/main.py b/main.py new file mode 100755 index 00000000..04620168 --- /dev/null +++ b/main.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import sys +import os + +from music_assistant import MusicAssistant + +if __name__ == "__main__": + datapath = sys.argv[1] + if not datapath: + datapath = os.path.dirname(os.path.abspath(__file__)) + MusicAssistant(datapath) + \ No newline at end of file diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index e69de29b..984e33b8 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# import os, sys; sys.path.append(os.path.dirname(os.path.realpath(__file__))) + +import sys +import asyncio +from concurrent.futures import ThreadPoolExecutor +from contextlib import suppress +import re +import uvloop +import os +import shutil +import slugify as unicode_slug +import uuid +import json +import time +# import stackimpact + +# __package__ = 'music_assistant' + +from .database import Database +from .utils import run_periodic, LOGGER +from .metadata import MetaData +from .cache import Cache +from .music_manager import Music +from .player_manager import PlayerManager +from .http_streamer import HTTPStreamer +from .homeassistant import setup as hass_setup +from .web import setup as web_setup + +def handle_exception(loop, context): + # context["message"] will always be there; but context["exception"] may not + msg = context.get("exception", context["message"]) + LOGGER.error(f"Caught exception: {msg}") + +class MusicAssistant(): + + def __init__(self, datapath): + uvloop.install() + self.datapath = datapath + self.parse_config() + self.event_loop = asyncio.get_event_loop() + self.bg_executor = ThreadPoolExecutor() + self.event_loop.set_default_executor(self.bg_executor) + self.event_loop.set_exception_handler(handle_exception) + self.event_listeners = {} + + # init database and metadata modules + self.db = Database(datapath, self.event_loop) + # allow some time for the database to initialize + while not self.db.db_ready: + time.sleep(0.15) + self.cache = Cache(datapath) + self.metadata = MetaData(self.event_loop, self.db, self.cache) + + # init modules + self.web = web_setup(self) + self.hass = hass_setup(self) + self.music = Music(self) + self.player = PlayerManager(self) + self.http_streamer = HTTPStreamer(self) + + # agent = stackimpact.start( + # agent_key = '4a00b6f2c7da20f692807d204ab3760318978ba3', + # app_name = 'MusicAssistant') + # print("profiler started...") + + # start the event loop + try: + self.event_loop.run_forever() + except (KeyboardInterrupt, SystemExit): + LOGGER.info('Exit requested!') + self.signal_event("system_shutdown") + self.event_loop.stop() + self.save_config() + time.sleep(5) + self.event_loop.close() + LOGGER.info('Shutdown complete.') + + def signal_event(self, msg, msg_details=None): + ''' signal (systemwide) event ''' + LOGGER.debug("Event: %s - %s" %(msg, msg_details)) + listeners = list(self.event_listeners.values()) + for callback, eventfilter in listeners: + if not eventfilter or eventfilter in msg: + if not asyncio.iscoroutinefunction(callback): + callback(msg, msg_details) + else: + self.event_loop.create_task(callback(msg, msg_details)) + + def add_event_listener(self, cb, eventfilter=None): + ''' add callback to our event listeners ''' + cb_id = str(uuid.uuid4()) + self.event_listeners[cb_id] = (cb, eventfilter) + return cb_id + + def remove_event_listener(self, cb_id): + ''' remove callback from our event listeners ''' + self.event_listeners.pop(cb_id, None) + + def save_config(self): + ''' save config to file ''' + # backup existing file + conf_file = os.path.join(self.datapath, 'config.json') + conf_file_backup = os.path.join(self.datapath, 'config.json.backup') + if os.path.isfile(conf_file): + shutil.move(conf_file, conf_file_backup) + # remove description keys from config + final_conf = {} + for key, value in self.config.items(): + final_conf[key] = {} + for subkey, subvalue in value.items(): + if subkey != "__desc__": + final_conf[key][subkey] = subvalue + with open(conf_file, 'w') as f: + f.write(json.dumps(final_conf, indent=4)) + + def parse_config(self): + '''get config from config file''' + config = { + "base": {}, + "musicproviders": {}, + "playerproviders": {}, + "player_settings": {} + } + conf_file = os.path.join(self.datapath, 'config.json') + if os.path.isfile(conf_file): + with open(conf_file) as f: + data = f.read() + if data: + data = json.loads(data) + for key, value in data.items(): + config[key] = value + self.config = config + + \ No newline at end of file diff --git a/music_assistant/cache.py b/music_assistant/cache.py new file mode 100644 index 00000000..583080f2 --- /dev/null +++ b/music_assistant/cache.py @@ -0,0 +1,237 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +'''provides a simple stateless caching system''' + +import datetime +import time +import sqlite3 +from functools import reduce +import os +import functools +import asyncio + +from .utils import run_periodic, LOGGER, parse_track_title + +class Cache(object): + '''basic stateless caching system ''' + _exit = False + _mem_cache = {} + _busy_tasks = [] + _database = None + + def __init__(self, datapath): + '''Initialize our caching class''' + self._datapath = datapath + asyncio.ensure_future(self._do_cleanup()) + LOGGER.debug("Initialized") + + async def get(self, endpoint, checksum=""): + ''' + get object from cache and return the results + endpoint: the (unique) name of the cache object as reference + checkum: optional argument to check if the checksum in the cacheobject matches the checkum provided + ''' + checksum = self._get_checksum(checksum) + cur_time = self._get_timestamp(datetime.datetime.now()) + result = None + # 1: try memory cache first + result = await self._get_mem_cache(endpoint, checksum, cur_time) + # 2: fallback to _database cache + if result is None: + result = await self._get_db_cache(endpoint, checksum, cur_time) + return result + + async def set(self, endpoint, data, checksum="", expiration=datetime.timedelta(days=14)): + ''' + set data in cache + ''' + task_name = "set.%s" % endpoint + self._busy_tasks.append(task_name) + checksum = self._get_checksum(checksum) + expires = self._get_timestamp(datetime.datetime.now() + expiration) + + # memory cache + await self._set_mem_cache(endpoint, checksum, expires, data) + + # db cache + if not self._exit: + await self._set_db_cache(endpoint, checksum, expires, data) + + # remove this task from list + self._busy_tasks.remove(task_name) + + async def _get_mem_cache(self, endpoint, checksum, cur_time): + ''' + get cache data from memory cache + ''' + result = None + cachedata = self._mem_cache.get(endpoint) + if cachedata: + cachedata = cachedata + if cachedata[0] > cur_time: + if checksum == None or checksum == cachedata[2]: + result = cachedata[1] + return result + + async def _set_mem_cache(self, endpoint, checksum, expires, data): + ''' + put data in memory cache + ''' + cachedata = (expires, data, checksum) + self._mem_cache[endpoint] = cachedata + + async def _get_db_cache(self, endpoint, checksum, cur_time): + '''get cache data from sqllite database''' + result = None + query = "SELECT expires, data, checksum FROM simplecache WHERE id = ?" + cache_data = self._execute_sql(query, (endpoint,)) + if cache_data: + cache_data = cache_data.fetchone() + if cache_data and cache_data[0] > cur_time: + if checksum == None or cache_data[2] == checksum: + result = eval(cache_data[1]) + # also set result in memory cache for further access + await self._set_mem_cache(endpoint, checksum, cache_data[0], result) + return result + + async def _set_db_cache(self, endpoint, checksum, expires, data): + ''' store cache data in _database ''' + query = "INSERT OR REPLACE INTO simplecache( id, expires, data, checksum) VALUES (?, ?, ?, ?)" + data = repr(data) + self._execute_sql(query, (endpoint, expires, data, checksum)) + + @run_periodic(3600) + async def _do_cleanup(self): + '''perform cleanup task''' + if self._exit: + return + self._busy_tasks.append(__name__) + cur_time = datetime.datetime.now() + cur_timestamp = self._get_timestamp(cur_time) + LOGGER.debug("Running cleanup...") + query = "SELECT id, expires FROM simplecache" + for cache_data in self._execute_sql(query).fetchall(): + cache_id = cache_data[0] + cache_expires = cache_data[1] + if self._exit: + return + # always cleanup all memory objects on each interval + self._mem_cache.pop(cache_id, None) + # clean up db cache object only if expired + if cache_expires < cur_timestamp: + query = 'DELETE FROM simplecache WHERE id = ?' + self._execute_sql(query, (cache_id,)) + LOGGER.debug("delete from db %s" % cache_id) + + # compact db + self._execute_sql("VACUUM") + + # remove task from list + self._busy_tasks.remove(__name__) + LOGGER.debug("Auto cleanup done") + + def _get_database(self): + '''get reference to our sqllite _database - performs basic integrity check''' + dbfile = os.path.join(self._datapath, "simplecache.db") + try: + connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None) + connection.execute('SELECT * FROM simplecache LIMIT 1') + return connection + except Exception as error: + # our _database is corrupt or doesn't exist yet, we simply try to recreate it + if os.path.isfile(dbfile): + os.remove(dbfile) + try: + connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None) + connection.execute( + """CREATE TABLE IF NOT EXISTS simplecache( + id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""") + return connection + except Exception as error: + LOGGER.warning("Exception while initializing _database: %s" % str(error)) + return None + + def _execute_sql(self, query, data=None): + '''little wrapper around execute and executemany to just retry a db command if db is locked''' + retries = 0 + result = None + error = None + # always use new db object because we need to be sure that data is available for other simplecache instances + with self._get_database() as _database: + while not retries == 10: + if self._exit: + return None + try: + if isinstance(data, list): + result = _database.executemany(query, data) + elif data: + result = _database.execute(query, data) + else: + result = _database.execute(query) + return result + except sqlite3.OperationalError as error: + if "_database is locked" in error: + LOGGER.debug("retrying DB commit...") + retries += 1 + time.sleep(0.5) + else: + break + except Exception as error: + LOGGER.error("_database ERROR ! -- %s" % str(error)) + break + return None + + @staticmethod + def _get_timestamp(date_time): + '''Converts a datetime object to unix timestamp''' + return int(time.mktime(date_time.timetuple())) + + @staticmethod + def _get_checksum(stringinput): + '''get int checksum from string''' + if not stringinput: + return 0 + else: + stringinput = str(stringinput) + return reduce(lambda x, y: x + y, map(ord, stringinput)) + +def use_cache(cache_days=14, cache_hours=8): + def wrapper(func): + @functools.wraps(func) + async def wrapped(*args, **kwargs): + if kwargs.get("ignore_cache"): + return await func(*args, **kwargs) + cache_checksum = kwargs.get("cache_checksum") + method_class = args[0] + method_class_name = method_class.__class__.__name__ + cache_str = "%s.%s" % (method_class_name, func.__name__) + # append args to cache identifier + for item in args[1:]: + if isinstance(item, dict): + for subkey in sorted(list(item.keys())): + subvalue = item[subkey] + cache_str += ".%s%s" %(subkey,subvalue) + else: + cache_str += ".%s" % item + # append kwargs to cache identifier + for key in sorted(list(kwargs.keys())): + if key in ["ignore_cache", "cache_checksum"]: + continue + value = kwargs[key] + if isinstance(value, dict): + for subkey in sorted(list(value.keys())): + subvalue = value[subkey] + cache_str += ".%s%s" %(subkey,subvalue) + else: + cache_str += ".%s%s" %(key,value) + cache_str = cache_str.lower() + cachedata = await method_class.cache.get(cache_str, checksum=cache_checksum) + if cachedata is not None: + return cachedata + else: + result = await func(*args, **kwargs) + await method_class.cache.set(cache_str, result, checksum=cache_checksum, expiration=datetime.timedelta(days=cache_days, hours=cache_hours)) + return result + return wrapped + return wrapper diff --git a/music_assistant/database.py b/music_assistant/database.py index dcd3715c..7503eb37 100755 --- a/music_assistant/database.py +++ b/music_assistant/database.py @@ -3,12 +3,13 @@ import asyncio import os -from utils import run_periodic, LOGGER, get_sort_name, try_parse_int -from models import MediaType, Artist, Album, Track, Playlist, Radio from typing import List import aiosqlite import operator +from .utils import run_periodic, LOGGER, get_sort_name, try_parse_int +from .models.media_types import MediaType, Artist, Album, Track, Playlist, Radio + class Database(): def __init__(self, datapath, event_loop): diff --git a/music_assistant/homeassistant.py b/music_assistant/homeassistant.py new file mode 100644 index 00000000..ae30d1f6 --- /dev/null +++ b/music_assistant/homeassistant.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import random +import aiohttp +import time +import datetime +import hashlib +from asyncio_throttle import Throttler +from aiocometd import Client, ConnectionType, Extension +import copy +import slugify as slug +import json +from .utils import run_periodic, LOGGER, parse_track_title, try_parse_int +from .models.media_types import Track +from .constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT +from .cache import use_cache + + +''' + Homeassistant integration + allows publishing of our players to hass + allows using hass entities (like switches, media_players or gui inputs) to be triggered +''' + +def setup(mass): + ''' setup the module and read/apply config''' + create_config_entries(mass.config) + conf = mass.config['base']['homeassistant'] + enabled = conf.get(CONF_ENABLED) + token = conf.get('token') + url = conf.get('url') + if enabled and url and token: + return HomeAssistant(mass, url, token) + return None + +def create_config_entries(config): + ''' get the config entries for this module (list with key/value pairs)''' + config_entries = [ + (CONF_ENABLED, False, 'enabled'), + ('url', 'localhost', 'hass_url'), + ('token', '', 'hass_token'), + ('publish_players', True, 'hass_publish') + ] + if not config['base'].get('homeassistant'): + config['base']['homeassistant'] = {} + config['base']['homeassistant']['__desc__'] = config_entries + for key, def_value, desc in config_entries: + if not key in config['base']['homeassistant']: + config['base']['homeassistant'][key] = def_value + +class HomeAssistant(): + ''' HomeAssistant integration ''' + + def __init__(self, mass, url, token): + self.mass = mass + self._published_players = {} + self._tracked_states = {} + self._state_listeners = [] + self._sources = [] + self._token = token + if url.startswith('https://'): + self._use_ssl = True + self._host = url.replace('https://','').split('/')[0] + else: + self._use_ssl = False + self._host = url.replace('http://','').split('/')[0] + self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) + self.__send_ws = None + self.__last_id = 10 + LOGGER.info('Homeassistant integration is enabled') + mass.event_loop.create_task(self.__hass_websocket()) + self.mass.add_event_listener(self.mass_event, "player updated") + mass.event_loop.create_task(self.__get_sources()) + + async def get_state(self, entity_id, attribute='state', register_listener=None): + ''' get state of a hass entity''' + if entity_id in self._tracked_states: + state_obj = self._tracked_states[entity_id] + else: + # first request + state_obj = await self.__get_data('states/%s' % entity_id) + if register_listener: + # register state listener + self._state_listeners.append( (entity_id, register_listener) ) + self._tracked_states[entity_id] = state_obj + if attribute == 'state': + return state_obj['state'] + elif not attribute: + return state_obj + else: + return state_obj['attributes'].get(attribute) + + async def mass_event(self, msg, msg_details): + ''' received event from mass ''' + if msg == "player updated": + await self.publish_player(msg_details) + + async def hass_event(self, event_type, event_data): + ''' received event from hass ''' + if event_type == 'state_changed': + if event_data['entity_id'] in self._tracked_states: + self._tracked_states[event_data['entity_id']] = event_data['new_state'] + for entity_id, handler in self._state_listeners: + if entity_id == event_data['entity_id']: + asyncio.create_task(handler()) + elif event_type == 'call_service' and event_data['domain'] == 'media_player': + await self.__handle_player_command(event_data['service'], event_data['service_data']) + + async def __handle_player_command(self, service, service_data): + ''' handle forwarded service call for one of our players ''' + if isinstance(service_data['entity_id'], list): + # can be a list of entity ids if action fired on multiple items + entity_ids = service_data['entity_id'] + else: + entity_ids = [service_data['entity_id']] + for entity_id in entity_ids: + if entity_id in self._published_players: + # call is for one of our players so handle it + player_id = self._published_players[entity_id] + if service == 'turn_on': + await self.mass.player.player_command(player_id, 'power', 'on') + elif service == 'turn_off': + await self.mass.player.player_command(player_id, 'power', 'off') + elif service == 'toggle': + await self.mass.player.player_command(player_id, 'power', 'toggle') + elif service == 'volume_mute': + args = 'on' if service_data['is_volume_muted'] else 'off' + await self.mass.player.player_command(player_id, 'mute', args) + elif service == 'volume_up': + await self.mass.player.player_command(player_id, 'volume', 'up') + elif service == 'volume_down': + await self.mass.player.player_command(player_id, 'volume', 'down') + elif service == 'volume_set': + volume_level = service_data['volume_level']*100 + await self.mass.player.player_command(player_id, 'volume', volume_level) + elif service == 'media_play': + await self.mass.player.player_command(player_id, 'play') + elif service == 'media_pause': + await self.mass.player.player_command(player_id, 'pause') + elif service == 'media_stop': + await self.mass.player.player_command(player_id, 'stop') + elif service == 'media_next_track': + await self.mass.player.player_command(player_id, 'next') + elif service == 'media_play_pause': + await self.mass.player.player_command(player_id, 'pause', 'toggle') + elif service == 'play_media': + return await self.__handle_play_media(player_id, service_data) + + async def __handle_play_media(self, player_id, service_data): + ''' handle play_media request from homeassistant''' + media_content_type = service_data['media_content_type'].lower() + media_content_id = service_data['media_content_id'] + queue_opt = 'add' if service_data.get('enqueue') else 'play' + if media_content_type == 'playlist' and not '://' in media_content_id: + media_items = [] + for playlist_str in media_content_id.split(','): + playlist_str = playlist_str.strip() + playlist = await self.mass.music.playlist_by_name(playlist_str) + if playlist: + media_items.append(playlist) + return await self.mass.player.play_media(player_id, media_items, queue_opt) + elif media_content_type == 'playlist' and 'spotify://playlist' in media_content_id: + # TODO: handle parsing of other uri's here + playlist = self.mass.music.providers['spotify'].playlist(media_content_id.split(':')[-1]) + return await self.mass.player.play_media(player_id, playlist, queue_opt) + elif media_content_id.startswith('http'): + track = Track() + track.uri = media_content_id + track.provider = 'http' + return await self.mass.player.play_media(player_id, track, queue_opt) + + async def publish_player(self, player): + ''' publish player details to hass''' + if not self.mass.config['base']['homeassistant']['publish_players']: + return False + player_id = player.player_id + entity_id = 'media_player.mass_' + slug.slugify(player.name, separator='_').lower() + state = player.state if player.powered else 'off' + state_attributes = { + "supported_features": 65471, + "friendly_name": player.name, + "source_list": self._sources, + "source": 'unknown', + "volume_level": player.volume_level/100, + "is_volume_muted": player.muted, + "media_duration": player.cur_item.duration if player.cur_item else 0, + "media_position": player.cur_time, + "media_title": player.cur_item.name if player.cur_item else "", + "media_artist": player.cur_item.artists[0].name if player.cur_item and player.cur_item.artists else "", + "media_album_name": player.cur_item.album.name if player.cur_item and player.cur_item.album else "", + "entity_picture": player.cur_item.album.metadata.get('image') if player.cur_item and player.cur_item.album else "" + } + self._published_players[entity_id] = player_id + await self.__set_state(entity_id, state, state_attributes) + + async def call_service(self, domain, service, service_data=None): + ''' call service on hass ''' + if not self.__send_ws: + return False + msg = { + "type": "call_service", + "domain": domain, + "service": service, + } + if service_data: + msg['service_data'] = service_data + return await self.__send_ws(msg) + + @run_periodic(120) + async def __get_sources(self): + ''' we build a list of all playlists to use as player sources ''' + self._sources = [playlist.name for playlist in await self.mass.music.playlists()] + + async def __set_state(self, entity_id, new_state, state_attributes={}): + ''' set state to hass entity ''' + data = { + "state": new_state, + "entity_id": entity_id, + "attributes": state_attributes + } + return await self.__post_data('states/%s' % entity_id, data) + + async def __hass_websocket(self): + ''' Receive events from Hass through websockets ''' + while self.mass.event_loop.is_running(): + try: + protocol = 'wss' if self._use_ssl else 'ws' + async with self.http_session.ws_connect('%s://%s/api/websocket' % (protocol, self._host)) as ws: + + async def send_msg(msg): + ''' callback to send message to the websockets client''' + self.__last_id += 1 + msg['id'] = self.__last_id + await ws.send_json(msg) + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + if msg.data == 'close cmd': + await ws.close() + break + else: + data = msg.json() + if data['type'] == 'auth_required': + # send auth token + auth_msg = {"type": "auth", "access_token": self._token} + await ws.send_json(auth_msg) + elif data['type'] == 'auth_invalid': + raise Exception(data) + elif data['type'] == 'auth_ok': + # register callback + self.__send_ws = send_msg + # subscribe to events + subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"} + await send_msg(subscribe_msg) + subscribe_msg = {"type": "subscribe_events", "event_type": "call_service"} + await send_msg(subscribe_msg) + elif data['type'] == 'event': + asyncio.create_task(self.hass_event(data['event']['event_type'], data['event']['data'])) + elif data['type'] == 'result' and data.get('result'): + # reply to our get_states request + asyncio.create_task(self.hass_event('all_states', data['result'])) + else: + LOGGER.info(data) + elif msg.type == aiohttp.WSMsgType.ERROR: + raise Exception("error in websocket") + except Exception as exc: + LOGGER.exception(exc) + await asyncio.sleep(10) + + async def __get_data(self, endpoint): + ''' get data from hass rest api''' + url = "http://%s/api/%s" % (self._host, endpoint) + if self._use_ssl: + url = "https://%s/api/%s" % (self._host, endpoint) + headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"} + async with self.http_session.get(url, headers=headers) as response: + return await response.json() + + async def __post_data(self, endpoint, data): + ''' post data to hass rest api''' + url = "http://%s/api/%s" % (self._host, endpoint) + if self._use_ssl: + url = "https://%s/api/%s" % (self._host, endpoint) + headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"} + async with self.http_session.post(url, headers=headers, json=data) as response: + return await response.json() \ No newline at end of file diff --git a/music_assistant/http_streamer.py b/music_assistant/http_streamer.py new file mode 100755 index 00000000..0c5a3433 --- /dev/null +++ b/music_assistant/http_streamer.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +import operator +from aiohttp import web +import threading +import urllib +from memory_tempfile import MemoryTempfile +import io +import soundfile as sf +import pyloudnorm as pyln +import aiohttp +from .utils import LOGGER, try_parse_int, get_ip, run_async_background_task, run_periodic, get_folder_size +from .models.media_types import TrackQuality, MediaType +from .models.player import PlayerState + + +class HTTPStreamer(): + ''' Built-in streamer using sox and webserver ''' + + def __init__(self, mass): + self.mass = mass + self.local_ip = get_ip() + self.analyze_jobs = {} + + async def stream(self, http_request): + ''' + start stream for a player + ''' + # make sure we have a valid player + player_id = http_request.match_info.get('player_id','') + player = await self.mass.player.get_player(player_id) + if not player: + LOGGER.error("Received stream request for non-existing player %s" %(player_id)) + return + queue_item_id = http_request.query.get('queue_item_id') + queue_item = await player.queue.by_item_id(queue_item_id) + # prepare headers as audio/flac content + resp = web.StreamResponse(status=200, reason='OK', headers={'Content-Type': 'audio/flac'}) + await resp.prepare(http_request) + # send content only on GET request + if http_request.method.upper() != 'HEAD': + # stream audio + queue = asyncio.Queue() + cancelled = threading.Event() + if queue_item: + # single stream requested + run_async_background_task( + self.mass.bg_executor, + self.__stream_single, player, queue_item, queue, cancelled) + else: + # no item is given, start queue stream + run_async_background_task( + self.mass.bg_executor, + self.__stream_queue, player, queue, cancelled) + await asyncio.sleep(2) + try: + while True: + chunk = await queue.get() + if not chunk: + queue.task_done() + break + await resp.write(chunk) + queue.task_done() + LOGGER.info("stream fininished for player %s" % player.name) + except asyncio.CancelledError: + cancelled.set() + LOGGER.warning("stream interrupted for player %s" % player.name) + raise asyncio.CancelledError() + return resp + + async def __stream_single(self, player, queue_item, buffer, cancelled): + ''' start streaming single track from provider ''' + try: + audio_stream = self.__get_audio_stream(player, queue_item, cancelled) + async for is_last_chunk, audio_chunk in audio_stream: + await buffer.put(audio_chunk) + # wait for the queue to consume the data + # this prevents that the entire track is sitting in memory + # while buffer.qsize() > 1 and not cancelled.is_set(): + # await asyncio.sleep(1) + await buffer.put(b'') # EOF + except (asyncio.CancelledError, asyncio.TimeoutError): + cancelled.set() + LOGGER.info("stream_track interrupted for %s" % queue_item.name) + raise asyncio.CancelledError() + else: + LOGGER.info("stream_track fininished for %s" % queue_item.name) + + async def __stream_queue(self, player, buffer, cancelled): + ''' start streaming all queue tracks ''' + sample_rate = player.settings['max_sample_rate'] + fade_length = player.settings["crossfade_duration"] + fade_bytes = int(sample_rate * 4 * 2 * fade_length) + pcm_args = 'raw -b 32 -c 2 -e signed-integer -r %s' % sample_rate + args = 'sox -t %s - -t flac -C 0 -' % pcm_args + sox_proc = await asyncio.create_subprocess_shell(args, + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE) + + async def fill_buffer(): + while not sox_proc.stdout.at_eof(): + chunk = await sox_proc.stdout.read(256000) + if not chunk: + break + await buffer.put(chunk) + await buffer.put(b'') # indicate EOF + asyncio.create_task(fill_buffer()) + + LOGGER.info("Start Queue Stream for player %s" %(player.name)) + last_fadeout_data = b'' + # report start of queue playback so we can calculate current track/duration etc. + # self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True)) + while True: + # get the (next) track in queue + queue_track = player.queue.next_item + LOGGER.info("got queue track %s" % queue_track.name) + if not queue_track: + break + LOGGER.debug("Start Streaming queue track: %s (%s) on player %s" % (queue_track.item_id, queue_track.name, player.name)) + fade_in_part = b'' + cur_chunk = 0 + prev_chunk = None + bytes_written = 0 + async for is_last_chunk, chunk in self.__get_audio_stream( + player, queue_track, cancelled, chunksize=fade_bytes, resample=sample_rate): + cur_chunk += 1 + if cur_chunk <= 2 and not last_fadeout_data: + # fade-in part but no fadeout_part available so just pass it to the output directly + sox_proc.stdin.write(chunk) + await sox_proc.stdin.drain() + bytes_written += len(chunk) + elif cur_chunk == 1 and last_fadeout_data: + prev_chunk = chunk + elif cur_chunk == 2 and last_fadeout_data: + # combine the first 2 chunks and strip off silence + args = 'sox --ignore-length -t %s - -t %s - silence 1 0.1 1%%' % (pcm_args, pcm_args) + process = await asyncio.create_subprocess_shell(args, + stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) + first_part, stderr = await process.communicate(prev_chunk + chunk) + fade_in_part = first_part[:fade_bytes] + remaining_bytes = first_part[fade_bytes:] + del first_part + # do crossfade + crossfade_part = await self.__crossfade_pcm_parts(fade_in_part, last_fadeout_data, pcm_args, fade_length) + sox_proc.stdin.write(crossfade_part) + await sox_proc.stdin.drain() + bytes_written += len(crossfade_part) + del crossfade_part + del fade_in_part + last_fadeout_data = b'' + # also write the leftover bytes from the strip action + sox_proc.stdin.write(remaining_bytes) + await sox_proc.stdin.drain() + bytes_written += len(remaining_bytes) + del remaining_bytes + prev_chunk = None # needed to prevent this chunk being sent again + elif prev_chunk and is_last_chunk: + # last chunk received so create the fadeout_part with the previous chunk and this chunk + # and strip off silence + args = 'sox --ignore-length -t %s - -t %s - reverse silence 1 0.1 1%% reverse' % (pcm_args, pcm_args) + process = await asyncio.create_subprocess_shell(args, + stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) + last_part, stderr = await process.communicate(prev_chunk + chunk) + if len(last_part) < fade_bytes: + # not enough data for crossfade duration after the strip action... + last_part = prev_chunk + chunk + if len(last_part) < fade_bytes: + # still not enough data so we'll skip the crossfading + LOGGER.warning("not enough data for fadeout so skip crossfade... %s" % len(last_part)) + sox_proc.stdin.write(last_part) + bytes_written += len(last_part) + await sox_proc.stdin.drain() + del last_part + else: + # store fade section to be picked up for next track + last_fadeout_data = last_part[-fade_bytes:] + remaining_bytes = last_part[:-fade_bytes] + # write remaining bytes + sox_proc.stdin.write(remaining_bytes) + bytes_written += len(remaining_bytes) + await sox_proc.stdin.drain() + del last_part + del remaining_bytes + else: + # middle part of the track + # keep previous chunk in memory so we have enough samples to perform the crossfade + if prev_chunk: + sox_proc.stdin.write(prev_chunk) + await sox_proc.stdin.drain() + bytes_written += len(prev_chunk) + prev_chunk = chunk + else: + prev_chunk = chunk + # wait for the queue to consume the data + # this prevents that the entire track is sitting in memory + # and it helps a bit in the quest to follow where we are in the queue + while buffer.qsize() > 1 and not cancelled.is_set(): + await asyncio.sleep(1) + # end of the track reached + if cancelled.is_set(): + # break out the loop if the http session is cancelled + LOGGER.warning("session cancelled") + break + else: + # WIP: update actual duration to the queue for more accurate now playing info + accurate_duration = bytes_written / int(sample_rate * 4 * 2) + queue_track.duration = accurate_duration + #self.mass.player.providers[player.player_provider]._player_queue[player_id][queue_index] = queue_track + # move to next queue index + #queue_index += 1 + #self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, False)) + LOGGER.info("Finished Streaming queue track: %s (%s) on player %s" % (queue_track.item_id, queue_track.name, player.name)) + LOGGER.info("bytes written: %s - duration: %s" % (bytes_written, accurate_duration)) + break + # end of queue reached, pass last fadeout bits to final output + if last_fadeout_data and not cancelled.is_set(): + sox_proc.stdin.write(last_fadeout_data) + await sox_proc.stdin.drain() + sox_proc.stdin.close() + await sox_proc.wait() + LOGGER.info("streaming of queue for player %s completed" % player.name) + + async def __get_audio_stream(self, player, queue_item, cancelled, + chunksize=512000, resample=None): + ''' get audio stream from provider and apply additional effects/processing where/if needed''' + sox_effects = await self.__get_player_sox_options(player, queue_item) + outputfmt = 'flac -C 0' + if resample: + outputfmt = 'raw -b 32 -c 2 -e signed-integer' + sox_effects += ' rate -v %s' % resample + # stream audio from provider + streamdetails = asyncio.run_coroutine_threadsafe( + self.mass.music.providers[queue_item.provider].get_stream_details(queue_item.item_id), + self.mass.event_loop).result() + if not streamdetails: + LOGGER.warning("no stream details!") + yield (True, b'') + return + if streamdetails["content_type"] == 'aac': + # support for AAC created with ffmpeg in between + args = 'ffmpeg -i "%s" -f flac - | sox -t flac - -t %s - %s' % (streamdetails["path"], outputfmt, sox_effects) + elif streamdetails['type'] == 'url': + args = 'sox -t %s "%s" -t %s - %s' % (streamdetails["content_type"], + streamdetails["path"], outputfmt, sox_effects) + elif streamdetails['type'] == 'executable': + args = '%s | sox -t %s - -t %s - %s' % (streamdetails["path"], + streamdetails["content_type"], outputfmt, sox_effects) + + LOGGER.info("Running sox with args: %s" % args) + process = await asyncio.create_subprocess_shell(args, + stdout=asyncio.subprocess.PIPE) + # fire event that streaming has started for this track (needed by some streaming providers) + streamdetails["provider"] = queue_item.provider + streamdetails["track_id"] = queue_item.item_id + streamdetails["player_id"] = player.player_id + self.mass.signal_event('streaming_started', streamdetails) + # yield chunks from stdout + # we keep 1 chunk behind to detect end of stream properly + prev_chunk = b'' + bytes_sent = 0 + while not process.stdout.at_eof(): + if cancelled.is_set(): + process.terminate() + try: + chunk = await process.stdout.readexactly(chunksize) + except asyncio.streams.IncompleteReadError: + chunk = await process.stdout.read(chunksize) + if not chunk: + break + if prev_chunk: + yield (False, prev_chunk) + bytes_sent += len(prev_chunk) + prev_chunk = chunk + # yield last chunk + if not cancelled.is_set(): + yield (True, prev_chunk) + bytes_sent += len(prev_chunk) + await process.wait() + if cancelled.is_set(): + LOGGER.warning("__get_audio_stream for track_id %s interrupted - bytes_sent: %s" % (queue_item.item_id, bytes_sent)) + else: + LOGGER.info("__get_audio_stream for track_id %s completed- bytes_sent: %s" % (queue_item.item_id, bytes_sent)) + # fire event that streaming has ended for this track (needed by some streaming providers) + if resample: + bytes_per_second = resample * (32/8) * 2 + else: + bytes_per_second = streamdetails["sample_rate"] * (streamdetails["bit_depth"]/8) * 2 + seconds_streamed = int(bytes_sent/bytes_per_second) + streamdetails["seconds"] = seconds_streamed + self.mass.signal_event('streaming_ended', streamdetails) + # send task to background to analyse the audio + self.mass.event_loop.create_task(self.__analyze_audio(queue_item.item_id, queue_item.provider)) + + async def __get_player_sox_options(self, player, queue_item): + ''' get player specific sox effect options ''' + sox_effects = [] + # volume normalisation enabled but not natively handled by player so handle with sox + if not player.supports_replay_gain and player.settings['volume_normalisation']: + target_gain = int(player.settings['target_volume']) + fallback_gain = int(player.settings['fallback_gain_correct']) + track_loudness = await self.mass.db.get_track_loudness( + queue_item.item_id, queue_item.provider) + if track_loudness == None: + gain_correct = fallback_gain + else: + gain_correct = target_gain - track_loudness + gain_correct = round(gain_correct,2) + sox_effects.append('vol %s dB ' % gain_correct) + else: + gain_correct = '' + # downsample if needed + if player.settings['max_sample_rate']: + max_sample_rate = try_parse_int(player.settings['max_sample_rate']) + if max_sample_rate: + quality = queue_item.quality + if quality > TrackQuality.FLAC_LOSSLESS_HI_RES_3 and max_sample_rate == 192000: + sox_effects.append('rate -v 192000') + elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_2 and max_sample_rate == 96000: + sox_effects.append('rate -v 96000') + elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_1 and max_sample_rate == 48000: + sox_effects.append('rate -v 48000') + if player.settings.get('sox_effects'): + sox_effects.append(player.settings['sox_effects']) + return " ".join(sox_effects) + + async def __analyze_audio(self, track_id, provider): + ''' analyze track audio, for now we only calculate EBU R128 loudness ''' + track_key = '%s%s' %(track_id, provider) + if track_key in self.analyze_jobs: + return # prevent multiple analyze jobs for same track + self.analyze_jobs[track_key] = True + streamdetails = await self.mass.music.providers[provider].get_stream_details(track_id) + track_loudness = await self.mass.db.get_track_loudness(track_id, provider) + if track_loudness == None: + # only when needed we do the analyze stuff + LOGGER.debug('Start analyzing track %s' % track_id) + if streamdetails['type'] == 'url': + async with aiohttp.ClientSession() as session: + async with session.get(streamdetails["path"]) as resp: + audio_data = await resp.read() + elif streamdetails['type'] == 'executable': + process = await asyncio.create_subprocess_shell(streamdetails["path"], + stdout=asyncio.subprocess.PIPE) + audio_data, stderr = await process.communicate() + # calculate BS.1770 R128 integrated loudness + if track_loudness == None: + with io.BytesIO(audio_data) as tmpfile: + data, rate = sf.read(tmpfile) + meter = pyln.Meter(rate) # create BS.1770 meter + loudness = meter.integrated_loudness(data) # measure loudness + del data + LOGGER.debug("Integrated loudness of track %s is: %s" %(track_id, loudness)) + await self.mass.db.set_track_loudness(track_id, provider, loudness) + del audio_data + LOGGER.debug('Finished analyzing track %s' % track_id) + self.analyze_jobs.pop(track_key, None) + + async def __crossfade_pcm_parts(self, fade_in_part, fade_out_part, pcm_args, fade_length): + ''' crossfade two chunks of audio using sox ''' + # create fade-in part + fadeinfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0) + args = 'sox --ignore-length -t %s - -t %s %s fade t %s' % (pcm_args, pcm_args, fadeinfile.name, fade_length) + process = await asyncio.create_subprocess_shell(args, stdin=asyncio.subprocess.PIPE) + await process.communicate(fade_in_part) + # create fade-out part + fadeoutfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0) + args = 'sox --ignore-length -t %s - -t %s %s reverse fade t %s reverse' % (pcm_args, pcm_args, fadeoutfile.name, fade_length) + process = await asyncio.create_subprocess_shell(args, + stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) + await process.communicate(fade_out_part) + # create crossfade using sox and some temp files + # TODO: figure out how to make this less complex and without the tempfiles + args = 'sox -m -v 1.0 -t %s %s -v 1.0 -t %s %s -t %s -' % (pcm_args, fadeoutfile.name, pcm_args, fadeinfile.name, pcm_args) + process = await asyncio.create_subprocess_shell(args, + stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) + crossfade_part, stderr = await process.communicate() + LOGGER.debug("Got %s bytes in memory for crossfade_part after sox" % len(crossfade_part)) + return crossfade_part diff --git a/music_assistant/main.py b/music_assistant/main.py deleted file mode 100755 index 8b31d17f..00000000 --- a/music_assistant/main.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import sys -import asyncio -from concurrent.futures import ThreadPoolExecutor -from contextlib import suppress -import re -import uvloop -import os -import shutil -import slugify as unicode_slug -import uuid -import json -import time -# import stackimpact - -from database import Database -from utils import run_periodic, LOGGER -from modules.metadata import MetaData -from modules.cache import Cache -from modules.music import Music -from modules.playermanager import PlayerManager -from modules.http_streamer import HTTPStreamer -from modules.homeassistant import setup as hass_setup -from modules.web import setup as web_setup - -class Main(): - - def __init__(self, datapath): - uvloop.install() - self.datapath = datapath - self.parse_config() - self.event_loop = asyncio.get_event_loop() - self.bg_executor = ThreadPoolExecutor() - self.event_loop.set_default_executor(self.bg_executor) - self.event_listeners = {} - - # init database and metadata modules - self.db = Database(datapath, self.event_loop) - # allow some time for the database to initialize - while not self.db.db_ready: - time.sleep(0.15) - self.cache = Cache(datapath) - self.metadata = MetaData(self.event_loop, self.db, self.cache) - - # init modules - self.web = web_setup(self) - self.hass = hass_setup(self) - self.music = Music(self) - self.player = PlayerManager(self) - self.http_streamer = HTTPStreamer(self) - - # agent = stackimpact.start( - # agent_key = '4a00b6f2c7da20f692807d204ab3760318978ba3', - # app_name = 'MusicAssistant') - # print("profiler started...") - - # start the event loop - try: - self.event_loop.run_forever() - except (KeyboardInterrupt, SystemExit): - LOGGER.info('Exit requested!') - self.signal_event("system_shutdown") - self.event_loop.stop() - self.save_config() - time.sleep(5) - self.event_loop.close() - LOGGER.info('Shutdown complete.') - - def signal_event(self, msg, msg_details=None): - ''' signal (systemwide) event ''' - LOGGER.debug("Event: %s - %s" %(msg, msg_details)) - listeners = list(self.event_listeners.values()) - for callback, eventfilter in listeners: - if not eventfilter or eventfilter in msg: - if not asyncio.iscoroutinefunction(callback): - callback(msg, msg_details) - else: - self.event_loop.create_task(callback(msg, msg_details)) - - def add_event_listener(self, cb, eventfilter=None): - ''' add callback to our event listeners ''' - cb_id = str(uuid.uuid4()) - self.event_listeners[cb_id] = (cb, eventfilter) - return cb_id - - def remove_event_listener(self, cb_id): - ''' remove callback from our event listeners ''' - self.event_listeners.pop(cb_id, None) - - def save_config(self): - ''' save config to file ''' - # backup existing file - conf_file = os.path.join(self.datapath, 'config.json') - conf_file_backup = os.path.join(self.datapath, 'config.json.backup') - if os.path.isfile(conf_file): - shutil.move(conf_file, conf_file_backup) - # remove description keys from config - final_conf = {} - for key, value in self.config.items(): - final_conf[key] = {} - for subkey, subvalue in value.items(): - if subkey != "__desc__": - final_conf[key][subkey] = subvalue - with open(conf_file, 'w') as f: - f.write(json.dumps(final_conf, indent=4)) - - def parse_config(self): - '''get config from config file''' - config = { - "base": {}, - "musicproviders": {}, - "playerproviders": {}, - "player_settings": {} - } - conf_file = os.path.join(self.datapath, 'config.json') - if os.path.isfile(conf_file): - with open(conf_file) as f: - data = f.read() - if data: - config = json.loads(data) - self.config = config - -if __name__ == "__main__": - datapath = sys.argv[1] - if not datapath: - datapath = os.path.dirname(os.path.abspath(__file__)) - Main(datapath) - \ No newline at end of file diff --git a/music_assistant/main.spec b/music_assistant/main.spec deleted file mode 100644 index 9a97d322..00000000 --- a/music_assistant/main.spec +++ /dev/null @@ -1,33 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -block_cipher = None - - -a = Analysis(['main.py'], - pathex=['/Users/marcelveldt/Workdir/musicassistant/music_assistant'], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False) -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) -exe = EXE(pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='main', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True ) diff --git a/music_assistant/metadata.py b/music_assistant/metadata.py new file mode 100755 index 00000000..0f087890 --- /dev/null +++ b/music_assistant/metadata.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +import json +import aiohttp +from asyncio_throttle import Throttler +from difflib import SequenceMatcher as Matcher +from yarl import URL +import re + +from .utils import run_periodic, LOGGER +from .cache import use_cache + +LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' + +class MetaData(): + ''' several helpers to search and store mediadata for mediaitems ''' + + def __init__(self, event_loop, db, cache): + self.event_loop = event_loop + self.db = db + self.cache = cache + self.musicbrainz = MusicBrainz(event_loop, cache) + self.fanarttv = FanartTv(event_loop, cache) + + async def get_artist_metadata(self, mb_artist_id, cur_metadata): + ''' get/update rich metadata for an artist by providing the musicbrainz artist id ''' + metadata = cur_metadata + if not ('fanart' in metadata or 'thumb' in metadata): + res = await self.fanarttv.artist_images(mb_artist_id) + self.merge_metadata(cur_metadata, res) + return metadata + + async def get_mb_artist_id(self, artistname, albumname=None, album_upc=None, trackname=None, track_isrc=None): + ''' retrieve musicbrainz artist id for the given details ''' + LOGGER.debug('searching musicbrainz for %s (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)' %(artistname, albumname, album_upc, trackname, track_isrc)) + mb_artist_id = None + if album_upc: + mb_artist_id = await self.musicbrainz.search_artist_by_album(artistname, None, album_upc) + if not mb_artist_id and track_isrc: + mb_artist_id = await self.musicbrainz.search_artist_by_track(artistname, None, track_isrc) + if not mb_artist_id and albumname: + mb_artist_id = await self.musicbrainz.search_artist_by_album(artistname, albumname) + if not mb_artist_id and trackname: + mb_artist_id = await self.musicbrainz.search_artist_by_track(artistname, trackname) + LOGGER.debug('Got musicbrainz artist id for artist %s --> %s' %(artistname, mb_artist_id)) + return mb_artist_id + + @staticmethod + def merge_metadata(cur_metadata, new_values): + ''' merge new info into the metadata dict without overwiteing existing values ''' + for key, value in new_values.items(): + if not cur_metadata.get(key): + cur_metadata[key] = value + return cur_metadata + +class MusicBrainz(): + + def __init__(self, event_loop, cache): + self.event_loop = event_loop + self.cache = cache + self.http_session = aiohttp.ClientSession(loop=event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) + self.throttler = Throttler(rate_limit=1, period=1) + + async def search_artist_by_album(self, artistname, albumname=None, album_upc=None): + ''' retrieve musicbrainz artist id by providing the artist name and albumname or upc ''' + if album_upc: + endpoint = 'release' + params = {'query': 'barcode:%s' % album_upc} + else: + searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname) + searchartist = searchartist.replace('/','').replace('\\','') + searchalbum = re.sub(LUCENE_SPECIAL, r'\\\1', albumname) + endpoint = 'release' + params = {'query': 'artist:"%s" AND release:"%s"' % (searchartist, searchalbum)} + result = await self.get_data(endpoint, params) + if result and result.get('releases'): + for strictness in [1, 0.95, 0.9]: + for item in result['releases']: + if album_upc or Matcher(None, item['title'].lower(), albumname.lower()).ratio() >= strictness: + for artist in item['artist-credit']: + artist = artist['artist'] + if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness: + return artist['id'] + for item in artist.get('aliases',[]): + if item['name'].lower() == artistname.lower(): + return artist['id'] + return '' + + async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None): + ''' retrieve artist id by providing the artist name and trackname or track isrc ''' + endpoint = 'recording' + searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname) + searchartist = searchartist.replace('/','').replace('\\','') + if track_isrc: + endpoint = 'isrc/%s' % track_isrc + params = {'inc': 'artist-credits'} + else: + searchtrack = re.sub(LUCENE_SPECIAL, r'\\\1', trackname) + endpoint = 'recording' + params = {'query': '"%s" AND artist:"%s"' % (searchtrack, searchartist)} + result = await self.get_data(endpoint, params) + if result and result.get('recordings'): + for strictness in [1, 0.95]: + for item in result['recordings']: + if track_isrc or Matcher(None, item['title'].lower(), trackname.lower()).ratio() >= strictness: + for artist in item['artist-credit']: + artist = artist['artist'] + if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness: + return artist['id'] + for item in artist.get('aliases',[]): + if item['name'].lower() == artistname.lower(): + return artist['id'] + return '' + + @use_cache(30) + async def get_data(self, endpoint, params={}): + ''' get data from api''' + url = 'http://musicbrainz.org/ws/2/%s' % endpoint + headers = {'User-Agent': 'Music Assistant/1.0.0 https://github.com/marcelveldt'} + params['fmt'] = 'json' + async with self.throttler: + async with self.http_session.get(url, headers=headers, params=params) as response: + try: + result = await response.json() + except Exception as exc: + msg = await response.text() + LOGGER.exception("%s - %s" % (str(exc), msg)) + result = None + return result + + +class FanartTv(): + + def __init__(self, event_loop, cache): + self.event_loop = event_loop + self.cache = cache + self.http_session = aiohttp.ClientSession(loop=event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) + self.throttler = Throttler(rate_limit=1, period=1) + + async def artist_images(self, mb_artist_id): + ''' retrieve images by musicbrainz artist id ''' + metadata = {} + data = await self.get_data("music/%s" % mb_artist_id) + if data: + if data.get('hdmusiclogo'): + metadata['logo'] = data['hdmusiclogo'][0]["url"] + elif data.get('musiclogo'): + metadata['logo'] = data['musiclogo'][0]["url"] + if data.get('artistbackground'): + count = 0 + for item in data['artistbackground']: + key = "fanart" if count == 0 else "fanart.%s" % count + metadata[key] = item["url"] + if data.get('artistthumb'): + url = data['artistthumb'][0]["url"] + if not '2a96cbd8b46e442fc41c2b86b821562f' in url: + metadata['image'] = url + if data.get('musicbanner'): + metadata['banner'] = data['musicbanner'][0]["url"] + return metadata + + @use_cache(30) + async def get_data(self, endpoint, params={}): + ''' get data from api''' + url = 'http://webservice.fanart.tv/v3/%s' % endpoint + params['api_key'] = '639191cb0774661597f28a47e7e2bad5' + async with self.throttler: + async with self.http_session.get(url, params=params) as response: + result = await response.json() + if 'error' in result and 'limit' in result['error']: + raise Exception(result['error']) + return result diff --git a/music_assistant/models/__init__.py b/music_assistant/models/__init__.py index e69de29b..05b72219 100644 --- a/music_assistant/models/__init__.py +++ b/music_assistant/models/__init__.py @@ -0,0 +1,5 @@ +from .media_types import * +from .musicprovider import * +from .player_queue import * +from .player import * +from .playerprovider import * \ No newline at end of file diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py index 1c968a65..13d98bd3 100755 --- a/music_assistant/models/media_types.py +++ b/music_assistant/models/media_types.py @@ -45,96 +45,70 @@ class TrackQuality(IntEnum): FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES - -class Artist(object): - ''' representation of an artist ''' +class MediaItem(object): + ''' representation of a media item ''' def __init__(self): self.item_id = None self.provider = 'database' self.name = '' - self.sort_name = '' self.metadata = {} self.tags = [] self.external_ids = [] self.provider_ids = [] - self.media_type = MediaType.Artist self.in_library = [] self.is_lazy = False + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name == other.name and + self.item_id == other.item_id and + self.provider == other.provider) + def __ne__(self, other): + return not self.__eq__(other) -class Album(object): +class Artist(MediaItem): + ''' representation of an artist ''' + def __init__(self): + super().__init__() + self.sort_name = '' + self.media_type = MediaType.Artist + +class Album(MediaItem): ''' representation of an album ''' def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' - self.metadata = {} + super().__init__() self.version = '' - self.external_ids = [] - self.tags = [] self.albumtype = AlbumType.Album self.year = 0 self.artist = None self.labels = [] - self.provider_ids = [] self.media_type = MediaType.Album - self.in_library = [] - self.is_lazy = False -class Track(object): +class Track(MediaItem): ''' representation of a track ''' def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' + super().__init__() self.duration = 0 self.version = '' - self.external_ids = [] - self.metadata = { } - self.tags = [] self.artists = [] - self.provider_ids = [] self.album = None self.disc_number = 1 self.track_number = 1 self.media_type = MediaType.Track - self.in_library = [] - self.is_lazy = False - self.uri = "" - def __eq__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - return (self.name == other.name and - self.version == other.version and - self.item_id == other.item_id and - self.provider == other.provider) - def __ne__(self, other): - return not self.__eq__(other) -class Playlist(object): +class Playlist(MediaItem): ''' representation of a playlist ''' def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' + super().__init__() self.owner = '' - self.provider_ids = [] - self.metadata = {} self.media_type = MediaType.Playlist - self.in_library = [] self.is_editable = False -class Radio(Track): +class Radio(MediaItem): ''' representation of a radio station ''' def __init__(self): super().__init__() - self.item_id = None - self.provider = 'database' - self.name = '' - self.provider_ids = [] - self.metadata = {} self.media_type = MediaType.Radio - self.in_library = [] - self.is_editable = False self.duration = 0 diff --git a/music_assistant/models/musicprovider.py b/music_assistant/models/musicprovider.py index c75e16a5..41c1ca2b 100755 --- a/music_assistant/models/musicprovider.py +++ b/music_assistant/models/musicprovider.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- +import asyncio from typing import List from ..utils import run_periodic, LOGGER, parse_track_title -import asyncio -from ..modules.cache import use_cache -from media_types import * +from ..cache import use_cache +from ..constants import CONF_ENABLED +from .media_types import Album, Artist, Track, Playlist, MediaType, Radio class MusicProvider(): @@ -373,20 +374,20 @@ class PlayerProvider(): async def players(self): ''' return all players for this provider ''' - return self.mass.provider_players(self.prov_id) + return await self.mass.provider_players(self.prov_id) async def get_player(self, player_id): ''' return player by id ''' - return self.mass.get_player(player_id) + return await self.mass.get_player(player_id) async def add_player(self, player_id, name='', is_group=False): ''' register a new player ''' - return self.mass.player.add_player(player_id, + return await self.mass.player.add_player(player_id, self.prov_id, name=name, is_group=is_group) async def remove_player(self, player_id): ''' remove a player ''' - return self.mass.player.remove_player(player_id) + return await self.mass.player.remove_player(player_id) ### Provider specific implementation ##### diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 05f7ad55..4ae2c4f6 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- +import asyncio from enum import Enum from typing import List +import operator from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, try_parse_bool, try_parse_float from ..constants import CONF_ENABLED -from ..modules.cache import use_cache -from media_types import Track, MediaType -from player_queue import PlayerQueue, QueueItem +from ..cache import use_cache +from .media_types import Track, MediaType +from .player_queue import PlayerQueue, QueueItem class PlayerState(str, Enum): @@ -21,57 +23,104 @@ class Player(): #### Provider specific implementation, should be overridden #### - async def get_config_entries(self): - ''' [MAY OVERRIDE] get the player-specific config entries for this player (list with key/value pairs)''' - return [] - - async def __stop(self): + async def cmd_stop(self): ''' [MUST OVERRIDE] send stop command to player ''' raise NotImplementedError - async def __play(self): + async def cmd_play(self): ''' [MUST OVERRIDE] send play (unpause) command to player ''' raise NotImplementedError - async def __pause(self): + async def cmd_pause(self): ''' [MUST OVERRIDE] send pause command to player ''' raise NotImplementedError + + async def cmd_next(self): + ''' [CAN OVERRIDE] send next track command to player ''' + return await self.queue.play_index(self.queue.cur_index+1) + + async def cmd_previous(self): + ''' [CAN OVERRIDE] send previous track command to player ''' + return await self.queue.play_index(self.queue.cur_index-1) - async def __power_on(self): + async def cmd_power_on(self): ''' [MUST OVERRIDE] send power ON command to player ''' raise NotImplementedError - async def __power_off(self): + async def cmd_power_off(self): ''' [MUST OVERRIDE] send power TOGGLE command to player ''' raise NotImplementedError - async def __volume_set(self, volume_level): + async def cmd_volume_set(self, volume_level): ''' [MUST OVERRIDE] send new volume level command to player ''' raise NotImplementedError - async def __volume_mute(self, is_muted=False): + async def cmd_volume_mute(self, is_muted=False): ''' [MUST OVERRIDE] send mute command to player ''' raise NotImplementedError - async def __play_queue(self): - ''' [MUST OVERRIDE] tell player to start playing the queue ''' + async def cmd_queue_play_index(self, index:int): + ''' + [OVERRIDE IF SUPPORTED] + play item at index X on player's queue + :attrib index: (int) index of the queue item that should start playing + ''' + raise NotImplementedError + + async def cmd_queue_load(self, queue_items): + ''' + [OVERRIDE IF SUPPORTED] + load/overwrite given items in the player's own queue implementation + :param queue_items: a list of QueueItems + ''' + raise NotImplementedError + + async def cmd_queue_insert(self, queue_items, offset=0): + ''' + [OVERRIDE IF SUPPORTED] + insert new items at position X into existing queue + if offset 0 or None, will start playing newly added item(s) + :param queue_items: a list of QueueItems + :param offset: offset from current queue position to insert new items + ''' + raise NotImplementedError + + async def cmd_queue_append(self, queue_items): + ''' + append new items at the end of the queue + :param queue_items: a list of QueueItems + ''' + raise NotImplementedError + + async def cmd_play_uri(self, uri:str): + ''' + [MUST OVERRIDE] + tell player to start playing a single uri + ''' raise NotImplementedError #### Common implementation, should NOT be overrridden ##### def __init__(self, mass, player_id, prov_id): + # private attributes self.mass = mass - self._player_id = player_id - self._prov_id = prov_id + self._player_id = player_id # unique id for this player + self._prov_id = prov_id # unique provider id for the player self._name = '' - self._is_group = False - self._state = PlayerState.Stopped - self._powered = False + self._is_group = False + self._state = PlayerState.Stopped + self._powered = False self._cur_time = 0 + self._cur_uri = '' self._volume_level = 0 self._muted = False self._group_parent = None self._queue = PlayerQueue(mass, self) + # public attributes + self.supports_queue = True # has native support for a queue + self.supports_gapless = True # has native gapless support + self.supports_crossfade = False # has native crossfading support + self.supports_replay_gain = False # has native support for replaygain volume leveling @property def player_id(self): @@ -116,8 +165,8 @@ class Player(): if not self.powered: return PlayerState.Off if self.group_parent: - group_player = self.mass.event_loop.run_until_complete( - self.mass.player.get_player(self.group_parent)) + group_player = self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_player(self.group_parent)).result() if group_player: return group_player.state return self._state @@ -125,7 +174,7 @@ class Player(): @state.setter def state(self, state:PlayerState): ''' [PROTECTED] set state property of this player ''' - if state != self.state: + if state != self._state: self._state = state self.mass.event_loop.create_task(self.update()) @@ -134,18 +183,18 @@ class Player(): ''' [PROTECTED] return power state for this player ''' # homeassistant integration if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): - hass_state = self.mass.event_loop.run_until_complete( + hass_state = self.mass.bg_executor.submit(asyncio.run, self.mass.hass.get_state( self.settings['hass_power_entity'], attribute='source', - register_listener=self.update())) + register_listener=self.update())).result() return hass_state == self.settings['hass_power_entity_source'] elif self.settings.get('hass_power_entity'): - hass_state = self.mass.event_loop.run_until_complete( + hass_state = self.mass.bg_executor.submit(asyncio.run, self.mass.hass.get_state( self.settings['hass_power_entity'], attribute='state', - register_listener=self.update())) + register_listener=self.update())).result() return hass_state != 'off' # mute as power elif self.settings.get('mute_as_power'): @@ -156,15 +205,17 @@ class Player(): @powered.setter def powered(self, powered): ''' [PROTECTED] set (real) power state for this player ''' - self._powered = powered + if powered != self._powered: + self._powered = powered + self.mass.event_loop.create_task(self.update()) @property def cur_time(self): ''' [PROTECTED] cur_time (player's elapsed time) property of this player ''' # handle group player if self.group_parent: - group_player = self.mass.event_loop.run_until_complete( - self.mass.player.get_player(self.group_parent)) + group_player = self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_player(self.group_parent)).result() if group_player: return group_player.cur_time return self._cur_time @@ -176,6 +227,24 @@ class Player(): self._cur_time = cur_time self.mass.event_loop.create_task(self.update()) + @property + def cur_uri(self): + ''' [PROTECTED] cur_uri (uri loaded in player) property of this player ''' + # handle group player + if self.group_parent: + group_player = self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_player(self.group_parent)).result() + if group_player: + return group_player.cur_uri + return self._cur_uri + + @cur_uri.setter + def cur_uri(self, cur_uri:str): + ''' [PROTECTED] set cur_uri (uri loaded in player) property of this player ''' + if cur_uri != self._cur_uri: + self._cur_uri = cur_uri + self.mass.event_loop.create_task(self.update()) + @property def volume_level(self): ''' [PROTECTED] volume_level property of this player ''' @@ -192,11 +261,11 @@ class Player(): return group_volume # handle hass integration elif self.mass.hass and self.settings.get('hass_volume_entity'): - hass_state = self.mass.event_loop.run_until_complete( + hass_state = self.mass.bg_executor.submit(asyncio.run, self.mass.hass.get_state( - self.settings['hass_volume_entity'], + self.settings['hass_volume_entity'], attribute='volume_level', - register_listener=self.update())) + register_listener=self.update())).result() return int(try_parse_float(hass_state)*100) else: return self._volume_level @@ -246,7 +315,9 @@ class Player(): ''' [PROTECTED] get the player config settings ''' player_settings = self.mass.config['player_settings'].get(self.player_id) if not player_settings: - return self.mass.event_loop.run_until_complete(self.__update_player_settings()) + player_settings = self.mass.bg_executor.submit(asyncio.run, + self.__update_player_settings()).result() + return player_settings @property def enabled(self): @@ -258,12 +329,17 @@ class Player(): ''' [PROTECTED] player's queue ''' # handle group player if self.group_parent: - group_player = self.mass.event_loop.run_until_complete( - self.mass.player.get_player(self.group_parent)) + group_player = self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_player(self.group_parent)).result() if group_player: return group_player.queue return self._queue + @property + def cur_item(self): + ''' current item in the player's queue ''' + return self.queue.cur_item + async def stop(self): ''' [PROTECTED] send stop command to player ''' if self.group_parent: @@ -272,7 +348,7 @@ class Player(): if group_player: return await group_player.stop() else: - return await self.__stop() + return await self.cmd_stop() async def play(self): ''' [PROTECTED] send play (unpause) command to player ''' @@ -282,9 +358,9 @@ class Player(): if group_player: return await group_player.play() elif self.state == PlayerState.Paused: - return await self.__play() + return await self.cmd_play() elif self.state != PlayerState.Playing: - return await self.play_queue() + return await self.queue.resume() async def pause(self): ''' [PROTECTED] send pause command to player ''' @@ -294,14 +370,42 @@ class Player(): if group_player: return await group_player.pause() else: - return await self.__pause() + return await self.cmd_pause() + async def next(self): + ''' [PROTECTED] send next command to player ''' + if self.group_parent: + # redirect playback related commands to parent player + group_player = await self.mass.player.get_player(self.group_parent) + if group_player: + return await group_player.next() + else: + return await self.queue.next() + + async def previous(self): + ''' [PROTECTED] send previous command to player ''' + if self.group_parent: + # redirect playback related commands to parent player + group_player = await self.mass.player.get_player(self.group_parent) + if group_player: + return await group_player.previous() + else: + return await self.queue.previous() + + async def power(self, power): + ''' [PROTECTED] send power ON command to player ''' + power = try_parse_bool(power) + if power: + return await self.power_on() + else: + return await self.power_off() + async def power_on(self): ''' [PROTECTED] send power ON command to player ''' - self.__power_on() + await self.cmd_power_on() # handle mute as power if self.settings['mute_as_power']: - self.volume_mute(False) + await self.volume_mute(False) # handle hass integration if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source') @@ -317,7 +421,7 @@ class Player(): await self.mass.hass.call_service(domain, 'turn_on', service_data) # handle play on power on if self.settings['play_power_on']: - self.play() + await self.play() # handle group power if self.group_parent: # player has a group parent, check if it should be turned on @@ -327,10 +431,10 @@ class Player(): async def power_off(self): ''' [PROTECTED] send power TOGGLE command to player ''' - self.__power_off() + await self.cmd_power_off() # handle mute as power if self.settings['mute_as_power']: - self.volume_mute(True) + await self.volume_mute(True) # handle hass integration if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source') @@ -390,119 +494,46 @@ class Player(): 'volume_level': volume_level/100 } await self.mass.hass.call_service('media_player', 'volume_set', service_data) - await self.__volume_set(100) # just force full volume on actual player if volume is outsourced to hass + await self.cmd_volume_set(100) # just force full volume on actual player if volume is outsourced to hass else: - await self.__volume_set(volume_level) + await self.cmd_volume_set(volume_level) async def volume_up(self): - ''' [MAY OVERRIDE] send volume up command to player ''' + ''' [PROTECTED] send volume up command to player ''' new_level = self.volume_level + 1 return await self.volume_set(new_level) async def volume_down(self): - ''' [MAY OVERRIDE] send volume down command to player ''' + ''' [PROTECTED] send volume down command to player ''' new_level = self.volume_level - 1 if new_level < 0: new_level = 0 return await self.volume_set(new_level) async def volume_mute(self, is_muted=False): - ''' [MUST OVERRIDE] send mute command to player ''' - return await self.__volume_mute(is_muted) - - async def play_queue(self): - ''' [PROTECTED] send play_queue (start stream) command to player ''' - if self.group_parent: - # redirect playback related commands to parent player - group_player = await self.mass.player.get_player(self.group_parent) - if group_player: - return await group_player.play_queue() - elif self.queue.items: - return await self.__play_queue() + ''' [PROTECTED] send mute command to player ''' + return await self.cmd_volume_mute(is_muted) - async def play_media(self, media_item, queue_opt='play'): - ''' - play media item(s) on this player - media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio) - single item or list of items - queue_opt: - play -> insert new items in queue and start playing at the inserted position - replace -> replace queue contents with these items - next -> play item(s) after current playing item - add -> append new items at end of the queue - ''' - # a single item or list of items may be provided - media_items = media_item if isinstance(media_item, list) else [media_item] - queue_tracks = [] - for media_item in media_items: - # collect tracks to play - if media_item.media_type == MediaType.Artist: - tracks = await self.mass.music.artist_toptracks(media_item.item_id, - provider=media_item.provider) - elif media_item.media_type == MediaType.Album: - tracks = await self.mass.music.album_tracks(media_item.item_id, - provider=media_item.provider) - elif media_item.media_type == MediaType.Playlist: - tracks = await self.mass.music.playlist_tracks(media_item.item_id, - provider=media_item.provider, offset=0, limit=0) - else: - tracks = [media_item] # single track - for track in tracks: - queue_item = QueueItem() - queue_item.name = track.name - queue_item.artists = track.artists - queue_item.album = track.album - queue_item.duration = track.duration - queue_item.version = track.version - queue_item.metadata = track.metadata - queue_item.media_type = track.media_type - queue_item.uri = 'http://%s:%s/stream_queue?player_id=%s'% ( - self.local_ip, self.mass.config['base']['web']['http_port'], player_id) - # sort by quality and check track availability - for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True): - media_provider = prov_media['provider'] - media_item_id = prov_media['item_id'] - player_supported_provs = player_prov.supported_musicproviders - if media_provider in player_supported_provs and not self.mass.config['player_settings'][player_id]['force_http_streamer']: - # the provider can handle this media_type directly ! - track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, is_radio=is_radio) - playable_tracks.append(track) - match_found = True - elif 'http' in player_prov.supported_musicproviders: - # fallback to http streaming if supported - track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True, is_radio=is_radio) - queue_tracks.append(track) - match_found = True - if match_found: - break - if queue_tracks: - if self._players[player_id].shuffle_enabled: - random.shuffle(playable_tracks) - if queue_opt in ['next', 'play'] and len(playable_tracks) > 1: - queue_opt = 'replace' # always assume playback of multiple items as new queue - return await player_prov.play_media(player_id, playable_tracks, queue_opt) - else: - raise Exception("Musicprovider and/or media not supported by player %s !" % (player_id) ) - async def update(self): ''' [PROTECTED] signal player updated ''' - self.__update_player_settings() + await self.__update_player_settings() LOGGER.info("player updated: %s" % self.name) self.mass.signal_event('player changed', self) async def __update_player_settings(self): ''' [PROTECTED] get (or create) player config settings ''' config_entries = [ # default config entries for a player - ("enabled", False, "player_enabled"), + ("enabled", True, "player_enabled"), ("name", "", "player_name"), ("mute_as_power", False, "player_mute_power"), ("max_sample_rate", 96000, "max_sample_rate"), ('volume_normalisation', True, 'enable_r128_volume_normalisation'), ('target_volume', '-23', 'target_volume_lufs'), - ('fallback_gain_correct', '-12', 'fallback_gain_correct') + ('fallback_gain_correct', '-12', 'fallback_gain_correct'), + ("crossfade_duration", 0, "crossfade_duration"), ] # append player specific settings - config_entries += await self.get_config_entries() + config_entries += await self.mass.player.providers[self._prov_id].get_player_config_entries() if self.is_group or not self.group_parent: config_entries += [ # play on power on setting ("play_power_on", False, "player_power_play"), @@ -522,4 +553,22 @@ class Player(): self.mass.config['player_settings'][self.player_id] = player_settings self.mass.config['player_settings'][self.player_id]['__desc__'] = config_entries return player_settings - \ No newline at end of file + + @property + def __dict__(self): + ''' instance attributes as dict so it can be serialized to json ''' + return { + "player_id": self.player_id, + "player_provider": self.player_provider, + "name": self.name, + "is_group": self.is_group, + "state": self.state, + "powered": self.powered, + "cur_time": self.cur_time, + "cur_uri": self.cur_uri, + "volume_level": self.volume_level, + "muted": self.muted, + "group_parent": self.group_parent, + "enabled": self.enabled, + "cur_item": self.cur_item.__dict__ if self.cur_item else None + } \ No newline at end of file diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 95d64375..ccf495d8 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -1,37 +1,28 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -from ..utils import LOGGER -from ..constants import CONF_ENABLED +import asyncio from typing import List -from player import PlayerState -from media_types import Track, TrackQuality import operator import random +import uuid + +from ..utils import LOGGER +from ..constants import CONF_ENABLED +from .media_types import Track, TrackQuality + -class QueueItem(object): +class QueueItem(Track): ''' representation of a queue item, simplified version of track ''' - def __init__(self): - self.item_id = None - self.provider = None - self.name = '' - self.duration = 0 - self.version = '' + def __init__(self, media_item=None): + super().__init__() self.quality = TrackQuality.FLAC_LOSSLESS - self.metadata = {} - self.artists = [] - self.album = None self.uri = "" - self.is_radio = False - def __eq__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - return (self.name == other.name and - self.version == other.version and - self.item_id == other.item_id and - self.provider == other.provider) - def __ne__(self, other): - return not self.__eq__(other) + self.queue_item_id = str(uuid.uuid4()) + # if existing media_item given, load those values + if media_item: + for attribute, value in media_item.__dict__.items(): + setattr(self, attribute, value) class PlayerQueue(): ''' @@ -45,7 +36,7 @@ class PlayerQueue(): self._player = player self._items = [] self._shuffle_enabled = True - self._repeat_enabled = True + self._repeat_enabled = False self._cur_index = None @property @@ -56,18 +47,30 @@ class PlayerQueue(): def repeat_enabled(self): return self._repeat_enabled + @property + def crossfade_enabled(self): + return self._player.settings['crossfade_duration'] + + @property + def gapless_enabled(self): + return self._player.settings.get('gapless_enabled', True) + @property def cur_index(self): + ''' match current uri with queue items to determine queue index ''' + for index, queue_item in enumerate(self.items): + if queue_item.uri == self._player.cur_uri: + return index return self._cur_index @property def cur_item(self): - if self._cur_index == None: + if self.cur_index == None: return None - return self.mass.event_loop.run_until_complete(self.get_item(self._cur_index)) + return self.mass.bg_executor.submit(asyncio.run,self.get_item(self.cur_index)).result() @property - async def next_index(self): + def next_index(self): ''' return the next queue index for this player ''' @@ -87,63 +90,128 @@ class PlayerQueue(): return None @property - async def next_item(self): + def next_item(self): ''' return the next item in the queue ''' - return self.mass.event_loop.run_until_complete( - self.get_item(self.next_index)) + return self.mass.bg_executor.submit( + asyncio.run, self.get_item(self.next_index)).result() @property - async def items(self): + def items(self): ''' return all queue items for this player ''' return self._items + @property + def use_queue_stream(self): + ''' + bool to indicate that we need to use the queue stream + for example if crossfading is requested but a player doesn't natively support it + it will send a constant stream of audio to the player and all tracks + ''' + return ((self.crossfade_enabled and not self._player.supports_crossfade) or + (self.gapless_enabled and not self._player.supports_gapless)) + async def get_item(self, index): ''' get item by index from queue ''' - if len(self._items) > index: - return self._items[index] + if index != None and len(self.items) > index: + return self.items[index] return None + async def by_item_id(self, queue_item_id:str): + ''' get item by queue_item_id from queue ''' + if not queue_item_id: + return None + for item in self.items: + if item.queue_item_id == queue_item_id: + return item + return None + async def shuffle(self, enable_shuffle:bool): ''' enable/disable shuffle ''' if not self._shuffle_enabled and enable_shuffle: # shuffle requested self._shuffle_enabled = True - self._items = await self.__shuffle_items(self._items) - self._cur_index = None - await self._player.play_queue() + await self.load(self._items) self.mass.event_loop.create_task(self._player.update()) elif self._shuffle_enabled and not enable_shuffle: self._shuffle_enabled = False # TODO: Unshuffle the list ? self.mass.event_loop.create_task(self._player.update()) + async def next(self): + ''' request next track in queue ''' + if self.use_queue_stream: + return await self.play_index(self.cur_index+1) + else: + return await self._player.cmd_next() + + async def previous(self): + ''' request previous track in queue ''' + if self.use_queue_stream: + return await self.play_index(self.cur_index-1) + else: + return await self._player.cmd_previous() + + async def resume(self): + ''' resume previous queue ''' + if self.items: + prev_index = self.cur_index + await self.load(self._items) + if prev_index: + await self.play_index(prev_index) + else: + LOGGER.warning("resume queue requested for %s but queue is empty" % self._player.name) + + async def play_index(self, index): + ''' play item at index X in queue ''' + if not len(self.items) > index: + return + if self.use_queue_stream: + self._cur_index = index -1 + queue_stream_uri = 'http://%s:%s/stream/%s'% ( + self.mass.web.local_ip, self.mass.web.http_port, self._player.player_id) + return await self._player.cmd_play_uri(queue_stream_uri) + elif self._player.supports_queue: + return await self._player.cmd_queue_play_index(index) + else: + return await self._player.cmd_play_uri(self._items[index].uri) + async def load(self, queue_items:List[QueueItem]): ''' load (overwrite) queue with new items ''' if self._shuffle_enabled: queue_items = await self.__shuffle_items(queue_items) self._items = queue_items self._cur_index = None - await self._player.play_queue() + if self.use_queue_stream or not self._player.supports_queue: + return await self.play_index(0) + else: + return await self._player.cmd_queue_load(queue_items) async def insert(self, queue_items:List[QueueItem], offset=0): ''' insert new items at offset x from current position keeps remaining items in queue if offset 0 or None, will start playing newly added item(s) + :param queue_items: a list of QueueItem + :param offset: offset from current queue position ''' - insert_at_index = self.cur_index + offset + if self.cur_index: + insert_at_index = self.cur_index + offset + else: + insert_at_index = 0 if not self.items or insert_at_index >= len(self.items): return await self.load(queue_items) if self.shuffle_enabled: queue_items = await self.__shuffle_items(queue_items) self._items = self._items[:insert_at_index] + queue_items + self._items[insert_at_index:] - if not offset: - await self._player.stop() - await self._player.play_queue() + if self.use_queue_stream or not self._player.supports_queue: + if offset == 0: + return await self.play_index(0) + else: + return await self._player.cmd_queue_insert(queue_items, offset) async def append(self, queue_items:List[QueueItem]): ''' @@ -152,6 +220,8 @@ class PlayerQueue(): if self.shuffle_enabled: queue_items = await self.__shuffle_items(queue_items) self._items = self._items + queue_items + if self._player.supports_queue: + return await self._player.cmd_queue_append(queue_items) async def __shuffle_items(self, queue_items): ''' shuffle a list of tracks ''' diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py index d2868b01..2b45955f 100755 --- a/music_assistant/models/playerprovider.py +++ b/music_assistant/models/playerprovider.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- +import asyncio from enum import Enum from typing import List from ..utils import run_periodic, LOGGER, parse_track_title from ..constants import CONF_ENABLED -from ..modules.cache import use_cache -from player_queue import PlayerQueue -from media_types import Track -from player import Player +from ..cache import use_cache +from .player_queue import PlayerQueue +from .media_types import Track +from .player import Player class PlayerProvider(): @@ -26,22 +27,27 @@ class PlayerProvider(): ### Common methods and properties #### + async def get_player_config_entries(self): + ''' [CAN OVERRIDE] get the player-specific config entries for this provider (list with key/value pairs)''' + return [] + @property - async def players(self): + def players(self): ''' return all players for this provider ''' - return self.mass.player.get_provider_players(self.prov_id) + return self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_provider_players(self.prov_id)).result() async def get_player(self, player_id:str): ''' return player by id ''' - return self.mass.player.get_player(player_id) + return await self.mass.player.get_player(player_id) async def add_player(self, player:Player): ''' register a new player ''' - return self.mass.player.add_player(player) + return await self.mass.player.add_player(player) async def remove_player(self, player_id:str): ''' remove a player ''' - return self.mass.player.remove_player(player_id) + return await self.mass.player.remove_player(player_id) ### Provider specific implementation ##### diff --git a/music_assistant/modules/__init__.py b/music_assistant/modules/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/music_assistant/modules/cache.py b/music_assistant/modules/cache.py deleted file mode 100644 index 85945daa..00000000 --- a/music_assistant/modules/cache.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- - -'''provides a simple stateless caching system''' - -import datetime -import time -import sqlite3 -from functools import reduce -import os -from utils import run_periodic, LOGGER, parse_track_title -import functools -import asyncio - - -class Cache(object): - '''basic stateless caching system ''' - _exit = False - _mem_cache = {} - _busy_tasks = [] - _database = None - - def __init__(self, datapath): - '''Initialize our caching class''' - self._datapath = datapath - asyncio.ensure_future(self._do_cleanup()) - LOGGER.debug("Initialized") - - async def get(self, endpoint, checksum=""): - ''' - get object from cache and return the results - endpoint: the (unique) name of the cache object as reference - checkum: optional argument to check if the checksum in the cacheobject matches the checkum provided - ''' - checksum = self._get_checksum(checksum) - cur_time = self._get_timestamp(datetime.datetime.now()) - result = None - # 1: try memory cache first - result = await self._get_mem_cache(endpoint, checksum, cur_time) - # 2: fallback to _database cache - if result is None: - result = await self._get_db_cache(endpoint, checksum, cur_time) - return result - - async def set(self, endpoint, data, checksum="", expiration=datetime.timedelta(days=14)): - ''' - set data in cache - ''' - task_name = "set.%s" % endpoint - self._busy_tasks.append(task_name) - checksum = self._get_checksum(checksum) - expires = self._get_timestamp(datetime.datetime.now() + expiration) - - # memory cache - await self._set_mem_cache(endpoint, checksum, expires, data) - - # db cache - if not self._exit: - await self._set_db_cache(endpoint, checksum, expires, data) - - # remove this task from list - self._busy_tasks.remove(task_name) - - async def _get_mem_cache(self, endpoint, checksum, cur_time): - ''' - get cache data from memory cache - ''' - result = None - cachedata = self._mem_cache.get(endpoint) - if cachedata: - cachedata = cachedata - if cachedata[0] > cur_time: - if checksum == None or checksum == cachedata[2]: - result = cachedata[1] - return result - - async def _set_mem_cache(self, endpoint, checksum, expires, data): - ''' - put data in memory cache - ''' - cachedata = (expires, data, checksum) - self._mem_cache[endpoint] = cachedata - - async def _get_db_cache(self, endpoint, checksum, cur_time): - '''get cache data from sqllite database''' - result = None - query = "SELECT expires, data, checksum FROM simplecache WHERE id = ?" - cache_data = self._execute_sql(query, (endpoint,)) - if cache_data: - cache_data = cache_data.fetchone() - if cache_data and cache_data[0] > cur_time: - if checksum == None or cache_data[2] == checksum: - result = eval(cache_data[1]) - # also set result in memory cache for further access - await self._set_mem_cache(endpoint, checksum, cache_data[0], result) - return result - - async def _set_db_cache(self, endpoint, checksum, expires, data): - ''' store cache data in _database ''' - query = "INSERT OR REPLACE INTO simplecache( id, expires, data, checksum) VALUES (?, ?, ?, ?)" - data = repr(data) - self._execute_sql(query, (endpoint, expires, data, checksum)) - - @run_periodic(3600) - async def _do_cleanup(self): - '''perform cleanup task''' - if self._exit: - return - self._busy_tasks.append(__name__) - cur_time = datetime.datetime.now() - cur_timestamp = self._get_timestamp(cur_time) - LOGGER.debug("Running cleanup...") - query = "SELECT id, expires FROM simplecache" - for cache_data in self._execute_sql(query).fetchall(): - cache_id = cache_data[0] - cache_expires = cache_data[1] - if self._exit: - return - # always cleanup all memory objects on each interval - self._mem_cache.pop(cache_id, None) - # clean up db cache object only if expired - if cache_expires < cur_timestamp: - query = 'DELETE FROM simplecache WHERE id = ?' - self._execute_sql(query, (cache_id,)) - LOGGER.debug("delete from db %s" % cache_id) - - # compact db - self._execute_sql("VACUUM") - - # remove task from list - self._busy_tasks.remove(__name__) - LOGGER.debug("Auto cleanup done") - - def _get_database(self): - '''get reference to our sqllite _database - performs basic integrity check''' - dbfile = os.path.join(self._datapath, "simplecache.db") - try: - connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None) - connection.execute('SELECT * FROM simplecache LIMIT 1') - return connection - except Exception as error: - # our _database is corrupt or doesn't exist yet, we simply try to recreate it - if os.path.isfile(dbfile): - os.remove(dbfile) - try: - connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None) - connection.execute( - """CREATE TABLE IF NOT EXISTS simplecache( - id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""") - return connection - except Exception as error: - LOGGER.warning("Exception while initializing _database: %s" % str(error)) - return None - - def _execute_sql(self, query, data=None): - '''little wrapper around execute and executemany to just retry a db command if db is locked''' - retries = 0 - result = None - error = None - # always use new db object because we need to be sure that data is available for other simplecache instances - with self._get_database() as _database: - while not retries == 10: - if self._exit: - return None - try: - if isinstance(data, list): - result = _database.executemany(query, data) - elif data: - result = _database.execute(query, data) - else: - result = _database.execute(query) - return result - except sqlite3.OperationalError as error: - if "_database is locked" in error: - LOGGER.debug("retrying DB commit...") - retries += 1 - time.sleep(0.5) - else: - break - except Exception as error: - LOGGER.error("_database ERROR ! -- %s" % str(error)) - break - return None - - @staticmethod - def _get_timestamp(date_time): - '''Converts a datetime object to unix timestamp''' - return int(time.mktime(date_time.timetuple())) - - @staticmethod - def _get_checksum(stringinput): - '''get int checksum from string''' - if not stringinput: - return 0 - else: - stringinput = str(stringinput) - return reduce(lambda x, y: x + y, map(ord, stringinput)) - -def use_cache(cache_days=14, cache_hours=8): - def wrapper(func): - @functools.wraps(func) - async def wrapped(*args, **kwargs): - if kwargs.get("ignore_cache"): - return await func(*args, **kwargs) - cache_checksum = kwargs.get("cache_checksum") - method_class = args[0] - method_class_name = method_class.__class__.__name__ - cache_str = "%s.%s" % (method_class_name, func.__name__) - # append args to cache identifier - for item in args[1:]: - if isinstance(item, dict): - for subkey in sorted(list(item.keys())): - subvalue = item[subkey] - cache_str += ".%s%s" %(subkey,subvalue) - else: - cache_str += ".%s" % item - # append kwargs to cache identifier - for key in sorted(list(kwargs.keys())): - if key in ["ignore_cache", "cache_checksum"]: - continue - value = kwargs[key] - if isinstance(value, dict): - for subkey in sorted(list(value.keys())): - subvalue = value[subkey] - cache_str += ".%s%s" %(subkey,subvalue) - else: - cache_str += ".%s%s" %(key,value) - cache_str = cache_str.lower() - cachedata = await method_class.cache.get(cache_str, checksum=cache_checksum) - if cachedata is not None: - return cachedata - else: - result = await func(*args, **kwargs) - await method_class.cache.set(cache_str, result, checksum=cache_checksum, expiration=datetime.timedelta(days=cache_days, hours=cache_hours)) - return result - return wrapped - return wrapper diff --git a/music_assistant/modules/homeassistant.py b/music_assistant/modules/homeassistant.py deleted file mode 100644 index 32961b47..00000000 --- a/music_assistant/modules/homeassistant.py +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from typing import List -import random -from utils import run_periodic, LOGGER, parse_track_title, try_parse_int -from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT -import json -import aiohttp -import time -import datetime -import hashlib -from asyncio_throttle import Throttler -from aiocometd import Client, ConnectionType, Extension -from modules.cache import use_cache -import copy -import slugify as slug - -''' - Homeassistant integration - allows publishing of our players to hass - allows using hass entities (like switches, media_players or gui inputs) to be triggered -''' - -def setup(mass): - ''' setup the module and read/apply config''' - create_config_entries(mass.config) - conf = mass.config['base']['homeassistant'] - enabled = conf.get(CONF_ENABLED) - token = conf.get('token') - url = conf.get('url') - if enabled and url and token: - return HomeAssistant(mass, url, token) - return None - -def create_config_entries(config): - ''' get the config entries for this module (list with key/value pairs)''' - config_entries = [ - (CONF_ENABLED, False, 'enabled'), - ('url', 'localhost', 'hass_url'), - ('token', '', 'hass_token'), - ('publish_players', True, 'hass_publish') - ] - if not config['base'].get('homeassistant'): - config['base']['homeassistant'] = {} - config['base']['homeassistant']['__desc__'] = config_entries - for key, def_value, desc in config_entries: - if not key in config['base']['homeassistant']: - config['base']['homeassistant'][key] = def_value - -class HomeAssistant(): - ''' HomeAssistant integration ''' - - def __init__(self, mass, url, token): - self.mass = mass - self._published_players = {} - self._tracked_states = {} - self._state_listeners = [] - self._sources = [] - self._token = token - if url.startswith('https://'): - self._use_ssl = True - self._host = url.replace('https://','').split('/')[0] - else: - self._use_ssl = False - self._host = url.replace('http://','').split('/')[0] - self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) - self.__send_ws = None - self.__last_id = 10 - LOGGER.info('Homeassistant integration is enabled') - mass.event_loop.create_task(self.__hass_websocket()) - self.mass.add_event_listener(self.mass_event, "player updated") - mass.event_loop.create_task(self.__get_sources()) - - async def get_state(self, entity_id, attribute='state', register_listener=None): - ''' get state of a hass entity''' - if entity_id in self._tracked_states: - state_obj = self._tracked_states[entity_id] - else: - # first request - state_obj = await self.__get_data('states/%s' % entity_id) - if register_listener: - # register state listener - self._state_listeners.append( (entity_id, register_listener) ) - self._tracked_states[entity_id] = state_obj - if attribute == 'state': - return state_obj['state'] - elif not attribute: - return state_obj - else: - return state_obj['attributes'].get(attribute) - - async def mass_event(self, msg, msg_details): - ''' received event from mass ''' - if msg == "player updated": - await self.publish_player(msg_details) - - async def hass_event(self, event_type, event_data): - ''' received event from hass ''' - if event_type == 'state_changed': - if event_data['entity_id'] in self._tracked_states: - self._tracked_states[event_data['entity_id']] = event_data['new_state'] - for entity_id, handler in self._state_listeners: - if entity_id == event_data['entity_id']: - asyncio.create_task(handler()) - elif event_type == 'call_service' and event_data['domain'] == 'media_player': - await self.__handle_player_command(event_data['service'], event_data['service_data']) - - async def __handle_player_command(self, service, service_data): - ''' handle forwarded service call for one of our players ''' - if isinstance(service_data['entity_id'], list): - # can be a list of entity ids if action fired on multiple items - entity_ids = service_data['entity_id'] - else: - entity_ids = [service_data['entity_id']] - for entity_id in entity_ids: - if entity_id in self._published_players: - # call is for one of our players so handle it - player_id = self._published_players[entity_id] - if service == 'turn_on': - await self.mass.player.player_command(player_id, 'power', 'on') - elif service == 'turn_off': - await self.mass.player.player_command(player_id, 'power', 'off') - elif service == 'toggle': - await self.mass.player.player_command(player_id, 'power', 'toggle') - elif service == 'volume_mute': - args = 'on' if service_data['is_volume_muted'] else 'off' - await self.mass.player.player_command(player_id, 'mute', args) - elif service == 'volume_up': - await self.mass.player.player_command(player_id, 'volume', 'up') - elif service == 'volume_down': - await self.mass.player.player_command(player_id, 'volume', 'down') - elif service == 'volume_set': - volume_level = service_data['volume_level']*100 - await self.mass.player.player_command(player_id, 'volume', volume_level) - elif service == 'media_play': - await self.mass.player.player_command(player_id, 'play') - elif service == 'media_pause': - await self.mass.player.player_command(player_id, 'pause') - elif service == 'media_stop': - await self.mass.player.player_command(player_id, 'stop') - elif service == 'media_next_track': - await self.mass.player.player_command(player_id, 'next') - elif service == 'media_play_pause': - await self.mass.player.player_command(player_id, 'pause', 'toggle') - elif service == 'play_media': - return await self.__handle_play_media(player_id, service_data) - - async def __handle_play_media(self, player_id, service_data): - ''' handle play_media request from homeassistant''' - media_content_type = service_data['media_content_type'].lower() - media_content_id = service_data['media_content_id'] - queue_opt = 'add' if service_data.get('enqueue') else 'play' - if media_content_type == 'playlist' and not '://' in media_content_id: - media_items = [] - for playlist_str in media_content_id.split(','): - playlist_str = playlist_str.strip() - playlist = await self.mass.music.playlist_by_name(playlist_str) - if playlist: - media_items.append(playlist) - return await self.mass.player.play_media(player_id, media_items, queue_opt) - elif media_content_type == 'playlist' and 'spotify://playlist' in media_content_id: - # TODO: handle parsing of other uri's here - playlist = self.mass.music.providers['spotify'].playlist(media_content_id.split(':')[-1]) - return await self.mass.player.play_media(player_id, playlist, queue_opt) - elif media_content_id.startswith('http'): - track = Track() - track.uri = media_content_id - track.provider = 'http' - return await self.mass.player.play_media(player_id, track, queue_opt) - - async def publish_player(self, player): - ''' publish player details to hass''' - if not self.mass.config['base']['homeassistant']['publish_players']: - return False - player_id = player.player_id - entity_id = 'media_player.mass_' + slug.slugify(player.name, separator='_').lower() - state = player.state if player.powered else 'off' - state_attributes = { - "supported_features": 65471, - "friendly_name": player.name, - "source_list": self._sources, - "source": 'unknown', - "volume_level": player.volume_level/100, - "is_volume_muted": player.muted, - "media_duration": player.cur_item.duration if player.cur_item else 0, - "media_position": player.cur_item_time, - "media_title": player.cur_item.name if player.cur_item else "", - "media_artist": player.cur_item.artists[0].name if player.cur_item and player.cur_item.artists else "", - "media_album_name": player.cur_item.album.name if player.cur_item and player.cur_item.album else "", - "entity_picture": player.cur_item.album.metadata.get('image') if player.cur_item and player.cur_item.album else "" - } - self._published_players[entity_id] = player_id - await self.__set_state(entity_id, state, state_attributes) - - async def call_service(self, domain, service, service_data=None): - ''' call service on hass ''' - if not self.__send_ws: - return False - msg = { - "type": "call_service", - "domain": domain, - "service": service, - } - if service_data: - msg['service_data'] = service_data - return await self.__send_ws(msg) - - @run_periodic(120) - async def __get_sources(self): - ''' we build a list of all playlists to use as player sources ''' - self._sources = [playlist.name for playlist in await self.mass.music.playlists()] - - async def __set_state(self, entity_id, new_state, state_attributes={}): - ''' set state to hass entity ''' - data = { - "state": new_state, - "entity_id": entity_id, - "attributes": state_attributes - } - return await self.__post_data('states/%s' % entity_id, data) - - async def __hass_websocket(self): - ''' Receive events from Hass through websockets ''' - while self.mass.event_loop.is_running(): - try: - protocol = 'wss' if self._use_ssl else 'ws' - async with self.http_session.ws_connect('%s://%s/api/websocket' % (protocol, self._host)) as ws: - - async def send_msg(msg): - ''' callback to send message to the websockets client''' - self.__last_id += 1 - msg['id'] = self.__last_id - await ws.send_json(msg) - - async for msg in ws: - if msg.type == aiohttp.WSMsgType.TEXT: - if msg.data == 'close cmd': - await ws.close() - break - else: - data = msg.json() - if data['type'] == 'auth_required': - # send auth token - auth_msg = {"type": "auth", "access_token": self._token} - await ws.send_json(auth_msg) - elif data['type'] == 'auth_invalid': - raise Exception(data) - elif data['type'] == 'auth_ok': - # register callback - self.__send_ws = send_msg - # subscribe to events - subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"} - await send_msg(subscribe_msg) - subscribe_msg = {"type": "subscribe_events", "event_type": "call_service"} - await send_msg(subscribe_msg) - elif data['type'] == 'event': - asyncio.create_task(self.hass_event(data['event']['event_type'], data['event']['data'])) - elif data['type'] == 'result' and data.get('result'): - # reply to our get_states request - asyncio.create_task(self.hass_event('all_states', data['result'])) - else: - LOGGER.info(data) - elif msg.type == aiohttp.WSMsgType.ERROR: - raise Exception("error in websocket") - except Exception as exc: - LOGGER.exception(exc) - await asyncio.sleep(10) - - async def __get_data(self, endpoint): - ''' get data from hass rest api''' - url = "http://%s/api/%s" % (self._host, endpoint) - if self._use_ssl: - url = "https://%s/api/%s" % (self._host, endpoint) - headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"} - async with self.http_session.get(url, headers=headers) as response: - return await response.json() - - async def __post_data(self, endpoint, data): - ''' post data to hass rest api''' - url = "http://%s/api/%s" % (self._host, endpoint) - if self._use_ssl: - url = "https://%s/api/%s" % (self._host, endpoint) - headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"} - async with self.http_session.post(url, headers=headers, json=data) as response: - return await response.json() \ No newline at end of file diff --git a/music_assistant/modules/http_streamer.py b/music_assistant/modules/http_streamer.py deleted file mode 100755 index e4c02612..00000000 --- a/music_assistant/modules/http_streamer.py +++ /dev/null @@ -1,449 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from utils import LOGGER, try_parse_int, get_ip, run_async_background_task, run_periodic, get_folder_size -from models import TrackQuality, MediaType, PlayerState -import operator -from aiohttp import web -import threading -import urllib -from memory_tempfile import MemoryTempfile -import io -import soundfile as sf -import pyloudnorm as pyln -import aiohttp - - -class HTTPStreamer(): - ''' Built-in streamer using sox and webserver ''' - - def __init__(self, mass): - self.mass = mass - self.create_config_entries() - self.local_ip = get_ip() - self.analyze_jobs = {} - - async def stream_track(self, http_request): - ''' start streaming track from provider ''' - player_id = http_request.query.get('player_id') - track_id = http_request.query.get('track_id') - provider = http_request.query.get('provider') - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'audio/flac'}) - await resp.prepare(http_request) - if http_request.method.upper() != 'HEAD': - # stream audio - cancelled = threading.Event() - queue = asyncio.Queue() - - async def fill_buffer(): - ''' fill buffer runs in background process to prevent deadlocks of the sox executable ''' - audio_stream = self.__get_audio_stream(track_id, provider, player_id, cancelled) - async for is_last_chunk, audio_chunk in audio_stream: - if not cancelled.is_set(): - await queue.put(audio_chunk) - # wait for the queue to consume the data - # this prevents that the entire track is sitting in memory - while queue.qsize() > 1 and not cancelled.is_set(): - await asyncio.sleep(1) - await queue.put(b'') # EOF - run_async_background_task(self.mass.bg_executor, fill_buffer) - - try: - while True: - chunk = await queue.get() - if not chunk: - queue.task_done() - break - await resp.write(chunk) - queue.task_done() - except (asyncio.CancelledError, asyncio.TimeoutError): - cancelled.set() - LOGGER.info("stream_track interrupted for %s" % track_id) - raise asyncio.CancelledError() - else: - LOGGER.info("stream_track fininished for %s" % track_id) - return resp - - async def stream_radio(self, http_request): - ''' start streaming radio from provider ''' - player_id = http_request.query.get('player_id') - radio_id = http_request.query.get('radio_id') - provider = http_request.query.get('provider') - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'audio/flac'}) - await resp.prepare(http_request) - if http_request.method.upper() != 'HEAD': - # stream audio with sox - sox_effects = await self.__get_player_sox_options(radio_id, provider, player_id, True) - if self.mass.config['base']['http_streamer']['volume_normalisation']: - gain_correct = await self.__get_track_gain_correct(radio_id, provider) - gain_correct = 'vol %s dB ' % gain_correct - else: - gain_correct = '' - media_item = await self.mass.music.item(radio_id, MediaType.Radio, provider) - stream = sorted(media_item.provider_ids, key=operator.itemgetter('quality'), reverse=True)[0] - stream_url = stream["details"] - if stream["quality"] == TrackQuality.LOSSY_AAC: - input_content_type = "aac" - elif stream["quality"] == TrackQuality.LOSSY_OGG: - input_content_type = "ogg" - else: - input_content_type = "mp3" - if input_content_type == "aac": - args = 'ffmpeg -i "%s" -f flac - | sox -t flac - -t flac -C 0 - %s %s' % (stream_url, gain_correct, sox_effects) - else: - args = 'sox -t %s "%s" -t flac -C 0 - %s %s' % (input_content_type, stream_url, gain_correct, sox_effects) - LOGGER.info("Running sox with args: %s" % args) - process = await asyncio.create_subprocess_shell(args, stdout=asyncio.subprocess.PIPE) - try: - while not process.stdout.at_eof(): - chunk = await process.stdout.read(128000) - if not chunk: - break - await resp.write(chunk) - await process.wait() - LOGGER.info("streaming of radio_id %s completed" % radio_id) - except asyncio.CancelledError: - process.terminate() - await process.wait() - LOGGER.info("streaming of radio_id %s interrupted" % radio_id) - raise asyncio.CancelledError() - return resp - - async def stream(self, http_request): - ''' - stream queue track(s) for player with http - ''' - player_id = request.match_info.get('player_id','') - #startindex = int(http_request.query.get('startindex')) - cancelled = threading.Event() - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'audio/flac'}) - await resp.prepare(http_request) - if http_request.method.upper() != 'HEAD': - # stream audio - queue = asyncio.Queue() - cancelled = threading.Event() - run_async_background_task( - self.mass.bg_executor, - self.__stream_queue, player_id, startindex, queue, cancelled) - try: - while True: - chunk = await queue.get() - if not chunk: - queue.task_done() - break - await resp.write(chunk) - queue.task_done() - LOGGER.info("stream fininished for %s" % player_id) - except asyncio.CancelledError: - cancelled.set() - LOGGER.info("stream interrupted for %s" % player_id) - raise asyncio.CancelledError() - return resp - - async def __stream_queue(self, player_id, startindex, buffer, cancelled): - ''' start streaming all queue tracks ''' - sample_rate = self.mass.config['player_settings'][player_id]['max_sample_rate'] - fade_length = self.mass.config['player_settings'][player_id]["crossfade_duration"] - fade_bytes = int(sample_rate * 4 * 2 * fade_length) - pcm_args = 'raw -b 32 -c 2 -e signed-integer -r %s' % sample_rate - args = 'sox -t %s - -t flac -C 0 -' % pcm_args - sox_proc = await asyncio.create_subprocess_shell(args, - stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE) - - async def fill_buffer(): - while not sox_proc.stdout.at_eof(): - chunk = await sox_proc.stdout.read(256000) - if not chunk: - break - await buffer.put(chunk) - await buffer.put(b'') # indicate EOF - asyncio.create_task(fill_buffer()) - - # retrieve player object - player = await self.mass.player.player(player_id) - queue_index = startindex - LOGGER.info("Start Queue Stream for player %s at index %s" %(player.name, queue_index)) - last_fadeout_data = b'' - # report start of queue playback so we can calculate current track/duration etc. - # self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True)) - while True: - # get the (next) track in queue - try: - queue_tracks = await self.mass.player.player_queue(player_id, queue_index, queue_index+1) - queue_track = queue_tracks[0] - except IndexError: - LOGGER.warning("queue index out of range or end reached") - break - - params = urllib.parse.parse_qs(queue_track.uri.split('?')[1]) - track_id = params['track_id'][0] - provider = params['provider'][0] - LOGGER.debug("Start Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name)) - fade_in_part = b'' - cur_chunk = 0 - prev_chunk = None - bytes_written = 0 - async for is_last_chunk, chunk in self.__get_audio_stream( - track_id, provider, player_id, cancelled, chunksize=fade_bytes, resample=sample_rate): - cur_chunk += 1 - if cur_chunk <= 2 and not last_fadeout_data: - # fade-in part but no fadeout_part available so just pass it to the output directly - sox_proc.stdin.write(chunk) - await sox_proc.stdin.drain() - bytes_written += len(chunk) - elif cur_chunk == 1 and last_fadeout_data: - prev_chunk = chunk - elif cur_chunk == 2 and last_fadeout_data: - # combine the first 2 chunks and strip off silence - args = 'sox --ignore-length -t %s - -t %s - silence 1 0.1 1%%' % (pcm_args, pcm_args) - process = await asyncio.create_subprocess_shell(args, - stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) - first_part, stderr = await process.communicate(prev_chunk + chunk) - fade_in_part = first_part[:fade_bytes] - remaining_bytes = first_part[fade_bytes:] - del first_part - # do crossfade - crossfade_part = await self.__crossfade_pcm_parts(fade_in_part, last_fadeout_data, pcm_args, fade_length) - sox_proc.stdin.write(crossfade_part) - await sox_proc.stdin.drain() - bytes_written += len(crossfade_part) - del crossfade_part - del fade_in_part - last_fadeout_data = b'' - # also write the leftover bytes from the strip action - sox_proc.stdin.write(remaining_bytes) - await sox_proc.stdin.drain() - bytes_written += len(remaining_bytes) - del remaining_bytes - prev_chunk = None # needed to prevent this chunk being sent again - elif prev_chunk and is_last_chunk: - # last chunk received so create the fadeout_part with the previous chunk and this chunk - # and strip off silence - args = 'sox --ignore-length -t %s - -t %s - reverse silence 1 0.1 1%% reverse' % (pcm_args, pcm_args) - process = await asyncio.create_subprocess_shell(args, - stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) - last_part, stderr = await process.communicate(prev_chunk + chunk) - if len(last_part) < fade_bytes: - # not enough data for crossfade duration after the strip action... - last_part = prev_chunk + chunk - if len(last_part) < fade_bytes: - # still not enough data so we'll skip the crossfading - LOGGER.warning("not enough data for fadeout so skip crossfade... %s" % len(last_part)) - sox_proc.stdin.write(last_part) - bytes_written += len(last_part) - await sox_proc.stdin.drain() - del last_part - else: - # store fade section to be picked up for next track - last_fadeout_data = last_part[-fade_bytes:] - remaining_bytes = last_part[:-fade_bytes] - # write remaining bytes - sox_proc.stdin.write(remaining_bytes) - bytes_written += len(remaining_bytes) - await sox_proc.stdin.drain() - del last_part - del remaining_bytes - else: - # middle part of the track - # keep previous chunk in memory so we have enough samples to perform the crossfade - if prev_chunk: - sox_proc.stdin.write(prev_chunk) - await sox_proc.stdin.drain() - bytes_written += len(prev_chunk) - prev_chunk = chunk - else: - prev_chunk = chunk - # wait for the queue to consume the data - # this prevents that the entire track is sitting in memory - # and it helps a bit in the quest to follow where we are in the queue - while buffer.qsize() > 1 and not cancelled.is_set(): - await asyncio.sleep(1) - # end of the track reached - if cancelled.is_set(): - # break out the loop if the http session is cancelled - break - else: - # WIP: update actual duration to the queue for more accurate now playing info - accurate_duration = bytes_written / int(sample_rate * 4 * 2) - queue_track.duration = accurate_duration - self.mass.player.providers[player.player_provider]._player_queue[player_id][queue_index] = queue_track - # move to next queue index - queue_index += 1 - self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, False)) - LOGGER.debug("Finished Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name)) - # end of queue reached, pass last fadeout bits to final output - if last_fadeout_data and not cancelled.is_set(): - sox_proc.stdin.write(last_fadeout_data) - await sox_proc.stdin.drain() - sox_proc.stdin.close() - await sox_proc.wait() - LOGGER.info("streaming of queue for player %s completed" % player.name) - - async def __get_audio_stream(self, track_id, provider, player_id, cancelled, - chunksize=512000, resample=None): - ''' get audio stream from provider and apply additional effects/processing where/if needed''' - if self.mass.config['base']['http_streamer']['volume_normalisation']: - gain_correct = await self.__get_track_gain_correct(track_id, provider) - gain_correct = 'vol %s dB ' % gain_correct - else: - gain_correct = '' - sox_effects = await self.__get_player_sox_options(track_id, provider, player_id, False) - outputfmt = 'flac -C 0' - if resample: - outputfmt = 'raw -b 32 -c 2 -e signed-integer' - sox_effects += ' rate -v %s' % resample - # stream audio from provider - streamdetails = asyncio.run_coroutine_threadsafe( - self.mass.music.providers[provider].get_stream_details(track_id), - self.mass.event_loop).result() - if not streamdetails: - yield (True, b'') - return - # TODO: add support for AAC streams (which sox doesn't natively support) - if streamdetails['type'] == 'url': - args = 'sox -t %s "%s" -t %s - %s %s' % (streamdetails["content_type"], - streamdetails["path"], outputfmt, gain_correct, sox_effects) - elif streamdetails['type'] == 'executable': - args = '%s | sox -t %s - -t %s - %s %s' % (streamdetails["path"], - streamdetails["content_type"], outputfmt, gain_correct, sox_effects) - LOGGER.debug("Running sox with args: %s" % args) - process = await asyncio.create_subprocess_shell(args, - stdout=asyncio.subprocess.PIPE) - # fire event that streaming has started for this track (needed by some streaming providers) - streamdetails["provider"] = provider - streamdetails["track_id"] = track_id - streamdetails["player_id"] = player_id - self.mass.signal_event('streaming_started', streamdetails) - # yield chunks from stdout - # we keep 1 chunk behind to detect end of stream properly - prev_chunk = b'' - bytes_sent = 0 - while not process.stdout.at_eof(): - try: - chunk = await process.stdout.readexactly(chunksize) - except asyncio.streams.IncompleteReadError: - chunk = await process.stdout.read(chunksize) - if not chunk: - break - if prev_chunk and not cancelled.is_set(): - yield (False, prev_chunk) - bytes_sent += len(prev_chunk) - prev_chunk = chunk - # yield last chunk - if not cancelled.is_set(): - yield (True, prev_chunk) - bytes_sent += len(prev_chunk) - #await process.wait() - if cancelled.is_set(): - LOGGER.warning("__get_audio_stream for track_id %s interrupted" % track_id) - else: - LOGGER.debug("__get_audio_stream for track_id %s completed" % track_id) - # fire event that streaming has ended for this track (needed by some streaming providers) - if resample: - bytes_per_second = resample * (32/8) * 2 - else: - bytes_per_second = streamdetails["sample_rate"] * (streamdetails["bit_depth"]/8) * 2 - seconds_streamed = int(bytes_sent/bytes_per_second) - streamdetails["seconds"] = seconds_streamed - self.mass.signal_event('streaming_ended', streamdetails) - # send task to background to analyse the audio - self.mass.event_loop.create_task(self.__analyze_audio(track_id, provider)) - - async def __get_player_sox_options(self, track_id, provider, player_id, is_radio): - ''' get player specific sox options ''' - sox_effects = '' - if player_id and not is_radio and self.mass.config['player_settings'][player_id]['max_sample_rate']: - # downsample if needed - max_sample_rate = try_parse_int(self.mass.config['player_settings'][player_id]['max_sample_rate']) - if max_sample_rate: - quality = TrackQuality.LOSSY_MP3 - track_future = asyncio.run_coroutine_threadsafe( - self.mass.music.track(track_id, provider), - self.mass.event_loop - ) - track = track_future.result() - for item in track.provider_ids: - if item['provider'] == provider and item['item_id'] == track_id: - quality = item['quality'] - break - if quality > TrackQuality.FLAC_LOSSLESS_HI_RES_3 and max_sample_rate == 192000: - sox_effects += 'rate -v 192000' - elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_2 and max_sample_rate == 96000: - sox_effects += 'rate -v 96000' - elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_1 and max_sample_rate == 48000: - sox_effects += 'rate -v 48000' - if player_id and self.mass.config['player_settings'][player_id]['sox_effects']: - sox_effects += ' ' + self.mass.config['player_settings'][player_id]['sox_effects'] - return sox_effects - - async def __analyze_audio(self, track_id, provider): - ''' analyze track audio, for now we only calculate EBU R128 loudness ''' - track_key = '%s%s' %(track_id, provider) - if track_key in self.analyze_jobs: - return # prevent multiple analyze jobs for same track - self.analyze_jobs[track_key] = True - streamdetails = await self.mass.music.providers[provider].get_stream_details(track_id) - track_loudness = await self.mass.db.get_track_loudness(track_id, provider) - if track_loudness == None: - # only when needed we do the analyze stuff - LOGGER.debug('Start analyzing track %s' % track_id) - if streamdetails['type'] == 'url': - async with aiohttp.ClientSession() as session: - async with session.get(streamdetails["path"]) as resp: - audio_data = await resp.read() - elif streamdetails['type'] == 'executable': - process = await asyncio.create_subprocess_shell(streamdetails["path"], - stdout=asyncio.subprocess.PIPE) - audio_data, stderr = await process.communicate() - # calculate BS.1770 R128 integrated loudness - if track_loudness == None: - with io.BytesIO(audio_data) as tmpfile: - data, rate = sf.read(tmpfile) - meter = pyln.Meter(rate) # create BS.1770 meter - loudness = meter.integrated_loudness(data) # measure loudness - del data - LOGGER.debug("Integrated loudness of track %s is: %s" %(track_id, loudness)) - await self.mass.db.set_track_loudness(track_id, provider, loudness) - del audio_data - LOGGER.debug('Finished analyzing track %s' % track_id) - self.analyze_jobs.pop(track_key, None) - - async def __get_track_gain_correct(self, track_id, provider): - ''' get the gain correction that should be applied to a track ''' - target_gain = int(self.mass.config['base']['http_streamer']['target_volume']) - fallback_gain = int(self.mass.config['base']['http_streamer']['fallback_gain_correct']) - track_loudness = await self.mass.db.get_track_loudness(track_id, provider) - if track_loudness == None: - return fallback_gain - gain_correct = target_gain - track_loudness - return round(gain_correct,2) - - async def __crossfade_pcm_parts(self, fade_in_part, fade_out_part, pcm_args, fade_length): - ''' crossfade two chunks of audio using sox ''' - # create fade-in part - fadeinfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0) - args = 'sox --ignore-length -t %s - -t %s %s fade t %s' % (pcm_args, pcm_args, fadeinfile.name, fade_length) - process = await asyncio.create_subprocess_shell(args, stdin=asyncio.subprocess.PIPE) - await process.communicate(fade_in_part) - # create fade-out part - fadeoutfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0) - args = 'sox --ignore-length -t %s - -t %s %s reverse fade t %s reverse' % (pcm_args, pcm_args, fadeoutfile.name, fade_length) - process = await asyncio.create_subprocess_shell(args, - stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) - await process.communicate(fade_out_part) - # create crossfade using sox and some temp files - # TODO: figure out how to make this less complex and without the tempfiles - args = 'sox -m -v 1.0 -t %s %s -v 1.0 -t %s %s -t %s -' % (pcm_args, fadeoutfile.name, pcm_args, fadeinfile.name, pcm_args) - process = await asyncio.create_subprocess_shell(args, - stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE) - crossfade_part, stderr = await process.communicate() - LOGGER.debug("Got %s bytes in memory for crossfade_part after sox" % len(crossfade_part)) - return crossfade_part diff --git a/music_assistant/modules/metadata.py b/music_assistant/modules/metadata.py deleted file mode 100755 index 9765d665..00000000 --- a/music_assistant/modules/metadata.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from utils import run_periodic, LOGGER -import json -import aiohttp -from asyncio_throttle import Throttler -from difflib import SequenceMatcher as Matcher -from modules.cache import use_cache -from yarl import URL -import re - -LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' - -class MetaData(): - ''' several helpers to search and store mediadata for mediaitems ''' - - def __init__(self, event_loop, db, cache): - self.event_loop = event_loop - self.db = db - self.cache = cache - self.musicbrainz = MusicBrainz(event_loop, cache) - self.fanarttv = FanartTv(event_loop, cache) - - async def get_artist_metadata(self, mb_artist_id, cur_metadata): - ''' get/update rich metadata for an artist by providing the musicbrainz artist id ''' - metadata = cur_metadata - if not ('fanart' in metadata or 'thumb' in metadata): - res = await self.fanarttv.artist_images(mb_artist_id) - self.merge_metadata(cur_metadata, res) - return metadata - - async def get_mb_artist_id(self, artistname, albumname=None, album_upc=None, trackname=None, track_isrc=None): - ''' retrieve musicbrainz artist id for the given details ''' - LOGGER.debug('searching musicbrainz for %s (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)' %(artistname, albumname, album_upc, trackname, track_isrc)) - mb_artist_id = None - if album_upc: - mb_artist_id = await self.musicbrainz.search_artist_by_album(artistname, None, album_upc) - if not mb_artist_id and track_isrc: - mb_artist_id = await self.musicbrainz.search_artist_by_track(artistname, None, track_isrc) - if not mb_artist_id and albumname: - mb_artist_id = await self.musicbrainz.search_artist_by_album(artistname, albumname) - if not mb_artist_id and trackname: - mb_artist_id = await self.musicbrainz.search_artist_by_track(artistname, trackname) - LOGGER.debug('Got musicbrainz artist id for artist %s --> %s' %(artistname, mb_artist_id)) - return mb_artist_id - - @staticmethod - def merge_metadata(cur_metadata, new_values): - ''' merge new info into the metadata dict without overwiteing existing values ''' - for key, value in new_values.items(): - if not cur_metadata.get(key): - cur_metadata[key] = value - return cur_metadata - -class MusicBrainz(): - - def __init__(self, event_loop, cache): - self.event_loop = event_loop - self.cache = cache - self.http_session = aiohttp.ClientSession(loop=event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) - self.throttler = Throttler(rate_limit=1, period=1) - - async def search_artist_by_album(self, artistname, albumname=None, album_upc=None): - ''' retrieve musicbrainz artist id by providing the artist name and albumname or upc ''' - if album_upc: - endpoint = 'release' - params = {'query': 'barcode:%s' % album_upc} - else: - searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname) - searchartist = searchartist.replace('/','').replace('\\','') - searchalbum = re.sub(LUCENE_SPECIAL, r'\\\1', albumname) - endpoint = 'release' - params = {'query': 'artist:"%s" AND release:"%s"' % (searchartist, searchalbum)} - result = await self.get_data(endpoint, params) - if result and result.get('releases'): - for strictness in [1, 0.95, 0.9]: - for item in result['releases']: - if album_upc or Matcher(None, item['title'].lower(), albumname.lower()).ratio() >= strictness: - for artist in item['artist-credit']: - artist = artist['artist'] - if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness: - return artist['id'] - for item in artist.get('aliases',[]): - if item['name'].lower() == artistname.lower(): - return artist['id'] - return '' - - async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None): - ''' retrieve artist id by providing the artist name and trackname or track isrc ''' - endpoint = 'recording' - searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname) - searchartist = searchartist.replace('/','').replace('\\','') - if track_isrc: - endpoint = 'isrc/%s' % track_isrc - params = {'inc': 'artist-credits'} - else: - searchtrack = re.sub(LUCENE_SPECIAL, r'\\\1', trackname) - endpoint = 'recording' - params = {'query': '"%s" AND artist:"%s"' % (searchtrack, searchartist)} - result = await self.get_data(endpoint, params) - if result and result.get('recordings'): - for strictness in [1, 0.95]: - for item in result['recordings']: - if track_isrc or Matcher(None, item['title'].lower(), trackname.lower()).ratio() >= strictness: - for artist in item['artist-credit']: - artist = artist['artist'] - if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness: - return artist['id'] - for item in artist.get('aliases',[]): - if item['name'].lower() == artistname.lower(): - return artist['id'] - return '' - - @use_cache(30) - async def get_data(self, endpoint, params={}): - ''' get data from api''' - url = 'http://musicbrainz.org/ws/2/%s' % endpoint - headers = {'User-Agent': 'Music Assistant/1.0.0 https://github.com/marcelveldt'} - params['fmt'] = 'json' - async with self.throttler: - async with self.http_session.get(url, headers=headers, params=params) as response: - try: - result = await response.json() - except Exception as exc: - msg = await response.text() - LOGGER.exception("%s - %s" % (str(exc), msg)) - result = None - return result - - -class FanartTv(): - - def __init__(self, event_loop, cache): - self.event_loop = event_loop - self.cache = cache - self.http_session = aiohttp.ClientSession(loop=event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) - self.throttler = Throttler(rate_limit=1, period=1) - - async def artist_images(self, mb_artist_id): - ''' retrieve images by musicbrainz artist id ''' - metadata = {} - data = await self.get_data("music/%s" % mb_artist_id) - if data: - if data.get('hdmusiclogo'): - metadata['logo'] = data['hdmusiclogo'][0]["url"] - elif data.get('musiclogo'): - metadata['logo'] = data['musiclogo'][0]["url"] - if data.get('artistbackground'): - count = 0 - for item in data['artistbackground']: - key = "fanart" if count == 0 else "fanart.%s" % count - metadata[key] = item["url"] - if data.get('artistthumb'): - url = data['artistthumb'][0]["url"] - if not '2a96cbd8b46e442fc41c2b86b821562f' in url: - metadata['image'] = url - if data.get('musicbanner'): - metadata['banner'] = data['musicbanner'][0]["url"] - return metadata - - @use_cache(30) - async def get_data(self, endpoint, params={}): - ''' get data from api''' - url = 'http://webservice.fanart.tv/v3/%s' % endpoint - params['api_key'] = '639191cb0774661597f28a47e7e2bad5' - async with self.throttler: - async with self.http_session.get(url, params=params) as response: - result = await response.json() - if 'error' in result and 'limit' in result['error']: - raise Exception(result['error']) - return result diff --git a/music_assistant/modules/music_manager.py b/music_assistant/modules/music_manager.py deleted file mode 100755 index f0689915..00000000 --- a/music_assistant/modules/music_manager.py +++ /dev/null @@ -1,414 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -from typing import List -import toolz -import operator -import os -from ..utils import run_periodic, LOGGER, try_supported -from ..models.media_types import MediaType, Track, Artist, Album, Playlist, Radio - - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -MODULES_PATH = os.path.join(BASE_DIR, "musicproviders" ) - -class Music(): - ''' several helpers around the musicproviders ''' - - def __init__(self, mass): - self.sync_running = False - self.mass = mass - self.providers = {} - # dynamically load musicprovider modules - self.load_music_providers() - # schedule sync task - mass.event_loop.create_task(self.sync_music_providers()) - - async def item(self, item_id, media_type:MediaType, provider='database', lazy=True): - ''' get single music item by id and media type''' - if media_type == MediaType.Artist: - return await self.artist(item_id, provider, lazy=lazy) - elif media_type == MediaType.Album: - return await self.album(item_id, provider, lazy=lazy) - elif media_type == MediaType.Track: - return await self.track(item_id, provider, lazy=lazy) - elif media_type == MediaType.Playlist: - return await self.playlist(item_id, provider) - elif media_type == MediaType.Radio: - return await self.radio(item_id, provider) - else: - return None - - async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Artist]: - ''' return all library artists, optionally filtered by provider ''' - return await self.mass.db.library_artists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) - - async def library_albums(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Album]: - ''' return all library albums, optionally filtered by provider ''' - return await self.mass.db.library_albums(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) - - async def library_tracks(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Track]: - ''' return all library tracks, optionally filtered by provider ''' - return await self.mass.db.library_tracks(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) - - async def playlists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]: - ''' return all library playlists, optionally filtered by provider ''' - return await self.mass.db.playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) - - async def radios(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]: - ''' return all library radios, optionally filtered by provider ''' - return await self.mass.db.radios(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) - - async def library_items(self, media_type:MediaType, limit=0, offset=0, orderby='name', provider_filter=None) -> List[object]: - ''' get multiple music items in library''' - if media_type == MediaType.Artist: - return await self.library_artists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) - elif media_type == MediaType.Album: - return await self.library_albums(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) - elif media_type == MediaType.Track: - return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) - elif media_type == MediaType.Playlist: - return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) - elif media_type == MediaType.Radio: - return await self.radios(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) - - async def artist(self, item_id, provider='database', lazy=True) -> Artist: - ''' get artist by id ''' - if not provider or provider == 'database': - return await self.mass.db.artist(item_id) - return await self.providers[provider].artist(item_id, lazy=lazy) - - async def album(self, item_id, provider='database', lazy=True) -> Album: - ''' get album by id ''' - if not provider or provider == 'database': - return await self.mass.db.album(item_id) - return await self.providers[provider].album(item_id, lazy=lazy) - - async def track(self, item_id, provider='database', lazy=True) -> Track: - ''' get track by id ''' - if not provider or provider == 'database': - return await self.mass.db.track(item_id) - return await self.providers[provider].track(item_id, lazy=lazy) - - async def playlist(self, item_id, provider='database') -> Playlist: - ''' get playlist by id ''' - if not provider or provider == 'database': - return await self.mass.db.playlist(item_id) - return await self.providers[provider].playlist(item_id) - - async def radio(self, item_id, provider='database') -> Radio: - ''' get radio by id ''' - if not provider or provider == 'database': - return await self.mass.db.radio(item_id) - return await self.providers[provider].radio(item_id) - - async def playlist_by_name(self, name) -> Playlist: - ''' get playlist by name ''' - for playlist in await self.playlists(): - if playlist.name == name: - return playlist - return None - - async def radio_by_name(self, name) -> Radio: - ''' get radio by name ''' - for radio in await self.radios(): - if radio.name == name: - return radio - return None - - async def artist_toptracks(self, artist_id, provider='database') -> List[Track]: - ''' get top tracks for given artist ''' - artist = await self.artist(artist_id, provider) - # always append database tracks - items = await self.mass.db.artist_tracks(artist.item_id) - for prov_mapping in artist.provider_ids: - prov_id = prov_mapping['provider'] - prov_item_id = prov_mapping['item_id'] - prov_obj = self.providers[prov_id] - items += await prov_obj.artist_toptracks(prov_item_id) - items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) - items.sort(key=lambda x: x.name, reverse=False) - return items - - async def artist_albums(self, artist_id, provider='database') -> List[Album]: - ''' get (all) albums for given artist ''' - artist = await self.artist(artist_id, provider) - # always append database tracks - items = await self.mass.db.artist_albums(artist.item_id) - for prov_mapping in artist.provider_ids: - prov_id = prov_mapping['provider'] - prov_item_id = prov_mapping['item_id'] - prov_obj = self.providers[prov_id] - items += await prov_obj.artist_albums(prov_item_id) - items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) - items.sort(key=lambda x: x.name, reverse=False) - return items - - async def album_tracks(self, album_id, provider='database') -> List[Track]: - ''' get the album tracks for given album ''' - items = [] - album = await self.album(album_id, provider) - for prov_mapping in album.provider_ids: - prov_id = prov_mapping['provider'] - prov_item_id = prov_mapping['item_id'] - prov_obj = self.providers[prov_id] - items += await prov_obj.album_tracks(prov_item_id) - items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) - items = sorted(items, key=operator.attrgetter('disc_number'), reverse=False) - items = sorted(items, key=operator.attrgetter('track_number'), reverse=False) - return items - - async def playlist_tracks(self, playlist_id, provider='database', offset=0, limit=50) -> List[Track]: - ''' get the tracks for given playlist ''' - playlist = None - if not provider or provider == 'database': - playlist = await self.mass.db.playlist(playlist_id) - if playlist and playlist.is_editable: - # database synced playlist, return tracks from db... - return await self.mass.db.playlist_tracks( - playlist.item_id, offset=offset, limit=limit) - else: - # return playlist tracks from provider - playlist = await self.playlist(playlist_id, provider) - prov = playlist.provider_ids[0] - return await self.providers[prov['provider']].playlist_tracks( - prov['item_id'], offset=offset, limit=limit) - - async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False) -> dict: - ''' search database or providers ''' - # get results from database - result = await self.mass.db.search(searchquery, media_types, limit) - if online: - # include results from music providers - for prov in self.providers.values(): - prov_results = await prov.search(searchquery, media_types, limit) - for item_type, items in prov_results.items(): - if not item_type in result: - result[item_type] = items - else: - result[item_type] += items - # filter out duplicates - for item_type, items in result.items(): - items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) - return result - - async def item_action(self, item_id, media_type, provider, action, action_details=None): - ''' perform action on item (such as library add/remove) ''' - result = None - item = await self.item(item_id, media_type, provider) - if item and action in ['library_add', 'library_remove']: - # remove or add item to the library - for prov_mapping in result.provider_ids: - prov_id = prov_mapping['provider'] - prov_item_id = prov_mapping['item_id'] - for prov in self.providers.values(): - if prov.prov_id == prov_id: - if action == 'add': - result = await prov.add_library(prov_item_id, media_type) - elif action == 'remove': - result = await prov.remove_library(prov_item_id, media_type) - return result - - async def add_playlist_tracks(self, playlist_id, tracks:List[Track]): - ''' add tracks to playlist - make sure we dont add dupes ''' - # we can only edit playlists that are in the database (marked as editable) - playlist = await self.playlist(playlist_id, 'database') - if not playlist or not playlist.is_editable: - LOGGER.warning("Playlist %s is not editable - skip addition of tracks" %(playlist.name)) - return False - playlist_prov = playlist.provider_ids[0] # playlist can only have one provider (for now) - cur_playlist_tracks = await self.mass.db.playlist_tracks(playlist_id, limit=0) - # grab all (database) track ids in the playlist so we can check for duplicates - cur_playlist_track_ids = [item.item_id for item in cur_playlist_tracks] - track_ids_to_add = [] - for track in tracks: - if not track.provider == 'database': - # make sure we have a database track - track = await self.track(track.item_id, track.provider, lazy=False) - if track.item_id in cur_playlist_track_ids: - LOGGER.warning("Track %s already in playlist %s - skip addition" %(track.name, playlist.name)) - continue - # we can only add a track to a provider playlist if the track is available on that provider - # exception is the file provider which does accept tracks from all providers in the m3u playlist - # this should all be handled in the frontend but these checks are here just to be safe - track_playlist_provs = [item['provider'] for item in track.provider_ids] - if playlist_prov['provider'] in track_playlist_provs: - # a track can contain multiple versions on the same provider - # # simply sort by quality and just add the first one (assuming the track is still available) - track_versions = sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True) - for track_version in track_versions: - if track_version['provider'] == playlist_prov['provider']: - track_ids_to_add.append(track_version['item_id']) - break - elif playlist_prov['provider'] == 'file': - # the file provider can handle uri's from all providers in the file so simply add the db id - track_ids_to_add.append(track.item_id) - else: - LOGGER.warning("Track %s not available on provider %s - skip addition to playlist %s" %(track.name, playlist_prov['provider'], playlist.name)) - continue - # actually add the tracks to the playlist on the provider - await self.providers[playlist_prov['provider']].add_playlist_tracks(playlist_prov['item_id'], track_ids_to_add) - # schedule sync - self.mass.event_loop.create_task(self.sync_playlist_tracks(playlist.item_id, playlist_prov['provider'], playlist_prov['item_id'])) - - @run_periodic(3600) - async def sync_music_providers(self): - ''' periodic sync of all music providers ''' - if self.sync_running: - return - self.sync_running = True - for prov_id in self.providers.keys(): - # sync library artists - await try_supported(self.sync_library_artists(prov_id)) - await try_supported(self.sync_library_albums(prov_id)) - await try_supported(self.sync_library_tracks(prov_id)) - await try_supported(self.sync_playlists(prov_id)) - await try_supported(self.sync_radios(prov_id)) - self.sync_running = False - - async def sync_library_artists(self, prov_id): - ''' sync library artists for given provider''' - music_provider = self.providers[prov_id] - prev_items = await self.library_artists(provider_filter=prov_id) - prev_db_ids = [item.item_id for item in prev_items] - cur_items = await music_provider.get_library_artists() - cur_db_ids = [] - for item in cur_items: - db_item = await music_provider.artist(item.item_id, lazy=False) - cur_db_ids.append(db_item.item_id) - if not db_item.item_id in prev_db_ids: - await self.mass.db.add_to_library(db_item.item_id, MediaType.Artist, prov_id) - # process deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id) - LOGGER.info("Finished syncing Artists for provider %s" % prov_id) - - async def sync_library_albums(self, prov_id): - ''' sync library albums for given provider''' - music_provider = self.providers[prov_id] - prev_items = await self.library_albums(provider_filter=prov_id) - prev_db_ids = [item.item_id for item in prev_items] - cur_items = await music_provider.get_library_albums() - cur_db_ids = [] - for item in cur_items: - db_item = await music_provider.album(item.item_id, lazy=False) - cur_db_ids.append(db_item.item_id) - # precache album tracks... - for album_track in await music_provider.get_album_tracks(item.item_id): - await music_provider.track(album_track.item_id) - if not db_item.item_id in prev_db_ids: - await self.mass.db.add_to_library(db_item.item_id, MediaType.Album, prov_id) - # process deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id) - LOGGER.info("Finished syncing Albums for provider %s" % prov_id) - - async def sync_library_tracks(self, prov_id): - ''' sync library tracks for given provider''' - music_provider = self.providers[prov_id] - prev_items = await self.library_tracks(provider_filter=prov_id) - prev_db_ids = [item.item_id for item in prev_items] - cur_items = await music_provider.get_library_tracks() - cur_db_ids = [] - for item in cur_items: - db_item = await music_provider.track(item.item_id, lazy=False) - cur_db_ids.append(db_item.item_id) - if not db_item.item_id in prev_db_ids: - await self.mass.db.add_to_library(db_item.item_id, MediaType.Track, prov_id) - # process deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id) - LOGGER.info("Finished syncing Tracks for provider %s" % prov_id) - - async def sync_playlists(self, prov_id): - ''' sync library playlists for given provider''' - music_provider = self.providers[prov_id] - prev_items = await self.playlists(provider_filter=prov_id) - prev_db_ids = [item.item_id for item in prev_items] - cur_items = await music_provider.get_playlists() - cur_db_ids = [] - for item in cur_items: - # always add to db because playlist attributes could have changed - db_id = await self.mass.db.add_playlist(item) - cur_db_ids.append(db_id) - if not db_id in prev_db_ids: - await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id) - if item.is_editable: - # precache/sync playlist tracks (user owned playlists only) - asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) ) - # process playlist deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, MediaType.Playlist, prov_id) - LOGGER.info("Finished syncing Playlists for provider %s" % prov_id) - - async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id): - ''' sync library playlists tracks for given provider''' - music_provider = self.providers[prov_id] - prev_items = await self.playlist_tracks(db_playlist_id) - prev_db_ids = [item.item_id for item in prev_items] - cur_items = await music_provider.get_playlist_tracks(prov_playlist_id, limit=0) - cur_db_ids = [] - pos = 0 - for item in cur_items: - # we need to do this the complicated way because the file provider can return tracks from other providers - for prov_mapping in item.provider_ids: - item_provider = prov_mapping['provider'] - prov_item_id = prov_mapping['item_id'] - db_item = await self.providers[item_provider].track(prov_item_id, lazy=False) - cur_db_ids.append(db_item.item_id) - if not db_item.item_id in prev_db_ids: - await self.mass.db.add_playlist_track(db_playlist_id, db_item.item_id, pos) - pos += 1 - # process playlist track deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.db.remove_playlist_track(db_playlist_id, db_id) - LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id)) - - async def sync_radios(self, prov_id): - ''' sync library radios for given provider''' - music_provider = self.providers[prov_id] - prev_items = await self.radios(provider_filter=prov_id) - prev_db_ids = [item.item_id for item in prev_items] - cur_items = await music_provider.get_radios() - cur_db_ids = [] - for item in cur_items: - db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Radio) - if not db_id: - db_id = await self.mass.db.add_radio(item) - cur_db_ids.append(db_id) - if not db_id in prev_db_ids: - await self.mass.db.add_to_library(db_id, MediaType.Radio, prov_id) - # process deletions - for db_id in prev_db_ids: - if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id) - LOGGER.info("Finished syncing Radios for provider %s" % prov_id) - - def load_music_providers(self): - ''' dynamically load musicproviders ''' - for item in os.listdir(MODULES_PATH): - if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and - item.endswith('.py') and not item.startswith('.')): - module_name = item.replace(".py","") - LOGGER.debug("Loading musicprovider module %s" % module_name) - try: - mod = __import__("modules.musicproviders." + module_name, fromlist=['']) - if not self.mass.config['musicproviders'].get(module_name): - self.mass.config['musicproviders'][module_name] = {} - self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries() - for key, def_value, desc in mod.config_entries(): - if not key in self.mass.config['musicproviders'][module_name]: - self.mass.config['musicproviders'][module_name][key] = def_value - mod = mod.setup(self.mass) - if mod: - self.providers[mod.prov_id] = mod - cls_name = mod.__class__.__name__ - LOGGER.info("Successfully initialized module %s" % cls_name) - except Exception as exc: - LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/modules/musicproviders/__init__.py b/music_assistant/modules/musicproviders/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/music_assistant/modules/musicproviders/file.py b/music_assistant/modules/musicproviders/file.py deleted file mode 100644 index 8f7900ab..00000000 --- a/music_assistant/modules/musicproviders/file.py +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from typing import List -import sys -import time -from utils import run_periodic, LOGGER, parse_track_title -from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_ENABLED -import taglib -from modules.cache import use_cache -import base64 - - -def setup(mass): - ''' setup the provider''' - enabled = mass.config["musicproviders"]['file'].get(CONF_ENABLED) - music_dir = mass.config["musicproviders"]['file'].get('music_dir') - playlists_dir = mass.config["musicproviders"]['file'].get('playlists_dir') - if enabled and (music_dir or playlists_dir): - file_provider = FileProvider(mass, music_dir, playlists_dir) - return file_provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - ("music_dir", "", "file_prov_music_path"), - ("playlists_dir", "", "file_prov_playlists_path") - ] - -class FileProvider(MusicProvider): - ''' - Very basic implementation of a musicprovider for local files - Assumes files are stored on disk in format // - Reads ID3 tags from file and falls back to parsing filename - Supports m3u files only for playlists - Supports having URI's from streaming providers within m3u playlist - Should be compatible with LMS - ''' - - - def __init__(self, mass, music_dir, playlists_dir): - self.name = 'Local files and playlists' - self.prov_id = 'file' - self.mass = mass - self.cache = mass.cache - self._music_dir = music_dir - self._playlists_dir = playlists_dir - - async def search(self, searchstring, media_types=List[MediaType], limit=5): - ''' perform search on the provider ''' - result = { - "artists": [], - "albums": [], - "tracks": [], - "playlists": [] - } - return result - - async def get_library_artists(self) -> List[Artist]: - ''' get artist folders in music directory ''' - if not os.path.isdir(self._music_dir): - LOGGER.error("music path does not exist: %s" % self._music_dir) - return [] - result = [] - for dirname in os.listdir(self._music_dir): - dirpath = os.path.join(self._music_dir, dirname) - if os.path.isdir(dirpath) and not dirpath.startswith('.'): - artist = await self.get_artist(dirpath) - if artist: - result.append(artist) - return result - - async def get_library_albums(self) -> List[Album]: - ''' get album folders recursively ''' - result = [] - for artist in await self.get_library_artists(): - result += await self.get_artist_albums(artist.item_id) - return result - - async def get_library_tracks(self) -> List[Track]: - ''' get all tracks recursively ''' - #TODO: support disk subfolders - result = [] - for album in await self.get_library_albums(): - result += await self.get_album_tracks(album.item_id) - return result - - async def get_playlists(self) -> List[Playlist]: - ''' retrieve playlists from disk ''' - if not self._playlists_dir: - return [] - result = [] - for filename in os.listdir(self._playlists_dir): - filepath = os.path.join(self._playlists_dir, filename) - if os.path.isfile(filepath) and not filename.startswith('.') and filename.lower().endswith('.m3u'): - playlist = await self.get_playlist(filepath) - if playlist: - result.append(playlist) - return result - - async def get_artist(self, prov_item_id) -> Artist: - ''' get full artist details by id ''' - if not os.sep in prov_item_id: - itempath = base64.b64decode(prov_item_id).decode('utf-8') - else: - itempath = prov_item_id - prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') - if not os.path.isdir(itempath): - LOGGER.error("artist path does not exist: %s" % itempath) - return None - name = itempath.split(os.sep)[-1] - artist = Artist() - artist.item_id = prov_item_id - artist.provider = self.prov_id - artist.name = name - artist.provider_ids.append({ - "provider": self.prov_id, - "item_id": artist.item_id - }) - return artist - - async def get_album(self, prov_item_id) -> Album: - ''' get full album details by id ''' - if not os.sep in prov_item_id: - itempath = base64.b64decode(prov_item_id).decode('utf-8') - else: - itempath = prov_item_id - prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') - if not os.path.isdir(itempath): - LOGGER.error("album path does not exist: %s" % itempath) - return None - name = itempath.split(os.sep)[-1] - artistpath = itempath.rsplit(os.sep, 1)[0] - album = Album() - album.item_id = prov_item_id - album.provider = self.prov_id - album.name, album.version = parse_track_title(name) - album.artist = await self.get_artist(artistpath) - if not album.artist: - raise Exception("No album artist ! %s" % artistpath) - album.provider_ids.append({ - "provider": self.prov_id, - "item_id": prov_item_id - }) - return album - - async def get_track(self, prov_item_id) -> Track: - ''' get full track details by id ''' - if not os.sep in prov_item_id: - itempath = base64.b64decode(prov_item_id).decode('utf-8') - else: - itempath = prov_item_id - if not os.path.isfile(itempath): - LOGGER.error("track path does not exist: %s" % itempath) - return None - return await self.__parse_track(itempath) - - async def get_playlist(self, prov_item_id) -> Playlist: - ''' get full playlist details by id ''' - if not os.sep in prov_item_id: - itempath = base64.b64decode(prov_item_id).decode('utf-8') - else: - itempath = prov_item_id - prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') - if not os.path.isfile(itempath): - LOGGER.error("playlist path does not exist: %s" % itempath) - return None - playlist = Playlist() - playlist.item_id = prov_item_id - playlist.provider = self.prov_id - playlist.name = itempath.split(os.sep)[-1].replace('.m3u', '') - playlist.is_editable = True - playlist.provider_ids.append({ - "provider": self.prov_id, - "item_id": prov_item_id - }) - playlist.owner = 'disk' - return playlist - - async def get_album_tracks(self, prov_album_id) -> List[Track]: - ''' get album tracks for given album id ''' - result = [] - if not os.sep in prov_album_id: - albumpath = base64.b64decode(prov_album_id).decode('utf-8') - else: - albumpath = prov_album_id - if not os.path.isdir(albumpath): - LOGGER.error("album path does not exist: %s" % albumpath) - return [] - album = await self.get_album(albumpath) - for filename in os.listdir(albumpath): - filepath = os.path.join(albumpath, filename) - if os.path.isfile(filepath) and not filepath.startswith('.'): - track = await self.__parse_track(filepath) - if track: - track.album = album - result.append(track) - return result - - async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]: - ''' get playlist tracks for given playlist id ''' - tracks = [] - if not os.sep in prov_playlist_id: - itempath = base64.b64decode(prov_playlist_id).decode('utf-8') - else: - itempath = prov_playlist_id - if not os.path.isfile(itempath): - LOGGER.error("playlist path does not exist: %s" % itempath) - return [] - counter = 0 - with open(itempath) as f: - for line in f.readlines(): - line = line.strip() - if line and not line.startswith('#'): - counter += 1 - if counter > offset: - track = await self.__parse_track_from_uri(line) - if track: - tracks.append(track) - if limit and len(tracks) == limit: - break - return tracks - - async def get_artist_albums(self, prov_artist_id) -> List[Album]: - ''' get a list of albums for the given artist ''' - result = [] - if not os.sep in prov_artist_id: - artistpath = base64.b64decode(prov_artist_id).decode('utf-8') - else: - artistpath = prov_artist_id - if not os.path.isdir(artistpath): - LOGGER.error("artist path does not exist: %s" % artistpath) - return [] - for dirname in os.listdir(artistpath): - dirpath = os.path.join(artistpath, dirname) - if os.path.isdir(dirpath) and not dirpath.startswith('.'): - album = await self.get_album(dirpath) - if album: - result.append(album) - return result - - async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - ''' get a list of 10 random tracks as we have no clue about preference ''' - tracks = [] - for album in await self.get_artist_albums(prov_artist_id): - tracks += await self.get_album_tracks(album.item_id) - return tracks[:10] - - async def get_stream_content_type(self, track_id): - ''' return the content type for the given track when it will be streamed''' - if not os.sep in track_id: - track_id = base64.b64decode(track_id).decode('utf-8') - return track_id.split('.')[-1] - - async def get_audio_stream(self, track_id): - ''' get audio stream for a track ''' - if not os.sep in track_id: - track_id = base64.b64decode(track_id).decode('utf-8') - with open(track_id) as f: - while True: - line = f.readline() - if line: - yield line - else: - break - - async def __parse_track(self, filename): - ''' try to parse a track from a filename with taglib ''' - track = Track() - try: - song = taglib.File(filename) - except: - return None # not a media file ? - prov_item_id = base64.b64encode(filename.encode('utf-8')).decode('utf-8') - track.duration = song.length - track.item_id = prov_item_id - track.provider = self.prov_id - name = song.tags['TITLE'][0] - track.name, track.version = parse_track_title(name) - albumpath = filename.rsplit(os.sep,1)[0] - track.album = await self.get_album(albumpath) - artists = [] - for artist_str in song.tags['ARTIST']: - local_artist_path = os.path.join(self._music_dir, artist_str) - if os.path.isfile(local_artist_path): - artist = await self.get_artist(local_artist_path) - else: - artist = Artist() - artist.name = artist_str - fake_artistpath = os.path.join(self._music_dir, artist_str) - artist.item_id = fake_artistpath # temporary id - artist.provider_ids.append({ - "provider": self.prov_id, - "item_id": base64.b64encode(fake_artistpath.encode('utf-8')).decode('utf-8') - }) - artists.append(artist) - track.artists = artists - if 'GENRE' in song.tags: - track.tags = song.tags['GENRE'] - if 'ISRC' in song.tags: - track.external_ids.append( {"isrc": song.tags['ISRC'][0]} ) - if 'DISCNUMBER' in song.tags: - track.disc_number = int(song.tags['DISCNUMBER'][0]) - if 'TRACKNUMBER' in song.tags: - track.track_number = int(song.tags['TRACKNUMBER'][0]) - quality_details = "" - if filename.endswith('.flac'): - # TODO: get bit depth - quality = TrackQuality.FLAC_LOSSLESS - if song.sampleRate > 192000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 - elif song.sampleRate > 96000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 - elif song.sampleRate > 48000: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - quality_details = "%s Khz" % (song.sampleRate/1000) - elif filename.endswith('.ogg'): - quality = TrackQuality.LOSSY_OGG - quality_details = "%s kbps" % (song.bitrate) - elif filename.endswith('.m4a'): - quality = TrackQuality.LOSSY_AAC - quality_details = "%s kbps" % (song.bitrate) - else: - quality = TrackQuality.LOSSY_MP3 - quality_details = "%s kbps" % (song.bitrate) - track.provider_ids.append({ - "provider": self.prov_id, - "item_id": prov_item_id, - "quality": quality, - "details": quality_details - }) - return track - - async def __parse_track_from_uri(self, uri): - ''' try to parse a track from an uri found in playlist ''' - if "://" in uri: - # track is uri from external provider? - prov_id = uri.split('://')[0] - prov_item_id = uri.split('/')[-1].split('.')[0].split(':')[-1] - try: - return await self.mass.music.providers[prov_id].track(prov_item_id, lazy=False) - except Exception as exc: - LOGGER.warning("Could not parse uri %s to track: %s" %(uri, str(exc))) - return None - # try to treat uri as filename - # TODO: filename could be related to musicdir or full path - track = await self.get_track(uri) - if track: - return track - track = await self.get_track(os.path.join(self._music_dir, uri)) - if track: - return track - return None diff --git a/music_assistant/modules/musicproviders/qobuz.py b/music_assistant/modules/musicproviders/qobuz.py deleted file mode 100644 index a8e21c36..00000000 --- a/music_assistant/modules/musicproviders/qobuz.py +++ /dev/null @@ -1,559 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from typing import List -from utils import run_periodic, LOGGER, parse_track_title -from app_vars import get_app_var -from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED -import json -import aiohttp -import time -import datetime -import hashlib -from asyncio_throttle import Throttler -from modules.cache import use_cache - - -def setup(mass): - ''' setup the provider''' - enabled = mass.config["musicproviders"]['qobuz'].get(CONF_ENABLED) - username = mass.config["musicproviders"]['qobuz'].get(CONF_USERNAME) - password = mass.config["musicproviders"]['qobuz'].get(CONF_PASSWORD) - if enabled and username and password: - provider = QobuzProvider(mass, username, password) - return provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, "", CONF_PASSWORD) - ] - -class QobuzProvider(MusicProvider): - - - def __init__(self, mass, username, password): - self.name = 'Qobuz' - self.prov_id = 'qobuz' - self.mass = mass - self.cache = mass.cache - self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) - self.__username = username - self.__password = password - self.__user_auth_info = None - self.__logged_in = False - self.throttler = Throttler(rate_limit=2, period=1) - mass.add_event_listener(self.mass_event, 'streaming_started') - mass.add_event_listener(self.mass_event, 'streaming_ended') - - async def search(self, searchstring, media_types=List[MediaType], limit=5): - ''' perform search on the provider ''' - result = { - "artists": [], - "albums": [], - "tracks": [], - "playlists": [] - } - params = {"query": searchstring, "limit": limit } - if len(media_types) == 1: - # qobuz does not support multiple searchtypes, falls back to all if no type given - if media_types[0] == MediaType.Artist: - params["type"] = "artists" - if media_types[0] == MediaType.Album: - params["type"] = "albums" - if media_types[0] == MediaType.Track: - params["type"] = "tracks" - if media_types[0] == MediaType.Playlist: - params["type"] = "playlists" - searchresult = await self.__get_data("catalog/search", params) - if searchresult: - if "artists" in searchresult: - for item in searchresult["artists"]["items"]: - artist = await self.__parse_artist(item) - if artist: - result["artists"].append(artist) - if "albums" in searchresult: - for item in searchresult["albums"]["items"]: - album = await self.__parse_album(item) - if album: - result["albums"].append(album) - if "tracks" in searchresult: - for item in searchresult["tracks"]["items"]: - track = await self.__parse_track(item) - if track: - result["tracks"].append(track) - if "playlists" in searchresult: - for item in searchresult["playlists"]["items"]: - result["playlists"].append(await self.__parse_playlist(item)) - return result - - async def get_library_artists(self) -> List[Artist]: - ''' retrieve library artists from qobuz ''' - result = [] - params = {'type': 'artists'} - for item in await self.__get_all_items("favorite/getUserFavorites", params, key='artists'): - artist = await self.__parse_artist(item) - if artist: - result.append(artist) - return result - - async def get_library_albums(self) -> List[Album]: - ''' retrieve library albums from qobuz ''' - result = [] - params = {'type': 'albums'} - for item in await self.__get_all_items("favorite/getUserFavorites", params, key='albums'): - album = await self.__parse_album(item) - if album: - result.append(album) - return result - - async def get_library_tracks(self) -> List[Track]: - ''' retrieve library tracks from qobuz ''' - result = [] - params = {'type': 'tracks'} - for item in await self.__get_all_items("favorite/getUserFavorites", params, key='tracks'): - track = await self.__parse_track(item) - if track: - result.append(track) - return result - - async def get_playlists(self) -> List[Playlist]: - ''' retrieve playlists from the provider ''' - result = [] - for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists', cache_checksum=time.time()): - playlist = await self.__parse_playlist(item) - if playlist: - result.append(playlist) - return result - - async def get_artist(self, prov_artist_id) -> Artist: - ''' get full artist details by id ''' - params = {'artist_id': prov_artist_id} - artist_obj = await self.__get_data("artist/get", params) - return await self.__parse_artist(artist_obj) - - async def get_album(self, prov_album_id) -> Album: - ''' get full album details by id ''' - params = {'album_id': prov_album_id} - album_obj = await self.__get_data("album/get", params) - return await self.__parse_album(album_obj) - - async def get_track(self, prov_track_id) -> Track: - ''' get full track details by id ''' - params = {'track_id': prov_track_id} - track_obj = await self.__get_data("track/get", params) - return await self.__parse_track(track_obj) - - async def get_playlist(self, prov_playlist_id) -> Playlist: - ''' get full playlist details by id ''' - params = {'playlist_id': prov_playlist_id} - playlist_obj = await self.__get_data("playlist/get", params) - return await self.__parse_playlist(playlist_obj) - - async def get_album_tracks(self, prov_album_id) -> List[Track]: - ''' get album tracks for given album id ''' - params = {'album_id': prov_album_id} - track_objs = await self.__get_all_items("album/get", params, key='tracks') - tracks = [] - for track_obj in track_objs: - track = await self.__parse_track(track_obj) - if track: - tracks.append(track) - return tracks - - async def get_playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]: - ''' get playlist tracks for given playlist id ''' - playlist_obj = await self.__get_data("playlist/get?playlist_id=%s" % prov_playlist_id, ignore_cache=True) - cache_checksum = playlist_obj["updated_at"] - params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'} - track_objs = await self.__get_all_items("playlist/get", params, key='tracks', limit=limit, offset=offset, cache_checksum=cache_checksum) - tracks = [] - for track_obj in track_objs: - playlist_track = await self.__parse_track(track_obj) - if playlist_track: - tracks.append(playlist_track) - # TODO: should we look for an alternative track version if the original is marked unavailable ? - return tracks - - async def get_artist_albums(self, prov_artist_id, limit=100, offset=0) -> List[Album]: - ''' get a list of albums for the given artist ''' - params = {'artist_id': prov_artist_id, 'extra': 'albums', 'limit': limit, 'offset': offset} - result = await self.__get_data('artist/get', params) - albums = [] - for item in result['albums']['items']: - if str(item['artist']['id']) == str(prov_artist_id): - album = await self.__parse_album(item) - if album: - albums.append(album) - return albums - - async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - ''' get a list of most popular tracks for the given artist ''' - # artist toptracks not supported on Qobuz, so use search instead - items = [] - artist = await self.get_artist(prov_artist_id) - params = {"query": artist.name, "limit": 10, "type": "tracks" } - searchresult = await self.__get_data("catalog/search", params) - for item in searchresult["tracks"]["items"]: - if "performer" in item and str(item["performer"]["id"]) == str(prov_artist_id): - track = await self.__parse_track(item) - items.append(track) - return items - - async def add_library(self, prov_item_id, media_type:MediaType): - ''' add item to library ''' - if media_type == MediaType.Artist: - result = await self.__get_data('favorite/create', {'artist_ids': prov_item_id}) - item = await self.artist(prov_item_id) - elif media_type == MediaType.Album: - result = await self.__get_data('favorite/create', {'album_ids': prov_item_id}) - item = await self.album(prov_item_id) - elif media_type == MediaType.Track: - result = await self.__get_data('favorite/create', {'track_ids': prov_item_id}) - item = await self.track(prov_item_id) - await self.mass.db.add_to_library(item.item_id, media_type, self.prov_id) - LOGGER.debug("added item %s to %s - %s" %(prov_item_id, self.prov_id, result)) - - async def remove_library(self, prov_item_id, media_type:MediaType): - ''' remove item from library ''' - if media_type == MediaType.Artist: - result = await self.__get_data('favorite/delete', {'artist_ids': prov_item_id}) - item = await self.artist(prov_item_id) - elif media_type == MediaType.Album: - result = await self.__get_data('favorite/delete', {'album_ids': prov_item_id}) - item = await self.album(prov_item_id) - elif media_type == MediaType.Track: - result = await self.__get_data('favorite/delete', {'track_ids': prov_item_id}) - item = await self.track(prov_item_id) - await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id) - LOGGER.debug("deleted item %s from %s - %s" %(prov_item_id, self.prov_id, result)) - - async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids): - ''' add track(s) to playlist ''' - params = { - 'playlist_id': prov_playlist_id, - 'track_ids': ",".join(prov_track_ids) - } - return await self.__get_data('playlist/addTracks', params) - - async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids): - ''' remove track(s) from playlist ''' - playlist_track_ids = [] - params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'} - for track in await self.__get_all_items("playlist/get", params, key='tracks', limit=0): - if track['id'] in prov_track_ids: - playlist_track_ids.append(track['playlist_track_id']) - params = {'playlist_id': prov_playlist_id, 'track_ids': ",".join(playlist_track_ids)} - return await self.__get_data('playlist/deleteTracks', params) - - async def get_stream_details(self, track_id): - ''' return the content details for the given track when it will be streamed''' - streamdetails = None - for format_id in [27, 7, 6, 5]: - # it seems that simply requesting for highest available quality does not work - # from time to time the api response is empty for this request ?! - params = {'format_id': format_id, 'track_id': track_id, 'intent': 'stream'} - streamdetails = await self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True) - if streamdetails and streamdetails.get('url'): - break - if not streamdetails or not streamdetails.get('url'): - LOGGER.error("Unable to retrieve stream url for track %s" % track_id) - return None - return { - "type": "url", - "path": streamdetails['url'], - "content_type": streamdetails['mime_type'].split('/')[1], - "sample_rate": int(streamdetails['sampling_rate']*1000), - "bit_depth": streamdetails['bit_depth'], - "details": streamdetails # we need these details for reporting playback - } - - async def mass_event(self, msg, msg_details): - ''' received event from mass ''' - # TODO: need to figure out if the streamed track is purchased - if msg == "streaming_started" and msg_details['provider'] == self.prov_id: - # report streaming started to qobuz - LOGGER.debug("streaming_started %s" % msg_details["track_id"]) - device_id = self.__user_auth_info["user"]["device"]["id"] - credential_id = self.__user_auth_info["user"]["credential"]["id"] - user_id = self.__user_auth_info["user"]["id"] - format_id = msg_details["details"]["format_id"] - timestamp = int(time.time()) - events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, - "track_id": msg_details["track_id"], "purchase": False, "date": timestamp, - "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}] - await self.__post_data("track/reportStreamingStart", data=events) - elif msg == "streaming_ended" and msg_details['provider'] == self.prov_id: - # report streaming ended to qobuz - LOGGER.debug("streaming_ended %s - seconds played: %s" %(msg_details["track_id"], msg_details["seconds"]) ) - device_id = self.__user_auth_info["user"]["device"]["id"] - credential_id = self.__user_auth_info["user"]["credential"]["id"] - user_id = self.__user_auth_info["user"]["id"] - format_id = msg_details["details"]["format_id"] - timestamp = int(time.time()) - events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, - "track_id": msg_details["track_id"], "purchase": False, "date": timestamp, "duration": msg_details["seconds"], - "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}] - await self.__post_data("track/reportStreamingStart", data=events) - - async def __parse_artist(self, artist_obj): - ''' parse qobuz artist object to generic layout ''' - artist = Artist() - if not artist_obj.get('id'): - return None - artist.item_id = artist_obj['id'] - artist.provider = self.prov_id - artist.provider_ids.append({ - "provider": self.prov_id, - "item_id": artist_obj['id'] - }) - artist.name = artist_obj['name'] - if artist_obj.get('image'): - for key in ['extralarge', 'large', 'medium', 'small']: - if artist_obj['image'].get(key): - if not '2a96cbd8b46e442fc41c2b86b821562f' in artist_obj['image'][key]: - artist.metadata["image"] = artist_obj['image'][key] - break - if artist_obj.get('biography'): - artist.metadata["biography"] = artist_obj['biography'].get('content','') - if artist_obj.get('url'): - artist.metadata["qobuz_url"] = artist_obj['url'] - return artist - - async def __parse_album(self, album_obj): - ''' parse qobuz album object to generic layout ''' - album = Album() - if not album_obj.get('id') or not album_obj["streamable"] or not album_obj["displayable"]: - # some safety checks - LOGGER.warning("invalid/unavailable album found: %s" % album_obj.get('id')) - return None - album.item_id = album_obj['id'] - album.provider = self.prov_id - album.provider_ids.append({ - "provider": self.prov_id, - "item_id": album_obj['id'], - "details": "%skHz %sbit" %(album_obj['maximum_sampling_rate'], album_obj['maximum_bit_depth']) - }) - album.name, album.version = parse_track_title(album_obj['title']) - album.artist = await self.__parse_artist(album_obj['artist']) - if not album.artist: - raise Exception("No album artist ! %s" % album_obj) - if album_obj.get('product_type','') == 'single': - album.albumtype = AlbumType.Single - elif album_obj.get('product_type','') == 'compilation' or 'Various' in album_obj['artist']['name']: - album.albumtype = AlbumType.Compilation - else: - album.albumtype = AlbumType.Album - if 'genre' in album_obj: - album.tags = [album_obj['genre']['name']] - if album_obj.get('image'): - for key in ['extralarge', 'large', 'medium', 'small']: - if album_obj['image'].get(key): - album.metadata["image"] = album_obj['image'][key] - break - album.external_ids.append({ "upc": album_obj['upc'] }) - if 'label' in album_obj: - album.labels = album_obj['label']['name'].split('/') - if album_obj.get('released_at'): - album.year = datetime.datetime.fromtimestamp(album_obj['released_at']).year - if album_obj.get('copyright'): - album.metadata["copyright"] = album_obj['copyright'] - if album_obj.get('hires'): - album.metadata["hires"] = "true" - if album_obj.get('url'): - album.metadata["qobuz_url"] = album_obj['url'] - if album_obj.get('description'): - album.metadata["description"] = album_obj['description'] - return album - - async def __parse_track(self, track_obj): - ''' parse qobuz track object to generic layout ''' - track = Track() - if not track_obj.get('id') or not track_obj["streamable"] or not track_obj["displayable"]: - # some safety checks - LOGGER.warning("invalid/unavailable track found: %s - %s" % (track_obj.get('id'), track_obj.get('name'))) - return None - track.item_id = track_obj['id'] - track.provider = self.prov_id - if track_obj.get('performer') and not 'Various ' in track_obj['performer']: - artist = await self.__parse_artist(track_obj['performer']) - if not artist: - artist = self.get_artist(track_obj['performer']['id']) - if artist: - track.artists.append(artist) - if not track.artists: - # try to grab artist from album - if track_obj.get('album') and track_obj['album'].get('artist') and not 'Various ' in track_obj['album']['artist']: - artist = await self.__parse_artist(track_obj['album']['artist']) - if artist: - track.artists.append(artist) - if not track.artists: - # last resort: parse from performers string - for performer_str in track_obj['performers'].split(' - '): - role = performer_str.split(', ')[1] - name = performer_str.split(', ')[0] - if 'artist' in role.lower(): - artist = Artist() - artist.name = name - artist.item_id = name - track.artists.append(artist) - # TODO: fix grabbing composer from details - track.name, track.version = parse_track_title(track_obj['title']) - if not track.version and track_obj['version']: - track.version = track_obj['version'] - track.duration = track_obj['duration'] - if 'album' in track_obj: - album = await self.__parse_album(track_obj['album']) - if album: - track.album = album - track.disc_number = track_obj['media_number'] - track.track_number = track_obj['track_number'] - if track_obj.get('hires'): - track.metadata["hires"] = "true" - if track_obj.get('url'): - track.metadata["qobuz_url"] = track_obj['url'] - if track_obj.get('isrc'): - track.external_ids.append({ - "isrc": track_obj['isrc'] - }) - if track_obj.get('performers'): - track.metadata["performers"] = track_obj['performers'] - if track_obj.get('copyright'): - track.metadata["copyright"] = track_obj['copyright'] - # get track quality - if track_obj['maximum_sampling_rate'] > 192: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 - elif track_obj['maximum_sampling_rate'] > 96: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 - elif track_obj['maximum_sampling_rate'] > 48: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - elif track_obj['maximum_bit_depth'] > 16: - quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1 - elif track_obj.get('format_id',0) == 5: - quality = TrackQuality.LOSSY_AAC - else: - quality = TrackQuality.FLAC_LOSSLESS - track.provider_ids.append({ - "provider": self.prov_id, - "item_id": track_obj['id'], - "quality": quality, - "details": "%skHz %sbit" %(track_obj['maximum_sampling_rate'], track_obj['maximum_bit_depth']) - }) - return track - - async def __parse_playlist(self, playlist_obj): - ''' parse qobuz playlist object to generic layout ''' - playlist = Playlist() - if not playlist_obj.get('id'): - return None - playlist.item_id = playlist_obj['id'] - playlist.provider = self.prov_id - playlist.provider_ids.append({ - "provider": self.prov_id, - "item_id": playlist_obj['id'] - }) - playlist.name = playlist_obj['name'] - playlist.owner = playlist_obj['owner']['name'] - playlist.is_editable = playlist_obj['owner']['id'] == self.__user_auth_info["user"]["id"] or playlist_obj['is_collaborative'] - if playlist_obj.get('images300'): - playlist.metadata["image"] = playlist_obj['images300'][0] - if playlist_obj.get('url'): - playlist.metadata["qobuz_url"] = playlist_obj['url'] - return playlist - - async def __auth_token(self): - ''' login to qobuz and store the token''' - if self.__user_auth_info: - return self.__user_auth_info["user_auth_token"] - params = { "username": self.__username, "password": self.__password, "device_manufacturer_id": "music_assistant"} - details = await self.__get_data("user/login", params, ignore_cache=True) - self.__user_auth_info = details - LOGGER.info("Succesfully logged in to Qobuz as %s" % (details["user"]["display_name"])) - return details["user_auth_token"] - - async def __get_all_items(self, endpoint, params={}, key="playlists", limit=0, offset=0, cache_checksum=None): - ''' get all items from a paged list ''' - if not cache_checksum: - params["limit"] = 1 - params["offset"] = 0 - cache_checksum = await self.__get_data(endpoint, params, ignore_cache=True) - cache_checksum = cache_checksum[key]["total"] - if limit: - # partial listing - params["limit"] = limit - params["offset"] = offset - result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) - return result[key]["items"] - else: - # full listing - offset = 0 - total_items = 1 - count = 0 - items = [] - while count < total_items: - params["limit"] = 200 - params["offset"] = offset - result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) - if result and key in result: - total_items = result[key]["total"] - offset += 200 - count += len(result[key]["items"]) - items += result[key]["items"] - else: - LOGGER.error("failed to retrieve items for %s (%s) --> %s" %(endpoint, params, result)) - break - return items - - @use_cache(7) - async def __get_data(self, endpoint, params={}, sign_request=False, ignore_cache=False, cache_checksum=None): - ''' get data from api''' - url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint - headers = {"X-App-Id": get_app_var(0)} - if endpoint != 'user/login': - headers["X-User-Auth-Token"] = await self.__auth_token() - if sign_request: - signing_data = "".join(endpoint.split('/')) - keys = list(params.keys()) - keys.sort() - for key in keys: - signing_data += "%s%s" %(key, params[key]) - request_ts = str(time.time()) - request_sig = signing_data + request_ts + get_app_var(1) - request_sig = str(hashlib.md5(request_sig.encode()).hexdigest()) - params["request_ts"] = request_ts - params["request_sig"] = request_sig - params["app_id"] = get_app_var(0) - params["user_auth_token"] = await self.__auth_token() - try: - async with self.throttler: - async with self.http_session.get(url, headers=headers, params=params) as response: - result = await response.json() - if not result or 'error' in result: - LOGGER.error(url) - LOGGER.debug(params) - LOGGER.debug(result) - return None - return result - except Exception as exc: - LOGGER.exception(exc) - return None - - async def __post_data(self, endpoint, params={}, data={}): - ''' post data to api''' - url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint - params["app_id"] = get_app_var(0) - params["user_auth_token"] = await self.__auth_token() - async with self.http_session.post(url, params=params, json=data) as response: - result = await response.json() - if not result or 'error' in result: - LOGGER.error(url) - LOGGER.debug(params) - LOGGER.debug(result) - result = None - return result \ No newline at end of file diff --git a/music_assistant/modules/musicproviders/spotify.py b/music_assistant/modules/musicproviders/spotify.py deleted file mode 100644 index d2bcef79..00000000 --- a/music_assistant/modules/musicproviders/spotify.py +++ /dev/null @@ -1,516 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from typing import List -import sys -import time -from utils import run_periodic, LOGGER, parse_track_title -from app_vars import get_app_var -from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED -from asyncio_throttle import Throttler -import json -import aiohttp -from modules.cache import use_cache -import concurrent - -def setup(mass): - ''' setup the provider''' - enabled = mass.config["musicproviders"]['spotify'].get(CONF_ENABLED) - username = mass.config["musicproviders"]['spotify'].get(CONF_USERNAME) - password = mass.config["musicproviders"]['spotify'].get(CONF_PASSWORD) - if enabled and username and password: - spotify_provider = SpotifyProvider(mass, username, password) - return spotify_provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, "", CONF_PASSWORD) - ] - -class SpotifyProvider(MusicProvider): - - - def __init__(self, mass, username, password): - self.name = 'Spotify' - self.prov_id = 'spotify' - self._cur_user = None - self.mass = mass - self.cache = mass.cache - self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) - self.throttler = Throttler(rate_limit=1, period=1) - self._username = username - self._password = password - self.__auth_token = {} - - async def search(self, searchstring, media_types=List[MediaType], limit=5): - ''' perform search on the provider ''' - result = { - "artists": [], - "albums": [], - "tracks": [], - "playlists": [] - } - searchtypes = [] - if MediaType.Artist in media_types: - searchtypes.append("artist") - if MediaType.Album in media_types: - searchtypes.append("album") - if MediaType.Track in media_types: - searchtypes.append("track") - if MediaType.Playlist in media_types: - searchtypes.append("playlist") - searchtype = ",".join(searchtypes) - params = {"q": searchstring, "type": searchtype, "limit": limit } - searchresult = await self.__get_data("search", params=params, cache_checksum="bla") - if searchresult: - if "artists" in searchresult: - for item in searchresult["artists"]["items"]: - artist = await self.__parse_artist(item) - if artist: - result["artists"].append(artist) - if "albums" in searchresult: - for item in searchresult["albums"]["items"]: - album = await self.__parse_album(item) - if album: - result["albums"].append(album) - if "tracks" in searchresult: - for item in searchresult["tracks"]["items"]: - track = await self.__parse_track(item) - if track: - result["tracks"].append(track) - if "playlists" in searchresult: - for item in searchresult["playlists"]["items"]: - playlist = await self.__parse_playlist(item) - if playlist: - result["playlists"].append(playlist) - return result - - async def get_library_artists(self) -> List[Artist]: - ''' retrieve library artists from spotify ''' - items = [] - spotify_artists = await self.__get_data("me/following?type=artist&limit=50") - if spotify_artists: - # TODO: use cursor method to retrieve more than 50 artists - for artist_obj in spotify_artists['artists']['items']: - prov_artist = await self.__parse_artist(artist_obj) - items.append(prov_artist) - return items - - async def get_library_albums(self) -> List[Album]: - ''' retrieve library albums from the provider ''' - result = [] - for item in await self.__get_all_items("me/albums"): - album = await self.__parse_album(item) - if album: - result.append(album) - return result - - async def get_library_tracks(self) -> List[Track]: - ''' retrieve library tracks from the provider ''' - result = [] - for item in await self.__get_all_items("me/tracks"): - track = await self.__parse_track(item) - if track: - result.append(track) - return result - - async def get_playlists(self) -> List[Playlist]: - ''' retrieve playlists from the provider ''' - result = [] - for item in await self.__get_all_items("me/playlists", cache_checksum=time.time()): - playlist = await self.__parse_playlist(item) - if playlist: - result.append(playlist) - return result - - async def get_artist(self, prov_artist_id) -> Artist: - ''' get full artist details by id ''' - artist_obj = await self.__get_data("artists/%s" % prov_artist_id) - return await self.__parse_artist(artist_obj) - - async def get_album(self, prov_album_id) -> Album: - ''' get full album details by id ''' - album_obj = await self.__get_data("albums/%s" % prov_album_id) - return await self.__parse_album(album_obj) - - async def get_track(self, prov_track_id) -> Track: - ''' get full track details by id ''' - track_obj = await self.__get_data("tracks/%s" % prov_track_id) - return await self.__parse_track(track_obj) - - async def get_playlist(self, prov_playlist_id) -> Playlist: - ''' get full playlist details by id ''' - playlist_obj = await self.__get_data("playlists/%s" % prov_playlist_id, ignore_cache=True) - return await self.__parse_playlist(playlist_obj) - - async def get_album_tracks(self, prov_album_id) -> List[Track]: - ''' get album tracks for given album id ''' - track_objs = await self.__get_all_items("albums/%s/tracks" % prov_album_id) - tracks = [] - for track_obj in track_objs: - track = await self.__parse_track(track_obj) - if track: - tracks.append(track) - return tracks - - async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]: - ''' get playlist tracks for given playlist id ''' - playlist_obj = await self.__get_data("playlists/%s?fields=snapshot_id" % prov_playlist_id, ignore_cache=True) - cache_checksum = playlist_obj["snapshot_id"] - track_objs = await self.__get_all_items("playlists/%s/tracks" % prov_playlist_id, limit=limit, offset=offset, cache_checksum=cache_checksum) - tracks = [] - for track_obj in track_objs: - playlist_track = await self.__parse_track(track_obj) - if playlist_track: - tracks.append(playlist_track) - return tracks - - async def get_artist_albums(self, prov_artist_id) -> List[Album]: - ''' get a list of albums for the given artist ''' - params = {'include_groups': 'album,single,compilation'} - items = await self.__get_all_items('artists/%s/albums' % prov_artist_id, params) - albums = [] - for item in items: - album = await self.__parse_album(item) - if album: - albums.append(album) - return albums - - async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - ''' get a list of 10 most popular tracks for the given artist ''' - artist = await self.get_artist(prov_artist_id) - items = await self.__get_data('artists/%s/top-tracks' % prov_artist_id) - tracks = [] - for item in items['tracks']: - track = await self.__parse_track(item) - if track: - track.artists = [artist] - tracks.append(track) - return tracks - - async def add_library(self, prov_item_id, media_type:MediaType): - ''' add item to library ''' - if media_type == MediaType.Artist: - result = await self.__put_data('me/following', {'ids': prov_item_id, 'type': 'artist'}) - item = await self.artist(prov_item_id) - elif media_type == MediaType.Album: - result = await self.__put_data('me/albums', {'ids': prov_item_id}) - item = await self.album(prov_item_id) - elif media_type == MediaType.Track: - result = await self.__put_data('me/tracks', {'ids': prov_item_id}) - item = await self.track(prov_item_id) - await self.mass.db.add_to_library(item.item_id, media_type, self.prov_id) - LOGGER.debug("added item %s to %s - %s" %(prov_item_id, self.prov_id, result)) - - async def remove_library(self, prov_item_id, media_type:MediaType): - ''' remove item from library ''' - if media_type == MediaType.Artist: - result = await self.__delete_data('me/following', {'ids': prov_item_id, 'type': 'artist'}) - item = await self.artist(prov_item_id) - elif media_type == MediaType.Album: - result = await self.__delete_data('me/albums', {'ids': prov_item_id}) - item = await self.album(prov_item_id) - elif media_type == MediaType.Track: - result = await self.__delete_data('me/tracks', {'ids': prov_item_id}) - item = await self.track(prov_item_id) - await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id) - LOGGER.debug("deleted item %s from %s - %s" %(prov_item_id, self.prov_id, result)) - - async def devices(self): - ''' list all available devices ''' - items = await self.__get_data('me/player/devices') - return items['devices'] - - async def play_media(self, device_id, uri, offset_pos=None, offset_uri=None): - ''' play uri on spotify device''' - opts = {} - if isinstance(uri, list): - opts['uris'] = uri - elif uri.startswith('spotify:track'): - opts['uris'] = [uri] - else: - opts['context_uri'] = uri - if offset_pos != None: # only for playlists/albums! - opts["offset"] = {"position": offset_pos } - elif offset_uri != None: # only for playlists/albums! - opts["offset"] = {"uri": offset_uri } - return await self.__put_data('me/player/play', {"device_id": device_id}, opts) - - async def get_stream_details(self, track_id): - ''' return the content details for the given track when it will be streamed''' - spotty = self.get_spotty_binary() - spotty_exec = "%s -n temp -u %s -p %s --pass-through --single-track %s" %(spotty, self._username, self._password, track_id) - return { - "type": "executable", - "path": spotty_exec, - "content_type": "ogg", - "sample_rate": 44100, - "bit_depth": 16 - } - - async def __parse_artist(self, artist_obj): - ''' parse spotify artist object to generic layout ''' - artist = Artist() - artist.item_id = artist_obj['id'] - artist.provider = self.prov_id - artist.provider_ids.append({ - "provider": self.prov_id, - "item_id": artist_obj['id'] - }) - artist.name = artist_obj['name'] - if 'genres' in artist_obj: - artist.tags = artist_obj['genres'] - if artist_obj.get('images'): - for img in artist_obj['images']: - img_url = img['url'] - if not '2a96cbd8b46e442fc41c2b86b821562f' in img_url: - artist.metadata["image"] = img_url - break - if artist_obj.get('external_urls'): - artist.metadata["spotify_url"] = artist_obj['external_urls']['spotify'] - return artist - - async def __parse_album(self, album_obj): - ''' parse spotify album object to generic layout ''' - if 'album' in album_obj: - album_obj = album_obj['album'] - if not album_obj['id'] or album_obj.get('is_playable') == False: - return None - album = Album() - album.item_id = album_obj['id'] - album.provider = self.prov_id - album.name, album.version = parse_track_title(album_obj['name']) - for artist in album_obj['artists']: - album.artist = await self.__parse_artist(artist) - if album.artist: - break - if not album.artist: - raise Exception("No album artist ! %s" % album_obj) - if album_obj['album_type'] == 'single': - album.albumtype = AlbumType.Single - elif album_obj['album_type'] == 'compilation': - album.albumtype = AlbumType.Compilation - else: - album.albumtype = AlbumType.Album - if 'genres' in album_obj: - album.tags = album_obj['genres'] - if album_obj.get('images'): - album.metadata["image"] = album_obj['images'][0]['url'] - if 'external_ids' in album_obj: - for key, value in album_obj['external_ids'].items(): - album.external_ids.append( { key: value } ) - if 'label' in album_obj: - album.labels = album_obj['label'].split('/') - if album_obj.get('release_date'): - album.year = int(album_obj['release_date'].split('-')[0]) - if album_obj.get('copyrights'): - album.metadata["copyright"] = album_obj['copyrights'][0]['text'] - if album_obj.get('external_urls'): - album.metadata["spotify_url"] = album_obj['external_urls']['spotify'] - if album_obj.get('explicit'): - album.metadata['explicit'] = str(album_obj['explicit']).lower() - album.provider_ids.append({ - "provider": self.prov_id, - "item_id": album_obj['id'] - }) - return album - - async def __parse_track(self, track_obj): - ''' parse spotify track object to generic layout ''' - if 'track' in track_obj: - track_obj = track_obj['track'] - if track_obj['is_local'] or not track_obj['id'] or not track_obj['is_playable']: - LOGGER.warning("invalid/unavailable track found: %s - %s" % (track_obj.get('id'), track_obj.get('name'))) - return None - track = Track() - track.item_id = track_obj['id'] - track.provider = self.prov_id - for track_artist in track_obj['artists']: - artist = await self.__parse_artist(track_artist) - if artist: - track.artists.append(artist) - track.name, track.version = parse_track_title(track_obj['name']) - track.duration = track_obj['duration_ms'] / 1000 - track.metadata['explicit'] = str(track_obj['explicit']).lower() - if not track.version and track_obj['explicit']: - track.version = 'Explicit' - if 'external_ids' in track_obj: - for key, value in track_obj['external_ids'].items(): - track.external_ids.append( { key: value } ) - if 'album' in track_obj: - track.album = await self.__parse_album(track_obj['album']) - if track_obj.get('copyright'): - track.metadata["copyright"] = track_obj['copyright'] - track.disc_number = track_obj['disc_number'] - track.track_number = track_obj['track_number'] - if track_obj.get('external_urls'): - track.metadata["spotify_url"] = track_obj['external_urls']['spotify'] - track.provider_ids.append({ - "provider": self.prov_id, - "item_id": track_obj['id'], - "quality": TrackQuality.LOSSY_OGG - }) - return track - - async def __parse_playlist(self, playlist_obj): - ''' parse spotify playlist object to generic layout ''' - playlist = Playlist() - if not playlist_obj.get('id'): - return None - playlist.item_id = playlist_obj['id'] - playlist.provider = self.prov_id - playlist.provider_ids.append({ - "provider": self.prov_id, - "item_id": playlist_obj['id'] - }) - playlist.name = playlist_obj['name'] - playlist.owner = playlist_obj['owner']['display_name'] - playlist.is_editable = playlist_obj['owner']['id'] == self.sp_user["id"] or playlist_obj['collaborative'] - if playlist_obj.get('images'): - playlist.metadata["image"] = playlist_obj['images'][0]['url'] - if playlist_obj.get('external_urls'): - playlist.metadata["spotify_url"] = playlist_obj['external_urls']['spotify'] - return playlist - - async def get_token(self): - ''' get auth token on spotify ''' - # return existing token if we have one in memory - if self.__auth_token and (self.__auth_token['expiresAt'] > int(time.time()) + 20): - return self.__auth_token - tokeninfo = {} - if not self._username or not self._password: - return tokeninfo - # try with spotipy-token module first, fallback to spotty - try: - import spotify_token as st - data = st.start_session(self._username, self._password) - if data and len(data) == 2: - tokeninfo = {"accessToken": data[0], "expiresIn": data[1] - int(time.time()), "expiresAt":data[1] } - except Exception as exc: - LOGGER.exception(exc) - if not tokeninfo: - # fallback to spotty approach - import subprocess - scopes = [ - "user-read-playback-state", - "user-read-currently-playing", - "user-modify-playback-state", - "playlist-read-private", - "playlist-read-collaborative", - "playlist-modify-public", - "playlist-modify-private", - "user-follow-modify", - "user-follow-read", - "user-library-read", - "user-library-modify", - "user-read-private", - "user-read-email", - "user-read-birthdate", - "user-top-read"] - scope = ",".join(scopes) - args = [self.get_spotty_binary(), "-t", "--client-id", get_app_var(2), "--scope", scope, "-n", "temp-spotty", "-u", self._username, "-p", self._password, "--disable-discovery"] - spotty = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - stdout, stderr = spotty.communicate() - result = json.loads(stdout) - # transform token info to spotipy compatible format - if result and "accessToken" in result: - tokeninfo = result - tokeninfo['expiresAt'] = tokeninfo['expiresIn'] + int(time.time()) - if tokeninfo: - self.__auth_token = tokeninfo - self.sp_user = await self.__get_data("me") - LOGGER.info("Succesfully logged in to Spotify as %s" % self.sp_user["id"]) - self.__auth_token = tokeninfo - else: - raise Exception("Can't get Spotify token for user %s" % self._username) - return tokeninfo - - async def __get_all_items(self, endpoint, params={}, limit=0, offset=0, cache_checksum=None): - ''' get all items from a paged list ''' - if not cache_checksum: - params["limit"] = 1 - params["offset"] = 0 - cache_checksum = await self.__get_data(endpoint, params, ignore_cache=True) - cache_checksum = cache_checksum["total"] - if limit: - # partial listing - params["limit"] = limit - params["offset"] = offset - result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) - return result["items"] - else: - # full listing - total_items = 1 - count = 0 - items = [] - while count < total_items: - params["limit"] = 50 - params["offset"] = offset - result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) - total_items = result["total"] - offset += 50 - count += len(result["items"]) - items += result["items"] - return items - - @use_cache(7) - async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None): - ''' get data from api''' - url = 'https://api.spotify.com/v1/%s' % endpoint - params['market'] = 'from_token' - params['country'] = 'from_token' - token = await self.get_token() - headers = {'Authorization': 'Bearer %s' % token["accessToken"]} - async with self.throttler: - async with self.http_session.get(url, headers=headers, params=params) as response: - result = await response.json() - if not result or 'error' in result: - LOGGER.error(url) - LOGGER.error(params) - result = None - return result - - async def __delete_data(self, endpoint, params={}): - ''' get data from api''' - url = 'https://api.spotify.com/v1/%s' % endpoint - token = await self.get_token() - headers = {'Authorization': 'Bearer %s' % token["accessToken"]} - async with self.http_session.delete(url, headers=headers, params=params) as response: - return await response.text() - - async def __put_data(self, endpoint, params={}, data=None): - ''' put data on api''' - url = 'https://api.spotify.com/v1/%s' % endpoint - token = await self.get_token() - headers = {'Authorization': 'Bearer %s' % token["accessToken"]} - async with self.http_session.put(url, headers=headers, params=params, json=data) as response: - return await response.text() - - @staticmethod - def get_spotty_binary(): - '''find the correct spotty binary belonging to the platform''' - import platform - sp_binary = None - if platform.system() == "Windows": - sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "windows", "spotty.exe") - elif platform.system() == "Darwin": - # macos binary is x86_64 intel - sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "darwin", "spotty") - elif platform.system() == "Linux": - # try to find out the correct architecture by trial and error - architecture = platform.machine() - if architecture.startswith('AMD64') or architecture.startswith('x86_64'): - # generic linux x86_64 binary - sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "x86-linux", "spotty-x86_64") - else: - sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "arm-linux", "spotty-muslhf") - return sp_binary - - diff --git a/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf b/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf deleted file mode 100755 index c928d8a8..00000000 Binary files a/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf and /dev/null differ diff --git a/music_assistant/modules/musicproviders/spotty/darwin/spotty b/music_assistant/modules/musicproviders/spotty/darwin/spotty deleted file mode 100755 index 44c6b604..00000000 Binary files a/music_assistant/modules/musicproviders/spotty/darwin/spotty and /dev/null differ diff --git a/music_assistant/modules/musicproviders/spotty/windows/spotty.exe b/music_assistant/modules/musicproviders/spotty/windows/spotty.exe deleted file mode 100755 index 6ce9b19e..00000000 Binary files a/music_assistant/modules/musicproviders/spotty/windows/spotty.exe and /dev/null differ diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty deleted file mode 100755 index b2c3f349..00000000 Binary files a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty and /dev/null differ diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 deleted file mode 100755 index 58911cf5..00000000 Binary files a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 and /dev/null differ diff --git a/music_assistant/modules/musicproviders/tunein.py b/music_assistant/modules/musicproviders/tunein.py deleted file mode 100644 index 1350df4e..00000000 --- a/music_assistant/modules/musicproviders/tunein.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from typing import List -import sys -import time -from utils import run_periodic, LOGGER, parse_track_title -from models import MusicProvider, MediaType, TrackQuality, Radio -from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED -from asyncio_throttle import Throttler -import json -import aiohttp -from modules.cache import use_cache -import concurrent - -def setup(mass): - ''' setup the provider''' - enabled = mass.config["musicproviders"]['tunein'].get(CONF_ENABLED) - username = mass.config["musicproviders"]['tunein'].get(CONF_USERNAME) - password = mass.config["musicproviders"]['tunein'].get(CONF_PASSWORD) - if enabled and username and password: - provider = TuneInProvider(mass, username, password) - return provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, "", CONF_PASSWORD) - ] - -class TuneInProvider(MusicProvider): - - - def __init__(self, mass, username, password): - self.name = 'TuneIn Radio' - self.prov_id = 'tunein' - self.mass = mass - self.cache = mass.cache - self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) - self.throttler = Throttler(rate_limit=1, period=1) - self._username = username - self._password = password - - async def search(self, searchstring, media_types=List[MediaType], limit=5): - ''' perform search on the provider ''' - result = { - "artists": [], - "albums": [], - "tracks": [], - "playlists": [], - "radios": [] - } - return result - - async def get_radios(self): - ''' get favorited/library radio stations ''' - items = [] - params = {"c": "presets"} - result = await self.__get_data("Browse.ashx", params, ignore_cache=True) - if result and "body" in result: - for item in result["body"]: - # TODO: expand folders - if item["type"] == "audio": - radio = await self.__parse_radio(item) - items.append(radio) - return items - - async def get_radio(self, radio_id): - ''' get radio station details ''' - radio = None - params = {"c": "composite", "detail": "listing", "id": radio_id} - result = await self.__get_data("Describe.ashx", params, ignore_cache=True) - if result and result.get("body") and result["body"][0].get("children"): - item = result["body"][0]["children"][0] - radio = await self.__parse_radio(item) - return radio - - async def __parse_radio(self, details): - ''' parse Radio object from json obj returned from api ''' - radio = Radio() - radio.item_id = details['preset_id'] - radio.provider = self.prov_id - if "name" in details: - radio.name = details["name"] - else: - # parse name from text attr - name = details["text"] - if " | " in name: - name = name.split(" | ")[1] - name = name.split(" (")[0] - radio.name = name - # parse stream urls and format - stream_info = await self.__get_stream_urls(radio.item_id) - for stream in stream_info["body"]: - if stream["media_type"] == 'aac': - quality = TrackQuality.LOSSY_AAC - elif stream["media_type"] == 'ogg': - quality = TrackQuality.LOSSY_OGG - else: - quality = TrackQuality.LOSSY_MP3 - radio.provider_ids.append({ - "provider": self.prov_id, - "item_id": details['preset_id'], - "quality": quality, - "details": stream['url'] - }) - # image - if "image" in details: - radio.metadata["image"] = details["image"] - elif "logo" in details: - radio.metadata["image"] = details["logo"] - return radio - - async def __get_stream_urls(self, radio_id): - ''' get the stream urls for the given radio id ''' - params = {"id": radio_id} - res = await self.__get_data("Tune.ashx", params) - return res - - # async def get_stream_content_type(self, radio_id): - # ''' return the content type for the given radio when it will be streamed''' - # return 'flac' #TODO handle other file formats on qobuz? - - # async def get_audio_stream(self, track_id): - # ''' get audio stream for a track ''' - # params = {'format_id': 27, 'track_id': track_id, 'intent': 'stream'} - # # we are called from other thread - # streamdetails_future = asyncio.run_coroutine_threadsafe( - # self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True), - # self.mass.event_loop - - @use_cache(7) - async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None): - ''' get data from api''' - url = 'https://opml.radiotime.com/%s' % endpoint - params['render'] = 'json' - params['formats'] = 'ogg,aac,wma,mp3' - params['username'] = self._username - params['partnerId'] = '1' - async with self.throttler: - async with self.http_session.get(url, params=params) as response: - result = await response.json() - if not result or 'error' in result: - LOGGER.error(url) - LOGGER.error(params) - result = None - return result - - \ No newline at end of file diff --git a/music_assistant/modules/player_manager.py b/music_assistant/modules/player_manager.py deleted file mode 100755 index 438ce7b2..00000000 --- a/music_assistant/modules/player_manager.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from enum import Enum -from ..utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task -from ..models.media_types import MediaType, TrackQuality -from ..models.player_queue import QueueItem -from ..models.player import PlayerState -import operator -import random -import functools -import urllib - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" ) - - - -class PlayerManager(): - ''' several helpers to handle playback through player providers ''' - - def __init__(self, mass): - self.mass = mass - self.providers = {} - self._players = {} - self.local_ip = get_ip() - # dynamically load provider modules - self.load_providers() - - @property - def players(self): - ''' all players as property ''' - return self.mass.event_loop.run_until_complete(self.get_players()) - - async def get_players(self): - ''' return all players as a list ''' - items = list(self._players.values()) - items.sort(key=lambda x: x.name, reverse=False) - return items - - async def get_player(self, player_id): - ''' return player by id ''' - return self._players.get(player_id, None) - - async def get_provider_players(self, player_provider): - ''' return all players for given provider_id ''' - return [item for item in self._players.values() if item.player_provider == player_provider] - - async def add_player(self, player): - ''' register a new player ''' - self._players[player.player_id] = player - self.mass.signal_event('player added', player) - # TODO: turn on player if it was previously turned on ? - return player - - async def remove_player(self, player_id): - ''' handle a player remove ''' - self._players.pop(player_id, None) - self.mass.signal_event('player removed', player_id) - - async def trigger_update(self, player_id): - ''' manually trigger update for a player ''' - if player_id in self._players: - await self._players[player_id].update() - - def load_providers(self): - ''' dynamically load providers ''' - for item in os.listdir(MODULES_PATH): - if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and - item.endswith('.py') and not item.startswith('.')): - module_name = item.replace(".py","") - LOGGER.debug("Loading playerprovider module %s" % module_name) - try: - mod = __import__("modules.playerproviders." + module_name, fromlist=['']) - if not self.mass.config['playerproviders'].get(module_name): - self.mass.config['playerproviders'][module_name] = {} - self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries() - for key, def_value, desc in mod.config_entries(): - if not key in self.mass.config['playerproviders'][module_name]: - self.mass.config['playerproviders'][module_name][key] = def_value - mod = mod.setup(self.mass) - if mod: - self.providers[mod.prov_id] = mod - cls_name = mod.__class__.__name__ - LOGGER.info("Successfully initialized module %s" % cls_name) - except Exception as exc: - LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/modules/playerproviders/__init__.py b/music_assistant/modules/playerproviders/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/modules/playerproviders/chromecast.py deleted file mode 100644 index 736528d4..00000000 --- a/music_assistant/modules/playerproviders/chromecast.py +++ /dev/null @@ -1,402 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -# import os -# from typing import List -# import random -# import sys -# import json -import aiohttp -# import time -# import datetime -# import hashlib -import pychromecast -from pychromecast.controllers.multizone import MultizoneController -from pychromecast.controllers import BaseController -from pychromecast.controllers.media import MediaController -import types -# import urllib -# import select -from ...utils import run_periodic, LOGGER, try_parse_int -from ...models.playerprovider import PlayerProvider -from ...models.player import Player, PlayerState -from ...constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT - -def setup(mass): - ''' setup the provider''' - enabled = mass.config["playerproviders"]['chromecast'].get(CONF_ENABLED) - if enabled: - provider = ChromecastProvider(mass) - return provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, True, CONF_ENABLED), - ] - -class ChromecastPlayer(Player): - ''' Chromecast player object ''' - cc = None - - async def __stop(self): - ''' send stop command to player ''' - self.cc.media_controller.stop() - - async def __play(self): - ''' send play command to player ''' - self.cc.media_controller.play() - - async def __pause(self): - ''' send pause command to player ''' - self.cc.media_controller.pause() - - async def __power_on(self): - ''' send power ON command to player ''' - self.powered = True - - async def __power_off(self): - ''' send power OFF command to player ''' - self.powered = False - # power is not supported so send quit_app instead - if not self.group_parent: - self.cc.quit_app() - - async def __volume_set(self, volume_level): - ''' send new volume level command to player ''' - self.cc.set_volume(volume_level/100) - self.volume_level = volume_level - - async def __volume_mute(self, is_muted=False): - ''' send mute command to player ''' - self.cc.set_volume_muted(is_muted) - - -class ChromecastProvider(PlayerProvider): - ''' support for ChromeCast Audio ''' - - def __init__(self, mass): - self.prov_id = 'chromecast' - self.name = 'Chromecast' - self.mass = mass - self._discovery_running = False - self.mass.event_loop.create_task(self.__periodic_chromecast_discovery()) - - async def __queue_load(self, player_id, new_tracks, startindex=None): - ''' load queue on player with given queue items ''' - castplayer = self._chromecasts[player_id] - player = self._players[player_id] - queue_items = await self.__create_queue_items(new_tracks[:50]) - self._player_queue_index[player_id] = 0 - queuedata = { - "type": 'QUEUE_LOAD', - "repeatMode": "REPEAT_ALL" if player.repeat_enabled else "REPEAT_OFF", - "shuffle": player.shuffle_enabled, - "queueType": "PLAYLIST", - "startIndex": startindex, # Item index to play after this request or keep same item if undefined - "items": queue_items # only load 50 tracks at once or the socket will crash - } - await self.__send_player_queue(castplayer, queuedata) - await asyncio.sleep(0.2) - if len(new_tracks) > 50: - await self.__queue_insert(player_id, new_tracks[51:]) - await asyncio.sleep(0.2) - - async def __play_stream_queue(self, player_id, startindex=0): - ''' tell the cast player to stream our special queue (crossfaded) stream ''' - castplayer = self._chromecasts[player_id] - uri = 'http://%s:%s/stream_queue?player_id=%s&startindex=%s'% ( - self.mass.player.local_ip, self.mass.config['base']['web']['http_port'], player_id, startindex) - castplayer.play_media(uri, 'audio/flac') - - async def __queue_insert(self, player_id, new_tracks, insert_before=None): - ''' insert item into the player queue ''' - castplayer = self._chromecasts[player_id] - queue_items = await self.__create_queue_items(new_tracks) - for chunk in chunks(queue_items, 50): - queuedata = { - "type": 'QUEUE_INSERT', - "insertBefore": insert_before, - "items": chunk - } - await self.__send_player_queue(castplayer, queuedata) - - async def __queue_update(self, player_id, queue_items_to_update): - ''' update the cast player queue ''' - castplayer = self._chromecasts[player_id] - queuedata = { - "type": 'QUEUE_UPDATE', - "items": queue_items_to_update - } - await self.__send_player_queue(castplayer, queuedata) - - async def __queue_remove(self, player_id, queue_item_ids): - ''' remove items from the cast player queue ''' - castplayer = self._chromecasts[player_id] - queuedata = { - "type": 'QUEUE_REMOVE', - "items": queue_item_ids - } - await self.__send_player_queue(castplayer, queuedata) - - async def __resume_queue(self, player_id): - ''' resume queue play after power off ''' - LOGGER.info('resuming queue....') - tracks = self._player_queue[player_id] - await self.play_media(player_id, tracks) - - async def __create_queue_items(self, tracks): - ''' create list of CC queue items from tracks ''' - queue_items = [] - for track in tracks: - queue_item = await self.__create_queue_item(track) - queue_items.append(queue_item) - return queue_items - - async def __create_queue_item(self, track): - '''create queue item from track info ''' - return { - 'autoplay' : True, - 'preloadTime' : 10, - 'playbackDuration': int(track.duration), - 'startTime' : 0, - 'activeTrackIds' : [], - 'media': { - 'contentId': track.uri, - 'customData': { - 'provider': track.provider, - 'uri': track.uri, - 'item_id': track.item_id - }, - 'contentType': "audio/flac", - 'streamType': 'BUFFERED', - 'metadata': { - 'title': track.name, - 'artist': track.artists[0].name if track.artists else "", - }, - 'duration': int(track.duration) - } - } - - async def __send_player_queue(self, castplayer, queuedata): - '''send new data to the CC queue''' - media_controller = castplayer.media_controller - receiver_ctrl = media_controller._socket_client.receiver_controller - def send_queue(): - """Plays media after chromecast has switched to requested app.""" - queuedata['mediaSessionId'] = media_controller.status.media_session_id - media_controller.send_message(queuedata, inc_session_id=False) - if not media_controller.status.media_session_id: - receiver_ctrl.launch_app(media_controller.app_id, callback_function=send_queue) - else: - send_queue() - await asyncio.sleep(0.2) - - async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None): - ''' handle a player state message from the socket ''' - player_id = str(chromecast.uuid) - player = self.get_player(player_id) - # always update player details that may change - player.name = chromecast.name - if caststatus: - player.muted = caststatus.volume_muted - player.volume_level = caststatus.volume_level * 100 - if mediastatus: - # chromecast does not support power on/of so we only set state - if mediastatus.player_state in ['PLAYING', 'BUFFERING']: - player.state = PlayerState.Playing - elif mediastatus.player_state == 'PAUSED': - player.state = PlayerState.Paused - else: - player.state = PlayerState.Stopped - if not mediastatus.content_id: - player.cur_item = None - player.cur_item_time = 0 - elif not 'stream_queue' in mediastatus.content_id: - player.cur_item = await self.__parse_track(mediastatus) - player.cur_item_time = mediastatus.adjusted_current_time - self._player_queue_index[player_id] = await self.__get_cur_queue_index(player_id, mediastatus.content_id) - elif 'stream_queue' in mediastatus.content_id: - # player is playing our special queue continuous stream - # try to work out the current time - # player is playing a constant stream of the queue so we need to do this the hard way - cur_time_queue = mediastatus.adjusted_current_time - total_time = 0 - track_time = 0 - queue_index = self._player_queue_stream_startindex[player_id] - queue_track = None - while True: - queue_track = self._player_queue[player_id][queue_index] - if cur_time_queue > (queue_track.duration + total_time): - total_time += queue_track.duration - queue_index += 1 - else: - track_time = cur_time_queue - total_time - break - player.cur_item = queue_track - player.cur_item_time = track_time - self._player_queue_index[player_id] = queue_index - - async def __handle_group_members_update(self, mz, added_player=None, removed_player=None): - ''' callback when cast group members update ''' - if added_player: - player = self.get_player(added_player) - group_player = self.get_player(str(mz._uuid)) - if player and group_player: - player.group_parent = str(mz._uuid) - LOGGER.debug("player %s added to group %s" %(player.name, group_player.name)) - elif removed_player: - player = self.get_player(added_player) - group_player = self.get_player(str(mz._uuid)) - if player and group_player: - player.group_parent = None - LOGGER.debug("player %s removed from group %s" %(player.name, group_player.name)) - else: - for member in mz.members: - player = self.get_player(member) - if player: - player.group_parent = str(mz._uuid) - - @run_periodic(1800) - async def __periodic_chromecast_discovery(self): - ''' run chromecast discovery on interval ''' - await self.__chromecast_discovery() - - async def __chromecast_discovery(self): - ''' background non-blocking chromecast discovery and handler ''' - if self._discovery_running: - return - self._discovery_running = True - LOGGER.info("Chromecast discovery started...") - # remove any disconnected players... - removed_players = [] - for player in self.players: - if not player.cc.socket_client or not player.cc.socket_client.is_connected: - LOGGER.info("%s is disconnected" % player.name) - # cleanup cast object - del player.cc - removed_players.append(player.player_id) - # signal removed players - for player_id in removed_players: - await self.remove_player(player_id) - # search for available chromecasts - from pychromecast.discovery import start_discovery, stop_discovery - def discovered_callback(name): - """Called when zeroconf has discovered a (new) chromecast.""" - discovery_info = listener.services[name] - ip_address, port, uuid, model_name, friendly_name = discovery_info - player_id = str(uuid) - if not self.get_player(player_id): - LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port)) - asyncio.run_coroutine_threadsafe( - self.__chromecast_discovered(player_id, discovery_info), self.mass.event_loop) - listener, browser = start_discovery(discovered_callback) - await asyncio.sleep(15) # run discovery for 15 seconds - stop_discovery(browser) - LOGGER.info("Chromecast discovery completed...") - self._discovery_running = False - - async def __chromecast_discovered(self, player_id, discovery_info): - ''' callback when a (new) chromecast device is discovered ''' - from pychromecast import _get_chromecast_from_host, ChromecastConnectionError - try: - chromecast = _get_chromecast_from_host(discovery_info, tries=2, retry_wait=5) - except ChromecastConnectionError: - LOGGER.warning("Could not connect to device %s" % player_id) - return - # patch the receive message method for handling queue status updates - chromecast.media_controller.queue_items = [] - chromecast.media_controller.queue_cur_id = None - chromecast.media_controller.receive_message = types.MethodType(receive_message, chromecast.media_controller) - listenerCast = StatusListener(chromecast, self.__handle_player_state, self.mass.event_loop) - chromecast.register_status_listener(listenerCast) - listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop) - chromecast.media_controller.register_status_listener(listenerMedia) - player = ChromecastPlayer(self.mass, player_id, self.prov_id) - if chromecast.cast_type == 'group': - player.is_group = True - mz = MultizoneController(chromecast.uuid) - mz.register_listener(MZListener(mz, self.__handle_group_members_update, self.mass.event_loop)) - chromecast.register_handler(mz) - chromecast.register_connection_listener(MZConnListener(mz)) - chromecast.mz = mz - player.cc = chromecast - player.cc.wait() - self.add_player(player) - self.update_all_group_members() - - def update_all_group_members(self): - ''' force member update of all cast groups ''' - for player in self.players: - if player.cc.cast_type == 'group': - player.cc.mz.update_members() - -def chunks(l, n): - """Yield successive n-sized chunks from l.""" - for i in range(0, len(l), n): - yield l[i:i + n] - - -class StatusListener: - def __init__(self, chromecast, callback, loop): - self.chromecast = chromecast - self.__handle_player_state = callback - self.loop = loop - def new_cast_status(self, status): - asyncio.run_coroutine_threadsafe( - self.__handle_player_state(self.chromecast, caststatus=status), self.loop) - -class StatusMediaListener: - def __init__(self, chromecast, callback, loop): - self.chromecast= chromecast - self.__handle_player_state = callback - self.loop = loop - def new_media_status(self, status): - asyncio.run_coroutine_threadsafe( - self.__handle_player_state(self.chromecast, mediastatus=status), self.loop) - -class MZConnListener: - def __init__(self, mz): - self._mz=mz - def new_connection_status(self, connection_status): - """Handle reception of a new ConnectionStatus.""" - if connection_status.status == 'CONNECTED': - self._mz.update_members() - -class MZListener: - def __init__(self, mz, callback, loop): - self._mz = mz - self._loop = loop - self.__handle_group_members_update = callback - - def multizone_member_added(self, uuid): - asyncio.run_coroutine_threadsafe( - self.__handle_group_members_update( - self._mz, added_player=str(uuid)), self._loop) - - def multizone_member_removed(self, uuid): - asyncio.run_coroutine_threadsafe( - self.__handle_group_members_update( - self._mz, removed_player=str(uuid)), self._loop) - - def multizone_status_received(self): - asyncio.run_coroutine_threadsafe( - self.__handle_group_members_update(self._mz), self._loop) - -def receive_message(self, message, data): - """ Called when a media message is received. """ - #LOGGER.info('message: %s - data: %s'%(message, data)) - if data['type'] == 'MEDIA_STATUS': - try: - self.queue_items = data['status'][0]['items'] - except: - pass - try: - self.queue_cur_id = data['status'][0]['currentItemId'] - except: - pass - self._process_media_status(data) - return True - return False \ No newline at end of file diff --git a/music_assistant/modules/playerproviders/lms.py b/music_assistant/modules/playerproviders/lms.py deleted file mode 100644 index 13db3f78..00000000 --- a/music_assistant/modules/playerproviders/lms.py +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from typing import List -import random -import sys -from utils import run_periodic, LOGGER, parse_track_title -from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT -import json -import aiohttp -import time -import datetime -import hashlib -from asyncio_throttle import Throttler -from aiocometd import Client, ConnectionType, Extension -from modules.cache import use_cache -import copy -import urllib - -def setup(mass): - ''' setup the provider''' - enabled = mass.config["playerproviders"]['lms'].get(CONF_ENABLED) - hostname = mass.config["playerproviders"]['lms'].get(CONF_HOSTNAME) - port = mass.config["playerproviders"]['lms'].get(CONF_PORT) - if enabled and hostname and port: - provider = LMSProvider(mass, hostname, port) - return provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, False, CONF_ENABLED), - (CONF_HOSTNAME, 'localhost', CONF_HOSTNAME), - (CONF_PORT, 9000, CONF_PORT) - ] - -class LMSProvider(PlayerProvider): - ''' support for Logitech Media Server ''' - - def __init__(self, mass, hostname, port): - self.prov_id = 'lms' - self.name = 'Logitech Media Server' - self.icon = '' - self.mass = mass - self._players = {} - self._host = hostname - self._port = port - self.last_msg_received = 0 - self.supported_musicproviders = ['qobuz', 'file', 'spotify', 'http'] - self.http_session = aiohttp.ClientSession(loop=mass.event_loop) - # we use a combi of active polling and subscriptions because the cometd implementation of LMS is somewhat unreliable - asyncio.ensure_future(self.__lms_events()) - asyncio.ensure_future(self.__get_players()) - - ### Provider specific implementation ##### - - async def player_config_entries(self): - ''' get the player config entries for this provider (list with key/value pairs)''' - return [] - - async def player_command(self, player_id, cmd:str, cmd_args=None): - ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' - lms_commands = [] - if cmd == 'play': - lms_commands = ['play'] - elif cmd == 'pause': - lms_commands = ['pause', '1'] - elif cmd == 'stop': - lms_commands = ['stop'] - elif cmd == 'next': - lms_commands = ['playlist', 'index', '+1'] - elif cmd == 'previous': - lms_commands = ['playlist', 'index', '-1'] - elif cmd == 'stop': - lms_commands = ['playlist', 'stop'] - elif cmd == 'power' and cmd_args == 'off': - lms_commands = ['power', '0'] - elif cmd == 'power': - lms_commands = ['power', '1'] - elif cmd == 'volume': - lms_commands = ['mixer', 'volume', cmd_args] - elif cmd == 'mute' and cmd_args == 'off': - lms_commands = ['mixer', 'muting', '0'] - elif cmd == 'mute': - lms_commands = ['mixer', 'muting', '1'] - return await self.__get_data(lms_commands, player_id=player_id) - - async def play_media(self, player_id, media_items, queue_opt='play'): - ''' - play media on a player - ''' - if queue_opt == 'play': - cmd = ['playlist', 'insert', media_items[0].uri] - await self.__get_data(cmd, player_id=player_id) - cmd = ['playlist', 'index', '+1'] - await self.__get_data(cmd, player_id=player_id) - for track in media_items[1:]: - cmd = ['playlist', 'insert', track.uri] - await self.__get_data(cmd, player_id=player_id) - elif queue_opt == 'replace': - cmd = ['playlist', 'play', media_items[0].uri] - await self.__get_data(cmd, player_id=player_id) - for track in media_items[1:]: - cmd = ['playlist', 'add', track.uri] - await self.__get_data(cmd, player_id=player_id) - elif queue_opt == 'next': - for track in media_items: - cmd = ['playlist', 'insert', track.uri] - await self.__get_data(cmd, player_id=player_id) - else: - for track in media_items: - cmd = ['playlist', 'add', track.uri] - await self.__get_data(cmd, player_id=player_id) - - async def player_queue(self, player_id, offset=0, limit=50): - ''' return the items in the player's queue ''' - items = [] - player_details = await self.__get_data(["status", offset, limit, "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id) - if 'playlist_loop' in player_details: - for item in player_details['playlist_loop']: - track = await self.__parse_track(item) - items.append(track) - return items - - ### Provider specific (helper) methods ##### - - async def __get_players(self): - ''' update all players, used as fallback if cometd is failing and to detect removed players''' - server_info = await self.__get_data(['players', 0, 1000]) - player_ids = await self.__process_serverstatus(server_info) - for player_id in player_ids: - player_details = await self.__get_data(["status", "-","1", "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id) - await self.__process_player_details(player_id, player_details) - - async def __process_player_details(self, player_id, player_details): - ''' get state of a given player ''' - if player_id not in self._players: - return - player = self._players[player_id] - volume = player_details.get('mixer volume',0) - player.muted = volume < 0 - if volume >= 0: - player.volume_level = player_details.get('mixer volume',0) - player.shuffle_enabled = player_details.get('playlist shuffle',0) != 0 - player.repeat_enabled = player_details.get('playlist repeat',0) != 0 - # player state - if 'power' in player_details: - player.powered = player_details['power'] == 1 - else: - print(player_details) # DEBUG - if player_details['mode'] == 'play': - player.state = PlayerState.Playing - elif player_details['mode'] == 'pause': - player.state = PlayerState.Paused - else: - player.state = PlayerState.Stopped - # current track - if player_details.get('playlist_loop'): - player.cur_item = await self.__parse_track(player_details['playlist_loop'][0]) - player.cur_item_time = player_details.get('time',0) - else: - player.cur_item = None - player.cur_item_time = 0 - await self.mass.player.update_player(player) - - async def __process_serverstatus(self, server_status): - ''' process players from server state msg (players_loop) ''' - cur_player_ids = [] - for lms_player in server_status['players_loop']: - if lms_player['isplayer'] != 1: - continue - player_id = lms_player['playerid'] - cur_player_ids.append(player_id) - if not player_id in self._players: - # new player - self._players[player_id] = MusicPlayer() - player = self._players[player_id] - player.player_id = player_id - player.player_provider = self.prov_id - else: - # existing player - player = self._players[player_id] - # always update player details that may change - player.name = lms_player['name'] - if lms_player['model'] == "group": - player.is_group = True - # player is a groupplayer, retrieve childs - group_player_child_ids = await self.__get_group_childs(player_id) - for child_player_id in group_player_child_ids: - if child_player_id in self._players: - self._players[child_player_id].group_parent = player_id - elif player.group_parent: - # check if player parent is still correct - group_player_child_ids = await self.__get_group_childs(player.group_parent) - if not player_id in group_player_child_ids: - player.group_parent = None - # process update - await self.mass.player.update_player(player) - # process removed players... - for player_id, player in self._players.items(): - if player_id not in cur_player_ids: - await self.mass.player.remove_player(player_id) - return cur_player_ids - - async def __parse_track(self, track_details): - ''' parse track in LMS to our internal format ''' - track_url = track_details.get('url','') - if track_url.startswith('qobuz://') and 'qobuz' in self.mass.music.providers: - # qobuz track! - try: - track_id = track_url.replace('qobuz://','').replace('.flac','') - return await self.mass.music.providers['qobuz'].track(track_id) - except Exception as exc: - LOGGER.error(exc) - elif track_url.startswith('spotify://track:') and 'spotify' in self.mass.music.providers: - # spotify track! - try: - track_id = track_url.replace('spotify://track:','') - return await self.mass.music.providers['spotify'].track(track_id) - except Exception as exc: - LOGGER.error(exc) - elif track_url.startswith('http') and '/stream' in track_url: - params = urllib.parse.parse_qs(track_url.split('?')[1]) - track_id = params['track_id'][0] - provider = params['provider'][0] - return await self.mass.music.providers[provider].track(track_id) - # fallback to a generic track - track = Track() - track.name = track_details['title'] - track.duration = int(track_details['duration']) - if 'artwork_url' in track_details: - image = "http://%s:%s%s" % (self._host, self._port, track_details['artwork_url']) - track.metadata['image'] = image - return track - - async def __get_group_childs(self, group_player_id): - ''' get child players for groupplayer ''' - group_childs = [] - result = await self.__get_data('playergroup', player_id=group_player_id) - if result and 'players_loop' in result: - group_childs = [item['id'] for item in result['players_loop']] - return group_childs - - async def __lms_events(self): - # Receive events from LMS through CometD socket - while self.mass.event_loop.is_running(): - try: - last_msg_received = 0 - async with Client("http://%s:%s/cometd" % (self._host, self._port), - connection_types=ConnectionType.LONG_POLLING, - extensions=[LMSExtension()]) as client: - # subscribe - watched_players = [] - await client.subscribe("/slim/subscribe/serverstatus") - - # listen for incoming messages - async for message in client: - last_msg_received = int(time.time()) - if 'playerstatus' in message['channel']: - # player state - player_id = message['channel'].split('playerstatus/')[1] - asyncio.ensure_future(self.__process_player_details(player_id, message['data'])) - elif '/slim/serverstatus' in message['channel']: - # server state with all players - player_ids = await self.__process_serverstatus(message['data']) - for player_id in player_ids: - if player_id not in watched_players: - # subscribe to player change events - watched_players.append(player_id) - await client.subscribe("/slim/subscribe/playerstatus/%s" % player_id) - except Exception as exc: - LOGGER.exception(exc) - - async def __get_data(self, cmds:List, player_id=''): - ''' get data from api''' - if not isinstance(cmds, list): - cmds = [cmds] - cmd = [player_id, cmds] - url = "http://%s:%s/jsonrpc.js" % (self._host, self._port) - params = {"id": 1, "method": "slim.request", "params": cmd} - try: - async with self.http_session.post(url, json=params) as response: - result = await response.json() - return result['result'] - except Exception as exc: - LOGGER.exception('Error executing LMS command %s' % params) - return None - - -class LMSExtension(Extension): - ''' Extension for the custom cometd implementation of LMS''' - - async def incoming(self, payload, headers=None): - pass - - async def outgoing(self, payload, headers): - ''' override outgoing messages to fit LMS custom implementation''' - - # LMS does not need/want id for the connect and handshake message - if payload[0]['channel'] == '/meta/handshake' or payload[0]['channel'] == '/meta/connect': - del payload[0]['id'] - - # handle subscriptions - if 'subscribe' in payload[0]['channel']: - client_id = payload[0]['clientId'] - if payload[0]['subscription'] == '/slim/subscribe/serverstatus': - # append additional request data to the request - payload[0]['data'] = {'response':'/%s/slim/serverstatus' % client_id, - 'request':['', ['serverstatus', 0, 100, 'subscribe:60']]} - payload[0]['channel'] = '/slim/subscribe' - if payload[0]['subscription'].startswith('/slim/subscribe/playerstatus'): - # append additional request data to the request - player_id = payload[0]['subscription'].split('/')[-1] - payload[0]['data'] = {'response':'/%s/slim/playerstatus/%s' % (client_id, player_id), - 'request':[player_id, ["status", "-", 1, "tags:aAcCdegGijJKlostuxyRwk", "subscribe:60"]]} - payload[0]['channel'] = '/slim/subscribe' \ No newline at end of file diff --git a/music_assistant/modules/playerproviders/pylms.py b/music_assistant/modules/playerproviders/pylms.py deleted file mode 100644 index 8c67b9b9..00000000 --- a/music_assistant/modules/playerproviders/pylms.py +++ /dev/null @@ -1,807 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -import struct -from collections import OrderedDict -import time -import decimal -from typing import List -import random -import sys -import socket -from utils import run_periodic, LOGGER, parse_track_title, try_parse_int, get_ip, get_hostname -from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_ENABLED - - -def setup(mass): - ''' setup the provider''' - enabled = mass.config["playerproviders"]['pylms'].get(CONF_ENABLED) - if enabled: - provider = PyLMSServer(mass) - return provider - return False - -def config_entries(): - ''' get the config entries for this provider (list with key/value pairs)''' - return [ - (CONF_ENABLED, True, CONF_ENABLED) - ] - - -class PyLMSServer(PlayerProvider): - ''' Python implementation of SlimProto server ''' - - def __init__(self, mass): - self.prov_id = 'pylms' - self.name = 'Logitech Media Server Emulation' - self.mass = mass - self._lmsplayers = {} - self.buffer = b'' - self.last_msg_received = 0 - self.supported_musicproviders = ['http'] - - # start slimproto server - mass.event_loop.create_task(asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483)) - # setup discovery - mass.event_loop.create_task(self.start_discovery()) - - ### Provider specific implementation ##### - - - async def start_discovery(self): - transport, protocol = await self.mass.event_loop.create_datagram_endpoint( - lambda: DiscoveryProtocol(self.mass.web._http_port), - local_addr=('0.0.0.0', 3483)) - try: - while True: - await asyncio.sleep(60) # serve forever - finally: - transport.close() - - async def player_config_entries(self): - ''' get the player config entries for this provider (list with key/value pairs)''' - return [ - ("crossfade_duration", 0, "crossfade_duration"), - ] - - async def player_command(self, player_id, cmd:str, cmd_args=None): - ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' - if cmd == 'play': - if self._players[player_id].state == PlayerState.Stopped: - await self.__queue_play(player_id, None) - else: - self._lmsplayers[player_id].unpause() - elif cmd == 'pause': - self._lmsplayers[player_id].pause() - elif cmd == 'stop': - self._lmsplayers[player_id].stop() - elif cmd == 'next': - self._lmsplayers[player_id].next() - elif cmd == 'previous': - await self.__queue_previous(player_id) - elif cmd == 'power' and cmd_args == 'off': - self._lmsplayers[player_id].power_off() - elif cmd == 'power': - self._lmsplayers[player_id].power_on() - elif cmd == 'volume': - self._lmsplayers[player_id].volume_set(try_parse_int(cmd_args)) - elif cmd == 'mute' and cmd_args == 'off': - self._lmsplayers[player_id].unmute() - elif cmd == 'mute': - self._lmsplayers[player_id].mute() - - async def play_media(self, player_id, media_items, queue_opt='play'): - ''' - play media on a player - ''' - player = self.get_player(player_id) - cur_index = player.cur_queue_index - - if queue_opt == 'replace' or not player.queue: - # overwrite queue with new items - player.queue = media_items - await self.__queue_play(player_id, 0, send_flush=True) - elif queue_opt == 'play': - # replace current item with new item(s) - player.queue = player.queue[player_id][:cur_index] + media_items + player.queue[player_id][cur_index+1:] - await self.__queue_play(player_id, cur_index, send_flush=True) - elif queue_opt == 'next': - # insert new items at current index +1 - player.queue[player_id] = player.queue[player_id][:cur_index+1] + media_items + player.queue[player_id][cur_index+1:] - elif queue_opt == 'add': - # add new items at end of queue - player.queue[player_id] = player.queue[player_id] + media_items - - ### Provider specific (helper) methods ##### - - async def __queue_play(self, player_id, index, send_flush=False): - ''' send play command to player ''' - if not player_id in player.queue or not player_id in player.queue_index: - return - if not player.queue[player_id]: - return - if index == None: - index = player.queue_index[player_id] - if len(player.queue[player_id]) >= index: - track = player.queue[player_id][index] - if send_flush: - self._lmsplayers[player_id].flush() - self._lmsplayers[player_id].play(track.uri) - player.queue_index[player_id] = index - - async def __queue_next(self, player_id): - ''' request next track from queue ''' - if not player_id in player.queue or not player_id in player.queue: - return - cur_queue_index = player.queue_index[player_id] - if len(player.queue[player_id]) > cur_queue_index: - new_queue_index = cur_queue_index + 1 - elif self._players[player_id].repeat_enabled: - new_queue_index = 0 - else: - LOGGER.warning("next track requested but no more tracks in queue") - return - return await self.__queue_play(player_id, new_queue_index) - - async def __queue_previous(self, player_id): - ''' request previous track from queue ''' - if not player_id in player.queue: - return - cur_queue_index = player.queue_index[player_id] - if cur_queue_index == 0 and len(player.queue[player_id]) > 1: - new_queue_index = len(player.queue[player_id]) -1 - elif cur_queue_index == 0: - new_queue_index = cur_queue_index - else: - new_queue_index -= 1 - player.queue_index[player_id] = new_queue_index - return await self.__queue_play(player_id, new_queue_index) - - async def __handle_player_event(self, player_id, event, event_data=None): - ''' handle event from player ''' - if not player_id: - return - LOGGER.debug("Event from player %s: %s - event_data: %s" %(player_id, event, str(event_data))) - lms_player = self._lmsplayers[player_id] - if event == "next_track": - return await self.__queue_next(player_id) - player - if not player_id in self._players: - player = MusicPlayer() - player.player_id = player_id - player.player_provider = self.prov_id - self._players[player_id] = player - if not player_id in player.queue: - player.queue[player_id] = [] - if not player_id in player.queue_index: - player.queue_index[player_id] = 0 - else: - player = self._players[player_id] - # update player properties - player.name = lms_player.player_name - player.volume_level = lms_player.volume_level - player.cur_item_time = lms_player._elapsed_seconds - if event == "disconnected": - return await self.mass.player.remove_player(player_id) - elif event == "power": - player.powered = event_data - elif event == "state": - player.state = event_data - if player.queue[player_id]: - cur_queue_index = player.queue_index[player_id] - player.cur_item = player.queue[player_id][cur_queue_index] - # update player details - await self.mass.player.update_player(player) - - async def __handle_socket_client(self, reader, writer): - ''' handle a client connection on the socket''' - LOGGER.debug("new socket client connected") - stream_host = get_ip() - stream_port = self.mass.config['base']['web']['http_port'] - lms_player = PyLMSPlayer(stream_host, stream_port) - - def send_frame(command, data): - ''' send command to lms player''' - packet = struct.pack('!H', len(data) + 4) + command + data - writer.write(packet) - - def handle_event(event, event_data=None): - ''' handle events from player''' - if event == "connected": - self._lmsplayers[lms_player.player_id] = lms_player - lms_player.player_settings = self.mass.config['player_settings'][lms_player.player_id] - asyncio.create_task(self.__handle_player_event(lms_player.player_id, event, event_data)) - - try: - @run_periodic(5) - async def send_heartbeat(): - timestamp = int(time.time()) - data = lms_player.pack_stream(b"t", replayGain=timestamp, flags=0) - lms_player.send_frame(b"strm", data) - - lms_player.send_frame = send_frame - lms_player.send_event = handle_event - heartbeat_task = asyncio.create_task(send_heartbeat()) - - # keep reading bytes from the socket - while True: - data = await reader.read(64) - if data: - lms_player.dataReceived(data) - else: - break - except Exception as exc: - # connection lost ? - LOGGER.warning(exc) - # disconnect - heartbeat_task.cancel() - asyncio.create_task(self.__handle_player_event(lms_player.player_id, 'disconnected')) - - -class PyLMSPlayer(object): - ''' very basic Python implementation of SlimProto ''' - - def __init__(self, stream_host, stream_port): - self.buffer = b'' - #self.display = Display() - self.send_frame = None - self.send_event = None - self.stream_host = stream_host - self.stream_port = stream_port - self.player_settings = {} - self.playback_millis = 0 - self._volume = PyLMSVolume() - self._device_type = None - self._mac_address = None - self._player_name = None - self._last_volume = 0 - self._last_heartbeat = 0 - self._elapsed_seconds = 0 - self._elapsed_milliseconds = 0 - - @property - def player_name(self): - if self._player_name: - return self._player_name - return "%s - %s" %(self._device_type, self._mac_address) - - @property - def player_id(self): - return self._mac_address - - @property - def volume_level(self): - return self._volume.volume - - def dataReceived(self, data): - self.buffer = self.buffer + data - if len(self.buffer) > 8: - operation, length = self.buffer[:4], self.buffer[4:8] - length = struct.unpack('!I', length)[0] - plen = length + 8 - if len(self.buffer) >= plen: - packet, self.buffer = self.buffer[8:plen], self.buffer[plen:] - operation = operation.strip(b"!").strip().decode() - #LOGGER.info("operation: %s" % operation) - handler = getattr(self, "process_%s" % operation, None) - if handler is None: - raise NotImplementedError - handler(packet) - - def send_version(self): - self.send_frame(b'vers', b'7.8') - - def pack_stream(self, command, autostart=b"1", formatbyte = b'o', pcmargs = (b'?',b'?',b'?',b'?'), threshold = 200, - spdif = b'0', transDuration = 0, transType = b'0', flags = 0x40, outputThreshold = 0, - replayGain=0, serverPort = 8095, serverIp = 0): - return struct.pack("!cccccccBcBcBBBLHL", - command, autostart, formatbyte, *pcmargs, - threshold, spdif, transDuration, transType, - flags, outputThreshold, 0, replayGain, serverPort, serverIp) - - def stop(self): - data = self.pack_stream(b"q", autostart=b"0", flags=0) - self.send_frame(b"strm", data) - - def flush(self): - data = self.pack_stream(b"f", autostart=b"0", flags=0) - self.send_frame(b"strm", data) - - def pause(self): - data = self.pack_stream(b"p", autostart=b"0", flags=0) - self.send_frame(b"strm", data) - LOGGER.info("Sending pause request") - - def unpause(self): - data = self.pack_stream(b"u", autostart=b"0", flags=0) - self.send_frame(b"strm", data) - LOGGER.info("Sending unpause request") - - def next(self): - data = self.pack_stream(b"f", autostart=b"0", flags=0) - self.send_frame(b"strm", data) - self.send_event("next_track") - - def previous(self): - data = self.pack_stream(b"f", autostart=b"0", flags=0) - self.send_frame(b"strm", data) - self.send_event("previous_track") - - def power_on(self): - self.send_frame(b"aude", struct.pack("2B", 1, 1)) - self.send_event("power", True) - - def power_off(self): - self.stop() - self.send_frame(b"aude", struct.pack("2B", 0, 0)) - self.send_event("power", False) - - def mute_on(self): - self.send_frame(b"aude", struct.pack("2B", 0, 0)) - self.send_event("mute", True) - - def mute_off(self): - self.send_frame(b"aude", struct.pack("2B", 1, 1)) - self.send_event("mute", False) - - def volume_up(self): - self._volume.increment() - self.send_volume() - - def volume_down(self): - self._volume.decrement() - self.send_volume() - - def volume_set(self, new_vol): - self._volume.volume = new_vol - self.send_volume() - - def play(self, uri): - enable_crossfade = self.player_settings["crossfade_duration"] > 0 - command = b's' - autostart = b'3' # we use direct stream for now so let the player do the messy work with buffers - transType= b'1' if enable_crossfade else b'0' - transDuration = self.player_settings["crossfade_duration"] - formatbyte = b'f' # fixed to flac - uri = '/stream' + uri.split('/stream')[1] - data = self.pack_stream(command, autostart=autostart, flags=0x00, formatbyte=formatbyte, transType=transType, transDuration=transDuration) - headers = "Connection: close\r\nAccept: */*\r\nHost: %s:%s\r\n" %(self.stream_host, self.stream_port) - request = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers) - data = data + request.encode("utf-8") - self.send_frame(b'strm', data) - LOGGER.info("Requesting play from squeezebox" ) - - def displayTrack(self, track): - self.render("%s by %s" % (track.title, track.artist)) - - def process_HELO(self, data): - (devId, rev, mac) = struct.unpack('BB6s', data[:8]) - device_mac = ':'.join("%02x" % x for x in mac) - self._device_type = devices.get(devId, 'unknown device') - self._mac_address = str(device_mac).lower() - LOGGER.debug("HELO received from %s %s" % (self._mac_address, self._device_type)) - self.init_client() - - def init_client(self): - ''' initialize a new connected client ''' - self.send_event("connected") - self.send_version() - self.stop() - self.setBrightness() - #self.set_visualisation(SpectrumAnalyser()) - self.send_frame(b"setd", struct.pack("B", 0)) - self.send_frame(b"setd", struct.pack("B", 4)) - self.power_on() - self.volume_set(40) # TODO: remember last volume - - def send_volume(self): - og = self._volume.old_gain() - ng = self._volume.new_gain() - LOGGER.info("Volume set to %d (%d/%d)" % (self._volume.volume, og, ng)) - d = self.send_frame(b"audg", struct.pack("!LLBBLL", og, og, 1, 255, ng, ng)) - self.send_event("volume", self._volume.volume) - - def setBrightness(self, level=4): - assert 0 <= level <= 4 - self.send_frame(b"grfb", struct.pack("!H", level)) - - def set_visualisation(self, visualisation): - self.send_frame(b"visu", visualisation.pack()) - - def render(self, text): - #self.display.clear() - #self.display.renderText(text, "DejaVu-Sans", 16, (0,0)) - #self.updateDisplay(self.display.frame()) - pass - - def updateDisplay(self, bitmap, transition = 'c', offset=0, param=0): - frame = struct.pack("!Hcb", offset, transition, param) + bitmap - self.send_frame(b"grfe", frame) - - def process_STAT(self, data): - ev = data[:4] - if ev == b'\x00\x00\x00\x00': - LOGGER.info("Presumed informational stat message") - else: - handler = getattr(self, 'stat_%s' % ev.decode(), None) - if handler is None: - raise NotImplementedError("Stat message %r not known" % ev) - handler(data[4:]) - - def stat_aude(self, data): - (spdif_enable, dac_enable) = struct.unpack("2B", data[:4]) - powered = spdif_enable or dac_enable - self.send_event("power", powered) - LOGGER.debug("ACK aude - Received player power: %s" % powered) - - def stat_audg(self, data): - LOGGER.info("Received volume_level from player %s" % data) - self.send_event("volume", self._volume.volume) - - def stat_strm(self, data): - LOGGER.debug("ACK strm") - #self.send_frame(b"cont", b"0") - - def stat_STMc(self, data): - LOGGER.debug("Status Message: Connect") - - def stat_STMd(self, data): - LOGGER.debug("Decoder Ready for next track") - self.send_event("next_track") - - def stat_STMe(self, data): - LOGGER.info("Connection established") - - def stat_STMf(self, data): - LOGGER.info("Status Message: Connection closed") - self.send_event("state", PlayerState.Stopped) - - def stat_STMh(self, data): - LOGGER.info("Status Message: End of headers") - - def stat_STMn(self, data): - LOGGER.error("Decoder does not support file format") - - def stat_STMo(self, data): - ''' No more decoded (uncompressed) data to play; triggers rebuffering. ''' - LOGGER.debug("Output Underrun") - - def stat_STMp(self, data): - '''Pause confirmed''' - self.send_event("state", PlayerState.Paused) - - def stat_STMr(self, data): - '''Resume confirmed''' - self.send_event("state", PlayerState.Playing) - - def stat_STMs(self, data): - '''Playback of new track has started''' - self.send_event("state", PlayerState.Playing) - - def stat_STMt(self, data): - """ heartbeat from client """ - timestamp = time.time() - self._last_heartbeat = timestamp - (num_crlf, mas_initialized, mas_mode, rptr, wptr, - bytes_received_h, bytes_received_l, signal_strength, - jiffies, output_buffer_size, output_buffer_fullness, - elapsed_seconds, voltage, elapsed_milliseconds, - server_timestamp, error_code) = struct.unpack("!BBBLLLLHLLLLHLLH", data) - if elapsed_seconds != self._elapsed_seconds: - self.send_event("progress") - self._elapsed_seconds = elapsed_seconds - self._elapsed_milliseconds = elapsed_milliseconds - - def stat_STMu(self, data): - '''Normal end of playback''' - LOGGER.info("End of playback - Underrun") - self.send_event("state", PlayerState.Stopped) - - def process_BYE(self, data): - LOGGER.info("BYE received") - self.send_event("disconnected") - - def process_RESP(self, data): - LOGGER.info("RESP received") - self.send_frame(b"cont", b"0") - - def process_BODY(self, data): - LOGGER.info("BODY received") - - def process_META(self, data): - LOGGER.info("META received") - - def process_DSCO(self, data): - LOGGER.info("Data Stream Disconnected") - - def process_DBUG(self, data): - LOGGER.info("DBUG received") - - def process_IR(self, data): - """ Slightly involved codepath here. This raises an event, which may - be picked up by the service and then the process_remote_* function in - this player will be called. This is mostly relevant for volume changes - - most other button presses will require some context to operate. """ - (time, code) = struct.unpack("!IxxI", data) - LOGGER.info("IR code %s" % code) - # command = Remote.codes.get(code, None) - # if command is not None: - # LOGGER.info("IR received: %r, %r" % (code, command)) - # #self.service.evreactor.fireEvent(RemoteButtonPressed(self, command)) - # else: - # LOGGER.info("Unknown IR received: %r, %r" % (time, code)) - - def process_RAWI(self, data): - LOGGER.info("RAWI received") - - def process_ANIC(self, data): - LOGGER.info("ANIC received") - - def process_BUTN(self, data): - LOGGER.info("BUTN received") - - def process_KNOB(self, data): - ''' Transporter only, knob-related ''' - LOGGER.info("KNOB received") - - def process_SETD(self, data): - ''' Get/set player firmware settings ''' - LOGGER.debug("SETD received %s" % data) - cmd_id = data[0] - if cmd_id == 0: - # received player name - data = data[1:].decode() - self._player_name = data - self.send_event("name") - - def process_UREQ(self, data): - LOGGER.info("UREQ received") - - - -# from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO -devices = { - 2: 'squeezebox', - 3: 'softsqueeze', - 4: 'squeezebox2', - 5: 'transporter', - 6: 'softsqueeze3', - 7: 'receiver', - 8: 'squeezeslave', - 9: 'controller', - 10: 'boom', - 11: 'softboom', - 12: 'squeezeplay', - } - - -class PyLMSVolume(object): - - """ Represents a sound volume. This is an awful lot more complex than it - sounds. """ - - minimum = 0 - maximum = 100 - step = 1 - - # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source - # i don't know how much magic it contains, or any way I can test it - old_map = [ - 0, 1, 1, 1, 2, 2, 2, 3, 3, 4, - 5, 5, 6, 6, 7, 8, 9, 9, 10, 11, - 12, 13, 14, 15, 16, 16, 17, 18, 19, 20, - 22, 23, 24, 25, 26, 27, 28, 29, 30, 32, - 33, 34, 35, 37, 38, 39, 40, 42, 43, 44, - 46, 47, 48, 50, 51, 53, 54, 56, 57, 59, - 60, 61, 63, 65, 66, 68, 69, 71, 72, 74, - 75, 77, 79, 80, 82, 84, 85, 87, 89, 90, - 92, 94, 96, 97, 99, 101, 103, 104, 106, 108, 110, - 112, 113, 115, 117, 119, 121, 123, 125, 127, 128 - ]; - - # new gain parameters, from the same place - total_volume_range = -50 # dB - step_point = -1 # Number of steps, up from the bottom, where a 2nd volume ramp kicks in. - step_fraction = 1 # fraction of totalVolumeRange where alternate volume ramp kicks in. - - def __init__(self): - self.volume = 50 - - def increment(self): - """ Increment the volume """ - self.volume += self.step - if self.volume > self.maximum: - self.volume = self.maximum - - def decrement(self): - """ Decrement the volume """ - self.volume -= self.step - if self.volume < self.minimum: - self.volume = self.minimum - - def old_gain(self): - """ Return the "Old" gain value as required by the squeezebox """ - return self.old_map[self.volume] - - def decibels(self): - """ Return the "new" gain value. """ - - step_db = self.total_volume_range * self.step_fraction - max_volume_db = 0 # different on the boom? - - # Equation for a line: - # y = mx+b - # y1 = mx1+b, y2 = mx2+b. - # y2-y1 = m(x2 - x1) - # y2 = m(x2 - x1) + y1 - slope_high = max_volume_db - step_db / (100.0 - self.step_point) - slope_low = step_db - self.total_volume_range / (self.step_point - 0.0) - x2 = self.volume - if (x2 > self.step_point): - m = slope_high - x1 = 100 - y1 = max_volume_db - else: - m = slope_low - x1 = 0 - y1 = self.total_volume_range - return m * (x2 - x1) + y1 - - def new_gain(self): - db = self.decibels() - floatmult = 10 ** (db/20.0) - # avoid rounding errors somehow - if -30 <= db <= 0: - return int(floatmult * (1 << 8) + 0.5) * (1<<8) - else: - return int((floatmult * (1<<16)) + 0.5) - - -##### UDP DISCOVERY STUFF ############# - -class Datagram(object): - - @classmethod - def decode(self, data): - if data[0] == 'e': - return TLVDiscoveryRequestDatagram(data) - elif data[0] == 'E': - return TLVDiscoveryResponseDatagram(data) - elif data[0] == 'd': - return ClientDiscoveryDatagram(data) - elif data[0] == 'h': - pass # Hello! - elif data[0] == 'i': - pass # IR - elif data[0] == '2': - pass # i2c? - elif data[0] == 'a': - pass # ack! - -class ClientDiscoveryDatagram(Datagram): - - device = None - firmware = None - client = None - - def __init__(self, data): - s = struct.unpack('!cxBB8x6B', data.encode()) - assert s[0] == 'd' - self.device = s[1] - self.firmware = hex(s[2]) - self.client = ":".join(["%02x" % (x,) for x in s[3:]]) - - def __repr__(self): - return "<%s device=%r firmware=%r client=%r>" % (self.__class__.__name__, self.device, self.firmware, self.client) - -class DiscoveryResponseDatagram(Datagram): - - def __init__(self, hostname, port): - hostname = hostname[:16].encode("UTF-8") - hostname += (16 - len(hostname)) * '\x00' - self.packet = struct.pack('!c16s', 'D', hostname).decode() - -class TLVDiscoveryRequestDatagram(Datagram): - - def __init__(self, data): - requestdata = OrderedDict() - assert data[0] == 'e' - idx = 1 - length = len(data)-5 - while idx <= length: - typ, l = struct.unpack_from("4sB", data.encode(), idx) - if l: - val = data[idx+5:idx+5+l] - idx += 5+l - else: - val = None - idx += 5 - typ = typ.decode() - requestdata[typ] = val - self.data = requestdata - - def __repr__(self): - return "<%s data=%r>" % (self.__class__.__name__, self.data.items()) - -class TLVDiscoveryResponseDatagram(Datagram): - - def __init__(self, responsedata): - parts = ['E'] # new discovery format - for typ, value in responsedata.items(): - if value is None: - value = '' - elif len(value) > 255: - LOGGER.warning("Response %s too long, truncating to 255 bytes" % typ) - value = value[:255] - parts.extend((typ, chr(len(value)), value)) - self.packet = ''.join(parts) - -class DiscoveryProtocol(): - - def __init__(self, web_port): - self.web_port = web_port - - def connection_made(self, transport): - self.transport = transport - # Allow receiving multicast broadcasts - sock = self.transport.get_extra_info('socket') - group = socket.inet_aton('239.255.255.250') - mreq = struct.pack('4sL', group, socket.INADDR_ANY) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) - - def build_TLV_response(self, requestdata): - responsedata = OrderedDict() - for typ, value in requestdata.items(): - if typ == 'NAME': - # send full host name - no truncation - value = get_hostname() - elif typ == 'IPAD': - # send ipaddress as a string only if it is set - value = get_ip() - # :todo: IPv6 - if value == '0.0.0.0': - # do not send back an ip address - typ = None - elif typ == 'JSON': - # send port as a string - json_port = self.web_port - value = str(json_port) - elif typ == 'VERS': - # send server version - value = '7.9' - elif typ == 'UUID': - # send server uuid - value = 'musicassistant' - else: - LOGGER.debug('Unexpected information request: %r', typ) - typ = None - if typ: - responsedata[typ] = value - return responsedata - - def datagram_received(self, data, addr): - try: - data = data.decode() - dgram = Datagram.decode(data) - LOGGER.debug("Data received from %s: %s" % (addr, dgram)) - if isinstance(dgram, ClientDiscoveryDatagram): - self.sendDiscoveryResponse(addr) - elif isinstance(dgram, TLVDiscoveryRequestDatagram): - resonsedata = self.build_TLV_response(dgram.data) - self.sendTLVDiscoveryResponse(resonsedata, addr) - except Exception as exc: - LOGGER.exception(exc) - - def sendDiscoveryResponse(self, addr): - dgram = DiscoveryResponseDatagram(get_hostname(), 3483) - LOGGER.debug("Sending discovery response %r" % (dgram.packet,)) - self.transport.sendto(dgram.packet.encode(), addr) - - def sendTLVDiscoveryResponse(self, resonsedata, addr): - dgram = TLVDiscoveryResponseDatagram(resonsedata) - LOGGER.debug("Sending discovery response %r" % (dgram.packet,)) - self.transport.sendto(dgram.packet.encode(), addr) - diff --git a/music_assistant/modules/web.py b/music_assistant/modules/web.py deleted file mode 100755 index 6761d6cd..00000000 --- a/music_assistant/modules/web.py +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from utils import run_periodic, LOGGER, run_async_background_task -import json -import aiohttp -from aiohttp import web -from models import MediaType, media_type_from_string -from functools import partial -json_serializer = partial(json.dumps, default=lambda x: x.__dict__) -import ssl -import concurrent -import threading - -def setup(mass): - ''' setup the module and read/apply config''' - create_config_entries(mass.config) - conf = mass.config['base']['web'] - if conf['ssl_certificate'] and os.path.isfile(conf['ssl_certificate']): - ssl_cert = conf['ssl_certificate'] - else: - ssl_cert = '' - if conf['ssl_key'] and os.path.isfile(conf['ssl_key']): - ssl_key = conf['ssl_key'] - else: - ssl_key = '' - cert_fqdn_host = conf['cert_fqdn_host'] - http_port = conf['http_port'] - https_port = conf['https_port'] - return Web(mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host) - -def create_config_entries(config): - ''' get the config entries for this module (list with key/value pairs)''' - config_entries = [ - ('http_port', 8095, 'web_http_port'), - ('https_port', 8096, 'web_https_port'), - ('ssl_certificate', '', 'web_ssl_cert'), - ('ssl_key', '', 'web_ssl_key'), - ('cert_fqdn_host', '', 'cert_fqdn_host') - ] - if not config['base'].get('web'): - config['base']['web'] = {} - config['base']['web']['__desc__'] = config_entries - for key, def_value, desc in config_entries: - if not key in config['base']['web']: - config['base']['web'][key] = def_value - -class Web(): - ''' webserver and json/websocket api ''' - - def __init__(self, mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host): - self.mass = mass - self._http_port = http_port - self._https_port = https_port - self._ssl_cert = ssl_cert - self._ssl_key = ssl_key - self._cert_fqdn_host = cert_fqdn_host - self.http_session = aiohttp.ClientSession() - mass.event_loop.create_task(self.setup_web()) - - def stop(self): - asyncio.create_task(self.runner.cleanup()) - asyncio.create_task(self.http_session.close()) - - async def setup_web(self): - app = web.Application() - app.add_routes([web.get('/jsonrpc.js', self.json_rpc)]) - app.add_routes([web.post('/jsonrpc.js', self.json_rpc)]) - app.add_routes([web.get('/ws', self.websocket_handler)]) - # app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)]) - # app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)]) - app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream_queue)]) - app.add_routes([web.get('/api/search', self.search)]) - app.add_routes([web.get('/api/config', self.get_config)]) - app.add_routes([web.post('/api/config', self.save_config)]) - app.add_routes([web.get('/api/players', self.players)]) - app.add_routes([web.get('/api/players/{player_id}/queue', self.player_queue)]) - app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}', self.player_command)]) - app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}/{cmd_args}', self.player_command)]) - app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}', self.play_media)]) - app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}', self.play_media)]) - app.add_routes([web.get('/api/playlists/{playlist_id}/tracks', self.playlist_tracks)]) - app.add_routes([web.get('/api/artists/{artist_id}/toptracks', self.artist_toptracks)]) - app.add_routes([web.get('/api/artists/{artist_id}/albums', self.artist_albums)]) - app.add_routes([web.get('/api/albums/{album_id}/tracks', self.album_tracks)]) - app.add_routes([web.get('/api/{media_type}', self.get_items)]) - app.add_routes([web.get('/api/{media_type}/{media_id}/{action}', self.get_item)]) - app.add_routes([web.get('/api/{media_type}/{media_id}', self.get_item)]) - app.add_routes([web.get('/', self.index)]) - app.router.add_static("/", "./web") - self.runner = web.AppRunner(app, access_log=None) - await self.runner.setup() - http_site = web.TCPSite(self.runner, '0.0.0.0', self._http_port) - await http_site.start() - if self._ssl_cert and self._ssl_key: - ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key) - https_site = web.TCPSite(self.runner, '0.0.0.0', self._https_port, ssl_context=ssl_context) - await https_site.start() - - async def get_items(self, request): - ''' get multiple library items''' - media_type_str = request.match_info.get('media_type') - media_type = media_type_from_string(media_type_str) - limit = int(request.query.get('limit', 50)) - offset = int(request.query.get('offset', 0)) - orderby = request.query.get('orderby', 'name') - provider_filter = request.rel_url.query.get('provider') - result = await self.mass.music.library_items(media_type, - limit=limit, offset=offset, - orderby=orderby, provider_filter=provider_filter) - return web.json_response(result, dumps=json_serializer) - - async def get_item(self, request): - ''' get item full details''' - media_type_str = request.match_info.get('media_type') - media_type = media_type_from_string(media_type_str) - media_id = request.match_info.get('media_id') - action = request.match_info.get('action','') - action_details = request.rel_url.query.get('action_details') - lazy = request.rel_url.query.get('lazy', '') != 'false' - provider = request.rel_url.query.get('provider') - if action: - result = await self.mass.music.item_action(media_id, media_type, provider, action, action_details) - else: - result = await self.mass.music.item(media_id, media_type, provider, lazy=lazy) - return web.json_response(result, dumps=json_serializer) - - async def artist_toptracks(self, request): - ''' get top tracks for given artist ''' - artist_id = request.match_info.get('artist_id') - provider = request.rel_url.query.get('provider') - result = await self.mass.music.artist_toptracks(artist_id, provider) - return web.json_response(result, dumps=json_serializer) - - async def artist_albums(self, request): - ''' get (all) albums for given artist ''' - artist_id = request.match_info.get('artist_id') - provider = request.rel_url.query.get('provider') - result = await self.mass.music.artist_albums(artist_id, provider) - return web.json_response(result, dumps=json_serializer) - - async def playlist_tracks(self, request): - ''' get playlist tracks from provider''' - playlist_id = request.match_info.get('playlist_id') - limit = int(request.query.get('limit', 50)) - offset = int(request.query.get('offset', 0)) - provider = request.rel_url.query.get('provider') - result = await self.mass.music.playlist_tracks(playlist_id, provider, offset=offset, limit=limit) - return web.json_response(result, dumps=json_serializer) - - async def album_tracks(self, request): - ''' get album tracks from provider''' - album_id = request.match_info.get('album_id') - provider = request.rel_url.query.get('provider') - result = await self.mass.music.album_tracks(album_id, provider) - return web.json_response(result, dumps=json_serializer) - - async def search(self, request): - ''' search database or providers ''' - searchquery = request.rel_url.query.get('query') - media_types_query = request.rel_url.query.get('media_types') - limit = request.rel_url.query.get('media_id', 5) - online = request.rel_url.query.get('online', False) - media_types = [] - if not media_types_query or "artists" in media_types_query: - media_types.append(MediaType.Artist) - if not media_types_query or "albums" in media_types_query: - media_types.append(MediaType.Album) - if not media_types_query or "tracks" in media_types_query: - media_types.append(MediaType.Track) - if not media_types_query or "playlists" in media_types_query: - media_types.append(MediaType.Playlist) - if not media_types_query or "radios" in media_types_query: - media_types.append(MediaType.Radio) - # get results from database - result = await self.mass.music.search(searchquery, media_types, limit=limit, online=online) - return web.json_response(result, dumps=json_serializer) - - async def players(self, request): - ''' get all players ''' - players = await self.mass.player.players() - return web.json_response(players, dumps=json_serializer) - - async def player_command(self, request): - ''' issue player command''' - result = False - player_id = request.match_info.get('player_id') - player = await self.mass.player.get_player(player_id) - if player: - cmd = request.match_info.get('cmd') - cmd_args = request.match_info.get('cmd_args') - player_cmd = getattr(player, cmd, None) - if player_cmd and cmd_args: - result = await player_cmd(player_id, cmd, cmd_args) - elif player_cmd and cmd_args: - result = await player_cmd(player_id, cmd, cmd_args) - else: - LOGGER.error("Received non-existing command %s for player %s" %(cmd, player.name)) - else: - LOGGER.error("Received command dor non-existing player %s" %(player_id)) - return web.json_response(result, dumps=json_serializer) - - async def play_media(self, request): - ''' issue player play_media command''' - player_id = request.match_info.get('player_id') - media_type_str = request.match_info.get('media_type') - media_type = media_type_from_string(media_type_str) - media_id = request.match_info.get('media_id') - queue_opt = request.match_info.get('queue_opt','') - provider = request.rel_url.query.get('provider') - media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True) - result = await self.mass.player.play_media(player_id, media_item, queue_opt) - return web.json_response(result, dumps=json_serializer) - - async def player_queue(self, request): - ''' return the items in the player's queue ''' - player_id = request.match_info.get('player_id') - limit = int(request.query.get('limit', 50)) - offset = int(request.query.get('offset', 0)) - result = await self.mass.player.player_queue(player_id, offset, limit) - return web.json_response(result, dumps=json_serializer) - - async def index(self, request): - return web.FileResponse("./web/index.html") - - async def websocket_handler(self, request): - ''' websockets handler ''' - cb_id = None - ws = None - try: - ws = web.WebSocketResponse() - await ws.prepare(request) - # register callback for internal events - async def send_event(msg, msg_details): - ws_msg = {"message": msg, "message_details": msg_details } - await ws.send_json(ws_msg, dumps=json_serializer) - cb_id = self.mass.add_event_listener(send_event) - # process incoming messages - async for msg in ws: - if msg.type != aiohttp.WSMsgType.TEXT: - continue - # for now we only use WS for (simple) player commands - if msg.data == 'players': - players = await self.mass.player.players() - ws_msg = {'message': 'players', 'message_details': players} - await ws.send_json(ws_msg, dumps=json_serializer) - elif msg.data.startswith('players') and '/cmd/' in msg.data: - # players/{player_id}/cmd/{cmd} or players/{player_id}/cmd/{cmd}/{cmd_args} - msg_data_parts = msg.data.split('/') - player_id = msg_data_parts[1] - cmd = msg_data_parts[3] - cmd_args = msg_data_parts[4] if len(msg_data_parts) == 5 else None - await self.mass.player.player_command(player_id, cmd, cmd_args) - finally: - self.mass.remove_event_listener(cb_id) - LOGGER.debug('websocket connection closed') - return ws - - async def get_config(self, request): - ''' get the config ''' - return web.json_response(self.mass.config) - - async def save_config(self, request): - ''' save (partial) config ''' - LOGGER.debug('save config called from api') - new_config = await request.json() - config_changed = False - for key, value in self.mass.config.items(): - if isinstance(value, dict): - for subkey, subvalue in value.items(): - if subkey in new_config[key]: - if self.mass.config[key][subkey] != new_config[key][subkey]: - config_changed = True - self.mass.config[key][subkey] = new_config[key][subkey] - elif key in new_config: - if self.mass.config[key] != new_config[key]: - config_changed = True - self.mass.config[key] = new_config[key] - if config_changed: - self.mass.save_config() - self.mass.signal_event('config_changed') - return web.Response(text='success') - - async def json_rpc(self, request): - ''' - implement LMS jsonrpc interface - for some compatability with tools that talk to lms - only support for basic commands - ''' - data = await request.json() - LOGGER.info("jsonrpc: %s" % data) - params = data['params'] - player_id = params[0] - cmds = params[1] - cmd_str = " ".join(cmds) - if cmd_str in ['play', 'pause', 'stop']: - await self.mass.player.player_command(player_id, cmd_str) - elif 'power' in cmd_str: - args = cmds[1] if len(cmds) > 1 else None - await self.mass.player.player_command(player_id, cmd_str, args) - elif cmd_str == 'playlist index +1': - await self.mass.player.player_command(player_id, 'next') - elif cmd_str == 'playlist index -1': - await self.mass.player.player_command(player_id, 'previous') - elif 'mixer volume' in cmd_str: - await self.mass.player.player_command(player_id, 'volume', cmds[2]) - elif cmd_str == 'mixer muting 1': - await self.mass.player.player_command(player_id, 'mute', 'on') - elif cmd_str == 'mixer muting 0': - await self.mass.player.player_command(player_id, 'mute', 'off') - elif cmd_str == 'button volup': - await self.mass.player.player_command(player_id, 'volume', 'up') - elif cmd_str == 'button voldown': - await self.mass.player.player_command(player_id, 'volume', 'down') - elif cmd_str == 'button power': - await self.mass.player.player_command(player_id, 'power', 'toggle') - else: - return web.Response(text='command not supported') - return web.Response(text='success') - \ No newline at end of file diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py new file mode 100755 index 00000000..4c4a2f85 --- /dev/null +++ b/music_assistant/music_manager.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +from typing import List +import toolz +import operator +import os +import importlib + +from .utils import run_periodic, LOGGER, try_supported +from .models.media_types import MediaType, Track, Artist, Album, Playlist, Radio + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MODULES_PATH = os.path.join(BASE_DIR, "musicproviders" ) + +class Music(): + ''' several helpers around the musicproviders ''' + + def __init__(self, mass): + self.sync_running = False + self.mass = mass + self.providers = {} + # dynamically load musicprovider modules + self.load_music_providers() + # schedule sync task + mass.event_loop.create_task(self.sync_music_providers()) + + async def item(self, item_id, media_type:MediaType, provider='database', lazy=True): + ''' get single music item by id and media type''' + if media_type == MediaType.Artist: + return await self.artist(item_id, provider, lazy=lazy) + elif media_type == MediaType.Album: + return await self.album(item_id, provider, lazy=lazy) + elif media_type == MediaType.Track: + return await self.track(item_id, provider, lazy=lazy) + elif media_type == MediaType.Playlist: + return await self.playlist(item_id, provider) + elif media_type == MediaType.Radio: + return await self.radio(item_id, provider) + else: + return None + + async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Artist]: + ''' return all library artists, optionally filtered by provider ''' + return await self.mass.db.library_artists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def library_albums(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Album]: + ''' return all library albums, optionally filtered by provider ''' + return await self.mass.db.library_albums(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def library_tracks(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Track]: + ''' return all library tracks, optionally filtered by provider ''' + return await self.mass.db.library_tracks(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def playlists(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]: + ''' return all library playlists, optionally filtered by provider ''' + return await self.mass.db.playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def radios(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]: + ''' return all library radios, optionally filtered by provider ''' + return await self.mass.db.radios(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def library_items(self, media_type:MediaType, limit=0, offset=0, orderby='name', provider_filter=None) -> List[object]: + ''' get multiple music items in library''' + if media_type == MediaType.Artist: + return await self.library_artists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + elif media_type == MediaType.Album: + return await self.library_albums(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + elif media_type == MediaType.Track: + return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + elif media_type == MediaType.Playlist: + return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + elif media_type == MediaType.Radio: + return await self.radios(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + + async def artist(self, item_id, provider='database', lazy=True) -> Artist: + ''' get artist by id ''' + if not provider or provider == 'database': + return await self.mass.db.artist(item_id) + return await self.providers[provider].artist(item_id, lazy=lazy) + + async def album(self, item_id, provider='database', lazy=True) -> Album: + ''' get album by id ''' + if not provider or provider == 'database': + return await self.mass.db.album(item_id) + return await self.providers[provider].album(item_id, lazy=lazy) + + async def track(self, item_id, provider='database', lazy=True) -> Track: + ''' get track by id ''' + if not provider or provider == 'database': + return await self.mass.db.track(item_id) + return await self.providers[provider].track(item_id, lazy=lazy) + + async def playlist(self, item_id, provider='database') -> Playlist: + ''' get playlist by id ''' + if not provider or provider == 'database': + return await self.mass.db.playlist(item_id) + return await self.providers[provider].playlist(item_id) + + async def radio(self, item_id, provider='database') -> Radio: + ''' get radio by id ''' + if not provider or provider == 'database': + return await self.mass.db.radio(item_id) + return await self.providers[provider].radio(item_id) + + async def playlist_by_name(self, name) -> Playlist: + ''' get playlist by name ''' + for playlist in await self.playlists(): + if playlist.name == name: + return playlist + return None + + async def radio_by_name(self, name) -> Radio: + ''' get radio by name ''' + for radio in await self.radios(): + if radio.name == name: + return radio + return None + + async def artist_toptracks(self, artist_id, provider='database') -> List[Track]: + ''' get top tracks for given artist ''' + artist = await self.artist(artist_id, provider) + # always append database tracks + items = await self.mass.db.artist_tracks(artist.item_id) + for prov_mapping in artist.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + prov_obj = self.providers[prov_id] + items += await prov_obj.artist_toptracks(prov_item_id) + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + items.sort(key=lambda x: x.name, reverse=False) + return items + + async def artist_albums(self, artist_id, provider='database') -> List[Album]: + ''' get (all) albums for given artist ''' + artist = await self.artist(artist_id, provider) + # always append database tracks + items = await self.mass.db.artist_albums(artist.item_id) + for prov_mapping in artist.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + prov_obj = self.providers[prov_id] + items += await prov_obj.artist_albums(prov_item_id) + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + items.sort(key=lambda x: x.name, reverse=False) + return items + + async def album_tracks(self, album_id, provider='database') -> List[Track]: + ''' get the album tracks for given album ''' + items = [] + album = await self.album(album_id, provider) + for prov_mapping in album.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + prov_obj = self.providers[prov_id] + items += await prov_obj.album_tracks(prov_item_id) + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + items = sorted(items, key=operator.attrgetter('disc_number'), reverse=False) + items = sorted(items, key=operator.attrgetter('track_number'), reverse=False) + return items + + async def playlist_tracks(self, playlist_id, provider='database', offset=0, limit=50) -> List[Track]: + ''' get the tracks for given playlist ''' + playlist = None + if not provider or provider == 'database': + playlist = await self.mass.db.playlist(playlist_id) + if playlist and playlist.is_editable: + # database synced playlist, return tracks from db... + return await self.mass.db.playlist_tracks( + playlist.item_id, offset=offset, limit=limit) + else: + # return playlist tracks from provider + playlist = await self.playlist(playlist_id, provider) + prov = playlist.provider_ids[0] + return await self.providers[prov['provider']].playlist_tracks( + prov['item_id'], offset=offset, limit=limit) + + async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False) -> dict: + ''' search database or providers ''' + # get results from database + result = await self.mass.db.search(searchquery, media_types, limit) + if online: + # include results from music providers + for prov in self.providers.values(): + prov_results = await prov.search(searchquery, media_types, limit) + for item_type, items in prov_results.items(): + if not item_type in result: + result[item_type] = items + else: + result[item_type] += items + # filter out duplicates + for item_type, items in result.items(): + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + return result + + async def item_action(self, item_id, media_type, provider, action, action_details=None): + ''' perform action on item (such as library add/remove) ''' + result = None + item = await self.item(item_id, media_type, provider) + if item and action in ['library_add', 'library_remove']: + # remove or add item to the library + for prov_mapping in result.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + for prov in self.providers.values(): + if prov.prov_id == prov_id: + if action == 'add': + result = await prov.add_library(prov_item_id, media_type) + elif action == 'remove': + result = await prov.remove_library(prov_item_id, media_type) + return result + + async def add_playlist_tracks(self, playlist_id, tracks:List[Track]): + ''' add tracks to playlist - make sure we dont add dupes ''' + # we can only edit playlists that are in the database (marked as editable) + playlist = await self.playlist(playlist_id, 'database') + if not playlist or not playlist.is_editable: + LOGGER.warning("Playlist %s is not editable - skip addition of tracks" %(playlist.name)) + return False + playlist_prov = playlist.provider_ids[0] # playlist can only have one provider (for now) + cur_playlist_tracks = await self.mass.db.playlist_tracks(playlist_id, limit=0) + # grab all (database) track ids in the playlist so we can check for duplicates + cur_playlist_track_ids = [item.item_id for item in cur_playlist_tracks] + track_ids_to_add = [] + for track in tracks: + if not track.provider == 'database': + # make sure we have a database track + track = await self.track(track.item_id, track.provider, lazy=False) + if track.item_id in cur_playlist_track_ids: + LOGGER.warning("Track %s already in playlist %s - skip addition" %(track.name, playlist.name)) + continue + # we can only add a track to a provider playlist if the track is available on that provider + # exception is the file provider which does accept tracks from all providers in the m3u playlist + # this should all be handled in the frontend but these checks are here just to be safe + track_playlist_provs = [item['provider'] for item in track.provider_ids] + if playlist_prov['provider'] in track_playlist_provs: + # a track can contain multiple versions on the same provider + # # simply sort by quality and just add the first one (assuming the track is still available) + track_versions = sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True) + for track_version in track_versions: + if track_version['provider'] == playlist_prov['provider']: + track_ids_to_add.append(track_version['item_id']) + break + elif playlist_prov['provider'] == 'file': + # the file provider can handle uri's from all providers in the file so simply add the db id + track_ids_to_add.append(track.item_id) + else: + LOGGER.warning("Track %s not available on provider %s - skip addition to playlist %s" %(track.name, playlist_prov['provider'], playlist.name)) + continue + # actually add the tracks to the playlist on the provider + await self.providers[playlist_prov['provider']].add_playlist_tracks(playlist_prov['item_id'], track_ids_to_add) + # schedule sync + self.mass.event_loop.create_task(self.sync_playlist_tracks(playlist.item_id, playlist_prov['provider'], playlist_prov['item_id'])) + + @run_periodic(3600) + async def sync_music_providers(self): + ''' periodic sync of all music providers ''' + if self.sync_running: + return + self.sync_running = True + for prov_id in self.providers.keys(): + # sync library artists + await try_supported(self.sync_library_artists(prov_id)) + await try_supported(self.sync_library_albums(prov_id)) + await try_supported(self.sync_library_tracks(prov_id)) + await try_supported(self.sync_playlists(prov_id)) + await try_supported(self.sync_radios(prov_id)) + self.sync_running = False + + async def sync_library_artists(self, prov_id): + ''' sync library artists for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.library_artists(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_library_artists() + cur_db_ids = [] + for item in cur_items: + db_item = await music_provider.artist(item.item_id, lazy=False) + cur_db_ids.append(db_item.item_id) + if not db_item.item_id in prev_db_ids: + await self.mass.db.add_to_library(db_item.item_id, MediaType.Artist, prov_id) + # process deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id) + LOGGER.info("Finished syncing Artists for provider %s" % prov_id) + + async def sync_library_albums(self, prov_id): + ''' sync library albums for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.library_albums(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_library_albums() + cur_db_ids = [] + for item in cur_items: + db_item = await music_provider.album(item.item_id, lazy=False) + cur_db_ids.append(db_item.item_id) + # precache album tracks... + for album_track in await music_provider.get_album_tracks(item.item_id): + await music_provider.track(album_track.item_id) + if not db_item.item_id in prev_db_ids: + await self.mass.db.add_to_library(db_item.item_id, MediaType.Album, prov_id) + # process deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id) + LOGGER.info("Finished syncing Albums for provider %s" % prov_id) + + async def sync_library_tracks(self, prov_id): + ''' sync library tracks for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.library_tracks(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_library_tracks() + cur_db_ids = [] + for item in cur_items: + db_item = await music_provider.track(item.item_id, lazy=False) + cur_db_ids.append(db_item.item_id) + if not db_item.item_id in prev_db_ids: + await self.mass.db.add_to_library(db_item.item_id, MediaType.Track, prov_id) + # process deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id) + LOGGER.info("Finished syncing Tracks for provider %s" % prov_id) + + async def sync_playlists(self, prov_id): + ''' sync library playlists for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.playlists(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_playlists() + cur_db_ids = [] + for item in cur_items: + # always add to db because playlist attributes could have changed + db_id = await self.mass.db.add_playlist(item) + cur_db_ids.append(db_id) + if not db_id in prev_db_ids: + await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id) + if item.is_editable: + # precache/sync playlist tracks (user owned playlists only) + asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) ) + # process playlist deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Playlist, prov_id) + LOGGER.info("Finished syncing Playlists for provider %s" % prov_id) + + async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id): + ''' sync library playlists tracks for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.playlist_tracks(db_playlist_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_playlist_tracks(prov_playlist_id, limit=0) + cur_db_ids = [] + pos = 0 + for item in cur_items: + # we need to do this the complicated way because the file provider can return tracks from other providers + for prov_mapping in item.provider_ids: + item_provider = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + db_item = await self.providers[item_provider].track(prov_item_id, lazy=False) + cur_db_ids.append(db_item.item_id) + if not db_item.item_id in prev_db_ids: + await self.mass.db.add_playlist_track(db_playlist_id, db_item.item_id, pos) + pos += 1 + # process playlist track deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_playlist_track(db_playlist_id, db_id) + LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id)) + + async def sync_radios(self, prov_id): + ''' sync library radios for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.radios(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_radios() + cur_db_ids = [] + for item in cur_items: + db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Radio) + if not db_id: + db_id = await self.mass.db.add_radio(item) + cur_db_ids.append(db_id) + if not db_id in prev_db_ids: + await self.mass.db.add_to_library(db_id, MediaType.Radio, prov_id) + # process deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id) + LOGGER.info("Finished syncing Radios for provider %s" % prov_id) + + def load_music_providers(self): + ''' dynamically load musicproviders ''' + for item in os.listdir(MODULES_PATH): + if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and + item.endswith('.py') and not item.startswith('.')): + module_name = item.replace(".py","") + LOGGER.debug("Loading musicprovider module %s" % module_name) + try: + mod = importlib.import_module("." + module_name, "music_assistant.musicproviders") + if not self.mass.config['musicproviders'].get(module_name): + self.mass.config['musicproviders'][module_name] = {} + self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries() + for key, def_value, desc in mod.config_entries(): + if not key in self.mass.config['musicproviders'][module_name]: + self.mass.config['musicproviders'][module_name][key] = def_value + mod = mod.setup(self.mass) + if mod: + self.providers[mod.prov_id] = mod + cls_name = mod.__class__.__name__ + LOGGER.info("Successfully initialized module %s" % cls_name) + except Exception as exc: + LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/musicproviders/__init__.py b/music_assistant/musicproviders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/music_assistant/musicproviders/file.py b/music_assistant/musicproviders/file.py new file mode 100644 index 00000000..a6ffabb4 --- /dev/null +++ b/music_assistant/musicproviders/file.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import sys +import time +import base64 +import taglib + +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_ENABLED + + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["musicproviders"]['file'].get(CONF_ENABLED) + music_dir = mass.config["musicproviders"]['file'].get('music_dir') + playlists_dir = mass.config["musicproviders"]['file'].get('playlists_dir') + if enabled and (music_dir or playlists_dir): + file_provider = FileProvider(mass, music_dir, playlists_dir) + return file_provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + ("music_dir", "", "file_prov_music_path"), + ("playlists_dir", "", "file_prov_playlists_path") + ] + +class FileProvider(MusicProvider): + ''' + Very basic implementation of a musicprovider for local files + Assumes files are stored on disk in format // + Reads ID3 tags from file and falls back to parsing filename + Supports m3u files only for playlists + Supports having URI's from streaming providers within m3u playlist + Should be compatible with LMS + ''' + + + def __init__(self, mass, music_dir, playlists_dir): + self.name = 'Local files and playlists' + self.prov_id = 'file' + self.mass = mass + self.cache = mass.cache + self._music_dir = music_dir + self._playlists_dir = playlists_dir + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [] + } + return result + + async def get_library_artists(self) -> List[Artist]: + ''' get artist folders in music directory ''' + if not os.path.isdir(self._music_dir): + LOGGER.error("music path does not exist: %s" % self._music_dir) + return [] + result = [] + for dirname in os.listdir(self._music_dir): + dirpath = os.path.join(self._music_dir, dirname) + if os.path.isdir(dirpath) and not dirpath.startswith('.'): + artist = await self.get_artist(dirpath) + if artist: + result.append(artist) + return result + + async def get_library_albums(self) -> List[Album]: + ''' get album folders recursively ''' + result = [] + for artist in await self.get_library_artists(): + result += await self.get_artist_albums(artist.item_id) + return result + + async def get_library_tracks(self) -> List[Track]: + ''' get all tracks recursively ''' + #TODO: support disk subfolders + result = [] + for album in await self.get_library_albums(): + result += await self.get_album_tracks(album.item_id) + return result + + async def get_playlists(self) -> List[Playlist]: + ''' retrieve playlists from disk ''' + if not self._playlists_dir: + return [] + result = [] + for filename in os.listdir(self._playlists_dir): + filepath = os.path.join(self._playlists_dir, filename) + if os.path.isfile(filepath) and not filename.startswith('.') and filename.lower().endswith('.m3u'): + playlist = await self.get_playlist(filepath) + if playlist: + result.append(playlist) + return result + + async def get_artist(self, prov_item_id) -> Artist: + ''' get full artist details by id ''' + if not os.sep in prov_item_id: + itempath = base64.b64decode(prov_item_id).decode('utf-8') + else: + itempath = prov_item_id + prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') + if not os.path.isdir(itempath): + LOGGER.error("artist path does not exist: %s" % itempath) + return None + name = itempath.split(os.sep)[-1] + artist = Artist() + artist.item_id = prov_item_id + artist.provider = self.prov_id + artist.name = name + artist.provider_ids.append({ + "provider": self.prov_id, + "item_id": artist.item_id + }) + return artist + + async def get_album(self, prov_item_id) -> Album: + ''' get full album details by id ''' + if not os.sep in prov_item_id: + itempath = base64.b64decode(prov_item_id).decode('utf-8') + else: + itempath = prov_item_id + prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') + if not os.path.isdir(itempath): + LOGGER.error("album path does not exist: %s" % itempath) + return None + name = itempath.split(os.sep)[-1] + artistpath = itempath.rsplit(os.sep, 1)[0] + album = Album() + album.item_id = prov_item_id + album.provider = self.prov_id + album.name, album.version = parse_track_title(name) + album.artist = await self.get_artist(artistpath) + if not album.artist: + raise Exception("No album artist ! %s" % artistpath) + album.provider_ids.append({ + "provider": self.prov_id, + "item_id": prov_item_id + }) + return album + + async def get_track(self, prov_item_id) -> Track: + ''' get full track details by id ''' + if not os.sep in prov_item_id: + itempath = base64.b64decode(prov_item_id).decode('utf-8') + else: + itempath = prov_item_id + if not os.path.isfile(itempath): + LOGGER.error("track path does not exist: %s" % itempath) + return None + return await self.__parse_track(itempath) + + async def get_playlist(self, prov_item_id) -> Playlist: + ''' get full playlist details by id ''' + if not os.sep in prov_item_id: + itempath = base64.b64decode(prov_item_id).decode('utf-8') + else: + itempath = prov_item_id + prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') + if not os.path.isfile(itempath): + LOGGER.error("playlist path does not exist: %s" % itempath) + return None + playlist = Playlist() + playlist.item_id = prov_item_id + playlist.provider = self.prov_id + playlist.name = itempath.split(os.sep)[-1].replace('.m3u', '') + playlist.is_editable = True + playlist.provider_ids.append({ + "provider": self.prov_id, + "item_id": prov_item_id + }) + playlist.owner = 'disk' + return playlist + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + ''' get album tracks for given album id ''' + result = [] + if not os.sep in prov_album_id: + albumpath = base64.b64decode(prov_album_id).decode('utf-8') + else: + albumpath = prov_album_id + if not os.path.isdir(albumpath): + LOGGER.error("album path does not exist: %s" % albumpath) + return [] + album = await self.get_album(albumpath) + for filename in os.listdir(albumpath): + filepath = os.path.join(albumpath, filename) + if os.path.isfile(filepath) and not filepath.startswith('.'): + track = await self.__parse_track(filepath) + if track: + track.album = album + result.append(track) + return result + + async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]: + ''' get playlist tracks for given playlist id ''' + tracks = [] + if not os.sep in prov_playlist_id: + itempath = base64.b64decode(prov_playlist_id).decode('utf-8') + else: + itempath = prov_playlist_id + if not os.path.isfile(itempath): + LOGGER.error("playlist path does not exist: %s" % itempath) + return [] + counter = 0 + with open(itempath) as f: + for line in f.readlines(): + line = line.strip() + if line and not line.startswith('#'): + counter += 1 + if counter > offset: + track = await self.__parse_track_from_uri(line) + if track: + tracks.append(track) + if limit and len(tracks) == limit: + break + return tracks + + async def get_artist_albums(self, prov_artist_id) -> List[Album]: + ''' get a list of albums for the given artist ''' + result = [] + if not os.sep in prov_artist_id: + artistpath = base64.b64decode(prov_artist_id).decode('utf-8') + else: + artistpath = prov_artist_id + if not os.path.isdir(artistpath): + LOGGER.error("artist path does not exist: %s" % artistpath) + return [] + for dirname in os.listdir(artistpath): + dirpath = os.path.join(artistpath, dirname) + if os.path.isdir(dirpath) and not dirpath.startswith('.'): + album = await self.get_album(dirpath) + if album: + result.append(album) + return result + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + ''' get a list of 10 random tracks as we have no clue about preference ''' + tracks = [] + for album in await self.get_artist_albums(prov_artist_id): + tracks += await self.get_album_tracks(album.item_id) + return tracks[:10] + + async def get_stream_content_type(self, track_id): + ''' return the content type for the given track when it will be streamed''' + if not os.sep in track_id: + track_id = base64.b64decode(track_id).decode('utf-8') + return track_id.split('.')[-1] + + async def get_audio_stream(self, track_id): + ''' get audio stream for a track ''' + if not os.sep in track_id: + track_id = base64.b64decode(track_id).decode('utf-8') + with open(track_id) as f: + while True: + line = f.readline() + if line: + yield line + else: + break + + async def __parse_track(self, filename): + ''' try to parse a track from a filename with taglib ''' + track = Track() + try: + song = taglib.File(filename) + except: + return None # not a media file ? + prov_item_id = base64.b64encode(filename.encode('utf-8')).decode('utf-8') + track.duration = song.length + track.item_id = prov_item_id + track.provider = self.prov_id + name = song.tags['TITLE'][0] + track.name, track.version = parse_track_title(name) + albumpath = filename.rsplit(os.sep,1)[0] + track.album = await self.get_album(albumpath) + artists = [] + for artist_str in song.tags['ARTIST']: + local_artist_path = os.path.join(self._music_dir, artist_str) + if os.path.isfile(local_artist_path): + artist = await self.get_artist(local_artist_path) + else: + artist = Artist() + artist.name = artist_str + fake_artistpath = os.path.join(self._music_dir, artist_str) + artist.item_id = fake_artistpath # temporary id + artist.provider_ids.append({ + "provider": self.prov_id, + "item_id": base64.b64encode(fake_artistpath.encode('utf-8')).decode('utf-8') + }) + artists.append(artist) + track.artists = artists + if 'GENRE' in song.tags: + track.tags = song.tags['GENRE'] + if 'ISRC' in song.tags: + track.external_ids.append( {"isrc": song.tags['ISRC'][0]} ) + if 'DISCNUMBER' in song.tags: + track.disc_number = int(song.tags['DISCNUMBER'][0]) + if 'TRACKNUMBER' in song.tags: + track.track_number = int(song.tags['TRACKNUMBER'][0]) + quality_details = "" + if filename.endswith('.flac'): + # TODO: get bit depth + quality = TrackQuality.FLAC_LOSSLESS + if song.sampleRate > 192000: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 + elif song.sampleRate > 96000: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 + elif song.sampleRate > 48000: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 + quality_details = "%s Khz" % (song.sampleRate/1000) + elif filename.endswith('.ogg'): + quality = TrackQuality.LOSSY_OGG + quality_details = "%s kbps" % (song.bitrate) + elif filename.endswith('.m4a'): + quality = TrackQuality.LOSSY_AAC + quality_details = "%s kbps" % (song.bitrate) + else: + quality = TrackQuality.LOSSY_MP3 + quality_details = "%s kbps" % (song.bitrate) + track.provider_ids.append({ + "provider": self.prov_id, + "item_id": prov_item_id, + "quality": quality, + "details": quality_details + }) + return track + + async def __parse_track_from_uri(self, uri): + ''' try to parse a track from an uri found in playlist ''' + if "://" in uri: + # track is uri from external provider? + prov_id = uri.split('://')[0] + prov_item_id = uri.split('/')[-1].split('.')[0].split(':')[-1] + try: + return await self.mass.music.providers[prov_id].track(prov_item_id, lazy=False) + except Exception as exc: + LOGGER.warning("Could not parse uri %s to track: %s" %(uri, str(exc))) + return None + # try to treat uri as filename + # TODO: filename could be related to musicdir or full path + track = await self.get_track(uri) + if track: + return track + track = await self.get_track(os.path.join(self._music_dir, uri)) + if track: + return track + return None diff --git a/music_assistant/musicproviders/qobuz.py b/music_assistant/musicproviders/qobuz.py new file mode 100644 index 00000000..40bab64d --- /dev/null +++ b/music_assistant/musicproviders/qobuz.py @@ -0,0 +1,560 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import json +import aiohttp +import time +import datetime +import hashlib +from asyncio_throttle import Throttler + +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..app_vars import get_app_var +from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["musicproviders"]['qobuz'].get(CONF_ENABLED) + username = mass.config["musicproviders"]['qobuz'].get(CONF_USERNAME) + password = mass.config["musicproviders"]['qobuz'].get(CONF_PASSWORD) + if enabled and username and password: + provider = QobuzProvider(mass, username, password) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, "", CONF_PASSWORD) + ] + +class QobuzProvider(MusicProvider): + + + def __init__(self, mass, username, password): + self.name = 'Qobuz' + self.prov_id = 'qobuz' + self.mass = mass + self.cache = mass.cache + self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) + self.__username = username + self.__password = password + self.__user_auth_info = None + self.__logged_in = False + self.throttler = Throttler(rate_limit=2, period=1) + mass.add_event_listener(self.mass_event, 'streaming_started') + mass.add_event_listener(self.mass_event, 'streaming_ended') + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [] + } + params = {"query": searchstring, "limit": limit } + if len(media_types) == 1: + # qobuz does not support multiple searchtypes, falls back to all if no type given + if media_types[0] == MediaType.Artist: + params["type"] = "artists" + if media_types[0] == MediaType.Album: + params["type"] = "albums" + if media_types[0] == MediaType.Track: + params["type"] = "tracks" + if media_types[0] == MediaType.Playlist: + params["type"] = "playlists" + searchresult = await self.__get_data("catalog/search", params) + if searchresult: + if "artists" in searchresult: + for item in searchresult["artists"]["items"]: + artist = await self.__parse_artist(item) + if artist: + result["artists"].append(artist) + if "albums" in searchresult: + for item in searchresult["albums"]["items"]: + album = await self.__parse_album(item) + if album: + result["albums"].append(album) + if "tracks" in searchresult: + for item in searchresult["tracks"]["items"]: + track = await self.__parse_track(item) + if track: + result["tracks"].append(track) + if "playlists" in searchresult: + for item in searchresult["playlists"]["items"]: + result["playlists"].append(await self.__parse_playlist(item)) + return result + + async def get_library_artists(self) -> List[Artist]: + ''' retrieve library artists from qobuz ''' + result = [] + params = {'type': 'artists'} + for item in await self.__get_all_items("favorite/getUserFavorites", params, key='artists'): + artist = await self.__parse_artist(item) + if artist: + result.append(artist) + return result + + async def get_library_albums(self) -> List[Album]: + ''' retrieve library albums from qobuz ''' + result = [] + params = {'type': 'albums'} + for item in await self.__get_all_items("favorite/getUserFavorites", params, key='albums'): + album = await self.__parse_album(item) + if album: + result.append(album) + return result + + async def get_library_tracks(self) -> List[Track]: + ''' retrieve library tracks from qobuz ''' + result = [] + params = {'type': 'tracks'} + for item in await self.__get_all_items("favorite/getUserFavorites", params, key='tracks'): + track = await self.__parse_track(item) + if track: + result.append(track) + return result + + async def get_playlists(self) -> List[Playlist]: + ''' retrieve playlists from the provider ''' + result = [] + for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists', cache_checksum=time.time()): + playlist = await self.__parse_playlist(item) + if playlist: + result.append(playlist) + return result + + async def get_artist(self, prov_artist_id) -> Artist: + ''' get full artist details by id ''' + params = {'artist_id': prov_artist_id} + artist_obj = await self.__get_data("artist/get", params) + return await self.__parse_artist(artist_obj) + + async def get_album(self, prov_album_id) -> Album: + ''' get full album details by id ''' + params = {'album_id': prov_album_id} + album_obj = await self.__get_data("album/get", params) + return await self.__parse_album(album_obj) + + async def get_track(self, prov_track_id) -> Track: + ''' get full track details by id ''' + params = {'track_id': prov_track_id} + track_obj = await self.__get_data("track/get", params) + return await self.__parse_track(track_obj) + + async def get_playlist(self, prov_playlist_id) -> Playlist: + ''' get full playlist details by id ''' + params = {'playlist_id': prov_playlist_id} + playlist_obj = await self.__get_data("playlist/get", params) + return await self.__parse_playlist(playlist_obj) + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + ''' get album tracks for given album id ''' + params = {'album_id': prov_album_id} + track_objs = await self.__get_all_items("album/get", params, key='tracks') + tracks = [] + for track_obj in track_objs: + track = await self.__parse_track(track_obj) + if track: + tracks.append(track) + return tracks + + async def get_playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]: + ''' get playlist tracks for given playlist id ''' + playlist_obj = await self.__get_data("playlist/get?playlist_id=%s" % prov_playlist_id, ignore_cache=True) + cache_checksum = playlist_obj["updated_at"] + params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'} + track_objs = await self.__get_all_items("playlist/get", params, key='tracks', limit=limit, offset=offset, cache_checksum=cache_checksum) + tracks = [] + for track_obj in track_objs: + playlist_track = await self.__parse_track(track_obj) + if playlist_track: + tracks.append(playlist_track) + # TODO: should we look for an alternative track version if the original is marked unavailable ? + return tracks + + async def get_artist_albums(self, prov_artist_id, limit=100, offset=0) -> List[Album]: + ''' get a list of albums for the given artist ''' + params = {'artist_id': prov_artist_id, 'extra': 'albums', 'limit': limit, 'offset': offset} + result = await self.__get_data('artist/get', params) + albums = [] + for item in result['albums']['items']: + if str(item['artist']['id']) == str(prov_artist_id): + album = await self.__parse_album(item) + if album: + albums.append(album) + return albums + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + ''' get a list of most popular tracks for the given artist ''' + # artist toptracks not supported on Qobuz, so use search instead + items = [] + artist = await self.get_artist(prov_artist_id) + params = {"query": artist.name, "limit": 10, "type": "tracks" } + searchresult = await self.__get_data("catalog/search", params) + for item in searchresult["tracks"]["items"]: + if "performer" in item and str(item["performer"]["id"]) == str(prov_artist_id): + track = await self.__parse_track(item) + items.append(track) + return items + + async def add_library(self, prov_item_id, media_type:MediaType): + ''' add item to library ''' + if media_type == MediaType.Artist: + result = await self.__get_data('favorite/create', {'artist_ids': prov_item_id}) + item = await self.artist(prov_item_id) + elif media_type == MediaType.Album: + result = await self.__get_data('favorite/create', {'album_ids': prov_item_id}) + item = await self.album(prov_item_id) + elif media_type == MediaType.Track: + result = await self.__get_data('favorite/create', {'track_ids': prov_item_id}) + item = await self.track(prov_item_id) + await self.mass.db.add_to_library(item.item_id, media_type, self.prov_id) + LOGGER.debug("added item %s to %s - %s" %(prov_item_id, self.prov_id, result)) + + async def remove_library(self, prov_item_id, media_type:MediaType): + ''' remove item from library ''' + if media_type == MediaType.Artist: + result = await self.__get_data('favorite/delete', {'artist_ids': prov_item_id}) + item = await self.artist(prov_item_id) + elif media_type == MediaType.Album: + result = await self.__get_data('favorite/delete', {'album_ids': prov_item_id}) + item = await self.album(prov_item_id) + elif media_type == MediaType.Track: + result = await self.__get_data('favorite/delete', {'track_ids': prov_item_id}) + item = await self.track(prov_item_id) + await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id) + LOGGER.debug("deleted item %s from %s - %s" %(prov_item_id, self.prov_id, result)) + + async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids): + ''' add track(s) to playlist ''' + params = { + 'playlist_id': prov_playlist_id, + 'track_ids': ",".join(prov_track_ids) + } + return await self.__get_data('playlist/addTracks', params) + + async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids): + ''' remove track(s) from playlist ''' + playlist_track_ids = [] + params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'} + for track in await self.__get_all_items("playlist/get", params, key='tracks', limit=0): + if track['id'] in prov_track_ids: + playlist_track_ids.append(track['playlist_track_id']) + params = {'playlist_id': prov_playlist_id, 'track_ids': ",".join(playlist_track_ids)} + return await self.__get_data('playlist/deleteTracks', params) + + async def get_stream_details(self, track_id): + ''' return the content details for the given track when it will be streamed''' + streamdetails = None + for format_id in [27, 7, 6, 5]: + # it seems that simply requesting for highest available quality does not work + # from time to time the api response is empty for this request ?! + params = {'format_id': format_id, 'track_id': track_id, 'intent': 'stream'} + streamdetails = await self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True) + if streamdetails and streamdetails.get('url'): + break + if not streamdetails or not streamdetails.get('url'): + LOGGER.error("Unable to retrieve stream url for track %s" % track_id) + return None + return { + "type": "url", + "path": streamdetails['url'], + "content_type": streamdetails['mime_type'].split('/')[1], + "sample_rate": int(streamdetails['sampling_rate']*1000), + "bit_depth": streamdetails['bit_depth'], + "details": streamdetails # we need these details for reporting playback + } + + async def mass_event(self, msg, msg_details): + ''' received event from mass ''' + # TODO: need to figure out if the streamed track is purchased + if msg == "streaming_started" and msg_details['provider'] == self.prov_id: + # report streaming started to qobuz + LOGGER.debug("streaming_started %s" % msg_details["track_id"]) + device_id = self.__user_auth_info["user"]["device"]["id"] + credential_id = self.__user_auth_info["user"]["credential"]["id"] + user_id = self.__user_auth_info["user"]["id"] + format_id = msg_details["details"]["format_id"] + timestamp = int(time.time()) + events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, + "track_id": msg_details["track_id"], "purchase": False, "date": timestamp, + "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}] + await self.__post_data("track/reportStreamingStart", data=events) + elif msg == "streaming_ended" and msg_details['provider'] == self.prov_id: + # report streaming ended to qobuz + LOGGER.debug("streaming_ended %s - seconds played: %s" %(msg_details["track_id"], msg_details["seconds"]) ) + device_id = self.__user_auth_info["user"]["device"]["id"] + credential_id = self.__user_auth_info["user"]["credential"]["id"] + user_id = self.__user_auth_info["user"]["id"] + format_id = msg_details["details"]["format_id"] + timestamp = int(time.time()) + events=[{"online": True, "sample": False, "intent": "stream", "device_id": device_id, + "track_id": msg_details["track_id"], "purchase": False, "date": timestamp, "duration": msg_details["seconds"], + "credential_id": credential_id, "user_id": user_id, "local": False, "format_id":format_id}] + await self.__post_data("track/reportStreamingStart", data=events) + + async def __parse_artist(self, artist_obj): + ''' parse qobuz artist object to generic layout ''' + artist = Artist() + if not artist_obj.get('id'): + return None + artist.item_id = artist_obj['id'] + artist.provider = self.prov_id + artist.provider_ids.append({ + "provider": self.prov_id, + "item_id": artist_obj['id'] + }) + artist.name = artist_obj['name'] + if artist_obj.get('image'): + for key in ['extralarge', 'large', 'medium', 'small']: + if artist_obj['image'].get(key): + if not '2a96cbd8b46e442fc41c2b86b821562f' in artist_obj['image'][key]: + artist.metadata["image"] = artist_obj['image'][key] + break + if artist_obj.get('biography'): + artist.metadata["biography"] = artist_obj['biography'].get('content','') + if artist_obj.get('url'): + artist.metadata["qobuz_url"] = artist_obj['url'] + return artist + + async def __parse_album(self, album_obj): + ''' parse qobuz album object to generic layout ''' + album = Album() + if not album_obj.get('id') or not album_obj["streamable"] or not album_obj["displayable"]: + # some safety checks + LOGGER.warning("invalid/unavailable album found: %s" % album_obj.get('id')) + return None + album.item_id = album_obj['id'] + album.provider = self.prov_id + album.provider_ids.append({ + "provider": self.prov_id, + "item_id": album_obj['id'], + "details": "%skHz %sbit" %(album_obj['maximum_sampling_rate'], album_obj['maximum_bit_depth']) + }) + album.name, album.version = parse_track_title(album_obj['title']) + album.artist = await self.__parse_artist(album_obj['artist']) + if not album.artist: + raise Exception("No album artist ! %s" % album_obj) + if album_obj.get('product_type','') == 'single': + album.albumtype = AlbumType.Single + elif album_obj.get('product_type','') == 'compilation' or 'Various' in album_obj['artist']['name']: + album.albumtype = AlbumType.Compilation + else: + album.albumtype = AlbumType.Album + if 'genre' in album_obj: + album.tags = [album_obj['genre']['name']] + if album_obj.get('image'): + for key in ['extralarge', 'large', 'medium', 'small']: + if album_obj['image'].get(key): + album.metadata["image"] = album_obj['image'][key] + break + album.external_ids.append({ "upc": album_obj['upc'] }) + if 'label' in album_obj: + album.labels = album_obj['label']['name'].split('/') + if album_obj.get('released_at'): + album.year = datetime.datetime.fromtimestamp(album_obj['released_at']).year + if album_obj.get('copyright'): + album.metadata["copyright"] = album_obj['copyright'] + if album_obj.get('hires'): + album.metadata["hires"] = "true" + if album_obj.get('url'): + album.metadata["qobuz_url"] = album_obj['url'] + if album_obj.get('description'): + album.metadata["description"] = album_obj['description'] + return album + + async def __parse_track(self, track_obj): + ''' parse qobuz track object to generic layout ''' + track = Track() + if not track_obj.get('id') or not track_obj["streamable"] or not track_obj["displayable"]: + # some safety checks + LOGGER.warning("invalid/unavailable track found: %s - %s" % (track_obj.get('id'), track_obj.get('name'))) + return None + track.item_id = track_obj['id'] + track.provider = self.prov_id + if track_obj.get('performer') and not 'Various ' in track_obj['performer']: + artist = await self.__parse_artist(track_obj['performer']) + if not artist: + artist = self.get_artist(track_obj['performer']['id']) + if artist: + track.artists.append(artist) + if not track.artists: + # try to grab artist from album + if track_obj.get('album') and track_obj['album'].get('artist') and not 'Various ' in track_obj['album']['artist']: + artist = await self.__parse_artist(track_obj['album']['artist']) + if artist: + track.artists.append(artist) + if not track.artists: + # last resort: parse from performers string + for performer_str in track_obj['performers'].split(' - '): + role = performer_str.split(', ')[1] + name = performer_str.split(', ')[0] + if 'artist' in role.lower(): + artist = Artist() + artist.name = name + artist.item_id = name + track.artists.append(artist) + # TODO: fix grabbing composer from details + track.name, track.version = parse_track_title(track_obj['title']) + if not track.version and track_obj['version']: + track.version = track_obj['version'] + track.duration = track_obj['duration'] + if 'album' in track_obj: + album = await self.__parse_album(track_obj['album']) + if album: + track.album = album + track.disc_number = track_obj['media_number'] + track.track_number = track_obj['track_number'] + if track_obj.get('hires'): + track.metadata["hires"] = "true" + if track_obj.get('url'): + track.metadata["qobuz_url"] = track_obj['url'] + if track_obj.get('isrc'): + track.external_ids.append({ + "isrc": track_obj['isrc'] + }) + if track_obj.get('performers'): + track.metadata["performers"] = track_obj['performers'] + if track_obj.get('copyright'): + track.metadata["copyright"] = track_obj['copyright'] + # get track quality + if track_obj['maximum_sampling_rate'] > 192: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 + elif track_obj['maximum_sampling_rate'] > 96: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 + elif track_obj['maximum_sampling_rate'] > 48: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 + elif track_obj['maximum_bit_depth'] > 16: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1 + elif track_obj.get('format_id',0) == 5: + quality = TrackQuality.LOSSY_AAC + else: + quality = TrackQuality.FLAC_LOSSLESS + track.provider_ids.append({ + "provider": self.prov_id, + "item_id": track_obj['id'], + "quality": quality, + "details": "%skHz %sbit" %(track_obj['maximum_sampling_rate'], track_obj['maximum_bit_depth']) + }) + return track + + async def __parse_playlist(self, playlist_obj): + ''' parse qobuz playlist object to generic layout ''' + playlist = Playlist() + if not playlist_obj.get('id'): + return None + playlist.item_id = playlist_obj['id'] + playlist.provider = self.prov_id + playlist.provider_ids.append({ + "provider": self.prov_id, + "item_id": playlist_obj['id'] + }) + playlist.name = playlist_obj['name'] + playlist.owner = playlist_obj['owner']['name'] + playlist.is_editable = playlist_obj['owner']['id'] == self.__user_auth_info["user"]["id"] or playlist_obj['is_collaborative'] + if playlist_obj.get('images300'): + playlist.metadata["image"] = playlist_obj['images300'][0] + if playlist_obj.get('url'): + playlist.metadata["qobuz_url"] = playlist_obj['url'] + return playlist + + async def __auth_token(self): + ''' login to qobuz and store the token''' + if self.__user_auth_info: + return self.__user_auth_info["user_auth_token"] + params = { "username": self.__username, "password": self.__password, "device_manufacturer_id": "music_assistant"} + details = await self.__get_data("user/login", params, ignore_cache=True) + self.__user_auth_info = details + LOGGER.info("Succesfully logged in to Qobuz as %s" % (details["user"]["display_name"])) + return details["user_auth_token"] + + async def __get_all_items(self, endpoint, params={}, key="playlists", limit=0, offset=0, cache_checksum=None): + ''' get all items from a paged list ''' + if not cache_checksum: + params["limit"] = 1 + params["offset"] = 0 + cache_checksum = await self.__get_data(endpoint, params, ignore_cache=True) + cache_checksum = cache_checksum[key]["total"] + if limit: + # partial listing + params["limit"] = limit + params["offset"] = offset + result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) + return result[key]["items"] + else: + # full listing + offset = 0 + total_items = 1 + count = 0 + items = [] + while count < total_items: + params["limit"] = 200 + params["offset"] = offset + result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) + if result and key in result: + total_items = result[key]["total"] + offset += 200 + count += len(result[key]["items"]) + items += result[key]["items"] + else: + LOGGER.error("failed to retrieve items for %s (%s) --> %s" %(endpoint, params, result)) + break + return items + + @use_cache(7) + async def __get_data(self, endpoint, params={}, sign_request=False, ignore_cache=False, cache_checksum=None): + ''' get data from api''' + url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint + headers = {"X-App-Id": get_app_var(0)} + if endpoint != 'user/login': + headers["X-User-Auth-Token"] = await self.__auth_token() + if sign_request: + signing_data = "".join(endpoint.split('/')) + keys = list(params.keys()) + keys.sort() + for key in keys: + signing_data += "%s%s" %(key, params[key]) + request_ts = str(time.time()) + request_sig = signing_data + request_ts + get_app_var(1) + request_sig = str(hashlib.md5(request_sig.encode()).hexdigest()) + params["request_ts"] = request_ts + params["request_sig"] = request_sig + params["app_id"] = get_app_var(0) + params["user_auth_token"] = await self.__auth_token() + try: + async with self.throttler: + async with self.http_session.get(url, headers=headers, params=params) as response: + result = await response.json() + if not result or 'error' in result: + LOGGER.error(url) + LOGGER.debug(params) + LOGGER.debug(result) + return None + return result + except Exception as exc: + LOGGER.exception(exc) + return None + + async def __post_data(self, endpoint, params={}, data={}): + ''' post data to api''' + url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint + params["app_id"] = get_app_var(0) + params["user_auth_token"] = await self.__auth_token() + async with self.http_session.post(url, params=params, json=data) as response: + result = await response.json() + if not result or 'error' in result: + LOGGER.error(url) + LOGGER.debug(params) + LOGGER.debug(result) + result = None + return result \ No newline at end of file diff --git a/music_assistant/musicproviders/spotify.py b/music_assistant/musicproviders/spotify.py new file mode 100644 index 00000000..43a56fc8 --- /dev/null +++ b/music_assistant/musicproviders/spotify.py @@ -0,0 +1,518 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import sys +import time +import concurrent +from asyncio_throttle import Throttler +import json +import aiohttp + +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..app_vars import get_app_var +from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["musicproviders"]['spotify'].get(CONF_ENABLED) + username = mass.config["musicproviders"]['spotify'].get(CONF_USERNAME) + password = mass.config["musicproviders"]['spotify'].get(CONF_PASSWORD) + if enabled and username and password: + spotify_provider = SpotifyProvider(mass, username, password) + return spotify_provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, "", CONF_PASSWORD) + ] + +class SpotifyProvider(MusicProvider): + + + def __init__(self, mass, username, password): + self.name = 'Spotify' + self.prov_id = 'spotify' + self._cur_user = None + self.mass = mass + self.cache = mass.cache + self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) + self.throttler = Throttler(rate_limit=1, period=1) + self._username = username + self._password = password + self.__auth_token = {} + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [] + } + searchtypes = [] + if MediaType.Artist in media_types: + searchtypes.append("artist") + if MediaType.Album in media_types: + searchtypes.append("album") + if MediaType.Track in media_types: + searchtypes.append("track") + if MediaType.Playlist in media_types: + searchtypes.append("playlist") + searchtype = ",".join(searchtypes) + params = {"q": searchstring, "type": searchtype, "limit": limit } + searchresult = await self.__get_data("search", params=params, cache_checksum="bla") + if searchresult: + if "artists" in searchresult: + for item in searchresult["artists"]["items"]: + artist = await self.__parse_artist(item) + if artist: + result["artists"].append(artist) + if "albums" in searchresult: + for item in searchresult["albums"]["items"]: + album = await self.__parse_album(item) + if album: + result["albums"].append(album) + if "tracks" in searchresult: + for item in searchresult["tracks"]["items"]: + track = await self.__parse_track(item) + if track: + result["tracks"].append(track) + if "playlists" in searchresult: + for item in searchresult["playlists"]["items"]: + playlist = await self.__parse_playlist(item) + if playlist: + result["playlists"].append(playlist) + return result + + async def get_library_artists(self) -> List[Artist]: + ''' retrieve library artists from spotify ''' + items = [] + spotify_artists = await self.__get_data("me/following?type=artist&limit=50") + if spotify_artists: + # TODO: use cursor method to retrieve more than 50 artists + for artist_obj in spotify_artists['artists']['items']: + prov_artist = await self.__parse_artist(artist_obj) + items.append(prov_artist) + return items + + async def get_library_albums(self) -> List[Album]: + ''' retrieve library albums from the provider ''' + result = [] + for item in await self.__get_all_items("me/albums"): + album = await self.__parse_album(item) + if album: + result.append(album) + return result + + async def get_library_tracks(self) -> List[Track]: + ''' retrieve library tracks from the provider ''' + result = [] + for item in await self.__get_all_items("me/tracks"): + track = await self.__parse_track(item) + if track: + result.append(track) + return result + + async def get_playlists(self) -> List[Playlist]: + ''' retrieve playlists from the provider ''' + result = [] + for item in await self.__get_all_items("me/playlists", cache_checksum=time.time()): + playlist = await self.__parse_playlist(item) + if playlist: + result.append(playlist) + return result + + async def get_artist(self, prov_artist_id) -> Artist: + ''' get full artist details by id ''' + artist_obj = await self.__get_data("artists/%s" % prov_artist_id) + return await self.__parse_artist(artist_obj) + + async def get_album(self, prov_album_id) -> Album: + ''' get full album details by id ''' + album_obj = await self.__get_data("albums/%s" % prov_album_id) + return await self.__parse_album(album_obj) + + async def get_track(self, prov_track_id) -> Track: + ''' get full track details by id ''' + track_obj = await self.__get_data("tracks/%s" % prov_track_id) + return await self.__parse_track(track_obj) + + async def get_playlist(self, prov_playlist_id) -> Playlist: + ''' get full playlist details by id ''' + playlist_obj = await self.__get_data("playlists/%s" % prov_playlist_id, ignore_cache=True) + return await self.__parse_playlist(playlist_obj) + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + ''' get album tracks for given album id ''' + track_objs = await self.__get_all_items("albums/%s/tracks" % prov_album_id) + tracks = [] + for track_obj in track_objs: + track = await self.__parse_track(track_obj) + if track: + tracks.append(track) + return tracks + + async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]: + ''' get playlist tracks for given playlist id ''' + playlist_obj = await self.__get_data("playlists/%s?fields=snapshot_id" % prov_playlist_id, ignore_cache=True) + cache_checksum = playlist_obj["snapshot_id"] + track_objs = await self.__get_all_items("playlists/%s/tracks" % prov_playlist_id, limit=limit, offset=offset, cache_checksum=cache_checksum) + tracks = [] + for track_obj in track_objs: + playlist_track = await self.__parse_track(track_obj) + if playlist_track: + tracks.append(playlist_track) + return tracks + + async def get_artist_albums(self, prov_artist_id) -> List[Album]: + ''' get a list of albums for the given artist ''' + params = {'include_groups': 'album,single,compilation'} + items = await self.__get_all_items('artists/%s/albums' % prov_artist_id, params) + albums = [] + for item in items: + album = await self.__parse_album(item) + if album: + albums.append(album) + return albums + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + ''' get a list of 10 most popular tracks for the given artist ''' + artist = await self.get_artist(prov_artist_id) + items = await self.__get_data('artists/%s/top-tracks' % prov_artist_id) + tracks = [] + for item in items['tracks']: + track = await self.__parse_track(item) + if track: + track.artists = [artist] + tracks.append(track) + return tracks + + async def add_library(self, prov_item_id, media_type:MediaType): + ''' add item to library ''' + if media_type == MediaType.Artist: + result = await self.__put_data('me/following', {'ids': prov_item_id, 'type': 'artist'}) + item = await self.artist(prov_item_id) + elif media_type == MediaType.Album: + result = await self.__put_data('me/albums', {'ids': prov_item_id}) + item = await self.album(prov_item_id) + elif media_type == MediaType.Track: + result = await self.__put_data('me/tracks', {'ids': prov_item_id}) + item = await self.track(prov_item_id) + await self.mass.db.add_to_library(item.item_id, media_type, self.prov_id) + LOGGER.debug("added item %s to %s - %s" %(prov_item_id, self.prov_id, result)) + + async def remove_library(self, prov_item_id, media_type:MediaType): + ''' remove item from library ''' + if media_type == MediaType.Artist: + result = await self.__delete_data('me/following', {'ids': prov_item_id, 'type': 'artist'}) + item = await self.artist(prov_item_id) + elif media_type == MediaType.Album: + result = await self.__delete_data('me/albums', {'ids': prov_item_id}) + item = await self.album(prov_item_id) + elif media_type == MediaType.Track: + result = await self.__delete_data('me/tracks', {'ids': prov_item_id}) + item = await self.track(prov_item_id) + await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id) + LOGGER.debug("deleted item %s from %s - %s" %(prov_item_id, self.prov_id, result)) + + async def devices(self): + ''' list all available devices ''' + items = await self.__get_data('me/player/devices') + return items['devices'] + + async def play_media(self, device_id, uri, offset_pos=None, offset_uri=None): + ''' play uri on spotify device''' + opts = {} + if isinstance(uri, list): + opts['uris'] = uri + elif uri.startswith('spotify:track'): + opts['uris'] = [uri] + else: + opts['context_uri'] = uri + if offset_pos != None: # only for playlists/albums! + opts["offset"] = {"position": offset_pos } + elif offset_uri != None: # only for playlists/albums! + opts["offset"] = {"uri": offset_uri } + return await self.__put_data('me/player/play', {"device_id": device_id}, opts) + + async def get_stream_details(self, track_id): + ''' return the content details for the given track when it will be streamed''' + spotty = self.get_spotty_binary() + spotty_exec = "%s -n temp -u %s -p %s --pass-through --single-track %s" %(spotty, self._username, self._password, track_id) + return { + "type": "executable", + "path": spotty_exec, + "content_type": "ogg", + "sample_rate": 44100, + "bit_depth": 16 + } + + async def __parse_artist(self, artist_obj): + ''' parse spotify artist object to generic layout ''' + artist = Artist() + artist.item_id = artist_obj['id'] + artist.provider = self.prov_id + artist.provider_ids.append({ + "provider": self.prov_id, + "item_id": artist_obj['id'] + }) + artist.name = artist_obj['name'] + if 'genres' in artist_obj: + artist.tags = artist_obj['genres'] + if artist_obj.get('images'): + for img in artist_obj['images']: + img_url = img['url'] + if not '2a96cbd8b46e442fc41c2b86b821562f' in img_url: + artist.metadata["image"] = img_url + break + if artist_obj.get('external_urls'): + artist.metadata["spotify_url"] = artist_obj['external_urls']['spotify'] + return artist + + async def __parse_album(self, album_obj): + ''' parse spotify album object to generic layout ''' + if 'album' in album_obj: + album_obj = album_obj['album'] + if not album_obj['id'] or album_obj.get('is_playable') == False: + return None + album = Album() + album.item_id = album_obj['id'] + album.provider = self.prov_id + album.name, album.version = parse_track_title(album_obj['name']) + for artist in album_obj['artists']: + album.artist = await self.__parse_artist(artist) + if album.artist: + break + if not album.artist: + raise Exception("No album artist ! %s" % album_obj) + if album_obj['album_type'] == 'single': + album.albumtype = AlbumType.Single + elif album_obj['album_type'] == 'compilation': + album.albumtype = AlbumType.Compilation + else: + album.albumtype = AlbumType.Album + if 'genres' in album_obj: + album.tags = album_obj['genres'] + if album_obj.get('images'): + album.metadata["image"] = album_obj['images'][0]['url'] + if 'external_ids' in album_obj: + for key, value in album_obj['external_ids'].items(): + album.external_ids.append( { key: value } ) + if 'label' in album_obj: + album.labels = album_obj['label'].split('/') + if album_obj.get('release_date'): + album.year = int(album_obj['release_date'].split('-')[0]) + if album_obj.get('copyrights'): + album.metadata["copyright"] = album_obj['copyrights'][0]['text'] + if album_obj.get('external_urls'): + album.metadata["spotify_url"] = album_obj['external_urls']['spotify'] + if album_obj.get('explicit'): + album.metadata['explicit'] = str(album_obj['explicit']).lower() + album.provider_ids.append({ + "provider": self.prov_id, + "item_id": album_obj['id'] + }) + return album + + async def __parse_track(self, track_obj): + ''' parse spotify track object to generic layout ''' + if 'track' in track_obj: + track_obj = track_obj['track'] + if track_obj['is_local'] or not track_obj['id'] or not track_obj['is_playable']: + LOGGER.warning("invalid/unavailable track found: %s - %s" % (track_obj.get('id'), track_obj.get('name'))) + return None + track = Track() + track.item_id = track_obj['id'] + track.provider = self.prov_id + for track_artist in track_obj['artists']: + artist = await self.__parse_artist(track_artist) + if artist: + track.artists.append(artist) + track.name, track.version = parse_track_title(track_obj['name']) + track.duration = track_obj['duration_ms'] / 1000 + track.metadata['explicit'] = str(track_obj['explicit']).lower() + if not track.version and track_obj['explicit']: + track.version = 'Explicit' + if 'external_ids' in track_obj: + for key, value in track_obj['external_ids'].items(): + track.external_ids.append( { key: value } ) + if 'album' in track_obj: + track.album = await self.__parse_album(track_obj['album']) + if track_obj.get('copyright'): + track.metadata["copyright"] = track_obj['copyright'] + track.disc_number = track_obj['disc_number'] + track.track_number = track_obj['track_number'] + if track_obj.get('external_urls'): + track.metadata["spotify_url"] = track_obj['external_urls']['spotify'] + track.provider_ids.append({ + "provider": self.prov_id, + "item_id": track_obj['id'], + "quality": TrackQuality.LOSSY_OGG + }) + return track + + async def __parse_playlist(self, playlist_obj): + ''' parse spotify playlist object to generic layout ''' + playlist = Playlist() + if not playlist_obj.get('id'): + return None + playlist.item_id = playlist_obj['id'] + playlist.provider = self.prov_id + playlist.provider_ids.append({ + "provider": self.prov_id, + "item_id": playlist_obj['id'] + }) + playlist.name = playlist_obj['name'] + playlist.owner = playlist_obj['owner']['display_name'] + playlist.is_editable = playlist_obj['owner']['id'] == self.sp_user["id"] or playlist_obj['collaborative'] + if playlist_obj.get('images'): + playlist.metadata["image"] = playlist_obj['images'][0]['url'] + if playlist_obj.get('external_urls'): + playlist.metadata["spotify_url"] = playlist_obj['external_urls']['spotify'] + return playlist + + async def get_token(self): + ''' get auth token on spotify ''' + # return existing token if we have one in memory + if self.__auth_token and (self.__auth_token['expiresAt'] > int(time.time()) + 20): + return self.__auth_token + tokeninfo = {} + if not self._username or not self._password: + return tokeninfo + # try with spotipy-token module first, fallback to spotty + try: + import spotify_token as st + data = st.start_session(self._username, self._password) + if data and len(data) == 2: + tokeninfo = {"accessToken": data[0], "expiresIn": data[1] - int(time.time()), "expiresAt":data[1] } + except Exception as exc: + LOGGER.debug(exc) + if not tokeninfo: + # fallback to spotty approach + import subprocess + scopes = [ + "user-read-playback-state", + "user-read-currently-playing", + "user-modify-playback-state", + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-public", + "playlist-modify-private", + "user-follow-modify", + "user-follow-read", + "user-library-read", + "user-library-modify", + "user-read-private", + "user-read-email", + "user-read-birthdate", + "user-top-read"] + scope = ",".join(scopes) + args = [self.get_spotty_binary(), "-t", "--client-id", get_app_var(2), "--scope", scope, "-n", "temp-spotty", "-u", self._username, "-p", self._password, "--disable-discovery"] + spotty = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + stdout, stderr = spotty.communicate() + result = json.loads(stdout) + # transform token info to spotipy compatible format + if result and "accessToken" in result: + tokeninfo = result + tokeninfo['expiresAt'] = tokeninfo['expiresIn'] + int(time.time()) + if tokeninfo: + self.__auth_token = tokeninfo + self.sp_user = await self.__get_data("me") + LOGGER.info("Succesfully logged in to Spotify as %s" % self.sp_user["id"]) + self.__auth_token = tokeninfo + else: + raise Exception("Can't get Spotify token for user %s" % self._username) + return tokeninfo + + async def __get_all_items(self, endpoint, params={}, limit=0, offset=0, cache_checksum=None): + ''' get all items from a paged list ''' + if not cache_checksum: + params["limit"] = 1 + params["offset"] = 0 + cache_checksum = await self.__get_data(endpoint, params, ignore_cache=True) + cache_checksum = cache_checksum["total"] + if limit: + # partial listing + params["limit"] = limit + params["offset"] = offset + result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) + return result["items"] + else: + # full listing + total_items = 1 + count = 0 + items = [] + while count < total_items: + params["limit"] = 50 + params["offset"] = offset + result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) + total_items = result["total"] + offset += 50 + count += len(result["items"]) + items += result["items"] + return items + + @use_cache(7) + async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None): + ''' get data from api''' + url = 'https://api.spotify.com/v1/%s' % endpoint + params['market'] = 'from_token' + params['country'] = 'from_token' + token = await self.get_token() + headers = {'Authorization': 'Bearer %s' % token["accessToken"]} + async with self.throttler: + async with self.http_session.get(url, headers=headers, params=params) as response: + result = await response.json() + if not result or 'error' in result: + LOGGER.error(url) + LOGGER.error(params) + result = None + return result + + async def __delete_data(self, endpoint, params={}): + ''' get data from api''' + url = 'https://api.spotify.com/v1/%s' % endpoint + token = await self.get_token() + headers = {'Authorization': 'Bearer %s' % token["accessToken"]} + async with self.http_session.delete(url, headers=headers, params=params) as response: + return await response.text() + + async def __put_data(self, endpoint, params={}, data=None): + ''' put data on api''' + url = 'https://api.spotify.com/v1/%s' % endpoint + token = await self.get_token() + headers = {'Authorization': 'Bearer %s' % token["accessToken"]} + async with self.http_session.put(url, headers=headers, params=params, json=data) as response: + return await response.text() + + @staticmethod + def get_spotty_binary(): + '''find the correct spotty binary belonging to the platform''' + import platform + sp_binary = None + if platform.system() == "Windows": + sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "windows", "spotty.exe") + elif platform.system() == "Darwin": + # macos binary is x86_64 intel + sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "darwin", "spotty") + elif platform.system() == "Linux": + # try to find out the correct architecture by trial and error + architecture = platform.machine() + if architecture.startswith('AMD64') or architecture.startswith('x86_64'): + # generic linux x86_64 binary + sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "x86-linux", "spotty-x86_64") + else: + sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "arm-linux", "spotty-muslhf") + return sp_binary + + diff --git a/music_assistant/musicproviders/spotty/arm-linux/spotty-hf b/music_assistant/musicproviders/spotty/arm-linux/spotty-hf new file mode 100755 index 00000000..c928d8a8 Binary files /dev/null and b/music_assistant/musicproviders/spotty/arm-linux/spotty-hf differ diff --git a/music_assistant/musicproviders/spotty/darwin/spotty b/music_assistant/musicproviders/spotty/darwin/spotty new file mode 100755 index 00000000..44c6b604 Binary files /dev/null and b/music_assistant/musicproviders/spotty/darwin/spotty differ diff --git a/music_assistant/musicproviders/spotty/windows/spotty.exe b/music_assistant/musicproviders/spotty/windows/spotty.exe new file mode 100755 index 00000000..6ce9b19e Binary files /dev/null and b/music_assistant/musicproviders/spotty/windows/spotty.exe differ diff --git a/music_assistant/musicproviders/spotty/x86-linux/spotty b/music_assistant/musicproviders/spotty/x86-linux/spotty new file mode 100755 index 00000000..b2c3f349 Binary files /dev/null and b/music_assistant/musicproviders/spotty/x86-linux/spotty differ diff --git a/music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64 b/music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64 new file mode 100755 index 00000000..58911cf5 Binary files /dev/null and b/music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64 differ diff --git a/music_assistant/musicproviders/tunein.py b/music_assistant/musicproviders/tunein.py new file mode 100644 index 00000000..cd9c7ba1 --- /dev/null +++ b/music_assistant/musicproviders/tunein.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import sys +import time +from asyncio_throttle import Throttler +import json +import aiohttp + +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..models import MusicProvider, MediaType, TrackQuality, Radio +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["musicproviders"]['tunein'].get(CONF_ENABLED) + username = mass.config["musicproviders"]['tunein'].get(CONF_USERNAME) + password = mass.config["musicproviders"]['tunein'].get(CONF_PASSWORD) + if enabled and username and password: + provider = TuneInProvider(mass, username, password) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, "", CONF_PASSWORD) + ] + +class TuneInProvider(MusicProvider): + + + def __init__(self, mass, username, password): + self.name = 'TuneIn Radio' + self.prov_id = 'tunein' + self.mass = mass + self.cache = mass.cache + self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) + self.throttler = Throttler(rate_limit=1, period=1) + self._username = username + self._password = password + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [], + "radios": [] + } + return result + + async def get_radios(self): + ''' get favorited/library radio stations ''' + items = [] + params = {"c": "presets"} + result = await self.__get_data("Browse.ashx", params, ignore_cache=True) + if result and "body" in result: + for item in result["body"]: + # TODO: expand folders + if item["type"] == "audio": + radio = await self.__parse_radio(item) + items.append(radio) + return items + + async def get_radio(self, radio_id): + ''' get radio station details ''' + radio = None + params = {"c": "composite", "detail": "listing", "id": radio_id} + result = await self.__get_data("Describe.ashx", params, ignore_cache=True) + if result and result.get("body") and result["body"][0].get("children"): + item = result["body"][0]["children"][0] + radio = await self.__parse_radio(item) + return radio + + async def __parse_radio(self, details): + ''' parse Radio object from json obj returned from api ''' + radio = Radio() + radio.item_id = details['preset_id'] + radio.provider = self.prov_id + if "name" in details: + radio.name = details["name"] + else: + # parse name from text attr + name = details["text"] + if " | " in name: + name = name.split(" | ")[1] + name = name.split(" (")[0] + radio.name = name + # parse stream urls and format + stream_info = await self.__get_stream_urls(radio.item_id) + for stream in stream_info["body"]: + if stream["media_type"] == 'aac': + quality = TrackQuality.LOSSY_AAC + elif stream["media_type"] == 'ogg': + quality = TrackQuality.LOSSY_OGG + else: + quality = TrackQuality.LOSSY_MP3 + radio.provider_ids.append({ + "provider": self.prov_id, + "item_id": "%s--%s" % (details['preset_id'], stream["media_type"]), + "quality": quality, + "details": stream['url'] + }) + # image + if "image" in details: + radio.metadata["image"] = details["image"] + elif "logo" in details: + radio.metadata["image"] = details["logo"] + return radio + + async def __get_stream_urls(self, radio_id): + ''' get the stream urls for the given radio id ''' + params = {"id": radio_id} + res = await self.__get_data("Tune.ashx", params) + return res + + async def get_stream_details(self, stream_id): + ''' return the content details for the given track when it will be streamed''' + radio_id, media_type = stream_id.split('--') + stream_info = await self.__get_stream_urls(radio_id) + for stream in stream_info["body"]: + if stream['media_type'] == media_type: + return { + "type": "url", + "path": stream['url'], + "content_type": media_type, + "sample_rate": 44100, + "bit_depth": 16 + } + return {} + + @use_cache(7) + async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None): + ''' get data from api''' + url = 'https://opml.radiotime.com/%s' % endpoint + params['render'] = 'json' + params['formats'] = 'ogg,aac,wma,mp3' + params['username'] = self._username + params['partnerId'] = '1' + async with self.throttler: + async with self.http_session.get(url, params=params) as response: + result = await response.json() + if not result or 'error' in result: + LOGGER.error(url) + LOGGER.error(params) + result = None + return result + + \ No newline at end of file diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py new file mode 100755 index 00000000..de6026d8 --- /dev/null +++ b/music_assistant/player_manager.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from enum import Enum +import operator +import random +import functools +import urllib +import importlib + +from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task +from .models.media_types import MediaType, TrackQuality +from .models.player_queue import QueueItem +from .models.player import PlayerState + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" ) + + + +class PlayerManager(): + ''' several helpers to handle playback through player providers ''' + + def __init__(self, mass): + self.mass = mass + self.providers = {} + self._players = {} + # dynamically load provider modules + self.load_providers() + + @property + def players(self): + ''' all players as property ''' + return self.mass.bg_executor.submit(asyncio.run, + self.get_players()).result() + + async def get_players(self): + ''' return all players as a list ''' + items = list(self._players.values()) + items.sort(key=lambda x: x.name, reverse=False) + return items + + async def get_player(self, player_id): + ''' return player by id ''' + return self._players.get(player_id, None) + + async def get_provider_players(self, player_provider): + ''' return all players for given provider_id ''' + return [item for item in self._players.values() if item.player_provider == player_provider] + + async def add_player(self, player): + ''' register a new player ''' + self._players[player.player_id] = player + self.mass.signal_event('player added', player) + # TODO: turn on player if it was previously turned on ? + return player + + async def remove_player(self, player_id): + ''' handle a player remove ''' + self._players.pop(player_id, None) + self.mass.signal_event('player removed', player_id) + + async def trigger_update(self, player_id): + ''' manually trigger update for a player ''' + if player_id in self._players: + await self._players[player_id].update() + + async def play_media(self, player_id, media_item, queue_opt='play'): + ''' + play media item(s) on the given player + :param media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio) + single item or list of items + :param queue_opt: + play -> insert new items in queue and start playing at the inserted position + replace -> replace queue contents with these items + next -> play item(s) after current playing item + add -> append new items at end of the queue + ''' + player = await self.get_player(player_id) + if not player: + return + # a single item or list of items may be provided + media_items = media_item if isinstance(media_item, list) else [media_item] + queue_items = [] + for media_item in media_items: + # collect tracks to play + if media_item.media_type == MediaType.Artist: + tracks = await self.mass.music.artist_toptracks(media_item.item_id, + provider=media_item.provider) + elif media_item.media_type == MediaType.Album: + tracks = await self.mass.music.album_tracks(media_item.item_id, + provider=media_item.provider) + elif media_item.media_type == MediaType.Playlist: + tracks = await self.mass.music.playlist_tracks(media_item.item_id, + provider=media_item.provider, offset=0, limit=0) + else: + tracks = [media_item] # single track + for track in tracks: + queue_item = QueueItem(track) + # generate uri for this queue item + queue_item.uri = 'http://%s:%s/stream/%s?queue_item_id=%s'% ( + self.mass.web.local_ip, self.mass.web.http_port, player_id, queue_item.queue_item_id) + # sort by quality and check track availability + for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True): + queue_item.provider = prov_media['provider'] + queue_item.item_id = prov_media['item_id'] + queue_item.quality = prov_media['quality'] + # TODO: check track availability + # TODO: handle direct stream capability + queue_items.append(queue_item) + break + # load items into the queue + if queue_opt == 'replace' or (queue_opt in ['next', 'play'] and len(queue_items) > 50): + return await player.queue.load(queue_items) + elif queue_opt == 'next': + return await player.queue.insert(queue_items, 1) + elif queue_opt == 'play': + return await player.queue.insert(queue_items, 0) + elif queue_opt == 'add': + return await player.queue.append(queue_items) + + def load_providers(self): + ''' dynamically load providers ''' + for item in os.listdir(MODULES_PATH): + if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and + item.endswith('.py') and not item.startswith('.')): + module_name = item.replace(".py","") + LOGGER.debug("Loading playerprovider module %s" % module_name) + try: + mod = importlib.import_module("." + module_name, "music_assistant.playerproviders") + if not self.mass.config['playerproviders'].get(module_name): + self.mass.config['playerproviders'][module_name] = {} + self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries() + for key, def_value, desc in mod.config_entries(): + if not key in self.mass.config['playerproviders'][module_name]: + self.mass.config['playerproviders'][module_name][key] = def_value + mod = mod.setup(self.mass) + if mod: + self.providers[mod.prov_id] = mod + cls_name = mod.__class__.__name__ + LOGGER.info("Successfully initialized module %s" % cls_name) + except Exception as exc: + LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/playerproviders/__init__.py b/music_assistant/playerproviders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/music_assistant/playerproviders/chromecast.py b/music_assistant/playerproviders/chromecast.py new file mode 100644 index 00000000..7441b6c4 --- /dev/null +++ b/music_assistant/playerproviders/chromecast.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import aiohttp +from typing import List +import pychromecast +from pychromecast.controllers.multizone import MultizoneController +from pychromecast.controllers import BaseController +from pychromecast.controllers.media import MediaController +import types + +from ..utils import run_periodic, LOGGER, try_parse_int +from ..models.playerprovider import PlayerProvider +from ..models.player import Player, PlayerState +from ..models.player_queue import QueueItem, PlayerQueue +from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["playerproviders"]['chromecast'].get(CONF_ENABLED) + if enabled: + provider = ChromecastProvider(mass) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, True, CONF_ENABLED), + ] + +class ChromecastPlayer(Player): + ''' Chromecast player object ''' + + async def cmd_stop(self): + ''' send stop command to player ''' + self.cc.media_controller.stop() + + async def cmd_play(self): + ''' send play command to player ''' + self.cc.media_controller.play() + + async def cmd_pause(self): + ''' send pause command to player ''' + self.cc.media_controller.pause() + + async def cmd_next(self): + ''' send next track command to player ''' + return await self.cc.media_controller.queue_next() + + async def cmd_previous(self): + ''' [CAN OVERRIDE] send previous track command to player ''' + return await self.cc.media_controller.queue_prev() + + async def cmd_power_on(self): + ''' send power ON command to player ''' + self.powered = True + + async def cmd_power_off(self): + ''' send power OFF command to player ''' + self.powered = False + # power is not supported so send quit_app instead + if not self.group_parent: + self.cc.quit_app() + + async def cmd_volume_set(self, volume_level): + ''' send new volume level command to player ''' + self.cc.set_volume(volume_level/100) + self.volume_level = volume_level + + async def cmd_volume_mute(self, is_muted=False): + ''' send mute command to player ''' + self.cc.set_volume_muted(is_muted) + + async def cmd_play_uri(self, uri:str): + ''' play single uri on player ''' + self.cc.play_media(uri, 'audio/flac') + + async def cmd_queue_load(self, queue_items:List[QueueItem]): + ''' load (overwrite) queue with new items ''' + cc_queue_items = await self.__create_queue_items(queue_items[:50]) + queuedata = { + "type": 'QUEUE_LOAD', + "repeatMode": "REPEAT_ALL" if self.queue.repeat_enabled else "REPEAT_OFF", + "shuffle": self.queue.shuffle_enabled, + "queueType": "PLAYLIST", + "startIndex": 0, # Item index to play after this request or keep same item if undefined + "items": cc_queue_items # only load 50 tracks at once or the socket will crash + } + await self.__send_player_queue(queuedata) + await asyncio.sleep(0.2) + if len(queue_items) > 50: + await self.cmd_queue_append(queue_items[51:]) + await asyncio.sleep(0.2) + + async def cmd_queue_insert(self, queue_items:List[QueueItem], offset=0): + ''' + insert new items at offset x from current position + keeps remaining items in queue + if offset 0 or None, will start playing newly added item(s) + :param queue_items: a list of QueueItem + :param offset: offset from current queue position + ''' + insert_before = self.queue.cur_index + offset + cc_queue_items = await self.__create_queue_items(queue_items) + for chunk in chunks(cc_queue_items, 50): + queuedata = { + "type": 'QUEUE_INSERT', + "insertBefore": insert_before, + "items": chunk + } + await self.__send_player_queue(queuedata) + + async def cmd_queue_append(self, queue_items:List[QueueItem]): + ''' + append new items at the end of the queue + ''' + cc_queue_items = await self.__create_queue_items(queue_items) + for chunk in chunks(cc_queue_items, 50): + queuedata = { + "type": 'QUEUE_INSERT', + "insertBefore": None, + "items": chunk + } + await self.__send_player_queue(queuedata) + + async def __create_queue_items(self, tracks): + ''' create list of CC queue items from tracks ''' + queue_items = [] + for track in tracks: + queue_item = await self.__create_queue_item(track) + queue_items.append(queue_item) + return queue_items + + async def __create_queue_item(self, track): + '''create CC queue item from track info ''' + return { + 'autoplay' : True, + 'preloadTime' : 10, + 'playbackDuration': int(track.duration), + 'startTime' : 0, + 'activeTrackIds' : [], + 'media': { + 'contentId': track.uri, + 'customData': { + 'provider': track.provider, + 'uri': track.uri, + 'item_id': track.item_id + }, + 'contentType': "audio/flac", + 'streamType': 'BUFFERED', + 'metadata': { + 'title': track.name, + 'artist': track.artists[0].name if track.artists else "", + }, + 'duration': int(track.duration) + } + } + + async def __send_player_queue(self, queuedata): + '''send new data to the CC queue''' + media_controller = self.cc.media_controller + receiver_ctrl = media_controller._socket_client.receiver_controller + def send_queue(): + """Plays media after chromecast has switched to requested app.""" + queuedata['mediaSessionId'] = media_controller.status.media_session_id + media_controller.send_message(queuedata, inc_session_id=False) + if not media_controller.status.media_session_id: + receiver_ctrl.launch_app(media_controller.app_id, callback_function=send_queue) + else: + send_queue() + await asyncio.sleep(0.2) + +class ChromecastProvider(PlayerProvider): + ''' support for ChromeCast Audio ''' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.prov_id = 'chromecast' + self.name = 'Chromecast' + self._discovery_running = False + self.mass.event_loop.create_task(self.__periodic_chromecast_discovery()) + + async def get_player_config_entries(self): + ''' get the player config entries for this provider (list with key/value pairs)''' + return [ + ("gapless_enabled", False, "gapless_enabled") + ] + + async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None): + ''' handle a player state message from the socket ''' + player_id = str(chromecast.uuid) + player = await self.get_player(player_id) + # always update player details that may change + player.name = chromecast.name + if caststatus: + player.muted = caststatus.volume_muted + player.volume_level = caststatus.volume_level * 100 + if mediastatus: + # chromecast does not support power on/of so we only set state + if mediastatus.player_state in ['PLAYING', 'BUFFERING']: + player.state = PlayerState.Playing + elif mediastatus.player_state == 'PAUSED': + player.state = PlayerState.Paused + else: + player.state = PlayerState.Stopped + player.cur_uri = mediastatus.content_id + player.cur_time = mediastatus.adjusted_current_time + + async def __handle_group_members_update(self, mz, added_player=None, removed_player=None): + ''' callback when cast group members update ''' + if added_player: + player = await self.get_player(added_player) + group_player = await self.get_player(str(mz._uuid)) + if player and group_player: + player.group_parent = str(mz._uuid) + LOGGER.debug("player %s added to group %s" %(player.name, group_player.name)) + elif removed_player: + player = await self.get_player(added_player) + group_player = await self.get_player(str(mz._uuid)) + if player and group_player: + player.group_parent = None + LOGGER.debug("player %s removed from group %s" %(player.name, group_player.name)) + else: + for member in mz.members: + player = await self.get_player(member) + if player: + player.group_parent = str(mz._uuid) + + @run_periodic(1800) + async def __periodic_chromecast_discovery(self): + ''' run chromecast discovery on interval ''' + await self.__chromecast_discovery() + + async def __chromecast_discovery(self): + ''' background non-blocking chromecast discovery and handler ''' + if self._discovery_running: + return + self._discovery_running = True + LOGGER.info("Chromecast discovery started...") + # remove any disconnected players... + removed_players = [] + for player in self.players: + if not player.cc.socket_client or not player.cc.socket_client.is_connected: + LOGGER.info("%s is disconnected" % player.name) + # cleanup cast object + del player.cc + removed_players.append(player.player_id) + # signal removed players + for player_id in removed_players: + await self.remove_player(player_id) + # search for available chromecasts + from pychromecast.discovery import start_discovery, stop_discovery + def discovered_callback(name): + """Called when zeroconf has discovered a (new) chromecast.""" + discovery_info = listener.services[name] + ip_address, port, uuid, model_name, friendly_name = discovery_info + player_id = str(uuid) + player = self.mass.bg_executor.submit(asyncio.run, + self.get_player(player_id)).result() + if not player: + LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port)) + asyncio.run_coroutine_threadsafe( + self.__chromecast_discovered(player_id, discovery_info), self.mass.event_loop) + listener, browser = start_discovery(discovered_callback) + await asyncio.sleep(15) # run discovery for 15 seconds + stop_discovery(browser) + LOGGER.info("Chromecast discovery completed...") + self._discovery_running = False + + async def __chromecast_discovered(self, player_id, discovery_info): + ''' callback when a (new) chromecast device is discovered ''' + from pychromecast import _get_chromecast_from_host, ChromecastConnectionError + try: + chromecast = _get_chromecast_from_host(discovery_info, tries=2, retry_wait=5) + except ChromecastConnectionError: + LOGGER.warning("Could not connect to device %s" % player_id) + return + # patch the receive message method for handling queue status updates + chromecast.media_controller.queue_items = [] + chromecast.media_controller.queue_cur_id = None + chromecast.media_controller.receive_message = types.MethodType(receive_message, chromecast.media_controller) + listenerCast = StatusListener(chromecast, self.__handle_player_state, self.mass.event_loop) + chromecast.register_status_listener(listenerCast) + listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop) + chromecast.media_controller.register_status_listener(listenerMedia) + player = ChromecastPlayer(self.mass, player_id, self.prov_id) + if chromecast.cast_type == 'group': + player.is_group = True + mz = MultizoneController(chromecast.uuid) + mz.register_listener(MZListener(mz, self.__handle_group_members_update, self.mass.event_loop)) + chromecast.register_handler(mz) + chromecast.register_connection_listener(MZConnListener(mz)) + chromecast.mz = mz + player.cc = chromecast + player.cc.wait() + await self.add_player(player) + await self.update_all_group_members() + + async def update_all_group_members(self): + ''' force member update of all cast groups ''' + for player in self.players: + if player.cc.cast_type == 'group': + player.cc.mz.update_members() + + +def chunks(l, n): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(l), n): + yield l[i:i + n] + + +class StatusListener: + def __init__(self, chromecast, callback, loop): + self.chromecast = chromecast + self.__handle_player_state = callback + self.loop = loop + def new_cast_status(self, status): + asyncio.run_coroutine_threadsafe( + self.__handle_player_state(self.chromecast, caststatus=status), self.loop) + +class StatusMediaListener: + def __init__(self, chromecast, callback, loop): + self.chromecast= chromecast + self.__handle_player_state = callback + self.loop = loop + def new_media_status(self, status): + asyncio.run_coroutine_threadsafe( + self.__handle_player_state(self.chromecast, mediastatus=status), self.loop) + +class MZConnListener: + def __init__(self, mz): + self._mz=mz + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + if connection_status.status == 'CONNECTED': + self._mz.update_members() + +class MZListener: + def __init__(self, mz, callback, loop): + self._mz = mz + self._loop = loop + self.__handle_group_members_update = callback + + def multizone_member_added(self, uuid): + asyncio.run_coroutine_threadsafe( + self.__handle_group_members_update( + self._mz, added_player=str(uuid)), self._loop) + + def multizone_member_removed(self, uuid): + asyncio.run_coroutine_threadsafe( + self.__handle_group_members_update( + self._mz, removed_player=str(uuid)), self._loop) + + def multizone_status_received(self): + asyncio.run_coroutine_threadsafe( + self.__handle_group_members_update(self._mz), self._loop) + +def receive_message(self, message, data): + """ Called when a media message is received. """ + #LOGGER.info('message: %s - data: %s'%(message, data)) + if data['type'] == 'MEDIA_STATUS': + try: + self.queue_items = data['status'][0]['items'] + except: + pass + try: + self.queue_cur_id = data['status'][0]['currentItemId'] + except: + pass + self._process_media_status(data) + return True + return False \ No newline at end of file diff --git a/music_assistant/playerproviders/lms.py b/music_assistant/playerproviders/lms.py new file mode 100644 index 00000000..ad5b5e38 --- /dev/null +++ b/music_assistant/playerproviders/lms.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import random +import sys +import json +import aiohttp +import time +import datetime +import hashlib +from asyncio_throttle import Throttler +from aiocometd import Client, ConnectionType, Extension +import copy +import urllib + +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["playerproviders"]['lms'].get(CONF_ENABLED) + hostname = mass.config["playerproviders"]['lms'].get(CONF_HOSTNAME) + port = mass.config["playerproviders"]['lms'].get(CONF_PORT) + if enabled and hostname and port: + provider = LMSProvider(mass, hostname, port) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_HOSTNAME, 'localhost', CONF_HOSTNAME), + (CONF_PORT, 9000, CONF_PORT) + ] + +class LMSProvider(PlayerProvider): + ''' support for Logitech Media Server ''' + + def __init__(self, mass, hostname, port): + self.prov_id = 'lms' + self.name = 'Logitech Media Server' + self.icon = '' + self.mass = mass + self._players = {} + self._host = hostname + self._port = port + self.last_msg_received = 0 + self.supported_musicproviders = ['qobuz', 'file', 'spotify', 'http'] + self.http_session = aiohttp.ClientSession(loop=mass.event_loop) + # we use a combi of active polling and subscriptions because the cometd implementation of LMS is somewhat unreliable + asyncio.ensure_future(self.__lms_events()) + asyncio.ensure_future(self.__get_players()) + + ### Provider specific implementation ##### + + async def player_config_entries(self): + ''' get the player config entries for this provider (list with key/value pairs)''' + return [] + + async def player_command(self, player_id, cmd:str, cmd_args=None): + ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' + lms_commands = [] + if cmd == 'play': + lms_commands = ['play'] + elif cmd == 'pause': + lms_commands = ['pause', '1'] + elif cmd == 'stop': + lms_commands = ['stop'] + elif cmd == 'next': + lms_commands = ['playlist', 'index', '+1'] + elif cmd == 'previous': + lms_commands = ['playlist', 'index', '-1'] + elif cmd == 'stop': + lms_commands = ['playlist', 'stop'] + elif cmd == 'power' and cmd_args == 'off': + lms_commands = ['power', '0'] + elif cmd == 'power': + lms_commands = ['power', '1'] + elif cmd == 'volume': + lms_commands = ['mixer', 'volume', cmd_args] + elif cmd == 'mute' and cmd_args == 'off': + lms_commands = ['mixer', 'muting', '0'] + elif cmd == 'mute': + lms_commands = ['mixer', 'muting', '1'] + return await self.__get_data(lms_commands, player_id=player_id) + + async def play_media(self, player_id, media_items, queue_opt='play'): + ''' + play media on a player + ''' + if queue_opt == 'play': + cmd = ['playlist', 'insert', media_items[0].uri] + await self.__get_data(cmd, player_id=player_id) + cmd = ['playlist', 'index', '+1'] + await self.__get_data(cmd, player_id=player_id) + for track in media_items[1:]: + cmd = ['playlist', 'insert', track.uri] + await self.__get_data(cmd, player_id=player_id) + elif queue_opt == 'replace': + cmd = ['playlist', 'play', media_items[0].uri] + await self.__get_data(cmd, player_id=player_id) + for track in media_items[1:]: + cmd = ['playlist', 'add', track.uri] + await self.__get_data(cmd, player_id=player_id) + elif queue_opt == 'next': + for track in media_items: + cmd = ['playlist', 'insert', track.uri] + await self.__get_data(cmd, player_id=player_id) + else: + for track in media_items: + cmd = ['playlist', 'add', track.uri] + await self.__get_data(cmd, player_id=player_id) + + async def player_queue(self, player_id, offset=0, limit=50): + ''' return the items in the player's queue ''' + items = [] + player_details = await self.__get_data(["status", offset, limit, "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id) + if 'playlist_loop' in player_details: + for item in player_details['playlist_loop']: + track = await self.__parse_track(item) + items.append(track) + return items + + ### Provider specific (helper) methods ##### + + async def __get_players(self): + ''' update all players, used as fallback if cometd is failing and to detect removed players''' + server_info = await self.__get_data(['players', 0, 1000]) + player_ids = await self.__process_serverstatus(server_info) + for player_id in player_ids: + player_details = await self.__get_data(["status", "-","1", "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id) + await self.__process_player_details(player_id, player_details) + + async def __process_player_details(self, player_id, player_details): + ''' get state of a given player ''' + if player_id not in self._players: + return + player = self._players[player_id] + volume = player_details.get('mixer volume',0) + player.muted = volume < 0 + if volume >= 0: + player.volume_level = player_details.get('mixer volume',0) + player.shuffle_enabled = player_details.get('playlist shuffle',0) != 0 + player.repeat_enabled = player_details.get('playlist repeat',0) != 0 + # player state + if 'power' in player_details: + player.powered = player_details['power'] == 1 + else: + print(player_details) # DEBUG + if player_details['mode'] == 'play': + player.state = PlayerState.Playing + elif player_details['mode'] == 'pause': + player.state = PlayerState.Paused + else: + player.state = PlayerState.Stopped + # current track + if player_details.get('playlist_loop'): + player.cur_item = await self.__parse_track(player_details['playlist_loop'][0]) + player.cur_time = player_details.get('time',0) + else: + player.cur_item = None + player.cur_time = 0 + await self.mass.player.update_player(player) + + async def __process_serverstatus(self, server_status): + ''' process players from server state msg (players_loop) ''' + cur_player_ids = [] + for lms_player in server_status['players_loop']: + if lms_player['isplayer'] != 1: + continue + player_id = lms_player['playerid'] + cur_player_ids.append(player_id) + if not player_id in self._players: + # new player + self._players[player_id] = MusicPlayer() + player = self._players[player_id] + player.player_id = player_id + player.player_provider = self.prov_id + else: + # existing player + player = self._players[player_id] + # always update player details that may change + player.name = lms_player['name'] + if lms_player['model'] == "group": + player.is_group = True + # player is a groupplayer, retrieve childs + group_player_child_ids = await self.__get_group_childs(player_id) + for child_player_id in group_player_child_ids: + if child_player_id in self._players: + self._players[child_player_id].group_parent = player_id + elif player.group_parent: + # check if player parent is still correct + group_player_child_ids = await self.__get_group_childs(player.group_parent) + if not player_id in group_player_child_ids: + player.group_parent = None + # process update + await self.mass.player.update_player(player) + # process removed players... + for player_id, player in self._players.items(): + if player_id not in cur_player_ids: + await self.mass.player.remove_player(player_id) + return cur_player_ids + + async def __parse_track(self, track_details): + ''' parse track in LMS to our internal format ''' + track_url = track_details.get('url','') + if track_url.startswith('qobuz://') and 'qobuz' in self.mass.music.providers: + # qobuz track! + try: + track_id = track_url.replace('qobuz://','').replace('.flac','') + return await self.mass.music.providers['qobuz'].track(track_id) + except Exception as exc: + LOGGER.error(exc) + elif track_url.startswith('spotify://track:') and 'spotify' in self.mass.music.providers: + # spotify track! + try: + track_id = track_url.replace('spotify://track:','') + return await self.mass.music.providers['spotify'].track(track_id) + except Exception as exc: + LOGGER.error(exc) + elif track_url.startswith('http') and '/stream' in track_url: + params = urllib.parse.parse_qs(track_url.split('?')[1]) + track_id = params['track_id'][0] + provider = params['provider'][0] + return await self.mass.music.providers[provider].track(track_id) + # fallback to a generic track + track = Track() + track.name = track_details['title'] + track.duration = int(track_details['duration']) + if 'artwork_url' in track_details: + image = "http://%s:%s%s" % (self._host, self._port, track_details['artwork_url']) + track.metadata['image'] = image + return track + + async def __get_group_childs(self, group_player_id): + ''' get child players for groupplayer ''' + group_childs = [] + result = await self.__get_data('playergroup', player_id=group_player_id) + if result and 'players_loop' in result: + group_childs = [item['id'] for item in result['players_loop']] + return group_childs + + async def __lms_events(self): + # Receive events from LMS through CometD socket + while self.mass.event_loop.is_running(): + try: + last_msg_received = 0 + async with Client("http://%s:%s/cometd" % (self._host, self._port), + connection_types=ConnectionType.LONG_POLLING, + extensions=[LMSExtension()]) as client: + # subscribe + watched_players = [] + await client.subscribe("/slim/subscribe/serverstatus") + + # listen for incoming messages + async for message in client: + last_msg_received = int(time.time()) + if 'playerstatus' in message['channel']: + # player state + player_id = message['channel'].split('playerstatus/')[1] + asyncio.ensure_future(self.__process_player_details(player_id, message['data'])) + elif '/slim/serverstatus' in message['channel']: + # server state with all players + player_ids = await self.__process_serverstatus(message['data']) + for player_id in player_ids: + if player_id not in watched_players: + # subscribe to player change events + watched_players.append(player_id) + await client.subscribe("/slim/subscribe/playerstatus/%s" % player_id) + except Exception as exc: + LOGGER.exception(exc) + + async def __get_data(self, cmds:List, player_id=''): + ''' get data from api''' + if not isinstance(cmds, list): + cmds = [cmds] + cmd = [player_id, cmds] + url = "http://%s:%s/jsonrpc.js" % (self._host, self._port) + params = {"id": 1, "method": "slim.request", "params": cmd} + try: + async with self.http_session.post(url, json=params) as response: + result = await response.json() + return result['result'] + except Exception as exc: + LOGGER.exception('Error executing LMS command %s' % params) + return None + + +class LMSExtension(Extension): + ''' Extension for the custom cometd implementation of LMS''' + + async def incoming(self, payload, headers=None): + pass + + async def outgoing(self, payload, headers): + ''' override outgoing messages to fit LMS custom implementation''' + + # LMS does not need/want id for the connect and handshake message + if payload[0]['channel'] == '/meta/handshake' or payload[0]['channel'] == '/meta/connect': + del payload[0]['id'] + + # handle subscriptions + if 'subscribe' in payload[0]['channel']: + client_id = payload[0]['clientId'] + if payload[0]['subscription'] == '/slim/subscribe/serverstatus': + # append additional request data to the request + payload[0]['data'] = {'response':'/%s/slim/serverstatus' % client_id, + 'request':['', ['serverstatus', 0, 100, 'subscribe:60']]} + payload[0]['channel'] = '/slim/subscribe' + if payload[0]['subscription'].startswith('/slim/subscribe/playerstatus'): + # append additional request data to the request + player_id = payload[0]['subscription'].split('/')[-1] + payload[0]['data'] = {'response':'/%s/slim/playerstatus/%s' % (client_id, player_id), + 'request':[player_id, ["status", "-", 1, "tags:aAcCdegGijJKlostuxyRwk", "subscribe:60"]]} + payload[0]['channel'] = '/slim/subscribe' \ No newline at end of file diff --git a/music_assistant/playerproviders/pylms.py b/music_assistant/playerproviders/pylms.py new file mode 100644 index 00000000..0a83f533 --- /dev/null +++ b/music_assistant/playerproviders/pylms.py @@ -0,0 +1,800 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +import struct +from collections import OrderedDict +import time +import decimal +from typing import List +import random +import sys +import socket +from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, get_ip, get_hostname +from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_ENABLED + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["playerproviders"]['pylms'].get(CONF_ENABLED) + if enabled: + provider = PyLMSServer(mass) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, True, CONF_ENABLED) + ] + + +class PyLMSServer(PlayerProvider): + ''' Python implementation of SlimProto server ''' + + def __init__(self, mass): + self.prov_id = 'pylms' + self.name = 'Logitech Media Server Emulation' + self.mass = mass + self._lmsplayers = {} + self.buffer = b'' + self.last_msg_received = 0 + + # start slimproto server + mass.event_loop.create_task(asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483)) + # setup discovery + mass.event_loop.create_task(self.start_discovery()) + + ### Provider specific implementation ##### + + + async def start_discovery(self): + transport, protocol = await self.mass.event_loop.create_datagram_endpoint( + lambda: DiscoveryProtocol(self.mass.web._http_port), + local_addr=('0.0.0.0', 3483)) + try: + while True: + await asyncio.sleep(60) # serve forever + finally: + transport.close() + + async def player_command(self, player_id, cmd:str, cmd_args=None): + ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' + if cmd == 'play': + if self._players[player_id].state == PlayerState.Stopped: + await self.__queue_play(player_id, None) + else: + self._lmsplayers[player_id].unpause() + elif cmd == 'pause': + self._lmsplayers[player_id].pause() + elif cmd == 'stop': + self._lmsplayers[player_id].stop() + elif cmd == 'next': + self._lmsplayers[player_id].next() + elif cmd == 'previous': + await self.__queue_previous(player_id) + elif cmd == 'power' and cmd_args == 'off': + self._lmsplayers[player_id].power_off() + elif cmd == 'power': + self._lmsplayers[player_id].power_on() + elif cmd == 'volume': + self._lmsplayers[player_id].volume_set(try_parse_int(cmd_args)) + elif cmd == 'mute' and cmd_args == 'off': + self._lmsplayers[player_id].unmute() + elif cmd == 'mute': + self._lmsplayers[player_id].mute() + + async def play_media(self, player_id, media_items, queue_opt='play'): + ''' + play media on a player + ''' + player = await self.get_player(player_id) + cur_index = player.cur_queue_index + + if queue_opt == 'replace' or not player.queue: + # overwrite queue with new items + player.queue = media_items + await self.__queue_play(player_id, 0, send_flush=True) + elif queue_opt == 'play': + # replace current item with new item(s) + player.queue = player.queue[player_id][:cur_index] + media_items + player.queue[player_id][cur_index+1:] + await self.__queue_play(player_id, cur_index, send_flush=True) + elif queue_opt == 'next': + # insert new items at current index +1 + player.queue[player_id] = player.queue[player_id][:cur_index+1] + media_items + player.queue[player_id][cur_index+1:] + elif queue_opt == 'add': + # add new items at end of queue + player.queue[player_id] = player.queue[player_id] + media_items + + ### Provider specific (helper) methods ##### + + async def __queue_play(self, player_id, index, send_flush=False): + ''' send play command to player ''' + if not player_id in player.queue or not player_id in player.queue_index: + return + if not player.queue[player_id]: + return + if index == None: + index = player.queue_index[player_id] + if len(player.queue[player_id]) >= index: + track = player.queue[player_id][index] + if send_flush: + self._lmsplayers[player_id].flush() + self._lmsplayers[player_id].play(track.uri) + player.queue_index[player_id] = index + + async def __queue_next(self, player_id): + ''' request next track from queue ''' + if not player_id in player.queue or not player_id in player.queue: + return + cur_queue_index = player.queue_index[player_id] + if len(player.queue[player_id]) > cur_queue_index: + new_queue_index = cur_queue_index + 1 + elif self._players[player_id].repeat_enabled: + new_queue_index = 0 + else: + LOGGER.warning("next track requested but no more tracks in queue") + return + return await self.__queue_play(player_id, new_queue_index) + + async def __queue_previous(self, player_id): + ''' request previous track from queue ''' + if not player_id in player.queue: + return + cur_queue_index = player.queue_index[player_id] + if cur_queue_index == 0 and len(player.queue[player_id]) > 1: + new_queue_index = len(player.queue[player_id]) -1 + elif cur_queue_index == 0: + new_queue_index = cur_queue_index + else: + new_queue_index -= 1 + player.queue_index[player_id] = new_queue_index + return await self.__queue_play(player_id, new_queue_index) + + async def __handle_player_event(self, player_id, event, event_data=None): + ''' handle event from player ''' + if not player_id: + return + LOGGER.debug("Event from player %s: %s - event_data: %s" %(player_id, event, str(event_data))) + lms_player = self._lmsplayers[player_id] + if event == "next_track": + return await self.__queue_next(player_id) + player + if not player_id in self._players: + player = MusicPlayer() + player.player_id = player_id + player.player_provider = self.prov_id + self._players[player_id] = player + if not player_id in player.queue: + player.queue[player_id] = [] + if not player_id in player.queue_index: + player.queue_index[player_id] = 0 + else: + player = self._players[player_id] + # update player properties + player.name = lms_player.player_name + player.volume_level = lms_player.volume_level + player.cur_time = lms_player._elapsed_seconds + if event == "disconnected": + return await self.mass.player.remove_player(player_id) + elif event == "power": + player.powered = event_data + elif event == "state": + player.state = event_data + if player.queue[player_id]: + cur_queue_index = player.queue_index[player_id] + player.cur_item = player.queue[player_id][cur_queue_index] + # update player details + await self.mass.player.update_player(player) + + async def __handle_socket_client(self, reader, writer): + ''' handle a client connection on the socket''' + LOGGER.debug("new socket client connected") + stream_host = get_ip() + stream_port = self.mass.config['base']['web']['http_port'] + lms_player = PyLMSPlayer(stream_host, stream_port) + + def send_frame(command, data): + ''' send command to lms player''' + packet = struct.pack('!H', len(data) + 4) + command + data + writer.write(packet) + + def handle_event(event, event_data=None): + ''' handle events from player''' + if event == "connected": + self._lmsplayers[lms_player.player_id] = lms_player + lms_player.player_settings = self.mass.config['player_settings'][lms_player.player_id] + asyncio.create_task(self.__handle_player_event(lms_player.player_id, event, event_data)) + + try: + @run_periodic(5) + async def send_heartbeat(): + timestamp = int(time.time()) + data = lms_player.pack_stream(b"t", replayGain=timestamp, flags=0) + lms_player.send_frame(b"strm", data) + + lms_player.send_frame = send_frame + lms_player.send_event = handle_event + heartbeat_task = asyncio.create_task(send_heartbeat()) + + # keep reading bytes from the socket + while True: + data = await reader.read(64) + if data: + lms_player.dataReceived(data) + else: + break + except Exception as exc: + # connection lost ? + LOGGER.warning(exc) + # disconnect + heartbeat_task.cancel() + asyncio.create_task(self.__handle_player_event(lms_player.player_id, 'disconnected')) + + +class PyLMSPlayer(object): + ''' very basic Python implementation of SlimProto ''' + + def __init__(self, stream_host, stream_port): + self.buffer = b'' + #self.display = Display() + self.send_frame = None + self.send_event = None + self.stream_host = stream_host + self.stream_port = stream_port + self.player_settings = {} + self.playback_millis = 0 + self._volume = PyLMSVolume() + self._device_type = None + self._mac_address = None + self._player_name = None + self._last_volume = 0 + self._last_heartbeat = 0 + self._elapsed_seconds = 0 + self._elapsed_milliseconds = 0 + + @property + def player_name(self): + if self._player_name: + return self._player_name + return "%s - %s" %(self._device_type, self._mac_address) + + @property + def player_id(self): + return self._mac_address + + @property + def volume_level(self): + return self._volume.volume + + def dataReceived(self, data): + self.buffer = self.buffer + data + if len(self.buffer) > 8: + operation, length = self.buffer[:4], self.buffer[4:8] + length = struct.unpack('!I', length)[0] + plen = length + 8 + if len(self.buffer) >= plen: + packet, self.buffer = self.buffer[8:plen], self.buffer[plen:] + operation = operation.strip(b"!").strip().decode() + #LOGGER.info("operation: %s" % operation) + handler = getattr(self, "process_%s" % operation, None) + if handler is None: + raise NotImplementedError + handler(packet) + + def send_version(self): + self.send_frame(b'vers', b'7.8') + + def pack_stream(self, command, autostart=b"1", formatbyte = b'o', pcmargs = (b'?',b'?',b'?',b'?'), threshold = 200, + spdif = b'0', transDuration = 0, transType = b'0', flags = 0x40, outputThreshold = 0, + replayGain=0, serverPort = 8095, serverIp = 0): + return struct.pack("!cccccccBcBcBBBLHL", + command, autostart, formatbyte, *pcmargs, + threshold, spdif, transDuration, transType, + flags, outputThreshold, 0, replayGain, serverPort, serverIp) + + def stop(self): + data = self.pack_stream(b"q", autostart=b"0", flags=0) + self.send_frame(b"strm", data) + + def flush(self): + data = self.pack_stream(b"f", autostart=b"0", flags=0) + self.send_frame(b"strm", data) + + def pause(self): + data = self.pack_stream(b"p", autostart=b"0", flags=0) + self.send_frame(b"strm", data) + LOGGER.info("Sending pause request") + + def unpause(self): + data = self.pack_stream(b"u", autostart=b"0", flags=0) + self.send_frame(b"strm", data) + LOGGER.info("Sending unpause request") + + def next(self): + data = self.pack_stream(b"f", autostart=b"0", flags=0) + self.send_frame(b"strm", data) + self.send_event("next_track") + + def previous(self): + data = self.pack_stream(b"f", autostart=b"0", flags=0) + self.send_frame(b"strm", data) + self.send_event("previous_track") + + def power_on(self): + self.send_frame(b"aude", struct.pack("2B", 1, 1)) + self.send_event("power", True) + + def power_off(self): + self.stop() + self.send_frame(b"aude", struct.pack("2B", 0, 0)) + self.send_event("power", False) + + def mute_on(self): + self.send_frame(b"aude", struct.pack("2B", 0, 0)) + self.send_event("mute", True) + + def mute_off(self): + self.send_frame(b"aude", struct.pack("2B", 1, 1)) + self.send_event("mute", False) + + def volume_up(self): + self._volume.increment() + self.send_volume() + + def volume_down(self): + self._volume.decrement() + self.send_volume() + + def volume_set(self, new_vol): + self._volume.volume = new_vol + self.send_volume() + + def play(self, uri): + enable_crossfade = self.player_settings["crossfade_duration"] > 0 + command = b's' + autostart = b'3' # we use direct stream for now so let the player do the messy work with buffers + transType= b'1' if enable_crossfade else b'0' + transDuration = self.player_settings["crossfade_duration"] + formatbyte = b'f' # fixed to flac + uri = '/stream' + uri.split('/stream')[1] + data = self.pack_stream(command, autostart=autostart, flags=0x00, formatbyte=formatbyte, transType=transType, transDuration=transDuration) + headers = "Connection: close\r\nAccept: */*\r\nHost: %s:%s\r\n" %(self.stream_host, self.stream_port) + request = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers) + data = data + request.encode("utf-8") + self.send_frame(b'strm', data) + LOGGER.info("Requesting play from squeezebox" ) + + def displayTrack(self, track): + self.render("%s by %s" % (track.title, track.artist)) + + def process_HELO(self, data): + (devId, rev, mac) = struct.unpack('BB6s', data[:8]) + device_mac = ':'.join("%02x" % x for x in mac) + self._device_type = devices.get(devId, 'unknown device') + self._mac_address = str(device_mac).lower() + LOGGER.debug("HELO received from %s %s" % (self._mac_address, self._device_type)) + self.init_client() + + def init_client(self): + ''' initialize a new connected client ''' + self.send_event("connected") + self.send_version() + self.stop() + self.setBrightness() + #self.set_visualisation(SpectrumAnalyser()) + self.send_frame(b"setd", struct.pack("B", 0)) + self.send_frame(b"setd", struct.pack("B", 4)) + self.power_on() + self.volume_set(40) # TODO: remember last volume + + def send_volume(self): + og = self._volume.old_gain() + ng = self._volume.new_gain() + LOGGER.info("Volume set to %d (%d/%d)" % (self._volume.volume, og, ng)) + d = self.send_frame(b"audg", struct.pack("!LLBBLL", og, og, 1, 255, ng, ng)) + self.send_event("volume", self._volume.volume) + + def setBrightness(self, level=4): + assert 0 <= level <= 4 + self.send_frame(b"grfb", struct.pack("!H", level)) + + def set_visualisation(self, visualisation): + self.send_frame(b"visu", visualisation.pack()) + + def render(self, text): + #self.display.clear() + #self.display.renderText(text, "DejaVu-Sans", 16, (0,0)) + #self.updateDisplay(self.display.frame()) + pass + + def updateDisplay(self, bitmap, transition = 'c', offset=0, param=0): + frame = struct.pack("!Hcb", offset, transition, param) + bitmap + self.send_frame(b"grfe", frame) + + def process_STAT(self, data): + ev = data[:4] + if ev == b'\x00\x00\x00\x00': + LOGGER.info("Presumed informational stat message") + else: + handler = getattr(self, 'stat_%s' % ev.decode(), None) + if handler is None: + raise NotImplementedError("Stat message %r not known" % ev) + handler(data[4:]) + + def stat_aude(self, data): + (spdif_enable, dac_enable) = struct.unpack("2B", data[:4]) + powered = spdif_enable or dac_enable + self.send_event("power", powered) + LOGGER.debug("ACK aude - Received player power: %s" % powered) + + def stat_audg(self, data): + LOGGER.info("Received volume_level from player %s" % data) + self.send_event("volume", self._volume.volume) + + def stat_strm(self, data): + LOGGER.debug("ACK strm") + #self.send_frame(b"cont", b"0") + + def stat_STMc(self, data): + LOGGER.debug("Status Message: Connect") + + def stat_STMd(self, data): + LOGGER.debug("Decoder Ready for next track") + self.send_event("next_track") + + def stat_STMe(self, data): + LOGGER.info("Connection established") + + def stat_STMf(self, data): + LOGGER.info("Status Message: Connection closed") + self.send_event("state", PlayerState.Stopped) + + def stat_STMh(self, data): + LOGGER.info("Status Message: End of headers") + + def stat_STMn(self, data): + LOGGER.error("Decoder does not support file format") + + def stat_STMo(self, data): + ''' No more decoded (uncompressed) data to play; triggers rebuffering. ''' + LOGGER.debug("Output Underrun") + + def stat_STMp(self, data): + '''Pause confirmed''' + self.send_event("state", PlayerState.Paused) + + def stat_STMr(self, data): + '''Resume confirmed''' + self.send_event("state", PlayerState.Playing) + + def stat_STMs(self, data): + '''Playback of new track has started''' + self.send_event("state", PlayerState.Playing) + + def stat_STMt(self, data): + """ heartbeat from client """ + timestamp = time.time() + self._last_heartbeat = timestamp + (num_crlf, mas_initialized, mas_mode, rptr, wptr, + bytes_received_h, bytes_received_l, signal_strength, + jiffies, output_buffer_size, output_buffer_fullness, + elapsed_seconds, voltage, elapsed_milliseconds, + server_timestamp, error_code) = struct.unpack("!BBBLLLLHLLLLHLLH", data) + if elapsed_seconds != self._elapsed_seconds: + self.send_event("progress") + self._elapsed_seconds = elapsed_seconds + self._elapsed_milliseconds = elapsed_milliseconds + + def stat_STMu(self, data): + '''Normal end of playback''' + LOGGER.info("End of playback - Underrun") + self.send_event("state", PlayerState.Stopped) + + def process_BYE(self, data): + LOGGER.info("BYE received") + self.send_event("disconnected") + + def process_RESP(self, data): + LOGGER.info("RESP received") + self.send_frame(b"cont", b"0") + + def process_BODY(self, data): + LOGGER.info("BODY received") + + def process_META(self, data): + LOGGER.info("META received") + + def process_DSCO(self, data): + LOGGER.info("Data Stream Disconnected") + + def process_DBUG(self, data): + LOGGER.info("DBUG received") + + def process_IR(self, data): + """ Slightly involved codepath here. This raises an event, which may + be picked up by the service and then the process_remote_* function in + this player will be called. This is mostly relevant for volume changes + - most other button presses will require some context to operate. """ + (time, code) = struct.unpack("!IxxI", data) + LOGGER.info("IR code %s" % code) + # command = Remote.codes.get(code, None) + # if command is not None: + # LOGGER.info("IR received: %r, %r" % (code, command)) + # #self.service.evreactor.fireEvent(RemoteButtonPressed(self, command)) + # else: + # LOGGER.info("Unknown IR received: %r, %r" % (time, code)) + + def process_RAWI(self, data): + LOGGER.info("RAWI received") + + def process_ANIC(self, data): + LOGGER.info("ANIC received") + + def process_BUTN(self, data): + LOGGER.info("BUTN received") + + def process_KNOB(self, data): + ''' Transporter only, knob-related ''' + LOGGER.info("KNOB received") + + def process_SETD(self, data): + ''' Get/set player firmware settings ''' + LOGGER.debug("SETD received %s" % data) + cmd_id = data[0] + if cmd_id == 0: + # received player name + data = data[1:].decode() + self._player_name = data + self.send_event("name") + + def process_UREQ(self, data): + LOGGER.info("UREQ received") + + + +# from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO +devices = { + 2: 'squeezebox', + 3: 'softsqueeze', + 4: 'squeezebox2', + 5: 'transporter', + 6: 'softsqueeze3', + 7: 'receiver', + 8: 'squeezeslave', + 9: 'controller', + 10: 'boom', + 11: 'softboom', + 12: 'squeezeplay', + } + + +class PyLMSVolume(object): + + """ Represents a sound volume. This is an awful lot more complex than it + sounds. """ + + minimum = 0 + maximum = 100 + step = 1 + + # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source + # i don't know how much magic it contains, or any way I can test it + old_map = [ + 0, 1, 1, 1, 2, 2, 2, 3, 3, 4, + 5, 5, 6, 6, 7, 8, 9, 9, 10, 11, + 12, 13, 14, 15, 16, 16, 17, 18, 19, 20, + 22, 23, 24, 25, 26, 27, 28, 29, 30, 32, + 33, 34, 35, 37, 38, 39, 40, 42, 43, 44, + 46, 47, 48, 50, 51, 53, 54, 56, 57, 59, + 60, 61, 63, 65, 66, 68, 69, 71, 72, 74, + 75, 77, 79, 80, 82, 84, 85, 87, 89, 90, + 92, 94, 96, 97, 99, 101, 103, 104, 106, 108, 110, + 112, 113, 115, 117, 119, 121, 123, 125, 127, 128 + ]; + + # new gain parameters, from the same place + total_volume_range = -50 # dB + step_point = -1 # Number of steps, up from the bottom, where a 2nd volume ramp kicks in. + step_fraction = 1 # fraction of totalVolumeRange where alternate volume ramp kicks in. + + def __init__(self): + self.volume = 50 + + def increment(self): + """ Increment the volume """ + self.volume += self.step + if self.volume > self.maximum: + self.volume = self.maximum + + def decrement(self): + """ Decrement the volume """ + self.volume -= self.step + if self.volume < self.minimum: + self.volume = self.minimum + + def old_gain(self): + """ Return the "Old" gain value as required by the squeezebox """ + return self.old_map[self.volume] + + def decibels(self): + """ Return the "new" gain value. """ + + step_db = self.total_volume_range * self.step_fraction + max_volume_db = 0 # different on the boom? + + # Equation for a line: + # y = mx+b + # y1 = mx1+b, y2 = mx2+b. + # y2-y1 = m(x2 - x1) + # y2 = m(x2 - x1) + y1 + slope_high = max_volume_db - step_db / (100.0 - self.step_point) + slope_low = step_db - self.total_volume_range / (self.step_point - 0.0) + x2 = self.volume + if (x2 > self.step_point): + m = slope_high + x1 = 100 + y1 = max_volume_db + else: + m = slope_low + x1 = 0 + y1 = self.total_volume_range + return m * (x2 - x1) + y1 + + def new_gain(self): + db = self.decibels() + floatmult = 10 ** (db/20.0) + # avoid rounding errors somehow + if -30 <= db <= 0: + return int(floatmult * (1 << 8) + 0.5) * (1<<8) + else: + return int((floatmult * (1<<16)) + 0.5) + + +##### UDP DISCOVERY STUFF ############# + +class Datagram(object): + + @classmethod + def decode(self, data): + if data[0] == 'e': + return TLVDiscoveryRequestDatagram(data) + elif data[0] == 'E': + return TLVDiscoveryResponseDatagram(data) + elif data[0] == 'd': + return ClientDiscoveryDatagram(data) + elif data[0] == 'h': + pass # Hello! + elif data[0] == 'i': + pass # IR + elif data[0] == '2': + pass # i2c? + elif data[0] == 'a': + pass # ack! + +class ClientDiscoveryDatagram(Datagram): + + device = None + firmware = None + client = None + + def __init__(self, data): + s = struct.unpack('!cxBB8x6B', data.encode()) + assert s[0] == 'd' + self.device = s[1] + self.firmware = hex(s[2]) + self.client = ":".join(["%02x" % (x,) for x in s[3:]]) + + def __repr__(self): + return "<%s device=%r firmware=%r client=%r>" % (self.__class__.__name__, self.device, self.firmware, self.client) + +class DiscoveryResponseDatagram(Datagram): + + def __init__(self, hostname, port): + hostname = hostname[:16].encode("UTF-8") + hostname += (16 - len(hostname)) * '\x00' + self.packet = struct.pack('!c16s', 'D', hostname).decode() + +class TLVDiscoveryRequestDatagram(Datagram): + + def __init__(self, data): + requestdata = OrderedDict() + assert data[0] == 'e' + idx = 1 + length = len(data)-5 + while idx <= length: + typ, l = struct.unpack_from("4sB", data.encode(), idx) + if l: + val = data[idx+5:idx+5+l] + idx += 5+l + else: + val = None + idx += 5 + typ = typ.decode() + requestdata[typ] = val + self.data = requestdata + + def __repr__(self): + return "<%s data=%r>" % (self.__class__.__name__, self.data.items()) + +class TLVDiscoveryResponseDatagram(Datagram): + + def __init__(self, responsedata): + parts = ['E'] # new discovery format + for typ, value in responsedata.items(): + if value is None: + value = '' + elif len(value) > 255: + LOGGER.warning("Response %s too long, truncating to 255 bytes" % typ) + value = value[:255] + parts.extend((typ, chr(len(value)), value)) + self.packet = ''.join(parts) + +class DiscoveryProtocol(): + + def __init__(self, web_port): + self.web_port = web_port + + def connection_made(self, transport): + self.transport = transport + # Allow receiving multicast broadcasts + sock = self.transport.get_extra_info('socket') + group = socket.inet_aton('239.255.255.250') + mreq = struct.pack('4sL', group, socket.INADDR_ANY) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + def build_TLV_response(self, requestdata): + responsedata = OrderedDict() + for typ, value in requestdata.items(): + if typ == 'NAME': + # send full host name - no truncation + value = get_hostname() + elif typ == 'IPAD': + # send ipaddress as a string only if it is set + value = get_ip() + # :todo: IPv6 + if value == '0.0.0.0': + # do not send back an ip address + typ = None + elif typ == 'JSON': + # send port as a string + json_port = self.web_port + value = str(json_port) + elif typ == 'VERS': + # send server version + value = '7.9' + elif typ == 'UUID': + # send server uuid + value = 'musicassistant' + else: + LOGGER.debug('Unexpected information request: %r', typ) + typ = None + if typ: + responsedata[typ] = value + return responsedata + + def datagram_received(self, data, addr): + try: + data = data.decode() + dgram = Datagram.decode(data) + LOGGER.debug("Data received from %s: %s" % (addr, dgram)) + if isinstance(dgram, ClientDiscoveryDatagram): + self.sendDiscoveryResponse(addr) + elif isinstance(dgram, TLVDiscoveryRequestDatagram): + resonsedata = self.build_TLV_response(dgram.data) + self.sendTLVDiscoveryResponse(resonsedata, addr) + except Exception as exc: + LOGGER.exception(exc) + + def sendDiscoveryResponse(self, addr): + dgram = DiscoveryResponseDatagram(get_hostname(), 3483) + LOGGER.debug("Sending discovery response %r" % (dgram.packet,)) + self.transport.sendto(dgram.packet.encode(), addr) + + def sendTLVDiscoveryResponse(self, resonsedata, addr): + dgram = TLVDiscoveryResponseDatagram(resonsedata) + LOGGER.debug("Sending discovery response %r" % (dgram.packet,)) + self.transport.sendto(dgram.packet.encode(), addr) + diff --git a/music_assistant/web.py b/music_assistant/web.py new file mode 100755 index 00000000..739e894d --- /dev/null +++ b/music_assistant/web.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +import json +import aiohttp +from aiohttp import web +from functools import partial +import ssl +import concurrent +import threading +from .models.media_types import MediaItem, MediaType, media_type_from_string +from .models.player import Player +from .utils import run_periodic, LOGGER, run_async_background_task, get_ip + +#json_serializer = partial(json.dumps, default=lambda x: x.__dict__) + +def json_serializer(obj): + # if isinstance(obj, list): + # lst = [] + # for item in obj: + # json_obj = json.dumps(item, skipkeys=True, default=lambda x: x.__dict__) + # lst.append(json_obj) + # return '[' + ','.join(lst) + ']' + return json.dumps(obj, skipkeys=True, default=lambda x: x.__dict__) + + +def setup(mass): + ''' setup the module and read/apply config''' + create_config_entries(mass.config) + conf = mass.config['base']['web'] + if conf['ssl_certificate'] and os.path.isfile(conf['ssl_certificate']): + ssl_cert = conf['ssl_certificate'] + else: + ssl_cert = '' + if conf['ssl_key'] and os.path.isfile(conf['ssl_key']): + ssl_key = conf['ssl_key'] + else: + ssl_key = '' + cert_fqdn_host = conf['cert_fqdn_host'] + http_port = conf['http_port'] + https_port = conf['https_port'] + return Web(mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host) + +def create_config_entries(config): + ''' get the config entries for this module (list with key/value pairs)''' + config_entries = [ + ('http_port', 8095, 'webhttp_port'), + ('https_port', 8096, 'web_https_port'), + ('ssl_certificate', '', 'web_ssl_cert'), + ('ssl_key', '', 'web_ssl_key'), + ('cert_fqdn_host', '', 'cert_fqdn_host') + ] + if not config['base'].get('web'): + config['base']['web'] = {} + config['base']['web']['__desc__'] = config_entries + for key, def_value, desc in config_entries: + if not key in config['base']['web']: + config['base']['web'][key] = def_value + +class Web(): + ''' webserver and json/websocket api ''' + + def __init__(self, mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host): + self.mass = mass + self.local_ip = get_ip() + self.http_port = http_port + self._https_port = https_port + self._ssl_cert = ssl_cert + self._ssl_key = ssl_key + self._cert_fqdn_host = cert_fqdn_host + self.http_session = aiohttp.ClientSession() + mass.event_loop.create_task(self.setup_web()) + + def stop(self): + asyncio.create_task(self.runner.cleanup()) + asyncio.create_task(self.http_session.close()) + + async def setup_web(self): + app = web.Application() + app.add_routes([web.get('/jsonrpc.js', self.json_rpc)]) + app.add_routes([web.post('/jsonrpc.js', self.json_rpc)]) + app.add_routes([web.get('/ws', self.websocket_handler)]) + # app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)]) + # app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)]) + app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream)]) + app.add_routes([web.get('/api/search', self.search)]) + app.add_routes([web.get('/api/config', self.get_config)]) + app.add_routes([web.post('/api/config', self.save_config)]) + app.add_routes([web.get('/api/players', self.players)]) + app.add_routes([web.get('/api/players/{player_id}/queue', self.player_queue)]) + app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}', self.player_command)]) + app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}/{cmd_args}', self.player_command)]) + app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}', self.play_media)]) + app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}', self.play_media)]) + app.add_routes([web.get('/api/playlists/{playlist_id}/tracks', self.playlist_tracks)]) + app.add_routes([web.get('/api/artists/{artist_id}/toptracks', self.artist_toptracks)]) + app.add_routes([web.get('/api/artists/{artist_id}/albums', self.artist_albums)]) + app.add_routes([web.get('/api/albums/{album_id}/tracks', self.album_tracks)]) + app.add_routes([web.get('/api/{media_type}', self.get_items)]) + app.add_routes([web.get('/api/{media_type}/{media_id}/{action}', self.get_item)]) + app.add_routes([web.get('/api/{media_type}/{media_id}', self.get_item)]) + app.add_routes([web.get('/', self.index)]) + app.router.add_static("/", "./web/") + self.runner = web.AppRunner(app, access_log=None) + await self.runner.setup() + http_site = web.TCPSite(self.runner, '0.0.0.0', self.http_port) + await http_site.start() + if self._ssl_cert and self._ssl_key: + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key) + https_site = web.TCPSite(self.runner, '0.0.0.0', self._https_port, ssl_context=ssl_context) + await https_site.start() + + async def get_items(self, request): + ''' get multiple library items''' + media_type_str = request.match_info.get('media_type') + media_type = media_type_from_string(media_type_str) + limit = int(request.query.get('limit', 50)) + offset = int(request.query.get('offset', 0)) + orderby = request.query.get('orderby', 'name') + provider_filter = request.rel_url.query.get('provider') + result = await self.mass.music.library_items(media_type, + limit=limit, offset=offset, + orderby=orderby, provider_filter=provider_filter) + return web.json_response(result, dumps=json_serializer) + + async def get_item(self, request): + ''' get item full details''' + media_type_str = request.match_info.get('media_type') + media_type = media_type_from_string(media_type_str) + media_id = request.match_info.get('media_id') + action = request.match_info.get('action','') + action_details = request.rel_url.query.get('action_details') + lazy = request.rel_url.query.get('lazy', '') != 'false' + provider = request.rel_url.query.get('provider') + if action: + result = await self.mass.music.item_action(media_id, media_type, provider, action, action_details) + else: + result = await self.mass.music.item(media_id, media_type, provider, lazy=lazy) + return web.json_response(result, dumps=json_serializer) + + async def artist_toptracks(self, request): + ''' get top tracks for given artist ''' + artist_id = request.match_info.get('artist_id') + provider = request.rel_url.query.get('provider') + result = await self.mass.music.artist_toptracks(artist_id, provider) + return web.json_response(result, dumps=json_serializer) + + async def artist_albums(self, request): + ''' get (all) albums for given artist ''' + artist_id = request.match_info.get('artist_id') + provider = request.rel_url.query.get('provider') + result = await self.mass.music.artist_albums(artist_id, provider) + return web.json_response(result, dumps=json_serializer) + + async def playlist_tracks(self, request): + ''' get playlist tracks from provider''' + playlist_id = request.match_info.get('playlist_id') + limit = int(request.query.get('limit', 50)) + offset = int(request.query.get('offset', 0)) + provider = request.rel_url.query.get('provider') + result = await self.mass.music.playlist_tracks(playlist_id, provider, offset=offset, limit=limit) + return web.json_response(result, dumps=json_serializer) + + async def album_tracks(self, request): + ''' get album tracks from provider''' + album_id = request.match_info.get('album_id') + provider = request.rel_url.query.get('provider') + result = await self.mass.music.album_tracks(album_id, provider) + return web.json_response(result, dumps=json_serializer) + + async def search(self, request): + ''' search database or providers ''' + searchquery = request.rel_url.query.get('query') + media_types_query = request.rel_url.query.get('media_types') + limit = request.rel_url.query.get('media_id', 5) + online = request.rel_url.query.get('online', False) + media_types = [] + if not media_types_query or "artists" in media_types_query: + media_types.append(MediaType.Artist) + if not media_types_query or "albums" in media_types_query: + media_types.append(MediaType.Album) + if not media_types_query or "tracks" in media_types_query: + media_types.append(MediaType.Track) + if not media_types_query or "playlists" in media_types_query: + media_types.append(MediaType.Playlist) + if not media_types_query or "radios" in media_types_query: + media_types.append(MediaType.Radio) + # get results from database + result = await self.mass.music.search(searchquery, media_types, limit=limit, online=online) + return web.json_response(result, dumps=json_serializer) + + async def players(self, request): + ''' get all players ''' + return web.json_response(self.mass.player.players, dumps=json_serializer) + + async def player_command(self, request): + ''' issue player command''' + result = False + player_id = request.match_info.get('player_id') + player = await self.mass.player.get_player(player_id) + if player: + cmd = request.match_info.get('cmd') + cmd_args = request.match_info.get('cmd_args') + player_cmd = getattr(player, cmd, None) + if player_cmd and cmd_args: + result = await player_cmd(cmd_args) + elif player_cmd: + result = await player_cmd() + else: + LOGGER.error("Received non-existing command %s for player %s" %(cmd, player.name)) + else: + LOGGER.error("Received command for non-existing player %s" %(player_id)) + return web.json_response(result, dumps=json_serializer) + + async def play_media(self, request): + ''' issue player play_media command''' + player_id = request.match_info.get('player_id') + media_type_str = request.match_info.get('media_type') + media_type = media_type_from_string(media_type_str) + media_id = request.match_info.get('media_id') + queue_opt = request.match_info.get('queue_opt','') + provider = request.rel_url.query.get('provider') + media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True) + result = await self.mass.player.play_media(player_id, media_item, queue_opt) + return web.json_response(result, dumps=json_serializer) + + async def player_queue(self, request): + ''' return the items in the player's queue ''' + player_id = request.match_info.get('player_id') + limit = int(request.query.get('limit', 50)) + offset = int(request.query.get('offset', 0)) + player = await self.mass.player.get_player(player_id) + # queue_items = player.queue.items + # queue_items = [item.__dict__ for item in queue_items] + # print(queue_items) + # result = queue_items[offset:limit] + return web.json_response(player.queue.items, dumps=json_serializer) + + async def index(self, request): + return web.FileResponse("./web/index.html") + + async def websocket_handler(self, request): + ''' websockets handler ''' + cb_id = None + ws = None + try: + ws = web.WebSocketResponse() + await ws.prepare(request) + # register callback for internal events + async def send_event(msg, msg_details): + ws_msg = {"message": msg, "message_details": msg_details } + await ws.send_json(ws_msg, dumps=json_serializer) + cb_id = self.mass.add_event_listener(send_event) + # process incoming messages + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + continue + # for now we only use WS for (simple) player commands + if msg.data == 'players': + ws_msg = {'message': 'players', 'message_details': self.mass.player.players} + await ws.send_json(ws_msg, dumps=json_serializer) + elif msg.data.startswith('players') and '/cmd/' in msg.data: + # players/{player_id}/cmd/{cmd} or players/{player_id}/cmd/{cmd}/{cmd_args} + msg_data_parts = msg.data.split('/') + player_id = msg_data_parts[1] + cmd = msg_data_parts[3] + cmd_args = msg_data_parts[4] if len(msg_data_parts) == 5 else None + player = await self.mass.player.get_player(player_id) + player_cmd = getattr(player, cmd, None) + if player_cmd and cmd_args: + result = await player_cmd(cmd_args) + elif player_cmd: + result = await player_cmd() + except Exception as exc: + LOGGER.exception(exc) + finally: + self.mass.remove_event_listener(cb_id) + LOGGER.debug('websocket connection closed') + return ws + + async def get_config(self, request): + ''' get the config ''' + return web.json_response(self.mass.config) + + async def save_config(self, request): + ''' save (partial) config ''' + LOGGER.debug('save config called from api') + new_config = await request.json() + config_changed = False + for key, value in self.mass.config.items(): + if isinstance(value, dict): + for subkey, subvalue in value.items(): + if subkey in new_config[key]: + if self.mass.config[key][subkey] != new_config[key][subkey]: + config_changed = True + self.mass.config[key][subkey] = new_config[key][subkey] + elif key in new_config: + if self.mass.config[key] != new_config[key]: + config_changed = True + self.mass.config[key] = new_config[key] + if config_changed: + self.mass.save_config() + self.mass.signal_event('config_changed') + return web.Response(text='success') + + async def json_rpc(self, request): + ''' + implement LMS jsonrpc interface + for some compatability with tools that talk to lms + only support for basic commands + ''' + data = await request.json() + LOGGER.info("jsonrpc: %s" % data) + params = data['params'] + player_id = params[0] + cmds = params[1] + cmd_str = " ".join(cmds) + if cmd_str in ['play', 'pause', 'stop']: + await self.mass.player.player_command(player_id, cmd_str) + elif 'power' in cmd_str: + args = cmds[1] if len(cmds) > 1 else None + await self.mass.player.player_command(player_id, cmd_str, args) + elif cmd_str == 'playlist index +1': + await self.mass.player.player_command(player_id, 'next') + elif cmd_str == 'playlist index -1': + await self.mass.player.player_command(player_id, 'previous') + elif 'mixer volume' in cmd_str: + await self.mass.player.player_command(player_id, 'volume', cmds[2]) + elif cmd_str == 'mixer muting 1': + await self.mass.player.player_command(player_id, 'mute', 'on') + elif cmd_str == 'mixer muting 0': + await self.mass.player.player_command(player_id, 'mute', 'off') + elif cmd_str == 'button volup': + await self.mass.player.player_command(player_id, 'volume', 'up') + elif cmd_str == 'button voldown': + await self.mass.player.player_command(player_id, 'volume', 'down') + elif cmd_str == 'button power': + await self.mass.player.player_command(player_id, 'power', 'toggle') + else: + return web.Response(text='command not supported') + return web.Response(text='success') + \ No newline at end of file diff --git a/music_assistant/web/components/headermenu.vue.js b/music_assistant/web/components/headermenu.vue.js deleted file mode 100755 index f0e8566f..00000000 --- a/music_assistant/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/music_assistant/web/components/infoheader.vue.js b/music_assistant/web/components/infoheader.vue.js deleted file mode 100644 index 9d8bc8c3..00000000 --- a/music_assistant/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/music_assistant/web/components/listviewItem.vue.js b/music_assistant/web/components/listviewItem.vue.js deleted file mode 100755 index 687c69c9..00000000 --- a/music_assistant/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/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js deleted file mode 100755 index 840b0575..00000000 --- a/music_assistant/web/components/player.vue.js +++ /dev/null @@ -1,314 +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_item_time: 0, - player_id: '', - volume_level: 0, - state: 'stopped' - }; - }, - progress() { - if (!this.active_player.cur_item) - return 0; - var total_sec = this.active_player.cur_item.duration; - var cur_sec = this.active_player.cur_item_time; - var cur_percent = cur_sec/total_sec*100; - return cur_percent; - }, - player_time_str_cur() { - if (!this.active_player.cur_item || !this.active_player.cur_item_time) - return "0:00"; - var cur_sec = this.active_player.cur_item_time; - return cur_sec.toString().formatDuration(); - }, - player_time_str_total() { - if (!this.active_player.cur_item) - return "0:00"; - var total_sec = this.active_player.cur_item.duration; - return total_sec.toString().formatDuration(); - } - }, - methods: { - playerCommand (cmd, cmd_opt=null, player_id=this.active_player_id) { - if (cmd_opt) - cmd = cmd + '/' + cmd_opt - cmd = 'players/' + player_id + '/cmd/' + cmd; - this.ws.send(cmd); - }, - playItem(item, queueopt) { - console.log('playItem: ' + item); - this.$globals.loading = true; - var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt; - axios - .get(api_url, { - params: { - provider: item.provider - } - }) - .then(result => { - console.log(result.data); - this.$globals.loading = false; - }) - .catch(error => { - console.log("error", error); - this.$globals.loading = false; - }); - }, - switchPlayer (new_player_id) { - this.active_player_id = new_player_id; - }, - isGroup(player_id) { - for (var item in this.players) - if (this.players[item].group_parent == player_id && this.players[item].enabled) - return true; - return false; - }, - updateProgress: function(){ - this.intervalid2 = setInterval(function(){ - if (this.active_player.state == 'playing') - this.active_player.cur_item_time +=1; - }.bind(this), 1000); - }, - setPlayerVolume: function(player_id, new_volume) { - this.players[player_id].volume_level = new_volume; - this.playerCommand('volume', new_volume, player_id); - }, - togglePlayerPower: function(player_id) { - if (this.players[player_id].powered) - this.playerCommand('power', 'off', player_id); - else - this.playerCommand('power', 'on', player_id); - }, - connectWS() { - var loc = window.location, new_uri; - if (loc.protocol === "https:") { - new_uri = "wss:"; - } else { - new_uri = "ws:"; - } - new_uri += "/" + loc.host; - new_uri += loc.pathname + "ws"; - this.ws = new WebSocket(new_uri); - - this.ws.onopen = function() { - console.log('websocket connected!'); - this.ws.send('players'); - }.bind(this); - - this.ws.onmessage = function(e) { - var msg = JSON.parse(e.data); - var players = []; - if (msg.message == 'player updated') - players = [msg.message_details]; - else if (msg.message == 'player removed') - this.players[msg.message_details].enabled = false; - else if (msg.message == 'players') - players = msg.message_details; - - for (var item of players) - if (item.player_id in this.players) - this.players[item.player_id] = Object.assign({}, this.players[item.player_id], item); - else - this.$set(this.players, item.player_id, item) - - // select new active player - // TODO: store previous player in local storage - if (!this.active_player_id || !this.players[this.active_player_id].enabled) - for (var player_id in this.players) - if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) { - // prefer the first playing player - this.active_player_id = player_id; - break; - } - if (!this.active_player_id || !this.players[this.active_player_id].enabled) - for (var player_id in this.players) { - // fallback to just the first player - if (this.players[player_id].enabled && !this.players[player_id].group_parent) - { - this.active_player_id = player_id; - break; - } - } - }.bind(this); - - this.ws.onclose = function(e) { - console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason); - setTimeout(function() { - this.connectWS(); - }.bind(this), 5000); - }.bind(this); - - this.ws.onerror = function(err) { - console.error('Socket encountered error: ', err.message, 'Closing socket'); - this.ws.close(); - }.bind(this); - } - } -}) diff --git a/music_assistant/web/components/playmenu.vue.js b/music_assistant/web/components/playmenu.vue.js deleted file mode 100644 index 611ecc36..00000000 --- a/music_assistant/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/music_assistant/web/components/providericons.vue.js b/music_assistant/web/components/providericons.vue.js deleted file mode 100644 index 0e0124d0..00000000 --- a/music_assistant/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/music_assistant/web/components/readmore.vue.js b/music_assistant/web/components/readmore.vue.js deleted file mode 100644 index 6af2fd3b..00000000 --- a/music_assistant/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/music_assistant/web/components/searchbox.vue.js b/music_assistant/web/components/searchbox.vue.js deleted file mode 100644 index 1570ab6c..00000000 --- a/music_assistant/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/music_assistant/web/components/volumecontrol.vue.js b/music_assistant/web/components/volumecontrol.vue.js deleted file mode 100644 index 7ef20ab8..00000000 --- a/music_assistant/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/music_assistant/web/css/nprogress.css b/music_assistant/web/css/nprogress.css deleted file mode 100644 index e4cb811e..00000000 --- a/music_assistant/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/music_assistant/web/css/site.css b/music_assistant/web/css/site.css deleted file mode 100755 index 2071f04a..00000000 --- a/music_assistant/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/music_assistant/web/css/vue-loading.css b/music_assistant/web/css/vue-loading.css deleted file mode 100644 index 6d62f807..00000000 --- a/music_assistant/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/music_assistant/web/images/default_artist.png b/music_assistant/web/images/default_artist.png deleted file mode 100644 index a530d5b4..00000000 Binary files a/music_assistant/web/images/default_artist.png and /dev/null differ diff --git a/music_assistant/web/images/icons/aac.png b/music_assistant/web/images/icons/aac.png deleted file mode 100644 index 7dafab27..00000000 Binary files a/music_assistant/web/images/icons/aac.png and /dev/null differ diff --git a/music_assistant/web/images/icons/chromecast.png b/music_assistant/web/images/icons/chromecast.png deleted file mode 100644 index f7d2a46f..00000000 Binary files a/music_assistant/web/images/icons/chromecast.png and /dev/null differ diff --git a/music_assistant/web/images/icons/file.png b/music_assistant/web/images/icons/file.png deleted file mode 100644 index bd2df042..00000000 Binary files a/music_assistant/web/images/icons/file.png and /dev/null differ diff --git a/music_assistant/web/images/icons/flac.png b/music_assistant/web/images/icons/flac.png deleted file mode 100644 index 33e1f175..00000000 Binary files a/music_assistant/web/images/icons/flac.png and /dev/null differ diff --git a/music_assistant/web/images/icons/hires.png b/music_assistant/web/images/icons/hires.png deleted file mode 100644 index a398c6e5..00000000 Binary files a/music_assistant/web/images/icons/hires.png and /dev/null differ diff --git a/music_assistant/web/images/icons/homeassistant.png b/music_assistant/web/images/icons/homeassistant.png deleted file mode 100644 index 5f28d69e..00000000 Binary files a/music_assistant/web/images/icons/homeassistant.png and /dev/null differ diff --git a/music_assistant/web/images/icons/http_streamer.png b/music_assistant/web/images/icons/http_streamer.png deleted file mode 100644 index c35c9839..00000000 Binary files a/music_assistant/web/images/icons/http_streamer.png and /dev/null differ diff --git a/music_assistant/web/images/icons/icon-128x128.png b/music_assistant/web/images/icons/icon-128x128.png deleted file mode 100644 index 01363c8b..00000000 Binary files a/music_assistant/web/images/icons/icon-128x128.png and /dev/null differ diff --git a/music_assistant/web/images/icons/icon-256x256.png b/music_assistant/web/images/icons/icon-256x256.png deleted file mode 100644 index 4c36796d..00000000 Binary files a/music_assistant/web/images/icons/icon-256x256.png and /dev/null differ diff --git a/music_assistant/web/images/icons/icon-apple.png b/music_assistant/web/images/icons/icon-apple.png deleted file mode 100644 index 67d26d53..00000000 Binary files a/music_assistant/web/images/icons/icon-apple.png and /dev/null differ diff --git a/music_assistant/web/images/icons/info_gradient.jpg b/music_assistant/web/images/icons/info_gradient.jpg deleted file mode 100644 index 9d0c0e3b..00000000 Binary files a/music_assistant/web/images/icons/info_gradient.jpg and /dev/null differ diff --git a/music_assistant/web/images/icons/lms.png b/music_assistant/web/images/icons/lms.png deleted file mode 100644 index 6dd9b06a..00000000 Binary files a/music_assistant/web/images/icons/lms.png and /dev/null differ diff --git a/music_assistant/web/images/icons/mp3.png b/music_assistant/web/images/icons/mp3.png deleted file mode 100644 index b894bda2..00000000 Binary files a/music_assistant/web/images/icons/mp3.png and /dev/null differ diff --git a/music_assistant/web/images/icons/pylms.png b/music_assistant/web/images/icons/pylms.png deleted file mode 100644 index 18531d79..00000000 Binary files a/music_assistant/web/images/icons/pylms.png and /dev/null differ diff --git a/music_assistant/web/images/icons/qobuz.png b/music_assistant/web/images/icons/qobuz.png deleted file mode 100644 index 9d7b726c..00000000 Binary files a/music_assistant/web/images/icons/qobuz.png and /dev/null differ diff --git a/music_assistant/web/images/icons/spotify.png b/music_assistant/web/images/icons/spotify.png deleted file mode 100644 index 805f5c71..00000000 Binary files a/music_assistant/web/images/icons/spotify.png and /dev/null differ diff --git a/music_assistant/web/images/icons/tunein.png b/music_assistant/web/images/icons/tunein.png deleted file mode 100644 index 3352c29c..00000000 Binary files a/music_assistant/web/images/icons/tunein.png and /dev/null differ diff --git a/music_assistant/web/images/icons/vorbis.png b/music_assistant/web/images/icons/vorbis.png deleted file mode 100644 index c6d69145..00000000 Binary files a/music_assistant/web/images/icons/vorbis.png and /dev/null differ diff --git a/music_assistant/web/images/icons/web.png b/music_assistant/web/images/icons/web.png deleted file mode 100644 index d3b5724e..00000000 Binary files a/music_assistant/web/images/icons/web.png and /dev/null differ diff --git a/music_assistant/web/images/info_gradient.jpg b/music_assistant/web/images/info_gradient.jpg deleted file mode 100644 index 9d0c0e3b..00000000 Binary files a/music_assistant/web/images/info_gradient.jpg and /dev/null differ diff --git a/music_assistant/web/index.html b/music_assistant/web/index.html deleted file mode 100755 index dcef414f..00000000 --- a/music_assistant/web/index.html +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - 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 deleted file mode 100644 index b3b9da10..00000000 --- a/music_assistant/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/music_assistant/web/manifest.json b/music_assistant/web/manifest.json deleted file mode 100755 index 6a3c4b97..00000000 --- a/music_assistant/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/music_assistant/web/pages/albumdetails.vue.js b/music_assistant/web/pages/albumdetails.vue.js deleted file mode 100755 index 4f60a91e..00000000 --- a/music_assistant/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/music_assistant/web/pages/artistdetails.vue.js b/music_assistant/web/pages/artistdetails.vue.js deleted file mode 100755 index 46600303..00000000 --- a/music_assistant/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/music_assistant/web/pages/browse.vue.js b/music_assistant/web/pages/browse.vue.js deleted file mode 100755 index 49bcdc8b..00000000 --- a/music_assistant/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/music_assistant/web/pages/config.vue.js b/music_assistant/web/pages/config.vue.js deleted file mode 100755 index c4164db2..00000000 --- a/music_assistant/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/music_assistant/web/pages/home.vue.js b/music_assistant/web/pages/home.vue.js deleted file mode 100755 index 91c0b33d..00000000 --- a/music_assistant/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/music_assistant/web/pages/playlistdetails.vue.js b/music_assistant/web/pages/playlistdetails.vue.js deleted file mode 100755 index b9c617d4..00000000 --- a/music_assistant/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/music_assistant/web/pages/queue.vue.js b/music_assistant/web/pages/queue.vue.js deleted file mode 100755 index 9bc25a94..00000000 --- a/music_assistant/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/music_assistant/web/pages/search.vue.js b/music_assistant/web/pages/search.vue.js deleted file mode 100755 index 996c01ef..00000000 --- a/music_assistant/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/music_assistant/web/pages/trackdetails.vue.js b/music_assistant/web/pages/trackdetails.vue.js deleted file mode 100755 index e8f08963..00000000 --- a/music_assistant/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/music_assistant/web/strings.js b/music_assistant/web/strings.js deleted file mode 100644 index 5c1df076..00000000 --- a/music_assistant/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 diff --git a/run.sh b/run.sh index 3e73292e..92cbdc4c 100755 --- a/run.sh +++ b/run.sh @@ -7,7 +7,8 @@ if [ "$autoupdate" == "true" ]; then cd /tmp curl -LOks "https://github.com/marcelveldt/musicassistant/archive/master.zip" unzip -q master.zip - cp -rf musicassistant-master/music_assistant/. /usr/src/app + cp -rf musicassistant-master/music_assistant /usr/src/app + cp -f musicassistant-master/main.py /usr/src/app rm -R /tmp/musicassistant-master fi diff --git a/web/components/headermenu.vue.js b/web/components/headermenu.vue.js new file mode 100755 index 00000000..f0e8566f --- /dev/null +++ b/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/web/components/infoheader.vue.js b/web/components/infoheader.vue.js new file mode 100644 index 00000000..9d8bc8c3 --- /dev/null +++ b/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/web/components/listviewItem.vue.js b/web/components/listviewItem.vue.js new file mode 100755 index 00000000..687c69c9 --- /dev/null +++ b/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/web/components/player.vue.js b/web/components/player.vue.js new file mode 100755 index 00000000..33247fe1 --- /dev/null +++ b/web/components/player.vue.js @@ -0,0 +1,315 @@ +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; + this.playerCommand('volume', new_volume, player_id); + }, + togglePlayerPower: function(player_id) { + if (this.players[player_id].powered) + this.playerCommand('power', 'off', player_id); + else + this.playerCommand('power', 'on', player_id); + }, + connectWS() { + var loc = window.location, new_uri; + if (loc.protocol === "https:") { + new_uri = "wss:"; + } else { + new_uri = "ws:"; + } + new_uri += "/" + loc.host; + new_uri += loc.pathname + "ws"; + this.ws = new WebSocket(new_uri); + + this.ws.onopen = function() { + console.log('websocket connected!'); + this.ws.send('players'); + }.bind(this); + + this.ws.onmessage = function(e) { + var msg = JSON.parse(e.data); + var players = []; + console.log(msg); + if (msg.message == 'player updated') + players = [msg.message_details]; + else if (msg.message == 'player removed') + this.players[msg.message_details].enabled = false; + else if (msg.message == 'players') + players = msg.message_details; + + for (var item of players) + if (item.player_id in this.players) + this.players[item.player_id] = Object.assign({}, this.players[item.player_id], item); + else + this.$set(this.players, item.player_id, item) + + // select new active player + // TODO: store previous player in local storage + if (!this.active_player_id || !this.players[this.active_player_id].enabled) + for (var player_id in this.players) + if (this.players[player_id].state == 'playing' && this.players[player_id].enabled && !this.players[player_id].group_parent) { + // prefer the first playing player + this.active_player_id = player_id; + break; + } + if (!this.active_player_id || !this.players[this.active_player_id].enabled) + for (var player_id in this.players) { + // fallback to just the first player + if (this.players[player_id].enabled && !this.players[player_id].group_parent) + { + this.active_player_id = player_id; + break; + } + } + }.bind(this); + + this.ws.onclose = function(e) { + console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason); + setTimeout(function() { + this.connectWS(); + }.bind(this), 5000); + }.bind(this); + + this.ws.onerror = function(err) { + console.error('Socket encountered error: ', err.message, 'Closing socket'); + this.ws.close(); + }.bind(this); + } + } +}) diff --git a/web/components/playmenu.vue.js b/web/components/playmenu.vue.js new file mode 100644 index 00000000..611ecc36 --- /dev/null +++ b/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/web/components/providericons.vue.js b/web/components/providericons.vue.js new file mode 100644 index 00000000..0e0124d0 --- /dev/null +++ b/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/web/components/readmore.vue.js b/web/components/readmore.vue.js new file mode 100644 index 00000000..6af2fd3b --- /dev/null +++ b/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/web/components/searchbox.vue.js b/web/components/searchbox.vue.js new file mode 100644 index 00000000..1570ab6c --- /dev/null +++ b/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/web/components/volumecontrol.vue.js b/web/components/volumecontrol.vue.js new file mode 100644 index 00000000..7ef20ab8 --- /dev/null +++ b/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/web/css/nprogress.css b/web/css/nprogress.css new file mode 100644 index 00000000..e4cb811e --- /dev/null +++ b/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/web/css/site.css b/web/css/site.css new file mode 100755 index 00000000..2071f04a --- /dev/null +++ b/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/web/css/vue-loading.css b/web/css/vue-loading.css new file mode 100644 index 00000000..6d62f807 --- /dev/null +++ b/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/web/images/default_artist.png b/web/images/default_artist.png new file mode 100644 index 00000000..a530d5b4 Binary files /dev/null and b/web/images/default_artist.png differ diff --git a/web/images/icons/aac.png b/web/images/icons/aac.png new file mode 100644 index 00000000..7dafab27 Binary files /dev/null and b/web/images/icons/aac.png differ diff --git a/web/images/icons/chromecast.png b/web/images/icons/chromecast.png new file mode 100644 index 00000000..f7d2a46f Binary files /dev/null and b/web/images/icons/chromecast.png differ diff --git a/web/images/icons/file.png b/web/images/icons/file.png new file mode 100644 index 00000000..bd2df042 Binary files /dev/null and b/web/images/icons/file.png differ diff --git a/web/images/icons/flac.png b/web/images/icons/flac.png new file mode 100644 index 00000000..33e1f175 Binary files /dev/null and b/web/images/icons/flac.png differ diff --git a/web/images/icons/hires.png b/web/images/icons/hires.png new file mode 100644 index 00000000..a398c6e5 Binary files /dev/null and b/web/images/icons/hires.png differ diff --git a/web/images/icons/homeassistant.png b/web/images/icons/homeassistant.png new file mode 100644 index 00000000..5f28d69e Binary files /dev/null and b/web/images/icons/homeassistant.png differ diff --git a/web/images/icons/http_streamer.png b/web/images/icons/http_streamer.png new file mode 100644 index 00000000..c35c9839 Binary files /dev/null and b/web/images/icons/http_streamer.png differ diff --git a/web/images/icons/icon-128x128.png b/web/images/icons/icon-128x128.png new file mode 100644 index 00000000..01363c8b Binary files /dev/null and b/web/images/icons/icon-128x128.png differ diff --git a/web/images/icons/icon-256x256.png b/web/images/icons/icon-256x256.png new file mode 100644 index 00000000..4c36796d Binary files /dev/null and b/web/images/icons/icon-256x256.png differ diff --git a/web/images/icons/icon-apple.png b/web/images/icons/icon-apple.png new file mode 100644 index 00000000..67d26d53 Binary files /dev/null and b/web/images/icons/icon-apple.png differ diff --git a/web/images/icons/info_gradient.jpg b/web/images/icons/info_gradient.jpg new file mode 100644 index 00000000..9d0c0e3b Binary files /dev/null and b/web/images/icons/info_gradient.jpg differ diff --git a/web/images/icons/lms.png b/web/images/icons/lms.png new file mode 100644 index 00000000..6dd9b06a Binary files /dev/null and b/web/images/icons/lms.png differ diff --git a/web/images/icons/mp3.png b/web/images/icons/mp3.png new file mode 100644 index 00000000..b894bda2 Binary files /dev/null and b/web/images/icons/mp3.png differ diff --git a/web/images/icons/pylms.png b/web/images/icons/pylms.png new file mode 100644 index 00000000..18531d79 Binary files /dev/null and b/web/images/icons/pylms.png differ diff --git a/web/images/icons/qobuz.png b/web/images/icons/qobuz.png new file mode 100644 index 00000000..9d7b726c Binary files /dev/null and b/web/images/icons/qobuz.png differ diff --git a/web/images/icons/spotify.png b/web/images/icons/spotify.png new file mode 100644 index 00000000..805f5c71 Binary files /dev/null and b/web/images/icons/spotify.png differ diff --git a/web/images/icons/tunein.png b/web/images/icons/tunein.png new file mode 100644 index 00000000..3352c29c Binary files /dev/null and b/web/images/icons/tunein.png differ diff --git a/web/images/icons/vorbis.png b/web/images/icons/vorbis.png new file mode 100644 index 00000000..c6d69145 Binary files /dev/null and b/web/images/icons/vorbis.png differ diff --git a/web/images/icons/web.png b/web/images/icons/web.png new file mode 100644 index 00000000..d3b5724e Binary files /dev/null and b/web/images/icons/web.png differ diff --git a/web/images/info_gradient.jpg b/web/images/info_gradient.jpg new file mode 100644 index 00000000..9d0c0e3b Binary files /dev/null and b/web/images/info_gradient.jpg differ diff --git a/web/index.html b/web/index.html new file mode 100755 index 00000000..dcef414f --- /dev/null +++ b/web/index.html @@ -0,0 +1,249 @@ + + + + + + Music Assistant + + + + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/lib/vue-loading-overlay.js b/web/lib/vue-loading-overlay.js new file mode 100644 index 00000000..b3b9da10 --- /dev/null +++ b/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/web/manifest.json b/web/manifest.json new file mode 100755 index 00000000..6a3c4b97 --- /dev/null +++ b/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/web/pages/albumdetails.vue.js b/web/pages/albumdetails.vue.js new file mode 100755 index 00000000..4f60a91e --- /dev/null +++ b/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/web/pages/artistdetails.vue.js b/web/pages/artistdetails.vue.js new file mode 100755 index 00000000..46600303 --- /dev/null +++ b/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/web/pages/browse.vue.js b/web/pages/browse.vue.js new file mode 100755 index 00000000..49bcdc8b --- /dev/null +++ b/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/web/pages/config.vue.js b/web/pages/config.vue.js new file mode 100755 index 00000000..c4164db2 --- /dev/null +++ b/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/web/pages/home.vue.js b/web/pages/home.vue.js new file mode 100755 index 00000000..91c0b33d --- /dev/null +++ b/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/web/pages/playlistdetails.vue.js b/web/pages/playlistdetails.vue.js new file mode 100755 index 00000000..b9c617d4 --- /dev/null +++ b/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/web/pages/queue.vue.js b/web/pages/queue.vue.js new file mode 100755 index 00000000..9bc25a94 --- /dev/null +++ b/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/web/pages/search.vue.js b/web/pages/search.vue.js new file mode 100755 index 00000000..996c01ef --- /dev/null +++ b/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/web/pages/trackdetails.vue.js b/web/pages/trackdetails.vue.js new file mode 100755 index 00000000..e8f08963 --- /dev/null +++ b/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/web/strings.js b/web/strings.js new file mode 100644 index 00000000..5c1df076 --- /dev/null +++ b/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