--- /dev/null
+# 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 <package> import <module>` 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!
# 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"]
--- /dev/null
+#!/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
+#!/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
--- /dev/null
+#!/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
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):
--- /dev/null
+#!/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', '<password>', '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
--- /dev/null
+#!/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
+++ /dev/null
-#!/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
+++ /dev/null
-# -*- 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 )
--- /dev/null
+#!/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
+from .media_types import *
+from .musicprovider import *
+from .player_queue import *
+from .player import *
+from .playerprovider import *
\ No newline at end of file
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
#!/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():
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 #####
#!/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):
#### 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):
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
@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())
''' [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'):
@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
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 '''
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
''' [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):
''' [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:
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 '''
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 '''
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')
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
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')
'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"),
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
#!/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():
'''
self._player = player
self._items = []
self._shuffle_enabled = True
- self._repeat_enabled = True
+ self._repeat_enabled = False
self._cur_index = None
@property
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
'''
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]):
'''
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 '''
#!/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():
### 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 #####
+++ /dev/null
-#!/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
+++ /dev/null
-#!/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', '<password>', '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
+++ /dev/null
-#!/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
+++ /dev/null
-#!/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
+++ /dev/null
-#!/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))
+++ /dev/null
-#!/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 <artist>/<album>/<track.ext>
- 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
+++ /dev/null
-#!/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, "<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
+++ /dev/null
-#!/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, "<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
-
-
+++ /dev/null
-#!/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, "<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
+++ /dev/null
-#!/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))
+++ /dev/null
-#!/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
+++ /dev/null
-#!/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
+++ /dev/null
-#!/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)
-
+++ /dev/null
-#!/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
--- /dev/null
+#!/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))
--- /dev/null
+#!/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 <artist>/<album>/<track.ext>
+ 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
--- /dev/null
+#!/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, "<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
--- /dev/null
+#!/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, "<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
+
+
--- /dev/null
+#!/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, "<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
--- /dev/null
+#!/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))
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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)
+
--- /dev/null
+#!/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
+++ /dev/null
-Vue.component("headermenu", {
- template: `<div>
- <v-navigation-drawer dark app clipped temporary v-model="menu">
- <v-list >
- <v-list-tile
- v-for="item in items" :key="item.title" @click="$router.push(item.path)">
- <v-list-tile-action>
- <v-icon>{{ item.icon }}</v-icon>
- </v-list-tile-action>
- <v-list-tile-content>
- <v-list-tile-title>{{ item.title }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
- </v-list>
- </v-navigation-drawer>
-
-
- <v-toolbar app flat dense dark v-if="$globals.windowtitle" >
- <div class="title justify-center" style="text-align:center;position:absolute;width:100%;margin-left:-16px;margin-right:0">
- {{ $globals.windowtitle }}
- </div>
- <v-layout align-center>
- <v-btn icon v-on:click="menu=!menu">
- <v-icon>menu</v-icon>
- </v-btn>
- <v-btn @click="$router.go(-1)" icon v-if="$route.path != '/'">
- <v-icon>arrow_back</v-icon>
- </v-btn>
- </v-layout>
- </v-toolbar>
- <v-toolbar flat fixed dense dark scroll-off-screen color="transparent" v-if="!$globals.windowtitle" >
- <v-layout align-center>
- <v-btn icon v-on:click="menu=!menu">
- <v-icon>menu</v-icon>
- </v-btn>
- <v-btn @click="$router.go(-1)" icon>
- <v-icon>arrow_back</v-icon>
- </v-btn>
- <v-spacer></v-spacer>
- <v-spacer></v-spacer>
- <v-btn icon v-on:click="$router.push({path: '/search'})">
- <v-icon>search</v-icon>
- </v-btn>
- </v-layout>
- </v-toolbar>
-</div>`,
- 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: { }
-})
+++ /dev/null
-Vue.component("infoheader", {\r
- template: `\r
- <v-flex xs12>\r
- <v-card color="cyan darken-2" class="white--text" img="../images/info_gradient.jpg">\r
- <v-img\r
- class="white--text"\r
- width="100%"\r
- :height="isMobile() ? '230' : '370'"\r
- position="center top" \r
- :src="getFanartImage()"\r
- gradient="to bottom, rgba(0,0,0,.65), rgba(0,0,0,.35)"\r
- >\r
- <div class="text-xs-center" style="height:40px" id="whitespace_top"/>\r
-\r
- <v-layout style="margin-left:5px;margin-right:5px">\r
- \r
- <!-- left side: cover image -->\r
- <v-flex xs5 pa-4 v-if="!isMobile()">\r
- <v-img :src="getThumb()" lazy-src="/images/default_artist.png" width="250px" height="250px" style="border: 4px solid grey;border-radius: 15px;"></v-img>\r
- \r
- <!-- tech specs and provider icons -->\r
- <div style="margin-top:10px;">\r
- <providericons v-bind:item="info" :height="30" :compact="false"/>\r
- </div>\r
- </v-flex>\r
- \r
- <v-flex>\r
- <!-- Main title -->\r
- <v-card-title class="display-1" style="text-shadow: 1px 1px #000000;padding-bottom:0px;">\r
- {{ info.name }} \r
- <span class="subheading" v-if="!!info.version" style="padding-left:10px;"> ({{ info.version }})</span>\r
- </v-card-title>\r
- \r
- <!-- item artists -->\r
- <v-card-title style="text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
- <span v-if="!!info.artists" v-for="(artist, artistindex) in info.artists" class="headline" :key="artist.db_id">\r
- <a style="color:#2196f3" v-on:click="clickItem(artist)">{{ artist.name }}</a>\r
- <label style="color:#2196f3" v-if="artistindex + 1 < info.artists.length" :key="artistindex"> / </label>\r
- </span>\r
- <span v-if="info.artist" class="headline">\r
- <a style="color:#2196f3" v-on:click="clickItem(info.artist)">{{ info.artist.name }}</a>\r
- </span>\r
- <span v-if="info.owner" class="headline">\r
- <a style="color:#2196f3" v-on:click="">{{ info.owner }}</a>\r
- </span>\r
- </v-card-title>\r
-\r
- <v-card-title v-if="info.album" style="color:#ffffff;text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
- <a class="headline" style="color:#ffffff" v-on:click="clickItem(info.album)">{{ info.album.name }}</a>\r
- </v-card-title>\r
-\r
- <!-- play/info buttons -->\r
- <div style="margin-left:8px;">\r
- <v-btn color="blue-grey" @click="showPlayMenu(info)" class="white--text"><v-icon v-if="!isMobile()" left dark>play_circle_outline</v-icon>{{ $t('play') }}</v-btn>\r
- <v-btn v-if="!!info.in_library && info.in_library.length == 0" color="blue-grey" @click="toggleLibrary(info)" class="white--text"><v-icon v-if="!isMobile()" left dark>favorite_border</v-icon>{{ $t('add_library') }}</v-btn>\r
- <v-btn v-if="!!info.in_library && info.in_library.length > 0" color="blue-grey" @click="toggleLibrary(info)" class="white--text"><v-icon v-if="!isMobile()" left dark>favorite</v-icon>{{ $t('remove_library') }}</v-btn>\r
- </div>\r
-\r
- <!-- Description/metadata -->\r
- <v-card-title class="subheading">\r
- <div class="justify-left" style="text-shadow: 1px 1px #000000;">\r
- <read-more :text="getDescription()" :max-chars="isMobile() ? 60 : 350"></read-more>\r
- </div>\r
- </v-card-title>\r
-\r
- </v-flex>\r
- </v-layout>\r
- \r
- </v-img>\r
- <div class="text-xs-center" v-if="info.tags">\r
- <v-chip small color="white" outline v-for="(tag, index) in info.tags" :key="tag" >{{ tag }}</v-chip>\r
- </div>\r
- \r
- </v-card>\r
- </v-flex>\r
-`,\r
- props: ['info'],\r
- data (){\r
- return{}\r
- },\r
- mounted() { },\r
- created() { },\r
- methods: { \r
- getFanartImage() {\r
- var img = '';\r
- if (!this.info)\r
- return ''\r
- if (this.info.metadata && this.info.metadata.fanart)\r
- img = this.info.metadata.fanart;\r
- else if (this.info.artists)\r
- this.info.artists.forEach(function(artist) {\r
- if (artist.metadata && artist.metadata.fanart)\r
- img = artist.metadata.fanart;\r
- });\r
- else if (this.info.artist && this.info.artist.metadata.fanart)\r
- img = this.info.artist.metadata.fanart;\r
- return img;\r
- },\r
- getThumb() {\r
- var img = '';\r
- if (!this.info)\r
- return ''\r
- if (this.info.metadata && this.info.metadata.image)\r
- img = this.info.metadata.image;\r
- else if (this.info.album && this.info.album.metadata && this.info.album.metadata.image)\r
- img = this.info.album.metadata.image;\r
- else if (this.info.artists)\r
- this.info.artists.forEach(function(artist) {\r
- if (artist.metadata && artist.metadata.image)\r
- img = artist.metadata.image;\r
- });\r
- return img;\r
- },\r
- getDescription() {\r
- var desc = '';\r
- if (!this.info)\r
- return ''\r
- if (this.info.metadata && this.info.metadata.description)\r
- return this.info.metadata.description;\r
- else if (this.info.metadata && this.info.metadata.biography)\r
- return this.info.metadata.biography;\r
- else if (this.info.metadata && this.info.metadata.copyright)\r
- return this.info.metadata.copyright;\r
- else if (this.info.artists)\r
- {\r
- this.info.artists.forEach(function(artist) {\r
- console.log(artist.metadata.biography);\r
- if (artist.metadata && artist.metadata.biography)\r
- desc = artist.metadata.biography;\r
- });\r
- }\r
- return desc;\r
- },\r
- }\r
-})\r
+++ /dev/null
-Vue.component("listviewItem", {
- template: `
- <div>
- <v-list-tile
- avatar
- ripple
- @click="clickItem(item)">
-
- <v-list-tile-avatar color="grey" v-if="!hideavatar">
- <img v-if="(item.media_type != 3) && item.metadata && item.metadata.image" :src="item.metadata.image"/>
- <img v-if="(item.media_type == 3) && item.album && item.album.metadata && item.album.metadata.image" :src="item.album.metadata.image"/>
- <v-icon v-if="(item.media_type == 3) && item.album && item.album.metadata && !item.album.metadata.image">audiotrack</v-icon>
- <v-icon v-if="(item.media_type != 1 && item.media_type != 3) && (!item.metadata || !item.metadata.image)">album</v-icon>
- <v-icon v-if="(item.media_type == 1) && (!item.metadata || !item.metadata.image)">person</v-icon>
- <v-icon v-if="(item.media_type == 3) && (!item.metadata || !item.album.metadata.image)">audiotrack</v-icon>
- </v-list-tile-avatar>
-
- <v-list-tile-content>
-
- <v-list-tile-title>
- {{ item.name }}<span v-if="!!item.version"> ({{ item.version }})</span>
- </v-list-tile-title>
-
- <v-list-tile-sub-title v-if="item.artists">
- <span v-for="(artist, artistindex) in item.artists">
- <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
- <label v-if="artistindex + 1 < item.artists.length" :key="artistindex"> / </label>
- </span>
- <a v-if="!!item.album && !!hidetracknum" v-on:click="clickItem(item.album)" @click.stop="" style="color:grey"> - {{ item.album.name }}</a>
- <label v-if="!hidetracknum && item.track_number" style="color:grey"> - disc {{ item.disc_number }} track {{ item.track_number }}</label>
- </v-list-tile-sub-title>
- <v-list-tile-sub-title v-if="item.artist">
- <a v-on:click="clickItem(item.artist)" @click.stop="">{{ item.artist.name }}</a>
- </v-list-tile-sub-title>
-
- <v-list-tile-sub-title v-if="!!item.owner">
- {{ item.owner }}
- </v-list-tile-sub-title>
-
- </v-list-tile-content>
-
- <providericons v-bind:item="item" :height="20" :compact="true" :dark="true" :hiresonly="hideproviders"/>
-
- <v-list-tile-action v-if="!hidelibrary">
- <v-tooltip bottom>
- <template v-slot:activator="{ on }">
- <v-btn icon ripple v-on="on" v-on:click="toggleLibrary(item)" @click.stop="" >
- <v-icon height="20" v-if="item.in_library.length > 0">favorite</v-icon>
- <v-icon height="20" v-if="item.in_library.length == 0">favorite_border</v-icon>
- </v-btn>
- </template>
- <span v-if="item.in_library.length > 0">{{ $t('remove_library') }}</span>
- <span v-if="item.in_library.length == 0">{{ $t('add_library') }}</span>
- </v-tooltip>
- </v-list-tile-action>
-
- <v-list-tile-action v-if="!hideduration && !!item.duration">
- {{ item.duration.toString().formatDuration() }}
- </v-list-tile-action>
-
- <!-- menu button/icon -->
- <v-icon v-if="!hidemenu" @click="showPlayMenu(item)" @click.stop="" color="grey lighten-1" style="margin-right:-10px;padding-left:10px">more_vert</v-icon>
-
-
- </v-list-tile>
- <v-divider v-if="index + 1 < totalitems" :key="index"></v-divider>
- </div>
- `,
-props: ['item', 'index', 'totalitems', 'hideavatar', 'hidetracknum', 'hideproviders', 'hidemenu', 'hidelibrary', 'hideduration'],
-data() {
- return {}
- },
-methods: {
- }
-})
+++ /dev/null
-Vue.component("player", {
- template: `
- <div>
-
- <!-- player bar in footer -->
- <v-footer app light height="auto">
-
- <v-card class="flex" tile style="background-color:#e8eaed;">
- <!-- divider -->
- <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
-
- <!-- now playing media -->
- <v-list-tile avatar ripple>
-
- <v-list-tile-avatar v-if="active_player.cur_item" style="align-items:center;padding-top:15px;">
- <img v-if="active_player.cur_item.metadata && active_player.cur_item.metadata.image" :src="active_player.cur_item.metadata.image"/>
- <img v-if="!active_player.cur_item.metadata.image && active_player.cur_item.album && active_player.cur_item.album.metadata && active_player.cur_item.album.metadata.image" :src="active_player.cur_item.album.metadata.image"/>
- </v-list-tile-avatar>
-
- <v-list-tile-content style="align-items:center;padding-top:15px;">
- <v-list-tile-title class="title">{{ active_player.cur_item ? active_player.cur_item.name : active_player.name }}</v-list-tile-title>
- <v-list-tile-sub-title v-if="active_player.cur_item && active_player.cur_item.artists">
- <span v-for="(artist, artistindex) in active_player.cur_item.artists">
- <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
- <label v-if="artistindex + 1 < active_player.cur_item.artists.length" :key="artistindex"> / </label>
- </span>
- </v-list-tile-sub-title>
- </v-list-tile-content>
-
- </v-list-tile>
-
- <!-- progress bar -->
- <div style="color:rgba(0,0,0,.65); height:30px;width:100%; vertical-align: middle; left:15px; right:0; margin-bottom:5px; margin-top:5px">
- <v-layout row style="vertical-align: middle" v-if="active_player.cur_item">
- <span style="text-align:left; width:60px; margin-top:7px; margin-left:15px;">{{ player_time_str_cur }}</span>
- <v-progress-linear v-model="progress"></v-progress-linear>
- <span style="text-align:right; width:60px; margin-top:7px; margin-right: 15px;">{{ player_time_str_total }}</span>
- </v-layout>
- </div>
-
- <!-- divider -->
- <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
-
- <!-- Control buttons -->
- <v-list-tile light avatar ripple style="margin-bottom:5px;">
-
- <!-- player controls -->
- <v-list-tile-content>
- <v-layout row style="content-align: left;vertical-align: middle; margin-top:10px;margin-left:-15px">
- <v-btn small icon style="padding:5px;" @click="playerCommand('previous')"><v-icon color="rgba(0,0,0,.54)">skip_previous</v-icon></v-btn>
- <v-btn small icon style="padding:5px;" v-if="active_player.state == 'playing'" @click="playerCommand('pause')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">pause</v-icon></v-btn>
- <v-btn small icon style="padding:5px;" v-if="active_player.state != 'playing'" @click="playerCommand('play')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">play_arrow</v-icon></v-btn>
- <v-btn small icon style="padding:5px;" @click="playerCommand('next')"><v-icon color="rgba(0,0,0,.54)">skip_next</v-icon></v-btn>
- </v-layout>
- </v-list-tile-content>
-
- <!-- active player queue button -->
- <v-list-tile-action style="padding:20px;" v-if="active_player_id">
- <v-btn x-small flat icon @click="$router.push('/queue/' + active_player_id)">
- <v-flex xs12 class="vertical-btn">
- <v-icon>queue_music</v-icon>
- <span class="caption">{{ $t('queue') }}</span>
- </v-flex>
- </v-btn>
- </v-list-tile-action>
-
- <!-- active player volume -->
- <v-list-tile-action style="padding:20px;" v-if="active_player_id">
- <v-menu :close-on-content-click="false" :nudge-width="250" offset-x top>
- <template v-slot:activator="{ on }">
- <v-btn x-small flat icon v-on="on">
- <v-flex xs12 class="vertical-btn">
- <v-icon>volume_up</v-icon>
- <span class="caption">{{ Math.round(players[active_player_id].volume_level) }}</span>
- </v-flex>
- </v-btn>
- </template>
- <volumecontrol v-bind:players="players" v-bind:player_id="active_player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
- </v-menu>
- </v-list-tile-action>
-
- <!-- active player btn -->
- <v-list-tile-action style="padding:30px;margin-right:-13px;">
- <v-btn x-small flat icon @click="menu = !menu">
- <v-flex xs12 class="vertical-btn">
- <v-icon>speaker</v-icon>
- <span class="caption">{{ active_player_id ? players[active_player_id].name : '' }}</span>
- </v-flex>
- </v-btn>
- </v-list-tile-action>
- </v-list-tile>
-
- <!-- add some additional whitespace in standalone mode only -->
- <v-list-tile avatar ripple style="height:14px" v-if="isInStandaloneMode()"/>
-
-
-
- </v-card>
- </v-footer>
-
- <!-- players side menu -->
- <v-navigation-drawer right app clipped temporary v-model="menu">
- <v-card-title class="headline">
- <b>{{ $t('players') }}</b>
- </v-card-title>
- <v-list two-line>
- <v-divider></v-divider>
- <div v-for="(player, player_id, index) in players" :key="player_id" v-if="player.enabled && !player.group_parent">
- <v-list-tile avatar ripple style="margin-left: -5px; margin-right: -15px" @click="switchPlayer(player.player_id)" :style="active_player_id == player.player_id ? 'background-color: rgba(50, 115, 220, 0.3);' : ''">
- <v-list-tile-avatar>
- <v-icon size="45">{{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }}</v-icon>
- </v-list-tile-avatar>
- <v-list-tile-content>
- <v-list-tile-title class="title">{{ player.name }}</v-list-tile-title>
-
- <v-list-tile-sub-title v-if="player.cur_item" class="body-1" :key="player.state">
- {{ $t('state.' + player.state) }}
- </v-list-tile-sub-title>
-
- </v-list-tile-content>
-
- <v-list-tile-action style="padding:30px;" v-if="active_player_id">
- <v-menu :close-on-content-click="false" :nudge-width="250" offset-x right>
- <template v-slot:activator="{ on }">
- <v-btn flat icon style="color:rgba(0,0,0,.54);" v-on="on">
- <v-flex xs12 class="vertical-btn">
- <v-icon>volume_up</v-icon>
- <span class="caption">{{ Math.round(player.volume_level) }}</span>
- </v-flex>
- </v-btn>
- </template>
- <volumecontrol v-bind:players="players" v-bind:player_id="player.player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
- </v-menu>
- </v-list-tile-action>
- </v-list-tile>
- <v-divider></v-divider>
- </div>
- </v-list>
- </v-navigation-drawer>
- <playmenu v-model="$globals.showplaymenu" v-on:playItem="playItem" :active_player="active_player" />
- </div>
-
- `,
- 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);
- }
- }
-})
+++ /dev/null
-Vue.component("playmenu", {\r
- template: `\r
- <v-dialog :value="value" @input="$emit('input', $event)" max-width="500px" v-if="$globals.playmenuitem">\r
- <v-card>\r
- <v-list>\r
- <v-subheader class="title">{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }}</v-subheader>\r
- <v-subheader>{{ $t('play_on') }} {{ active_player.name }}</v-subheader>\r
- \r
- <v-list-tile avatar @click="itemClick('play')">\r
- <v-list-tile-avatar>\r
- <v-icon>play_circle_outline</v-icon>\r
- </v-list-tile-avatar>\r
- <v-list-tile-content>\r
- <v-list-tile-title>{{ $t('play_now') }}</v-list-tile-title>\r
- </v-list-tile-content>\r
- </v-list-tile>\r
- <v-divider></v-divider>\r
-\r
- <v-list-tile avatar @click="itemClick('next')">\r
- <v-list-tile-avatar>\r
- <v-icon>queue_play_next</v-icon>\r
- </v-list-tile-avatar>\r
- <v-list-tile-content>\r
- <v-list-tile-title>{{ $t('play_next') }}</v-list-tile-title>\r
- </v-list-tile-content>\r
- </v-list-tile>\r
- <v-divider></v-divider>\r
-\r
- <v-list-tile avatar @click="itemClick('add')">\r
- <v-list-tile-avatar>\r
- <v-icon>playlist_add</v-icon>\r
- </v-list-tile-avatar>\r
- <v-list-tile-content>\r
- <v-list-tile-title>{{ $t('add_queue') }}</v-list-tile-title>\r
- </v-list-tile-content>\r
- </v-list-tile>\r
- <v-divider></v-divider>\r
-\r
- <v-list-tile avatar @click="itemClick('info')" v-if="$globals.playmenuitem.media_type == 3">\r
- <v-list-tile-avatar>\r
- <v-icon>info</v-icon>\r
- </v-list-tile-avatar>\r
- <v-list-tile-content>\r
- <v-list-tile-title>{{ $t('show_info') }}</v-list-tile-title>\r
- </v-list-tile-content>\r
- </v-list-tile>\r
- <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
-\r
- <v-list-tile avatar @click="itemClick('add_playlist')" v-if="$globals.playmenuitem.media_type == 3">\r
- <v-list-tile-avatar>\r
- <v-icon>add_circle_outline</v-icon>\r
- </v-list-tile-avatar>\r
- <v-list-tile-content>\r
- <v-list-tile-title>{{ $t('add_playlist') }}</v-list-tile-title>\r
- </v-list-tile-content>\r
- </v-list-tile>\r
- <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
-\r
- <v-list-tile avatar @click="itemClick('remove_playlist')" v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')">\r
- <v-list-tile-avatar>\r
- <v-icon>remove_circle_outline</v-icon>\r
- </v-list-tile-avatar>\r
- <v-list-tile-content>\r
- <v-list-tile-title>{{ $t('remove_playlist') }}</v-list-tile-title>\r
- </v-list-tile-content>\r
- </v-list-tile>\r
- <v-divider v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')"/>\r
- \r
- </v-list>\r
- </v-card>\r
- </v-dialog>\r
-`,\r
- props: ['value', 'active_player'],\r
- data (){\r
- return{\r
- fav: true,\r
- message: false,\r
- hints: true,\r
- }\r
- },\r
- mounted() { },\r
- created() { },\r
- methods: { \r
- itemClick(cmd) {\r
- if (cmd == 'info')\r
- this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}})\r
- else\r
- this.$emit('playItem', this.$globals.playmenuitem, cmd)\r
- // close dialog\r
- this.$globals.showplaymenu = false;\r
- },\r
- }\r
- })\r
+++ /dev/null
-Vue.component("providericons", {\r
- template: `\r
- <div :style="'height:' + height + 'px;'">\r
- <span v-for="provider in uniqueProviders" :key="provider.item_id" style="padding:5px;vertical-align: middle;" v-if="!hiresonly || provider.quality > 6">\r
- <v-tooltip bottom>\r
- <template v-slot:activator="{ on }">\r
- <img v-on="on" :height="height" src="images/icons/hires.png" v-if="provider.quality > 6" style="margin-right:9px"/>\r
- <img v-on="on" :height="height" :src="'images/icons/' + provider.provider + '.png'" v-if="!hiresonly"/>\r
- </template>\r
- <div align="center" v-if="item.media_type == 3">\r
- <img height="35px" :src="getFileFormatLogo(provider)"/>\r
- <span><br>{{ getFileFormatDesc(provider) }}</span>\r
- </div>\r
- <span v-if="item.media_type != 3">{{ provider.provider }}</span>\r
- </v-tooltip> \r
- </span> \r
- </div>\r
-`,\r
- props: ['item','height','compact', 'dark', 'hiresonly'],\r
- data (){\r
- return{}\r
- },\r
- mounted() { },\r
- created() { },\r
- computed: {\r
- uniqueProviders() {\r
- var keys = [];\r
- var qualities = [];\r
- if (!this.item || !this.item.provider_ids)\r
- return []\r
- let sorted_item_ids = this.item.provider_ids.sort((a,b) => (a.quality < b.quality) ? 1 : ((b.quality < a.quality) ? -1 : 0));\r
- if (!this.compact)\r
- return sorted_item_ids;\r
- for (provider of sorted_item_ids) {\r
- if (!keys.includes(provider.provider)){\r
- qualities.push(provider);\r
- keys.push(provider.provider);\r
- }\r
- }\r
- return qualities;\r
- }\r
- },\r
- methods: { \r
-\r
- getFileFormatLogo(provider) {\r
- if (provider.quality == 0)\r
- return 'images/icons/mp3.png'\r
- else if (provider.quality == 1)\r
- return 'images/icons/vorbis.png'\r
- else if (provider.quality == 2)\r
- return 'images/icons/aac.png'\r
- else if (provider.quality > 2)\r
- return 'images/icons/flac.png'\r
- },\r
- getFileFormatDesc(provider) {\r
- var desc = '';\r
- if (provider.details)\r
- desc += ' ' + provider.details;\r
- return desc;\r
- },\r
- getMaxQualityFormatDesc() {\r
- var desc = '';\r
- if (provider.details)\r
- desc += ' ' + provider.details;\r
- return desc;\r
- }\r
- }\r
- })\r
+++ /dev/null
-Vue.component("read-more", {\r
- template: `\r
- <div>\r
- <span v-html="formattedString"/> <a style="color:white" :href="link" id="readmore" v-if="text.length > maxChars" v-on:click="triggerReadMore($event, true)">{{moreStr}}</a></p>\r
- <v-dialog v-model="isReadMore" width="80%">\r
- <v-card>\r
- <v-card-text class="subheading"><span v-html="text"/></v-card-text>\r
- </v-card>\r
- </v-dialog>\r
- </div>`,\r
- props: {\r
- moreStr: {\r
- type: String,\r
- default: 'read more'\r
- },\r
- lessStr: {\r
- type: String,\r
- default: ''\r
- },\r
- text: {\r
- type: String,\r
- required: true\r
- },\r
- link: {\r
- type: String,\r
- default: '#'\r
- },\r
- maxChars: {\r
- type: Number,\r
- default: 100\r
- }\r
- },\r
- $_veeValidate: {\r
- validator: "new"\r
- },\r
- data (){\r
- return{\r
- isReadMore: false\r
- }\r
- },\r
- mounted() { },\r
- computed: {\r
- formattedString(){\r
- var val_container = this.text;\r
- if(this.text.length > this.maxChars){\r
- val_container = val_container.substring(0,this.maxChars) + '...';\r
- }\r
- return(val_container);\r
- }\r
- },\r
-\r
- methods: {\r
- triggerReadMore(e, b){\r
- if(this.link == '#'){\r
- e.preventDefault();\r
- }\r
- if(this.lessStr !== null || this.lessStr !== '')\r
- {\r
- this.isReadMore = b;\r
- }\r
- }\r
- }\r
- })\r
+++ /dev/null
-Vue.component("searchbox", {
- template: `
- <v-dialog :value="$globals.showsearchbox" @input="$emit('input', $event)" max-width="500px">
- <v-text-field
- solo
- clearable
- :label="$t('type_to_search')"
- prepend-inner-icon="search"
- v-model="searchQuery">
- </v-text-field>
- </v-dialog>
- `,
- 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}});
- },
- }
-})
-/* <style>
-.searchbar {
- padding: 1rem 1.5rem!important;
- width: 100%;
- box-shadow: 0 0 70px 0 rgba(0, 0, 0, 0.3);
- background: #fff;
-}
-</style> */
\ No newline at end of file
+++ /dev/null
-Vue.component("volumecontrol", {\r
- template: `\r
- <v-card>\r
- <v-list>\r
- <v-list-tile avatar>\r
- <v-list-tile-avatar>\r
- <v-icon large>{{ isGroup ? 'speaker_group' : 'speaker' }}</v-icon>\r
- </v-list-tile-avatar>\r
- <v-list-tile-content>\r
- <v-list-tile-title>{{ players[player_id].name }}</v-list-tile-title>\r
- <v-list-tile-sub-title>{{ $t('state.' + players[player_id].state) }}</v-list-tile-sub-title>\r
- </v-list-tile-content>\r
- </v-list-tile-action>\r
- </v-list-tile>\r
- </v-list>\r
-\r
- <v-divider></v-divider>\r
-\r
- <v-list two-line>\r
-\r
- <div v-for="child_id in volumePlayerIds" :key="child_id">\r
- <v-list-tile>\r
- \r
- <v-list-tile-content>\r
-\r
- <v-list-tile-title>\r
- </v-list-tile-title>\r
- <div class="v-list__tile__sub-title" style="position: absolute; left:47px; top:10px; z-index:99;">\r
- <span :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">{{ players[child_id].name }}</span>\r
- </div>\r
- <div class="v-list__tile__sub-title" style="position: absolute; left:0px; top:-4px; z-index:99;">\r
- <v-btn icon @click="$emit('togglePlayerPower', child_id)">\r
- <v-icon :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">power_settings_new</v-icon>\r
- </v-btn>\r
- </div>\r
- <v-list-tile-sub-title>\r
- <v-slider lazy :disabled="!players[child_id].powered" v-if="!players[child_id].disable_volume"\r
- :value="Math.round(players[child_id].volume_level)"\r
- prepend-icon="volume_down"\r
- append-icon="volume_up"\r
- @end="$emit('setPlayerVolume', child_id, $event)"\r
- @click:append="$emit('setPlayerVolume', child_id, 'up')"\r
- @click:prepend="$emit('setPlayerVolume', child_id, 'down')"\r
- ></v-slider>\r
- </v-list-tile-sub-title>\r
- </v-list-tile-content>\r
- </v-list-tile>\r
- <v-divider></v-divider>\r
- </div>\r
- \r
- </v-list>\r
-\r
- <v-spacer></v-spacer>\r
- </v-card>\r
-`,\r
- props: ['value', 'players', 'player_id'],\r
- data (){\r
- return{\r
- }\r
- },\r
- computed: {\r
- volumePlayerIds() {\r
- var volume_ids = [this.player_id];\r
- for (var player_id in this.players)\r
- if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled)\r
- volume_ids.push(player_id);\r
- return volume_ids;\r
- },\r
- isGroup() {\r
- return this.volumePlayerIds.length > 1;\r
- }\r
- },\r
- mounted() { },\r
- created() { },\r
- methods: {}\r
- })\r
+++ /dev/null
-/* 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
+++ /dev/null
-[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
+++ /dev/null
-.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
-}
-
+++ /dev/null
-<!DOCTYPE html>
-<html>
-
- <head>
- <meta charset="utf-8" />
- <title>Music Assistant</title>
- <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
- <link href="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.css" rel="stylesheet">
- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
- <link rel="icon" href="./images/icons/icon-256x256.png">
- <link rel="manifest" href="./manifest.json">
- <link rel="apple-touch-icon" href="./images/icons/icon-apple.png">
- <meta name="apple-mobile-web-app-capable" content="yes">
- <link href="./css/site.css" rel="stylesheet">
- <link href="./css/vue-loading.css" rel="stylesheet">
- </head>
-
- <body>
-
- <div id="app">
- <v-app light>
- <v-content>
- <headermenu></headermenu>
- <player></player>
- <router-view app :key="$route.path"></router-view>
- <searchbox/>
- </v-content>
- <loading :active.sync="$globals.loading" :can-cancel="true" color="#2196f3" loader="dots"></loading>
- </v-app>
- </div>
-
-
- <script src="https://unpkg.com/vue/dist/vue.js"></script>
- <script src="https://unpkg.com/vue-i18n/dist/vue-i18n.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.js"></script>
- <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
- <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js"></script>
- <script src="https://unpkg.com/vee-validate@2.0.0-rc.25/dist/vee-validate.js"></script>
- <script src="./lib/vue-loading-overlay.js"></script>
- <script src="https://unpkg.com/vue-toasted"></script>
-
-
- <script>
- const isMobile = () => (document.body.clientWidth < 800);
- const isInStandaloneMode = () => ('standalone' in window.navigator) && (window.navigator.standalone);
-
- function showPlayMenu (item) {
- this.$globals.playmenuitem = item;
- this.$globals.showplaymenu = !this.$globals.showplaymenu;
- }
-
- function clickItem (item) {
- var endpoint = "";
- if (item.media_type == 1)
- endpoint = "/artists/"
- else if (item.media_type == 2)
- endpoint = "/albums/"
- else if (item.media_type == 3 || item.media_type == 5)
- {
- this.showPlayMenu(item);
- return;
- }
- else if (item.media_type == 4)
- endpoint = "/playlists/"
- item_id = item.item_id.toString();
- var url = endpoint + item_id;
- router.push({ path: url, query: {provider: item.provider}});
- }
-
- String.prototype.formatDuration = function () {
- var sec_num = parseInt(this, 10); // don't forget the second param
- var hours = Math.floor(sec_num / 3600);
- var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
- var seconds = sec_num - (hours * 3600) - (minutes * 60);
-
- if (hours < 10) {hours = "0"+hours;}
- if (minutes < 10) {minutes = "0"+minutes;}
- if (seconds < 10) {seconds = "0"+seconds;}
- if (hours == '00')
- return minutes+':'+seconds;
- else
- return hours+':'+minutes+':'+seconds;
- }
- function toggleLibrary (item) {
- var endpoint = "/api/" + item.media_type + "/";
- item_id = item.item_id.toString();
- var action = "/library_remove"
- if (item.in_library.length == 0)
- action = "/library_add"
- var url = endpoint + item_id + action;
- console.log('loading ' + url);
- axios
- .get(url, { params: { provider: item.provider }})
- .then(result => {
- data = result.data;
- console.log(data);
- if (action == "/library_remove")
- item.in_library = []
- else
- item.in_library = [provider]
- })
- .catch(error => {
- console.log("error", error);
- });
-
- };
- </script>
-
- <!-- Vue Pages and Components here -->
- <script src='./pages/home.vue.js'></script>
- <script src='./pages/browse.vue.js'></script>
-
- <script src='./pages/artistdetails.vue.js'></script>
- <script src='./pages/albumdetails.vue.js'></script>
- <script src='./pages/trackdetails.vue.js'></script>
- <script src='./pages/playlistdetails.vue.js'></script>
- <script src='./pages/search.vue.js'></script>
- <script src='./pages/queue.vue.js'></script>
- <script src='./pages/config.vue.js'></script>
-
-
- <script src='./components/headermenu.vue.js'></script>
- <script src='./components/player.vue.js'></script>
- <script src='./components/listviewItem.vue.js'></script>
- <script src='./components/readmore.vue.js'></script>
- <script src='./components/playmenu.vue.js'></script>
- <script src='./components/volumecontrol.vue.js'></script>
- <script src='./components/infoheader.vue.js'></script>
- <script src='./components/providericons.vue.js'></script>
- <script src='./components/searchbox.vue.js'></script>
-
- <script src='./strings.js'></script>
-
- <script>
- Vue.use(VueRouter);
- Vue.use(VeeValidate);
- Vue.use(Vuetify);
- Vue.use(VueI18n);
- Vue.use(VueLoading);
- Vue.use(Toasted, {duration: 5000, fullWidth: true});
-
-
- const routes = [
- {
- path: '/',
- component: home
- },
- {
- path: '/config',
- component: Config,
- },
- {
- path: '/queue/:player_id',
- component: Queue,
- props: route => ({ ...route.params, ...route.query })
- },
- {
- path: '/artists/:media_id',
- component: ArtistDetails,
- props: route => ({ ...route.params, ...route.query })
- },
- {
- path: '/albums/:media_id',
- component: AlbumDetails,
- props: route => ({ ...route.params, ...route.query })
- },
- {
- path: '/tracks/:media_id',
- component: TrackDetails,
- props: route => ({ ...route.params, ...route.query })
- },
- {
- path: '/playlists/:media_id',
- component: PlaylistDetails,
- props: route => ({ ...route.params, ...route.query })
- },
- {
- path: '/search',
- component: Search,
- props: route => ({ ...route.params, ...route.query })
- },
- {
- path: '/:mediatype',
- component: Browse,
- props: route => ({ ...route.params, ...route.query })
- },
- ]
-
- let router = new VueRouter({
- //mode: 'history',
- routes // short for `routes: routes`
- })
-
- router.beforeEach((to, from, next) => {
- next()
- })
-
- const globalStore = new Vue({
- data: {
- windowtitle: 'Home',
- loading: false,
- showplaymenu: false,
- showsearchbox: false,
- playmenuitem: null
- }
- })
- Vue.prototype.$globals = globalStore;
- Vue.prototype.isMobile = isMobile;
- Vue.prototype.isInStandaloneMode = isInStandaloneMode;
- Vue.prototype.toggleLibrary = toggleLibrary;
- Vue.prototype.showPlayMenu = showPlayMenu;
- Vue.prototype.clickItem= clickItem;
-
- const i18n = new VueI18n({
- locale: navigator.language.split('-')[0],
- fallbackLocale: 'en',
- enableInSFC: true,
- messages
- })
-
- var app = new Vue({
- i18n,
- el: '#app',
- watch: {},
- mounted() {
- },
- components: {
- Loading: VueLoading
- },
- created() {
- // little hack to force refresh PWA on iOS by simple reloading it every hour
- var d = new Date();
- var cur_update = d.getDay() + d.getHours();
- if (localStorage.getItem('last_update') != cur_update)
- {
- localStorage.setItem('last_update', cur_update);
- window.location.reload(true);
- }
- },
- data: { },
- methods: {},
- router
- })
- </script>
- </body>
-
-</html>
\ No newline at end of file
+++ /dev/null
-!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
+++ /dev/null
-{
- "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
+++ /dev/null
-var AlbumDetails = Vue.component('AlbumDetails', {
- template: `
- <section>
- <infoheader v-bind:info="info"/>
- <v-tabs
- v-model="active"
- color="transparent"
- light
- slider-color="black"
- >
- <v-tab ripple>Album tracks</v-tab>
- <v-tab-item>
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in albumtracks"
- v-bind:item="item"
- :key="item.db_id"
- v-bind:totalitems="albumtracks.length"
- v-bind:index="index"
- :hideavatar="true"
- :hideproviders="isMobile()"
- >
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
-
- <v-tab ripple>Versions</v-tab>
- <v-tab-item>
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in albumversions"
- v-bind:item="item"
- :key="item.db_id"
- v-bind:totalitems="albumversions.length"
- v-bind:index="index"
- >
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
- </v-tabs>
-
- </section>`,
- 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);
- });
- },
- }
-})
+++ /dev/null
-var ArtistDetails = Vue.component('ArtistDetails', {
- template: `
- <section>
- <infoheader v-bind:info="info"/>
- <v-tabs
- v-model="active"
- color="transparent"
- light
- slider-color="black"
- >
- <v-tab ripple>Top tracks</v-tab>
- <v-tab-item>
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in toptracks"
- v-bind:item="item"
- v-bind:totalitems="toptracks.length"
- v-bind:index="index"
- :key="item.db_id"
- :hideavatar="isMobile()"
- :hidetracknum="true"
- :hideproviders="isMobile()"
- :hidelibrary="isMobile()">
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
-
- <v-tab ripple>Albums</v-tab>
- <v-tab-item>
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in artistalbums"
- v-bind:item="item"
- :key="item.db_id"
- v-bind:totalitems="artistalbums.length"
- v-bind:index="index"
- :hideproviders="isMobile()"
- >
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
- </v-tabs>
- </section>`,
- 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);
- });
- },
- }
-})
+++ /dev/null
-var Browse = Vue.component('Browse', {
- template: `
- <section>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in items"
- :key="item.db_id"
- v-bind:item="item"
- v-bind:totalitems="items.length"
- v-bind:index="index"
- :hideavatar="item.media_type == 3 ? isMobile() : false"
- :hidetracknum="true"
- :hideproviders="isMobile()"
- :hidelibrary="isMobile() ? true : item.media_type != 3">
- </listviewItem>
- </v-list>
- </section>
- `,
- 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();
- }
- };
- }
- }
-})
+++ /dev/null
-var Config = Vue.component('Config', {
- template: `
- <section>
-
- <v-tabs v-model="active" color="transparent" light slider-color="black">
- <v-tab ripple v-for="(conf_value, conf_key) in conf" :key="conf_key">{{ $t('conf.'+conf_key) }}</v-tab>
- <v-tab-item v-for="(conf_value, conf_key) in conf" :key="conf_key">
-
- <!-- generic and module settings -->
- <v-list two-line v-if="conf_key != 'player_settings'">
- <v-list-group no-action v-for="(conf_subvalue, conf_subkey) in conf[conf_key]" :key="conf_key+conf_subkey">
- <template v-slot:activator>
- <v-list-tile>
- <v-list-tile-avatar>
- <img :src="'images/icons/' + conf_subkey + '.png'"/>
- </v-list-tile-avatar>
- <v-list-tile-content>
- <v-list-tile-title>{{ $t('conf.'+conf_subkey) }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
- </template>
- <div v-for="conf_item_key in conf[conf_key][conf_subkey].__desc__">
- <v-list-tile>
- <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
- <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
- <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-select>
- <v-text-field v-else v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
- </v-list-tile>
- </div>
- <v-divider></v-divider>
- </v-list-group>
- </v-list two-line>
-
- <!-- player settings -->
- <v-list two-line v-if="conf_key == 'player_settings'">
- <v-list-group no-action v-for="(player, key) in players" v-if="key != '__desc__' && key in players" :key="key">
- <template v-slot:activator>
- <v-list-tile>
- <v-list-tile-avatar>
- <img :src="'images/icons/' + players[key].player_provider + '.png'"/>
- </v-list-tile-avatar>
- <v-list-tile-content>
- <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
- <v-list-tile-sub-title class="title">{{ key }}</v-list-tile-sub-title>
- </v-list-tile-content>
- </v-list-tile>
- </template>
- <div v-for="conf_item_key in conf.player_settings[key].__desc__" v-if="conf.player_settings[key].enabled">
- <v-list-tile>
- <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
- <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
- <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"
- :items="playersLst"
- item-text="name"
- item-value="id" box>
- </v-select>
- <v-select v-else-if="conf_item_key[0] == 'max_sample_rate'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" :items="sample_rates" box></v-select>
- <v-slider v-else-if="conf_item_key[0] == 'crossfade_duration'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" min=0 max=10 box thumb-label></v-slider>
- <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
- </v-list-tile>
- <v-list-tile v-if="!conf.player_settings[key].enabled">
- <v-switch v-model="conf.player_settings[key].enabled" :label="$t('conf.'+'enabled')"></v-switch>
- </v-list-tile>
- </div>
- <div v-if="!conf.player_settings[key].enabled">
- <v-list-tile>
- <v-switch v-model="conf.player_settings[key].enabled" :label="$t('conf.'+'enabled')"></v-switch>
- </v-list-tile>
- </div>
- <v-divider></v-divider>
- </v-list-group>
- </v-list two-line>
- </v-tab-item>
- </v-tab>
- </v-tabs>
-
-
- </section>
- `,
- 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;
- });
- },
- }
-})
+++ /dev/null
-var home = Vue.component("Home", {
- template: `
- <section>
- <v-list>
- <v-list-tile
- v-for="item in items" :key="item.title" @click="$router.push(item.path)">
- <v-list-tile-action style="margin-left:15px">
- <v-icon>{{ item.icon }}</v-icon>
- </v-list-tile-action>
- <v-list-tile-content>
- <v-list-tile-title>{{ item.title }}</v-list-tile-title>
- </v-list-tile-content>
- </v-list-tile>
- </v-list>
- </section>
-`,
- 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})
- }
- }
-});
+++ /dev/null
-var PlaylistDetails = Vue.component('PlaylistDetails', {
- template: `
- <section>
- <infoheader v-bind:info="info"/>
- <v-tabs
- v-model="active"
- color="transparent"
- light
- slider-color="black"
- >
- <v-tab ripple>Playlist tracks</v-tab>
- <v-tab-item>
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in items"
- v-bind:item="item"
- :key="item.db_id"
- :hideavatar="isMobile()"
- :hidetracknum="true"
- :hideproviders="isMobile()"
- :hidelibrary="isMobile()">
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
- </v-tabs>
- </section>`,
- 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();
- }
- };
- }
- }
-})
+++ /dev/null
-var Queue = Vue.component('Queue', {
- template: `
- <section>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in items"
- v-bind:item="item"
- :key="item.db_id"
- :hideavatar="isMobile()"
- :hidetracknum="true"
- :hideproviders="isMobile()"
- :hidelibrary="isMobile()">
- </listviewItem>
- </v-list>
- </section>`,
- 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)
- })
- }
- }
-})
+++ /dev/null
-var Search = Vue.component('Search', {
- template: `
- <section>
-
- <v-text-field
- solo
- clearable
- :label="$t('type_to_search')"
- append-icon="search"
- v-model="searchQuery" v-on:keyup.enter="Search" @click:append="Search" style="margin-left:30px; margin-right:30px; margin-top:10px">
- </v-text-field>
-
- <v-tabs
- v-model="active"
- color="transparent"
- light
- slider-color="black"
- >
-
- <v-tab ripple v-if="tracks.length">{{ $t('tracks') }}</v-tab>
- <v-tab-item v-if="tracks.length">
- <v-card flat>
- <v-list two-line style="margin-left:15px; margin-right:15px">
- <listviewItem
- v-for="(item, index) in tracks"
- v-bind:item="item"
- :key="item.db_id"
- v-bind:totalitems="tracks.length"
- v-bind:index="index"
- :hideavatar="isMobile()"
- :hidetracknum="true"
- :hideproviders="isMobile()"
- :hideduration="isMobile()"
- :showlibrary="true">
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
-
- <v-tab ripple v-if="artists.length">{{ $t('artists') }}</v-tab>
- <v-tab-item v-if="artists.length">
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in artists"
- v-bind:item="item"
- :key="item.db_id"
- v-bind:totalitems="artists.length"
- v-bind:index="index"
- :hideproviders="isMobile()"
- >
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
-
- <v-tab ripple v-if="albums.length">{{ $t('albums') }}</v-tab>
- <v-tab-item v-if="albums.length">
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in albums"
- v-bind:item="item"
- :key="item.db_id"
- v-bind:totalitems="albums.length"
- v-bind:index="index"
- :hideproviders="isMobile()"
- >
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
-
- <v-tab ripple v-if="playlists.length">{{ $t('playlists') }}</v-tab>
- <v-tab-item v-if="playlists.length">
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in playlists"
- v-bind:item="item"
- :key="item.db_id"
- v-bind:totalitems="playlists.length"
- v-bind:index="index"
- :hidelibrary="true">
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
-
- </v-tabs>
-
- </section>`,
- 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);
- });
- }
-
- },
- }
-})
+++ /dev/null
-var TrackDetails = Vue.component('TrackDetails', {
- template: `
- <section>
- <infoheader v-bind:info="info"/>
- <v-tabs
- v-model="active"
- color="transparent"
- light
- slider-color="black"
- >
- <v-tab ripple>Other versions</v-tab>
- <v-tab-item>
- <v-card flat>
- <v-list two-line>
- <listviewItem
- v-for="(item, index) in trackversions"
- v-bind:item="item"
- :key="item.db_id"
- v-bind:totalitems="trackversions.length"
- v-bind:index="index"
- :hideavatar="isMobile()"
- :hidetracknum="true"
- :hideproviders="isMobile()"
- :hidelibrary="isMobile()">
- </listviewItem>
- </v-list>
- </v-card>
- </v-tab-item>
- </v-tabs>
-
- </section>`,
- 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);
- });
- },
- }
-})
+++ /dev/null
-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
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
--- /dev/null
+Vue.component("headermenu", {
+ template: `<div>
+ <v-navigation-drawer dark app clipped temporary v-model="menu">
+ <v-list >
+ <v-list-tile
+ v-for="item in items" :key="item.title" @click="$router.push(item.path)">
+ <v-list-tile-action>
+ <v-icon>{{ item.icon }}</v-icon>
+ </v-list-tile-action>
+ <v-list-tile-content>
+ <v-list-tile-title>{{ item.title }}</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+ </v-list>
+ </v-navigation-drawer>
+
+
+ <v-toolbar app flat dense dark v-if="$globals.windowtitle" >
+ <div class="title justify-center" style="text-align:center;position:absolute;width:100%;margin-left:-16px;margin-right:0">
+ {{ $globals.windowtitle }}
+ </div>
+ <v-layout align-center>
+ <v-btn icon v-on:click="menu=!menu">
+ <v-icon>menu</v-icon>
+ </v-btn>
+ <v-btn @click="$router.go(-1)" icon v-if="$route.path != '/'">
+ <v-icon>arrow_back</v-icon>
+ </v-btn>
+ </v-layout>
+ </v-toolbar>
+ <v-toolbar flat fixed dense dark scroll-off-screen color="transparent" v-if="!$globals.windowtitle" >
+ <v-layout align-center>
+ <v-btn icon v-on:click="menu=!menu">
+ <v-icon>menu</v-icon>
+ </v-btn>
+ <v-btn @click="$router.go(-1)" icon>
+ <v-icon>arrow_back</v-icon>
+ </v-btn>
+ <v-spacer></v-spacer>
+ <v-spacer></v-spacer>
+ <v-btn icon v-on:click="$router.push({path: '/search'})">
+ <v-icon>search</v-icon>
+ </v-btn>
+ </v-layout>
+ </v-toolbar>
+</div>`,
+ 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: { }
+})
--- /dev/null
+Vue.component("infoheader", {\r
+ template: `\r
+ <v-flex xs12>\r
+ <v-card color="cyan darken-2" class="white--text" img="../images/info_gradient.jpg">\r
+ <v-img\r
+ class="white--text"\r
+ width="100%"\r
+ :height="isMobile() ? '230' : '370'"\r
+ position="center top" \r
+ :src="getFanartImage()"\r
+ gradient="to bottom, rgba(0,0,0,.65), rgba(0,0,0,.35)"\r
+ >\r
+ <div class="text-xs-center" style="height:40px" id="whitespace_top"/>\r
+\r
+ <v-layout style="margin-left:5px;margin-right:5px">\r
+ \r
+ <!-- left side: cover image -->\r
+ <v-flex xs5 pa-4 v-if="!isMobile()">\r
+ <v-img :src="getThumb()" lazy-src="/images/default_artist.png" width="250px" height="250px" style="border: 4px solid grey;border-radius: 15px;"></v-img>\r
+ \r
+ <!-- tech specs and provider icons -->\r
+ <div style="margin-top:10px;">\r
+ <providericons v-bind:item="info" :height="30" :compact="false"/>\r
+ </div>\r
+ </v-flex>\r
+ \r
+ <v-flex>\r
+ <!-- Main title -->\r
+ <v-card-title class="display-1" style="text-shadow: 1px 1px #000000;padding-bottom:0px;">\r
+ {{ info.name }} \r
+ <span class="subheading" v-if="!!info.version" style="padding-left:10px;"> ({{ info.version }})</span>\r
+ </v-card-title>\r
+ \r
+ <!-- item artists -->\r
+ <v-card-title style="text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
+ <span v-if="!!info.artists" v-for="(artist, artistindex) in info.artists" class="headline" :key="artist.db_id">\r
+ <a style="color:#2196f3" v-on:click="clickItem(artist)">{{ artist.name }}</a>\r
+ <label style="color:#2196f3" v-if="artistindex + 1 < info.artists.length" :key="artistindex"> / </label>\r
+ </span>\r
+ <span v-if="info.artist" class="headline">\r
+ <a style="color:#2196f3" v-on:click="clickItem(info.artist)">{{ info.artist.name }}</a>\r
+ </span>\r
+ <span v-if="info.owner" class="headline">\r
+ <a style="color:#2196f3" v-on:click="">{{ info.owner }}</a>\r
+ </span>\r
+ </v-card-title>\r
+\r
+ <v-card-title v-if="info.album" style="color:#ffffff;text-shadow: 1px 1px #000000;padding-top:0px;padding-bottom:10px;">\r
+ <a class="headline" style="color:#ffffff" v-on:click="clickItem(info.album)">{{ info.album.name }}</a>\r
+ </v-card-title>\r
+\r
+ <!-- play/info buttons -->\r
+ <div style="margin-left:8px;">\r
+ <v-btn color="blue-grey" @click="showPlayMenu(info)" class="white--text"><v-icon v-if="!isMobile()" left dark>play_circle_outline</v-icon>{{ $t('play') }}</v-btn>\r
+ <v-btn v-if="!!info.in_library && info.in_library.length == 0" color="blue-grey" @click="toggleLibrary(info)" class="white--text"><v-icon v-if="!isMobile()" left dark>favorite_border</v-icon>{{ $t('add_library') }}</v-btn>\r
+ <v-btn v-if="!!info.in_library && info.in_library.length > 0" color="blue-grey" @click="toggleLibrary(info)" class="white--text"><v-icon v-if="!isMobile()" left dark>favorite</v-icon>{{ $t('remove_library') }}</v-btn>\r
+ </div>\r
+\r
+ <!-- Description/metadata -->\r
+ <v-card-title class="subheading">\r
+ <div class="justify-left" style="text-shadow: 1px 1px #000000;">\r
+ <read-more :text="getDescription()" :max-chars="isMobile() ? 60 : 350"></read-more>\r
+ </div>\r
+ </v-card-title>\r
+\r
+ </v-flex>\r
+ </v-layout>\r
+ \r
+ </v-img>\r
+ <div class="text-xs-center" v-if="info.tags">\r
+ <v-chip small color="white" outline v-for="(tag, index) in info.tags" :key="tag" >{{ tag }}</v-chip>\r
+ </div>\r
+ \r
+ </v-card>\r
+ </v-flex>\r
+`,\r
+ props: ['info'],\r
+ data (){\r
+ return{}\r
+ },\r
+ mounted() { },\r
+ created() { },\r
+ methods: { \r
+ getFanartImage() {\r
+ var img = '';\r
+ if (!this.info)\r
+ return ''\r
+ if (this.info.metadata && this.info.metadata.fanart)\r
+ img = this.info.metadata.fanart;\r
+ else if (this.info.artists)\r
+ this.info.artists.forEach(function(artist) {\r
+ if (artist.metadata && artist.metadata.fanart)\r
+ img = artist.metadata.fanart;\r
+ });\r
+ else if (this.info.artist && this.info.artist.metadata.fanart)\r
+ img = this.info.artist.metadata.fanart;\r
+ return img;\r
+ },\r
+ getThumb() {\r
+ var img = '';\r
+ if (!this.info)\r
+ return ''\r
+ if (this.info.metadata && this.info.metadata.image)\r
+ img = this.info.metadata.image;\r
+ else if (this.info.album && this.info.album.metadata && this.info.album.metadata.image)\r
+ img = this.info.album.metadata.image;\r
+ else if (this.info.artists)\r
+ this.info.artists.forEach(function(artist) {\r
+ if (artist.metadata && artist.metadata.image)\r
+ img = artist.metadata.image;\r
+ });\r
+ return img;\r
+ },\r
+ getDescription() {\r
+ var desc = '';\r
+ if (!this.info)\r
+ return ''\r
+ if (this.info.metadata && this.info.metadata.description)\r
+ return this.info.metadata.description;\r
+ else if (this.info.metadata && this.info.metadata.biography)\r
+ return this.info.metadata.biography;\r
+ else if (this.info.metadata && this.info.metadata.copyright)\r
+ return this.info.metadata.copyright;\r
+ else if (this.info.artists)\r
+ {\r
+ this.info.artists.forEach(function(artist) {\r
+ console.log(artist.metadata.biography);\r
+ if (artist.metadata && artist.metadata.biography)\r
+ desc = artist.metadata.biography;\r
+ });\r
+ }\r
+ return desc;\r
+ },\r
+ }\r
+})\r
--- /dev/null
+Vue.component("listviewItem", {
+ template: `
+ <div>
+ <v-list-tile
+ avatar
+ ripple
+ @click="clickItem(item)">
+
+ <v-list-tile-avatar color="grey" v-if="!hideavatar">
+ <img v-if="(item.media_type != 3) && item.metadata && item.metadata.image" :src="item.metadata.image"/>
+ <img v-if="(item.media_type == 3) && item.album && item.album.metadata && item.album.metadata.image" :src="item.album.metadata.image"/>
+ <v-icon v-if="(item.media_type == 3) && item.album && item.album.metadata && !item.album.metadata.image">audiotrack</v-icon>
+ <v-icon v-if="(item.media_type != 1 && item.media_type != 3) && (!item.metadata || !item.metadata.image)">album</v-icon>
+ <v-icon v-if="(item.media_type == 1) && (!item.metadata || !item.metadata.image)">person</v-icon>
+ <v-icon v-if="(item.media_type == 3) && (!item.metadata || !item.album.metadata.image)">audiotrack</v-icon>
+ </v-list-tile-avatar>
+
+ <v-list-tile-content>
+
+ <v-list-tile-title>
+ {{ item.name }}<span v-if="!!item.version"> ({{ item.version }})</span>
+ </v-list-tile-title>
+
+ <v-list-tile-sub-title v-if="item.artists">
+ <span v-for="(artist, artistindex) in item.artists">
+ <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
+ <label v-if="artistindex + 1 < item.artists.length" :key="artistindex"> / </label>
+ </span>
+ <a v-if="!!item.album && !!hidetracknum" v-on:click="clickItem(item.album)" @click.stop="" style="color:grey"> - {{ item.album.name }}</a>
+ <label v-if="!hidetracknum && item.track_number" style="color:grey"> - disc {{ item.disc_number }} track {{ item.track_number }}</label>
+ </v-list-tile-sub-title>
+ <v-list-tile-sub-title v-if="item.artist">
+ <a v-on:click="clickItem(item.artist)" @click.stop="">{{ item.artist.name }}</a>
+ </v-list-tile-sub-title>
+
+ <v-list-tile-sub-title v-if="!!item.owner">
+ {{ item.owner }}
+ </v-list-tile-sub-title>
+
+ </v-list-tile-content>
+
+ <providericons v-bind:item="item" :height="20" :compact="true" :dark="true" :hiresonly="hideproviders"/>
+
+ <v-list-tile-action v-if="!hidelibrary">
+ <v-tooltip bottom>
+ <template v-slot:activator="{ on }">
+ <v-btn icon ripple v-on="on" v-on:click="toggleLibrary(item)" @click.stop="" >
+ <v-icon height="20" v-if="item.in_library.length > 0">favorite</v-icon>
+ <v-icon height="20" v-if="item.in_library.length == 0">favorite_border</v-icon>
+ </v-btn>
+ </template>
+ <span v-if="item.in_library.length > 0">{{ $t('remove_library') }}</span>
+ <span v-if="item.in_library.length == 0">{{ $t('add_library') }}</span>
+ </v-tooltip>
+ </v-list-tile-action>
+
+ <v-list-tile-action v-if="!hideduration && !!item.duration">
+ {{ item.duration.toString().formatDuration() }}
+ </v-list-tile-action>
+
+ <!-- menu button/icon -->
+ <v-icon v-if="!hidemenu" @click="showPlayMenu(item)" @click.stop="" color="grey lighten-1" style="margin-right:-10px;padding-left:10px">more_vert</v-icon>
+
+
+ </v-list-tile>
+ <v-divider v-if="index + 1 < totalitems" :key="index"></v-divider>
+ </div>
+ `,
+props: ['item', 'index', 'totalitems', 'hideavatar', 'hidetracknum', 'hideproviders', 'hidemenu', 'hidelibrary', 'hideduration'],
+data() {
+ return {}
+ },
+methods: {
+ }
+})
--- /dev/null
+Vue.component("player", {
+ template: `
+ <div>
+
+ <!-- player bar in footer -->
+ <v-footer app light height="auto">
+
+ <v-card class="flex" tile style="background-color:#e8eaed;">
+ <!-- divider -->
+ <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
+
+ <!-- now playing media -->
+ <v-list-tile avatar ripple>
+
+ <v-list-tile-avatar v-if="active_player.cur_item" style="align-items:center;padding-top:15px;">
+ <img v-if="active_player.cur_item.metadata && active_player.cur_item.metadata.image" :src="active_player.cur_item.metadata.image"/>
+ <img v-if="!active_player.cur_item.metadata.image && active_player.cur_item.album && active_player.cur_item.album.metadata && active_player.cur_item.album.metadata.image" :src="active_player.cur_item.album.metadata.image"/>
+ </v-list-tile-avatar>
+
+ <v-list-tile-content style="align-items:center;padding-top:15px;">
+ <v-list-tile-title class="title">{{ active_player.cur_item ? active_player.cur_item.name : active_player.name }}</v-list-tile-title>
+ <v-list-tile-sub-title v-if="active_player.cur_item && active_player.cur_item.artists">
+ <span v-for="(artist, artistindex) in active_player.cur_item.artists">
+ <a v-on:click="clickItem(artist)" @click.stop="">{{ artist.name }}</a>
+ <label v-if="artistindex + 1 < active_player.cur_item.artists.length" :key="artistindex"> / </label>
+ </span>
+ </v-list-tile-sub-title>
+ </v-list-tile-content>
+
+ </v-list-tile>
+
+ <!-- progress bar -->
+ <div style="color:rgba(0,0,0,.65); height:30px;width:100%; vertical-align: middle; left:15px; right:0; margin-bottom:5px; margin-top:5px">
+ <v-layout row style="vertical-align: middle" v-if="active_player.cur_item">
+ <span style="text-align:left; width:60px; margin-top:7px; margin-left:15px;">{{ player_time_str_cur }}</span>
+ <v-progress-linear v-model="progress"></v-progress-linear>
+ <span style="text-align:right; width:60px; margin-top:7px; margin-right: 15px;">{{ player_time_str_total }}</span>
+ </v-layout>
+ </div>
+
+ <!-- divider -->
+ <v-list-tile avatar ripple style="height:1px;background-color:#cccccc;"/>
+
+ <!-- Control buttons -->
+ <v-list-tile light avatar ripple style="margin-bottom:5px;">
+
+ <!-- player controls -->
+ <v-list-tile-content>
+ <v-layout row style="content-align: left;vertical-align: middle; margin-top:10px;margin-left:-15px">
+ <v-btn small icon style="padding:5px;" @click="playerCommand('previous')"><v-icon color="rgba(0,0,0,.54)">skip_previous</v-icon></v-btn>
+ <v-btn small icon style="padding:5px;" v-if="active_player.state == 'playing'" @click="playerCommand('pause')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">pause</v-icon></v-btn>
+ <v-btn small icon style="padding:5px;" v-if="active_player.state != 'playing'" @click="playerCommand('play')"><v-icon size="45" color="rgba(0,0,0,.65)" style="margin-top:-9px;">play_arrow</v-icon></v-btn>
+ <v-btn small icon style="padding:5px;" @click="playerCommand('next')"><v-icon color="rgba(0,0,0,.54)">skip_next</v-icon></v-btn>
+ </v-layout>
+ </v-list-tile-content>
+
+ <!-- active player queue button -->
+ <v-list-tile-action style="padding:20px;" v-if="active_player_id">
+ <v-btn x-small flat icon @click="$router.push('/queue/' + active_player_id)">
+ <v-flex xs12 class="vertical-btn">
+ <v-icon>queue_music</v-icon>
+ <span class="caption">{{ $t('queue') }}</span>
+ </v-flex>
+ </v-btn>
+ </v-list-tile-action>
+
+ <!-- active player volume -->
+ <v-list-tile-action style="padding:20px;" v-if="active_player_id">
+ <v-menu :close-on-content-click="false" :nudge-width="250" offset-x top>
+ <template v-slot:activator="{ on }">
+ <v-btn x-small flat icon v-on="on">
+ <v-flex xs12 class="vertical-btn">
+ <v-icon>volume_up</v-icon>
+ <span class="caption">{{ Math.round(players[active_player_id].volume_level) }}</span>
+ </v-flex>
+ </v-btn>
+ </template>
+ <volumecontrol v-bind:players="players" v-bind:player_id="active_player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
+ </v-menu>
+ </v-list-tile-action>
+
+ <!-- active player btn -->
+ <v-list-tile-action style="padding:30px;margin-right:-13px;">
+ <v-btn x-small flat icon @click="menu = !menu">
+ <v-flex xs12 class="vertical-btn">
+ <v-icon>speaker</v-icon>
+ <span class="caption">{{ active_player_id ? players[active_player_id].name : '' }}</span>
+ </v-flex>
+ </v-btn>
+ </v-list-tile-action>
+ </v-list-tile>
+
+ <!-- add some additional whitespace in standalone mode only -->
+ <v-list-tile avatar ripple style="height:14px" v-if="isInStandaloneMode()"/>
+
+
+
+ </v-card>
+ </v-footer>
+
+ <!-- players side menu -->
+ <v-navigation-drawer right app clipped temporary v-model="menu">
+ <v-card-title class="headline">
+ <b>{{ $t('players') }}</b>
+ </v-card-title>
+ <v-list two-line>
+ <v-divider></v-divider>
+ <div v-for="(player, player_id, index) in players" :key="player_id" v-if="player.enabled && !player.group_parent">
+ <v-list-tile avatar ripple style="margin-left: -5px; margin-right: -15px" @click="switchPlayer(player.player_id)" :style="active_player_id == player.player_id ? 'background-color: rgba(50, 115, 220, 0.3);' : ''">
+ <v-list-tile-avatar>
+ <v-icon size="45">{{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }}</v-icon>
+ </v-list-tile-avatar>
+ <v-list-tile-content>
+ <v-list-tile-title class="title">{{ player.name }}</v-list-tile-title>
+
+ <v-list-tile-sub-title v-if="player.cur_item" class="body-1" :key="player.state">
+ {{ $t('state.' + player.state) }}
+ </v-list-tile-sub-title>
+
+ </v-list-tile-content>
+
+ <v-list-tile-action style="padding:30px;" v-if="active_player_id">
+ <v-menu :close-on-content-click="false" :nudge-width="250" offset-x right>
+ <template v-slot:activator="{ on }">
+ <v-btn flat icon style="color:rgba(0,0,0,.54);" v-on="on">
+ <v-flex xs12 class="vertical-btn">
+ <v-icon>volume_up</v-icon>
+ <span class="caption">{{ Math.round(player.volume_level) }}</span>
+ </v-flex>
+ </v-btn>
+ </template>
+ <volumecontrol v-bind:players="players" v-bind:player_id="player.player_id" v-on:setPlayerVolume="setPlayerVolume" v-on:togglePlayerPower="togglePlayerPower"/>
+ </v-menu>
+ </v-list-tile-action>
+ </v-list-tile>
+ <v-divider></v-divider>
+ </div>
+ </v-list>
+ </v-navigation-drawer>
+ <playmenu v-model="$globals.showplaymenu" v-on:playItem="playItem" :active_player="active_player" />
+ </div>
+
+ `,
+ 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);
+ }
+ }
+})
--- /dev/null
+Vue.component("playmenu", {\r
+ template: `\r
+ <v-dialog :value="value" @input="$emit('input', $event)" max-width="500px" v-if="$globals.playmenuitem">\r
+ <v-card>\r
+ <v-list>\r
+ <v-subheader class="title">{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : '' }}</v-subheader>\r
+ <v-subheader>{{ $t('play_on') }} {{ active_player.name }}</v-subheader>\r
+ \r
+ <v-list-tile avatar @click="itemClick('play')">\r
+ <v-list-tile-avatar>\r
+ <v-icon>play_circle_outline</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ $t('play_now') }}</v-list-tile-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider></v-divider>\r
+\r
+ <v-list-tile avatar @click="itemClick('next')">\r
+ <v-list-tile-avatar>\r
+ <v-icon>queue_play_next</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ $t('play_next') }}</v-list-tile-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider></v-divider>\r
+\r
+ <v-list-tile avatar @click="itemClick('add')">\r
+ <v-list-tile-avatar>\r
+ <v-icon>playlist_add</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ $t('add_queue') }}</v-list-tile-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider></v-divider>\r
+\r
+ <v-list-tile avatar @click="itemClick('info')" v-if="$globals.playmenuitem.media_type == 3">\r
+ <v-list-tile-avatar>\r
+ <v-icon>info</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ $t('show_info') }}</v-list-tile-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
+\r
+ <v-list-tile avatar @click="itemClick('add_playlist')" v-if="$globals.playmenuitem.media_type == 3">\r
+ <v-list-tile-avatar>\r
+ <v-icon>add_circle_outline</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ $t('add_playlist') }}</v-list-tile-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider v-if="$globals.playmenuitem.media_type == 3"/>\r
+\r
+ <v-list-tile avatar @click="itemClick('remove_playlist')" v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')">\r
+ <v-list-tile-avatar>\r
+ <v-icon>remove_circle_outline</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ $t('remove_playlist') }}</v-list-tile-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider v-if="$globals.playmenuitem.media_type == 3 && this.$route.path.startsWith('/playlists/')"/>\r
+ \r
+ </v-list>\r
+ </v-card>\r
+ </v-dialog>\r
+`,\r
+ props: ['value', 'active_player'],\r
+ data (){\r
+ return{\r
+ fav: true,\r
+ message: false,\r
+ hints: true,\r
+ }\r
+ },\r
+ mounted() { },\r
+ created() { },\r
+ methods: { \r
+ itemClick(cmd) {\r
+ if (cmd == 'info')\r
+ this.$router.push({ path: '/tracks/' + this.$globals.playmenuitem.item_id, query: {provider: this.$globals.playmenuitem.provider}})\r
+ else\r
+ this.$emit('playItem', this.$globals.playmenuitem, cmd)\r
+ // close dialog\r
+ this.$globals.showplaymenu = false;\r
+ },\r
+ }\r
+ })\r
--- /dev/null
+Vue.component("providericons", {\r
+ template: `\r
+ <div :style="'height:' + height + 'px;'">\r
+ <span v-for="provider in uniqueProviders" :key="provider.item_id" style="padding:5px;vertical-align: middle;" v-if="!hiresonly || provider.quality > 6">\r
+ <v-tooltip bottom>\r
+ <template v-slot:activator="{ on }">\r
+ <img v-on="on" :height="height" src="images/icons/hires.png" v-if="provider.quality > 6" style="margin-right:9px"/>\r
+ <img v-on="on" :height="height" :src="'images/icons/' + provider.provider + '.png'" v-if="!hiresonly"/>\r
+ </template>\r
+ <div align="center" v-if="item.media_type == 3">\r
+ <img height="35px" :src="getFileFormatLogo(provider)"/>\r
+ <span><br>{{ getFileFormatDesc(provider) }}</span>\r
+ </div>\r
+ <span v-if="item.media_type != 3">{{ provider.provider }}</span>\r
+ </v-tooltip> \r
+ </span> \r
+ </div>\r
+`,\r
+ props: ['item','height','compact', 'dark', 'hiresonly'],\r
+ data (){\r
+ return{}\r
+ },\r
+ mounted() { },\r
+ created() { },\r
+ computed: {\r
+ uniqueProviders() {\r
+ var keys = [];\r
+ var qualities = [];\r
+ if (!this.item || !this.item.provider_ids)\r
+ return []\r
+ let sorted_item_ids = this.item.provider_ids.sort((a,b) => (a.quality < b.quality) ? 1 : ((b.quality < a.quality) ? -1 : 0));\r
+ if (!this.compact)\r
+ return sorted_item_ids;\r
+ for (provider of sorted_item_ids) {\r
+ if (!keys.includes(provider.provider)){\r
+ qualities.push(provider);\r
+ keys.push(provider.provider);\r
+ }\r
+ }\r
+ return qualities;\r
+ }\r
+ },\r
+ methods: { \r
+\r
+ getFileFormatLogo(provider) {\r
+ if (provider.quality == 0)\r
+ return 'images/icons/mp3.png'\r
+ else if (provider.quality == 1)\r
+ return 'images/icons/vorbis.png'\r
+ else if (provider.quality == 2)\r
+ return 'images/icons/aac.png'\r
+ else if (provider.quality > 2)\r
+ return 'images/icons/flac.png'\r
+ },\r
+ getFileFormatDesc(provider) {\r
+ var desc = '';\r
+ if (provider.details)\r
+ desc += ' ' + provider.details;\r
+ return desc;\r
+ },\r
+ getMaxQualityFormatDesc() {\r
+ var desc = '';\r
+ if (provider.details)\r
+ desc += ' ' + provider.details;\r
+ return desc;\r
+ }\r
+ }\r
+ })\r
--- /dev/null
+Vue.component("read-more", {\r
+ template: `\r
+ <div>\r
+ <span v-html="formattedString"/> <a style="color:white" :href="link" id="readmore" v-if="text.length > maxChars" v-on:click="triggerReadMore($event, true)">{{moreStr}}</a></p>\r
+ <v-dialog v-model="isReadMore" width="80%">\r
+ <v-card>\r
+ <v-card-text class="subheading"><span v-html="text"/></v-card-text>\r
+ </v-card>\r
+ </v-dialog>\r
+ </div>`,\r
+ props: {\r
+ moreStr: {\r
+ type: String,\r
+ default: 'read more'\r
+ },\r
+ lessStr: {\r
+ type: String,\r
+ default: ''\r
+ },\r
+ text: {\r
+ type: String,\r
+ required: true\r
+ },\r
+ link: {\r
+ type: String,\r
+ default: '#'\r
+ },\r
+ maxChars: {\r
+ type: Number,\r
+ default: 100\r
+ }\r
+ },\r
+ $_veeValidate: {\r
+ validator: "new"\r
+ },\r
+ data (){\r
+ return{\r
+ isReadMore: false\r
+ }\r
+ },\r
+ mounted() { },\r
+ computed: {\r
+ formattedString(){\r
+ var val_container = this.text;\r
+ if(this.text.length > this.maxChars){\r
+ val_container = val_container.substring(0,this.maxChars) + '...';\r
+ }\r
+ return(val_container);\r
+ }\r
+ },\r
+\r
+ methods: {\r
+ triggerReadMore(e, b){\r
+ if(this.link == '#'){\r
+ e.preventDefault();\r
+ }\r
+ if(this.lessStr !== null || this.lessStr !== '')\r
+ {\r
+ this.isReadMore = b;\r
+ }\r
+ }\r
+ }\r
+ })\r
--- /dev/null
+Vue.component("searchbox", {
+ template: `
+ <v-dialog :value="$globals.showsearchbox" @input="$emit('input', $event)" max-width="500px">
+ <v-text-field
+ solo
+ clearable
+ :label="$t('type_to_search')"
+ prepend-inner-icon="search"
+ v-model="searchQuery">
+ </v-text-field>
+ </v-dialog>
+ `,
+ 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}});
+ },
+ }
+})
+/* <style>
+.searchbar {
+ padding: 1rem 1.5rem!important;
+ width: 100%;
+ box-shadow: 0 0 70px 0 rgba(0, 0, 0, 0.3);
+ background: #fff;
+}
+</style> */
\ No newline at end of file
--- /dev/null
+Vue.component("volumecontrol", {\r
+ template: `\r
+ <v-card>\r
+ <v-list>\r
+ <v-list-tile avatar>\r
+ <v-list-tile-avatar>\r
+ <v-icon large>{{ isGroup ? 'speaker_group' : 'speaker' }}</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>{{ players[player_id].name }}</v-list-tile-title>\r
+ <v-list-tile-sub-title>{{ $t('state.' + players[player_id].state) }}</v-list-tile-sub-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile-action>\r
+ </v-list-tile>\r
+ </v-list>\r
+\r
+ <v-divider></v-divider>\r
+\r
+ <v-list two-line>\r
+\r
+ <div v-for="child_id in volumePlayerIds" :key="child_id">\r
+ <v-list-tile>\r
+ \r
+ <v-list-tile-content>\r
+\r
+ <v-list-tile-title>\r
+ </v-list-tile-title>\r
+ <div class="v-list__tile__sub-title" style="position: absolute; left:47px; top:10px; z-index:99;">\r
+ <span :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">{{ players[child_id].name }}</span>\r
+ </div>\r
+ <div class="v-list__tile__sub-title" style="position: absolute; left:0px; top:-4px; z-index:99;">\r
+ <v-btn icon @click="$emit('togglePlayerPower', child_id)">\r
+ <v-icon :style="!players[child_id].powered ? 'color:rgba(0,0,0,.38);' : 'color:rgba(0,0,0,.54);'">power_settings_new</v-icon>\r
+ </v-btn>\r
+ </div>\r
+ <v-list-tile-sub-title>\r
+ <v-slider lazy :disabled="!players[child_id].powered" v-if="!players[child_id].disable_volume"\r
+ :value="Math.round(players[child_id].volume_level)"\r
+ prepend-icon="volume_down"\r
+ append-icon="volume_up"\r
+ @end="$emit('setPlayerVolume', child_id, $event)"\r
+ @click:append="$emit('setPlayerVolume', child_id, 'up')"\r
+ @click:prepend="$emit('setPlayerVolume', child_id, 'down')"\r
+ ></v-slider>\r
+ </v-list-tile-sub-title>\r
+ </v-list-tile-content>\r
+ </v-list-tile>\r
+ <v-divider></v-divider>\r
+ </div>\r
+ \r
+ </v-list>\r
+\r
+ <v-spacer></v-spacer>\r
+ </v-card>\r
+`,\r
+ props: ['value', 'players', 'player_id'],\r
+ data (){\r
+ return{\r
+ }\r
+ },\r
+ computed: {\r
+ volumePlayerIds() {\r
+ var volume_ids = [this.player_id];\r
+ for (var player_id in this.players)\r
+ if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled)\r
+ volume_ids.push(player_id);\r
+ return volume_ids;\r
+ },\r
+ isGroup() {\r
+ return this.volumePlayerIds.length > 1;\r
+ }\r
+ },\r
+ mounted() { },\r
+ created() { },\r
+ methods: {}\r
+ })\r
--- /dev/null
+/* 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
--- /dev/null
+[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
--- /dev/null
+.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
+}
+
--- /dev/null
+<!DOCTYPE html>
+<html>
+
+ <head>
+ <meta charset="utf-8" />
+ <title>Music Assistant</title>
+ <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
+ <link href="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.css" rel="stylesheet">
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
+ <link rel="icon" href="./images/icons/icon-256x256.png">
+ <link rel="manifest" href="./manifest.json">
+ <link rel="apple-touch-icon" href="./images/icons/icon-apple.png">
+ <meta name="apple-mobile-web-app-capable" content="yes">
+ <link href="./css/site.css" rel="stylesheet">
+ <link href="./css/vue-loading.css" rel="stylesheet">
+ </head>
+
+ <body>
+
+ <div id="app">
+ <v-app light>
+ <v-content>
+ <headermenu></headermenu>
+ <player></player>
+ <router-view app :key="$route.path"></router-view>
+ <searchbox/>
+ </v-content>
+ <loading :active.sync="$globals.loading" :can-cancel="true" color="#2196f3" loader="dots"></loading>
+ </v-app>
+ </div>
+
+
+ <script src="https://unpkg.com/vue/dist/vue.js"></script>
+ <script src="https://unpkg.com/vue-i18n/dist/vue-i18n.js"></script>
+ <script src="https://cdn.jsdelivr.net/npm/vuetify@1.5.16/dist/vuetify.min.js"></script>
+ <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
+ <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.10/lodash.min.js"></script>
+ <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
+ <script src="https://cdn.jsdelivr.net/npm/moment@2.20.1/moment.min.js"></script>
+ <script src="https://unpkg.com/vee-validate@2.0.0-rc.25/dist/vee-validate.js"></script>
+ <script src="./lib/vue-loading-overlay.js"></script>
+ <script src="https://unpkg.com/vue-toasted"></script>
+
+
+ <script>
+ const isMobile = () => (document.body.clientWidth < 800);
+ const isInStandaloneMode = () => ('standalone' in window.navigator) && (window.navigator.standalone);
+
+ function showPlayMenu (item) {
+ this.$globals.playmenuitem = item;
+ this.$globals.showplaymenu = !this.$globals.showplaymenu;
+ }
+
+ function clickItem (item) {
+ var endpoint = "";
+ if (item.media_type == 1)
+ endpoint = "/artists/"
+ else if (item.media_type == 2)
+ endpoint = "/albums/"
+ else if (item.media_type == 3 || item.media_type == 5)
+ {
+ this.showPlayMenu(item);
+ return;
+ }
+ else if (item.media_type == 4)
+ endpoint = "/playlists/"
+ item_id = item.item_id.toString();
+ var url = endpoint + item_id;
+ router.push({ path: url, query: {provider: item.provider}});
+ }
+
+ String.prototype.formatDuration = function () {
+ var sec_num = parseInt(this, 10); // don't forget the second param
+ var hours = Math.floor(sec_num / 3600);
+ var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
+ var seconds = sec_num - (hours * 3600) - (minutes * 60);
+
+ if (hours < 10) {hours = "0"+hours;}
+ if (minutes < 10) {minutes = "0"+minutes;}
+ if (seconds < 10) {seconds = "0"+seconds;}
+ if (hours == '00')
+ return minutes+':'+seconds;
+ else
+ return hours+':'+minutes+':'+seconds;
+ }
+ function toggleLibrary (item) {
+ var endpoint = "/api/" + item.media_type + "/";
+ item_id = item.item_id.toString();
+ var action = "/library_remove"
+ if (item.in_library.length == 0)
+ action = "/library_add"
+ var url = endpoint + item_id + action;
+ console.log('loading ' + url);
+ axios
+ .get(url, { params: { provider: item.provider }})
+ .then(result => {
+ data = result.data;
+ console.log(data);
+ if (action == "/library_remove")
+ item.in_library = []
+ else
+ item.in_library = [provider]
+ })
+ .catch(error => {
+ console.log("error", error);
+ });
+
+ };
+ </script>
+
+ <!-- Vue Pages and Components here -->
+ <script src='./pages/home.vue.js'></script>
+ <script src='./pages/browse.vue.js'></script>
+
+ <script src='./pages/artistdetails.vue.js'></script>
+ <script src='./pages/albumdetails.vue.js'></script>
+ <script src='./pages/trackdetails.vue.js'></script>
+ <script src='./pages/playlistdetails.vue.js'></script>
+ <script src='./pages/search.vue.js'></script>
+ <script src='./pages/queue.vue.js'></script>
+ <script src='./pages/config.vue.js'></script>
+
+
+ <script src='./components/headermenu.vue.js'></script>
+ <script src='./components/player.vue.js'></script>
+ <script src='./components/listviewItem.vue.js'></script>
+ <script src='./components/readmore.vue.js'></script>
+ <script src='./components/playmenu.vue.js'></script>
+ <script src='./components/volumecontrol.vue.js'></script>
+ <script src='./components/infoheader.vue.js'></script>
+ <script src='./components/providericons.vue.js'></script>
+ <script src='./components/searchbox.vue.js'></script>
+
+ <script src='./strings.js'></script>
+
+ <script>
+ Vue.use(VueRouter);
+ Vue.use(VeeValidate);
+ Vue.use(Vuetify);
+ Vue.use(VueI18n);
+ Vue.use(VueLoading);
+ Vue.use(Toasted, {duration: 5000, fullWidth: true});
+
+
+ const routes = [
+ {
+ path: '/',
+ component: home
+ },
+ {
+ path: '/config',
+ component: Config,
+ },
+ {
+ path: '/queue/:player_id',
+ component: Queue,
+ props: route => ({ ...route.params, ...route.query })
+ },
+ {
+ path: '/artists/:media_id',
+ component: ArtistDetails,
+ props: route => ({ ...route.params, ...route.query })
+ },
+ {
+ path: '/albums/:media_id',
+ component: AlbumDetails,
+ props: route => ({ ...route.params, ...route.query })
+ },
+ {
+ path: '/tracks/:media_id',
+ component: TrackDetails,
+ props: route => ({ ...route.params, ...route.query })
+ },
+ {
+ path: '/playlists/:media_id',
+ component: PlaylistDetails,
+ props: route => ({ ...route.params, ...route.query })
+ },
+ {
+ path: '/search',
+ component: Search,
+ props: route => ({ ...route.params, ...route.query })
+ },
+ {
+ path: '/:mediatype',
+ component: Browse,
+ props: route => ({ ...route.params, ...route.query })
+ },
+ ]
+
+ let router = new VueRouter({
+ //mode: 'history',
+ routes // short for `routes: routes`
+ })
+
+ router.beforeEach((to, from, next) => {
+ next()
+ })
+
+ const globalStore = new Vue({
+ data: {
+ windowtitle: 'Home',
+ loading: false,
+ showplaymenu: false,
+ showsearchbox: false,
+ playmenuitem: null
+ }
+ })
+ Vue.prototype.$globals = globalStore;
+ Vue.prototype.isMobile = isMobile;
+ Vue.prototype.isInStandaloneMode = isInStandaloneMode;
+ Vue.prototype.toggleLibrary = toggleLibrary;
+ Vue.prototype.showPlayMenu = showPlayMenu;
+ Vue.prototype.clickItem= clickItem;
+
+ const i18n = new VueI18n({
+ locale: navigator.language.split('-')[0],
+ fallbackLocale: 'en',
+ enableInSFC: true,
+ messages
+ })
+
+ var app = new Vue({
+ i18n,
+ el: '#app',
+ watch: {},
+ mounted() {
+ },
+ components: {
+ Loading: VueLoading
+ },
+ created() {
+ // little hack to force refresh PWA on iOS by simple reloading it every hour
+ var d = new Date();
+ var cur_update = d.getDay() + d.getHours();
+ if (localStorage.getItem('last_update') != cur_update)
+ {
+ localStorage.setItem('last_update', cur_update);
+ window.location.reload(true);
+ }
+ },
+ data: { },
+ methods: {},
+ router
+ })
+ </script>
+ </body>
+
+</html>
\ No newline at end of file
--- /dev/null
+!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
--- /dev/null
+{
+ "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
--- /dev/null
+var AlbumDetails = Vue.component('AlbumDetails', {
+ template: `
+ <section>
+ <infoheader v-bind:info="info"/>
+ <v-tabs
+ v-model="active"
+ color="transparent"
+ light
+ slider-color="black"
+ >
+ <v-tab ripple>Album tracks</v-tab>
+ <v-tab-item>
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in albumtracks"
+ v-bind:item="item"
+ :key="item.db_id"
+ v-bind:totalitems="albumtracks.length"
+ v-bind:index="index"
+ :hideavatar="true"
+ :hideproviders="isMobile()"
+ >
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+
+ <v-tab ripple>Versions</v-tab>
+ <v-tab-item>
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in albumversions"
+ v-bind:item="item"
+ :key="item.db_id"
+ v-bind:totalitems="albumversions.length"
+ v-bind:index="index"
+ >
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+ </v-tabs>
+
+ </section>`,
+ 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);
+ });
+ },
+ }
+})
--- /dev/null
+var ArtistDetails = Vue.component('ArtistDetails', {
+ template: `
+ <section>
+ <infoheader v-bind:info="info"/>
+ <v-tabs
+ v-model="active"
+ color="transparent"
+ light
+ slider-color="black"
+ >
+ <v-tab ripple>Top tracks</v-tab>
+ <v-tab-item>
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in toptracks"
+ v-bind:item="item"
+ v-bind:totalitems="toptracks.length"
+ v-bind:index="index"
+ :key="item.db_id"
+ :hideavatar="isMobile()"
+ :hidetracknum="true"
+ :hideproviders="isMobile()"
+ :hidelibrary="isMobile()">
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+
+ <v-tab ripple>Albums</v-tab>
+ <v-tab-item>
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in artistalbums"
+ v-bind:item="item"
+ :key="item.db_id"
+ v-bind:totalitems="artistalbums.length"
+ v-bind:index="index"
+ :hideproviders="isMobile()"
+ >
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+ </v-tabs>
+ </section>`,
+ 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);
+ });
+ },
+ }
+})
--- /dev/null
+var Browse = Vue.component('Browse', {
+ template: `
+ <section>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in items"
+ :key="item.db_id"
+ v-bind:item="item"
+ v-bind:totalitems="items.length"
+ v-bind:index="index"
+ :hideavatar="item.media_type == 3 ? isMobile() : false"
+ :hidetracknum="true"
+ :hideproviders="isMobile()"
+ :hidelibrary="isMobile() ? true : item.media_type != 3">
+ </listviewItem>
+ </v-list>
+ </section>
+ `,
+ 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();
+ }
+ };
+ }
+ }
+})
--- /dev/null
+var Config = Vue.component('Config', {
+ template: `
+ <section>
+
+ <v-tabs v-model="active" color="transparent" light slider-color="black">
+ <v-tab ripple v-for="(conf_value, conf_key) in conf" :key="conf_key">{{ $t('conf.'+conf_key) }}</v-tab>
+ <v-tab-item v-for="(conf_value, conf_key) in conf" :key="conf_key">
+
+ <!-- generic and module settings -->
+ <v-list two-line v-if="conf_key != 'player_settings'">
+ <v-list-group no-action v-for="(conf_subvalue, conf_subkey) in conf[conf_key]" :key="conf_key+conf_subkey">
+ <template v-slot:activator>
+ <v-list-tile>
+ <v-list-tile-avatar>
+ <img :src="'images/icons/' + conf_subkey + '.png'"/>
+ </v-list-tile-avatar>
+ <v-list-tile-content>
+ <v-list-tile-title>{{ $t('conf.'+conf_subkey) }}</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+ </template>
+ <div v-for="conf_item_key in conf[conf_key][conf_subkey].__desc__">
+ <v-list-tile>
+ <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
+ <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
+ <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-select>
+ <v-text-field v-else v-model="conf[conf_key][conf_subkey][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
+ </v-list-tile>
+ </div>
+ <v-divider></v-divider>
+ </v-list-group>
+ </v-list two-line>
+
+ <!-- player settings -->
+ <v-list two-line v-if="conf_key == 'player_settings'">
+ <v-list-group no-action v-for="(player, key) in players" v-if="key != '__desc__' && key in players" :key="key">
+ <template v-slot:activator>
+ <v-list-tile>
+ <v-list-tile-avatar>
+ <img :src="'images/icons/' + players[key].player_provider + '.png'"/>
+ </v-list-tile-avatar>
+ <v-list-tile-content>
+ <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
+ <v-list-tile-sub-title class="title">{{ key }}</v-list-tile-sub-title>
+ </v-list-tile-content>
+ </v-list-tile>
+ </template>
+ <div v-for="conf_item_key in conf.player_settings[key].__desc__" v-if="conf.player_settings[key].enabled">
+ <v-list-tile>
+ <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"></v-switch>
+ <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box type="password"></v-text-field>
+ <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])"
+ :items="playersLst"
+ item-text="name"
+ item-value="id" box>
+ </v-select>
+ <v-select v-else-if="conf_item_key[0] == 'max_sample_rate'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" :items="sample_rates" box></v-select>
+ <v-slider v-else-if="conf_item_key[0] == 'crossfade_duration'" v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" min=0 max=10 box thumb-label></v-slider>
+ <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="$t('conf.'+conf_item_key[2])" box></v-text-field>
+ </v-list-tile>
+ <v-list-tile v-if="!conf.player_settings[key].enabled">
+ <v-switch v-model="conf.player_settings[key].enabled" :label="$t('conf.'+'enabled')"></v-switch>
+ </v-list-tile>
+ </div>
+ <div v-if="!conf.player_settings[key].enabled">
+ <v-list-tile>
+ <v-switch v-model="conf.player_settings[key].enabled" :label="$t('conf.'+'enabled')"></v-switch>
+ </v-list-tile>
+ </div>
+ <v-divider></v-divider>
+ </v-list-group>
+ </v-list two-line>
+ </v-tab-item>
+ </v-tab>
+ </v-tabs>
+
+
+ </section>
+ `,
+ 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;
+ });
+ },
+ }
+})
--- /dev/null
+var home = Vue.component("Home", {
+ template: `
+ <section>
+ <v-list>
+ <v-list-tile
+ v-for="item in items" :key="item.title" @click="$router.push(item.path)">
+ <v-list-tile-action style="margin-left:15px">
+ <v-icon>{{ item.icon }}</v-icon>
+ </v-list-tile-action>
+ <v-list-tile-content>
+ <v-list-tile-title>{{ item.title }}</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+ </v-list>
+ </section>
+`,
+ 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})
+ }
+ }
+});
--- /dev/null
+var PlaylistDetails = Vue.component('PlaylistDetails', {
+ template: `
+ <section>
+ <infoheader v-bind:info="info"/>
+ <v-tabs
+ v-model="active"
+ color="transparent"
+ light
+ slider-color="black"
+ >
+ <v-tab ripple>Playlist tracks</v-tab>
+ <v-tab-item>
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in items"
+ v-bind:item="item"
+ :key="item.db_id"
+ :hideavatar="isMobile()"
+ :hidetracknum="true"
+ :hideproviders="isMobile()"
+ :hidelibrary="isMobile()">
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+ </v-tabs>
+ </section>`,
+ 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();
+ }
+ };
+ }
+ }
+})
--- /dev/null
+var Queue = Vue.component('Queue', {
+ template: `
+ <section>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in items"
+ v-bind:item="item"
+ :key="item.db_id"
+ :hideavatar="isMobile()"
+ :hidetracknum="true"
+ :hideproviders="isMobile()"
+ :hidelibrary="isMobile()">
+ </listviewItem>
+ </v-list>
+ </section>`,
+ 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)
+ })
+ }
+ }
+})
--- /dev/null
+var Search = Vue.component('Search', {
+ template: `
+ <section>
+
+ <v-text-field
+ solo
+ clearable
+ :label="$t('type_to_search')"
+ append-icon="search"
+ v-model="searchQuery" v-on:keyup.enter="Search" @click:append="Search" style="margin-left:30px; margin-right:30px; margin-top:10px">
+ </v-text-field>
+
+ <v-tabs
+ v-model="active"
+ color="transparent"
+ light
+ slider-color="black"
+ >
+
+ <v-tab ripple v-if="tracks.length">{{ $t('tracks') }}</v-tab>
+ <v-tab-item v-if="tracks.length">
+ <v-card flat>
+ <v-list two-line style="margin-left:15px; margin-right:15px">
+ <listviewItem
+ v-for="(item, index) in tracks"
+ v-bind:item="item"
+ :key="item.db_id"
+ v-bind:totalitems="tracks.length"
+ v-bind:index="index"
+ :hideavatar="isMobile()"
+ :hidetracknum="true"
+ :hideproviders="isMobile()"
+ :hideduration="isMobile()"
+ :showlibrary="true">
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+
+ <v-tab ripple v-if="artists.length">{{ $t('artists') }}</v-tab>
+ <v-tab-item v-if="artists.length">
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in artists"
+ v-bind:item="item"
+ :key="item.db_id"
+ v-bind:totalitems="artists.length"
+ v-bind:index="index"
+ :hideproviders="isMobile()"
+ >
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+
+ <v-tab ripple v-if="albums.length">{{ $t('albums') }}</v-tab>
+ <v-tab-item v-if="albums.length">
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in albums"
+ v-bind:item="item"
+ :key="item.db_id"
+ v-bind:totalitems="albums.length"
+ v-bind:index="index"
+ :hideproviders="isMobile()"
+ >
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+
+ <v-tab ripple v-if="playlists.length">{{ $t('playlists') }}</v-tab>
+ <v-tab-item v-if="playlists.length">
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in playlists"
+ v-bind:item="item"
+ :key="item.db_id"
+ v-bind:totalitems="playlists.length"
+ v-bind:index="index"
+ :hidelibrary="true">
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+
+ </v-tabs>
+
+ </section>`,
+ 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);
+ });
+ }
+
+ },
+ }
+})
--- /dev/null
+var TrackDetails = Vue.component('TrackDetails', {
+ template: `
+ <section>
+ <infoheader v-bind:info="info"/>
+ <v-tabs
+ v-model="active"
+ color="transparent"
+ light
+ slider-color="black"
+ >
+ <v-tab ripple>Other versions</v-tab>
+ <v-tab-item>
+ <v-card flat>
+ <v-list two-line>
+ <listviewItem
+ v-for="(item, index) in trackversions"
+ v-bind:item="item"
+ :key="item.db_id"
+ v-bind:totalitems="trackversions.length"
+ v-bind:index="index"
+ :hideavatar="isMobile()"
+ :hidetracknum="true"
+ :hideproviders="isMobile()"
+ :hidelibrary="isMobile()">
+ </listviewItem>
+ </v-list>
+ </v-card>
+ </v-tab-item>
+ </v-tabs>
+
+ </section>`,
+ 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);
+ });
+ },
+ }
+})
--- /dev/null
+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