--- /dev/null
+
+.DS_Store
+*.db
+*.pyc
+music_assistant/config.json
--- /dev/null
+FROM python:3.7.3-alpine
+
+# install deps
+RUN pip install --upgrade requirements.txt
+
+# copy files
+RUN mkdir -p /usr/src/app
+WORKDIR /usr/src/app
+COPY music_assistant /usr/src/app
+RUN chmod a+x /usr/src/app/main.py
+RUN pip install --upgrade requirements.txt
+
+VOLUME ["/data"]
+
+CMD ["python3.7", "/usr/src/app/main.py", "/data"]
\ No newline at end of file
--- /dev/null
+{
+ "name": "Music Assistant",
+ "version": "0.0.1",
+ "description": "Media library manager for (streaming) media",
+ "slug": "music_assistant",
+ "startup": "application",
+ "boot": "auto",
+ "map": [],
+ "host_network": true,
+ "options": {
+ },
+ "schema": {
+ }
+}
--- /dev/null
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Launch Program",
+ "program": "${workspaceFolder}/web/components/vue-read-more/index.js"
+ }
+ ]
+}
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from utils import run_periodic, LOGGER
+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__)
+
+
+class Api():
+ ''' expose our data through json api '''
+
+ def __init__(self, mass):
+ self.mass = mass
+ self.http_session = aiohttp.ClientSession()
+
+ def stop(self):
+ self.runner.cleanup()
+ self.http_session.close()
+
+ async def setup_web(self):
+ app = web.Application()
+ app.add_routes([web.get('/ws', self.websocket_handler)])
+ app.add_routes([web.get('/stream/{provider}/{track_id}', self.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)
+ await self.runner.setup()
+ site = web.TCPSite(self.runner, '0.0.0.0', 8095)
+ await 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','')
+ lazy = request.rel_url.query.get('lazy', '') != 'false'
+ if action:
+ result = await self.mass.music.item_action(media_id, media_type, action)
+ else:
+ result = await self.mass.music.item(media_id, media_type, 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')
+ result = await self.mass.music.artist_toptracks(artist_id)
+ 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')
+ result = await self.mass.music.artist_albums(artist_id)
+ 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))
+ result = await self.mass.music.playlist_tracks(playlist_id, 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')
+ result = await self.mass.music.album_tracks(album_id)
+ 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)
+ # 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'''
+ player_id = request.match_info.get('player_id')
+ cmd = request.match_info.get('cmd')
+ cmd_args = request.match_info.get('cmd_args')
+ result = await self.mass.player.player_command(player_id, cmd, cmd_args)
+ 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','')
+ media_item = await self.mass.music.item(media_id, media_type, 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 '''
+ 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 }
+ try:
+ await ws.send_json(ws_msg, dumps=json_serializer)
+ except Exception as exc:
+ if 'the handler is closed' in str(exc):
+ await self.mass.remove_event_listener(cb_id)
+ else:
+ LOGGER.exception(exc)
+
+ cb_id = await self.mass.add_event_listener(send_event)
+ # process incoming messages
+ async for msg in ws:
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ if msg.data == 'close':
+ await self.mass.remove_event_listener(cb_id)
+ await ws.close()
+ else:
+ # for now we only use WS for 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 '/play_media/' in msg.data:
+ #'players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}'
+ msg_data_parts = msg.data.split('/')
+ player_id = msg_data_parts[1]
+ media_type = msg_data_parts[3]
+ media_type = media_type_from_string(media_type)
+ media_id = msg_data_parts[4]
+ queue_opt = msg_data_parts[5] if len(msg_data_parts) == 6 else 'replace'
+ media_item = await self.mass.music.item(media_id, media_type, lazy=True)
+ await self.mass.player.play_media(player_id, media_item, queue_opt)
+
+ 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)
+ elif msg.type == aiohttp.WSMsgType.ERROR:
+ LOGGER.error('ws connection closed with exception %s' %
+ ws.exception())
+ LOGGER.info('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 the config '''
+ LOGGER.debug('save config called from api')
+ new_config = await request.json()
+ for key, value in self.mass.config.items():
+ if isinstance(value, dict):
+ for subkey, subvalue in value.items():
+ if subkey in new_config[key]:
+ self.mass.config[key][subkey] = new_config[key][subkey]
+ elif key in new_config:
+ self.mass.config[key] = new_config[key]
+ self.mass.save_config()
+ return web.Response(text='success')
+
+ async def stream(self, request):
+ ''' start streaming audio from provider '''
+ track_id = request.match_info.get('track_id')
+ provider = request.match_info.get('provider')
+ stream_details = await self.mass.music.providers[provider].get_stream_details(track_id)
+ resp = web.StreamResponse(status=200,
+ reason='OK',
+ headers={'Content-Type': stream_details['mime_type']})
+ await resp.prepare(request)
+ async for chunk in self.mass.music.providers[provider].get_stream(track_id):
+ await resp.write(chunk)
+ return resp
\ 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
+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):
+ '''Initialize our caching class'''
+ 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 = "/tmp/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 -*-
+
+CONF_USERNAME = "username"
+CONF_PASSWORD = "password"
+CONF_ENABLED = "enabled"
+CONF_HOSTNAME = "hostname"
+CONF_PORT = "port"
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from utils import run_periodic, LOGGER, get_sort_name, try_parse_int
+from models import MediaType, Artist, Album, Track, Playlist
+from typing import List
+import aiosqlite
+
+DBFILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),"database.db")
+
+class Database():
+
+ def __init__(self, event_loop, dbfile=DBFILE):
+ self.event_loop = event_loop
+ self.dbfile = dbfile
+ self.db_ready = False
+ event_loop.run_until_complete(self.__init_database())
+
+ async def __init_database(self):
+ ''' init database tables'''
+ async with aiosqlite.connect(self.dbfile) as db:
+
+ await db.execute('CREATE TABLE IF NOT EXISTS library_items(item_id INTEGER NOT NULL, provider TEXT NOT NULL, media_type INTEGER NOT NULL, UNIQUE(item_id, provider, media_type));')
+
+ await db.execute('CREATE TABLE IF NOT EXISTS artists(artist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sort_name TEXT, musicbrainz_id TEXT NOT NULL UNIQUE);')
+ await db.execute('CREATE TABLE IF NOT EXISTS albums(album_id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL, name TEXT NOT NULL, albumtype TEXT, year INTEGER, version TEXT, UNIQUE(artist_id, name, version, albumtype));')
+ await db.execute('CREATE TABLE IF NOT EXISTS labels(label_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);')
+ await db.execute('CREATE TABLE IF NOT EXISTS album_labels(album_id INTEGER, label_id INTEGER, UNIQUE(album_id, label_id));')
+
+ await db.execute('CREATE TABLE IF NOT EXISTS tracks(track_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, album_id INTEGER, duration INTEGER, version TEXT, disc_number INT, track_number INT, UNIQUE(name, album_id, version));')
+ await db.execute('CREATE TABLE IF NOT EXISTS track_artists(track_id INTEGER, artist_id INTEGER, UNIQUE(track_id, artist_id));')
+
+ await db.execute('CREATE TABLE IF NOT EXISTS tags(tag_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);')
+ await db.execute('CREATE TABLE IF NOT EXISTS media_tags(item_id INTEGER, media_type INTEGER, tag_id, UNIQUE(item_id, media_type, tag_id));')
+
+ await db.execute('CREATE TABLE IF NOT EXISTS provider_mappings(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, prov_item_id TEXT NOT NULL, provider TEXT NOT NULL, quality INTEGER NOT NULL, details TEXT NULL, UNIQUE(item_id, media_type, prov_item_id, provider, quality));')
+
+ await db.execute('CREATE TABLE IF NOT EXISTS metadata(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(item_id, media_type, key));')
+ await db.execute('CREATE TABLE IF NOT EXISTS external_ids(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(item_id, media_type, key, value));')
+
+ await db.execute('CREATE TABLE IF NOT EXISTS playlists(playlist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner TEXT NOT NULL, UNIQUE(name, owner));')
+ await db.execute('CREATE TABLE IF NOT EXISTS playlist_tracks(playlist_id INTEGER NOT NULL, track_id INTEGER NOT NULL, position INTEGER, UNIQUE(playlist_id, track_id));')
+
+ await db.commit()
+ await db.execute('VACUUM;')
+ self.db_ready = True
+
+ async def get_database_id(self, provider:str, prov_item_id:str, media_type:MediaType):
+ ''' get the database id for the given prov_id '''
+ async with aiosqlite.connect(self.dbfile) as db:
+ sql_query = 'SELECT item_id FROM provider_mappings WHERE prov_item_id = ? AND provider = ? AND media_type = ?;'
+ cursor = await db.execute(sql_query, (prov_item_id, provider, media_type))
+ item_id = await cursor.fetchone()
+ if item_id:
+ item_id = item_id[0]
+ await cursor.close()
+ return item_id
+
+ async def search(self, searchquery, media_types:List[MediaType], limit=10):
+ ''' search library for the given searchphrase '''
+ result = {
+ "artists": [],
+ "albums": [],
+ "tracks": [],
+ "playlists": []
+ }
+ searchquery = "%" + searchquery + "%"
+ sql_query = ' WHERE name LIKE "%s"' % searchquery
+ if MediaType.Artist in media_types:
+ result["artists"] = await self.artists(sql_query, limit=limit)
+ if MediaType.Album in media_types:
+ result["albums"] = await self.albums(sql_query, limit=limit)
+ if MediaType.Track in media_types:
+ result["tracks"] = await self.tracks(sql_query, limit=limit)
+ if MediaType.Playlist in media_types:
+ result["playlists"] = await self.playlists(sql_query, limit=limit)
+ return result
+
+ async def library_artists(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Artist]:
+ ''' get all library artists, optionally filtered by provider'''
+ if provider != None:
+ sql_query = ' WHERE artist_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Artist)
+ else:
+ sql_query = ' WHERE artist_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Artist
+ return await self.artists(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+ async def library_albums(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Album]:
+ ''' get all library albums, optionally filtered by provider'''
+ if provider != None:
+ sql_query = ' WHERE album_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Album)
+ else:
+ sql_query = ' WHERE album_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Album
+ return await self.albums(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+ async def library_tracks(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Track]:
+ ''' get all library tracks, optionally filtered by provider'''
+ if provider != None:
+ sql_query = ' WHERE track_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Track)
+ else:
+ sql_query = ' WHERE track_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Track
+ return await self.tracks(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+ async def library_playlists(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Playlist]:
+ ''' get all library playlists, optionally filtered by provider'''
+ if provider != None:
+ sql_query = ' WHERE playlist_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Playlist)
+ else:
+ sql_query = ' WHERE playlist_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Playlist
+ return await self.playlists(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+ async def add_to_library(self, item_id:int, media_type:MediaType, provider:str):
+ ''' add an item to the library (item must already be present in the db!) '''
+ item_id = try_parse_int(item_id)
+ async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+ sql_query = 'INSERT or REPLACE INTO library_items (item_id, provider, media_type) VALUES(?,?,?);'
+ await db.execute(sql_query, (item_id, provider, media_type))
+ await db.commit()
+
+ async def remove_from_library(self, item_id:int, media_type:MediaType, provider:str):
+ ''' remove item from the library '''
+ item_id = try_parse_int(item_id)
+ async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+ sql_query = 'DELETE FROM library_items WHERE item_id=? AND provider=? AND media_type=?;'
+ await db.execute(sql_query, (item_id, provider, media_type))
+ if media_type == MediaType.Playlist:
+ sql_query = 'DELETE FROM playlist_tracks WHERE playlist_id=?;'
+ await db.execute(sql_query, (item_id,))
+ await db.commit()
+
+ async def artists(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Artist]:
+ ''' fetch artist records from table'''
+ artists = []
+ sql_query = 'SELECT * FROM artists'
+ if filter_query:
+ sql_query += ' ' + filter_query
+ sql_query += ' ORDER BY %s' % orderby
+ if limit:
+ sql_query += ' LIMIT %d OFFSET %d' %(limit, offset)
+ async with aiosqlite.connect(self.dbfile) as db:
+ async with db.execute(sql_query) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ artist = Artist()
+ artist.item_id = db_row[0]
+ artist.name = db_row[1]
+ artist.sort_name = db_row[2]
+ artist.provider_ids = await self.__get_prov_ids(artist.item_id, MediaType.Artist, db)
+ artist.in_library = await self.__get_library_providers(artist.item_id, MediaType.Artist, db)
+ artist.external_ids = await self.__get_external_ids(artist.item_id, MediaType.Artist, db)
+ if fulldata:
+ artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db)
+ artist.tags = await self.__get_tags(artist.item_id, MediaType.Artist, db)
+ else:
+ artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db, filter_key='image')
+ artists.append(artist)
+ return artists
+
+ async def artist(self, artist_id:int, fulldata=True) -> Artist:
+ ''' get artist record by id '''
+ artist_id = try_parse_int(artist_id)
+ artists = await self.artists('WHERE artist_id = %s' % artist_id, fulldata=fulldata)
+ if not artists:
+ return None
+ return artists[0]
+
+ async def add_artist(self, artist:Artist):
+ ''' add a new artist record into table'''
+ artist_id = None
+ async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+ # always prefer to grab existing artist with external_id (=musicbrainz_id)
+ artist_id = await self.__get_item_by_external_id(artist, db)
+ if not artist_id:
+ # insert artist
+ musicbrainz_id = None
+ for item in artist.external_ids:
+ if item.get('musicbrainz'):
+ musicbrainz_id = item['musicbrainz']
+ break
+ assert(musicbrainz_id) # musicbrainz id is required
+ if not artist.sort_name:
+ artist.sort_name = get_sort_name(artist.name)
+ sql_query = 'INSERT OR IGNORE INTO artists (name, sort_name, musicbrainz_id) VALUES(?,?,?);'
+ await db.execute(sql_query, (artist.name, artist.sort_name, musicbrainz_id))
+ await db.commit()
+ # get id from (newly created) item (the safe way)
+ artist_id = await self.__get_item_by_external_id(artist, db)
+ if not artist_id:
+ async with db.execute('SELECT (artist_id) FROM artists WHERE musicbrainz_id=?;', (musicbrainz_id,)) as cursor:
+ artist_id = await cursor.fetchone()
+ artist_id = artist_id[0]
+ # add metadata and tags etc.
+ await self.__add_prov_ids(artist_id, MediaType.Artist, artist.provider_ids, db)
+ await self.__add_metadata(artist_id, MediaType.Artist, artist.metadata, db)
+ await self.__add_tags(artist_id, MediaType.Artist, artist.tags, db)
+ await self.__add_external_ids(artist_id, MediaType.Artist, artist.external_ids, db)
+ # save
+ await db.commit()
+ LOGGER.debug('added artist %s (%s) to database: %s' %(artist.name, artist.provider_ids, artist_id))
+ return artist_id
+
+ async def albums(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Album]:
+ ''' fetch all album records from table'''
+ albums = []
+ sql_query = 'SELECT * FROM albums'
+ if filter_query:
+ sql_query += ' ' + filter_query
+ sql_query += ' ORDER BY %s' % orderby
+ if limit:
+ sql_query += ' LIMIT %d OFFSET %d' %(limit, offset)
+ async with aiosqlite.connect(self.dbfile) as db:
+ async with db.execute(sql_query) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ album = Album()
+ album.item_id = db_row[0]
+ album.artist = await self.artist(db_row[1], fulldata=fulldata)
+ album.name = db_row[2]
+ album.albumtype = db_row[3]
+ album.year = db_row[4]
+ album.version = db_row[5]
+ album.provider_ids = await self.__get_prov_ids(album.item_id, MediaType.Album, db)
+ album.in_library = await self.__get_library_providers(album.item_id, MediaType.Album, db)
+ album.external_ids = await self.__get_external_ids(album.item_id, MediaType.Album, db)
+ if fulldata:
+ album.metadata = await self.__get_metadata(album.item_id, MediaType.Album, db)
+ album.tags = await self.__get_tags(album.item_id, MediaType.Album, db)
+ album.labels = await self.__get_album_labels(album.item_id, db)
+ else:
+ album.metadata = await self.__get_metadata(album.item_id, MediaType.Album, db, filter_key='image')
+ albums.append(album)
+ return albums
+
+ async def album(self, album_id:int, fulldata=True) -> Album:
+ ''' get album record by id '''
+ album_id = try_parse_int(album_id)
+ albums = await self.albums('WHERE album_id = %s' % album_id, fulldata=fulldata)
+ if not albums:
+ return None
+ return albums[0]
+
+ async def add_album(self, album:Album):
+ ''' add a new album record into table'''
+ album_id = None
+ async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+ # always try to grab existing album with external_id
+ album_id = await self.__get_item_by_external_id(album, db)
+ # fallback to matching on artist_id, name and version
+ if not album_id:
+ async with db.execute('SELECT (album_id) FROM albums WHERE artist_id=? AND name=? AND version=?;', (album.artist.item_id, album.name, album.version)) as cursor:
+ result = await cursor.fetchone()
+ if result:
+ album_id = result[0]
+ if not album_id and album.year:
+ async with db.execute('SELECT (album_id) FROM albums WHERE year=? AND name=? AND version=?;', (album.year, album.name, album.version)) as cursor:
+ result = await cursor.fetchone()
+ if result:
+ album_id = result[0]
+ if not album_id:
+ # insert album
+ sql_query = 'INSERT OR IGNORE INTO albums (artist_id, name, albumtype, year, version) VALUES(?,?,?,?,?);'
+ await db.execute(sql_query, (album.artist.item_id, album.name, album.albumtype, album.year, album.version))
+ await db.commit()
+ # get id from newly created item
+ async with db.execute('SELECT (album_id) FROM albums WHERE artist_id=? AND name=? AND version=?;', (album.artist.item_id, album.name, album.version)) as cursor:
+ album_id = await cursor.fetchone()
+ assert(album_id)
+ album_id = album_id[0]
+ # add metadata, artists and tags etc.
+ await self.__add_prov_ids(album_id, MediaType.Album, album.provider_ids, db)
+ await self.__add_metadata(album_id, MediaType.Album, album.metadata, db)
+ await self.__add_tags(album_id, MediaType.Album, album.tags, db)
+ await self.__add_album_labels(album_id, album.labels, db)
+ await self.__add_external_ids(album_id, MediaType.Album, album.external_ids, db)
+ # save
+ await db.commit()
+ LOGGER.debug('added album %s (%s) to database: %s' %(album.name, album.provider_ids, album_id))
+ return album_id
+
+ async def tracks(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Track]:
+ ''' fetch all track records from table'''
+ tracks = []
+ sql_query = 'SELECT * FROM tracks'
+ if filter_query:
+ sql_query += ' ' + filter_query
+ sql_query += ' ORDER BY %s' % orderby
+ if limit:
+ sql_query += ' LIMIT %d OFFSET %d' %(limit, offset)
+ async with aiosqlite.connect(self.dbfile) as db:
+ async with db.execute(sql_query) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ track = Track()
+ track.item_id = db_row[0]
+ track.name = db_row[1]
+ track.album = await self.album(db_row[2], fulldata=fulldata)
+ track.duration = db_row[3]
+ track.version = db_row[4]
+ track.disc_number = db_row[5]
+ track.track_number = db_row[6]
+ track.metadata = await self.__get_metadata(track.item_id, MediaType.Track, db)
+ track.tags = await self.__get_tags(track.item_id, MediaType.Track, db)
+ track.provider_ids = await self.__get_prov_ids(track.item_id, MediaType.Track, db)
+ track.in_library = await self.__get_library_providers(track.item_id, MediaType.Track, db)
+ track.artists = await self.__get_track_artists(track.item_id, db, fulldata=fulldata)
+ track.external_ids = await self.__get_external_ids(track.item_id, MediaType.Track, db)
+ tracks.append(track)
+ return tracks
+
+ async def track(self, track_id:int, fulldata=True) -> Track:
+ ''' get track record by id '''
+ track_id = try_parse_int(track_id)
+ tracks = await self.tracks('WHERE track_id = %s' % track_id, fulldata=fulldata)
+ if not tracks:
+ return None
+ return tracks[0]
+
+ async def add_track(self, track:Track):
+ ''' add a new track record into table'''
+ assert(track.name and track.album)
+ track_id = None
+ async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+ # always try to grab existing track with external_id
+ track_id = await self.__get_item_by_external_id(track, db)
+ # fallback to matching on album_id, name and version or track number
+ if not track_id and track.track_number:
+ async with db.execute('SELECT (track_id) FROM tracks WHERE album_id=? AND track_number=?;', (track.album.item_id, track.track_number)) as cursor:
+ result = await cursor.fetchone()
+ if result:
+ track_id = result[0]
+ if not track_id:
+ async with db.execute('SELECT (track_id) FROM tracks WHERE album_id=? AND name=? AND version=?;', (track.album.item_id, track.name, track.version)) as cursor:
+ result = await cursor.fetchone()
+ if result:
+ track_id = result[0]
+ if not track_id:
+ # insert track
+ assert(track.name and track.album.item_id)
+ sql_query = 'INSERT OR IGNORE INTO tracks (name, album_id, duration, version, disc_number, track_number) VALUES(?,?,?,?,?,?);'
+ await db.execute(sql_query, (track.name, track.album.item_id, track.duration, track.version, track.disc_number, track.track_number))
+ await db.commit()
+ # get id from newly created item (the safe way)
+ async with db.execute('SELECT (track_id) FROM tracks WHERE name=? AND album_id=? AND version=?;', (track.name, track.album.item_id, track.version)) as cursor:
+ track_id = await cursor.fetchone()
+ assert(track_id)
+ track_id = track_id[0]
+ # add track artists
+ for artist in track.artists:
+ sql_query = 'INSERT or IGNORE INTO track_artists (track_id, artist_id) VALUES(?,?);'
+ await db.execute(sql_query, (track_id, artist.item_id))
+ # add metadata, tags and artists etc.
+ await self.__add_prov_ids(track_id, MediaType.Track, track.provider_ids, db)
+ await self.__add_metadata(track_id, MediaType.Track, track.metadata, db)
+ await self.__add_tags(track_id, MediaType.Track, track.tags, db)
+ await self.__add_external_ids(track_id, MediaType.Track, track.external_ids, db)
+ # save to db
+ await db.commit()
+ LOGGER.debug('added track %s (%s) to database: %s' %(track.name, track.provider_ids, track_id))
+ return track_id
+
+ async def playlists(self, filter_query=None, limit=100000, offset=0, orderby='name') -> List[Playlist]:
+ ''' fetch all playlist records from table'''
+ playlists = []
+ sql_query = 'SELECT * FROM playlists'
+ if filter_query:
+ sql_query += ' ' + filter_query
+ sql_query += ' ORDER BY %s' % orderby
+ if limit:
+ sql_query += ' LIMIT %d OFFSET %d' %(limit, offset)
+ async with aiosqlite.connect(self.dbfile) as db:
+ async with db.execute(sql_query) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ playlist = Playlist()
+ playlist.item_id = db_row[0]
+ playlist.name = db_row[1]
+ playlist.owner = db_row[2]
+ playlist.metadata = await self.__get_metadata(playlist.item_id, MediaType.Playlist, db)
+ playlist.provider_ids = await self.__get_prov_ids(playlist.item_id, MediaType.Playlist, db)
+ playlist.in_library = await self.__get_library_providers(playlist.item_id, MediaType.Playlist, db)
+ playlists.append(playlist)
+ return playlists
+
+ async def playlist(self, playlist_id:int) -> Playlist:
+ ''' get playlist record by id '''
+ playlist_id = try_parse_int(playlist_id)
+ playlists = await self.playlists('WHERE playlist_id = %s' % playlist_id)
+ if not playlists:
+ return None
+ return playlists[0]
+
+ async def add_playlist(self, playlist:Playlist):
+ ''' add a new playlist record into table'''
+ assert(playlist.name)
+ async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+ # insert playlist
+ sql_query = 'INSERT OR IGNORE INTO playlists (name, owner) VALUES(?,?);'
+ await db.execute(sql_query, (playlist.name, playlist.owner))
+ # get id from newly created item (the safe way)
+ async with db.execute('SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;', (playlist.name,playlist.owner)) as cursor:
+ playlist_id = await cursor.fetchone()
+ playlist_id = playlist_id[0]
+ # add metadata
+ await self.__add_prov_ids(playlist_id, MediaType.Playlist, playlist.provider_ids, db)
+ await self.__add_metadata(playlist_id, MediaType.Playlist, playlist.metadata, db)
+ # save
+ await db.commit()
+ LOGGER.debug('added playlist %s to database: %s' %(playlist.name, playlist_id))
+ return playlist_id
+
+ async def artist_tracks(self, artist_id, limit=1000000, offset=0, orderby='name') -> List[Track]:
+ ''' get all library tracks for the given artist '''
+ artist_id = try_parse_int(artist_id)
+ sql_query = ' WHERE track_id in (SELECT track_id FROM track_artists WHERE artist_id = %d)' % artist_id
+ return await self.tracks(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+ async def artist_albums(self, artist_id, limit=1000000, offset=0, orderby='name') -> List[Album]:
+ ''' get all library albums for the given artist '''
+ sql_query = ' WHERE artist_id = %d' % artist_id
+ return await self.albums(sql_query, limit=limit, offset=offset, orderby=orderby)
+
+ async def playlist_tracks(self, playlist_id:int, limit=100000, offset=0, orderby='position', fulldata=False) -> List[Track]:
+ ''' get playlist tracks for the given playlist_id '''
+ playlist_id = try_parse_int(playlist_id)
+ playlist_tracks = []
+ sql_query = 'SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY %s' % orderby
+ if limit:
+ sql_query += ' LIMIT %d OFFSET %d' %(limit, offset)
+ async with aiosqlite.connect(self.dbfile) as db:
+ async with db.execute(sql_query, (playlist_id,)) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ playlist_track = await self.track(db_row[0], fulldata=fulldata)
+ playlist_track.position = db_row[1]
+ playlist_tracks.append(playlist_track)
+ return playlist_tracks
+
+ async def add_playlist_track(self, playlist_id:int, track_id, position):
+ ''' add playlist track to playlist '''
+ async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+ sql_query = 'INSERT or IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES(?,?,?);'
+ await db.execute(sql_query, (playlist_id, track_id, position))
+ await db.commit()
+
+ async def remove_playlist_track(self, playlist_id:int, track_id):
+ ''' remove playlist track from playlist '''
+ async with aiosqlite.connect(self.dbfile, timeout=20) as db:
+ sql_query = 'DELETE FROM playlist_tracks WHERE playlist_id=? AND track_id=?;'
+ await db.execute(sql_query, (playlist_id, track_id))
+ await db.commit()
+
+ async def __add_metadata(self, item_id, media_type, metadata, db):
+ ''' add or update metadata'''
+ for key, value in metadata.items():
+ if value:
+ sql_query = 'INSERT or REPLACE INTO metadata (item_id, media_type, key, value) VALUES(?,?,?,?);'
+ await db.execute(sql_query, (item_id, media_type, key, value))
+
+ async def __get_metadata(self, item_id, media_type, db, filter_key=None):
+ ''' get metadata for media item '''
+ metadata = {}
+ sql_query = 'SELECT key, value FROM metadata WHERE item_id = ? AND media_type = ?'
+ if filter_key:
+ sql_query += ' AND key = "%s"' % filter_key
+ async with db.execute(sql_query, (item_id, media_type)) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ key = db_row[0]
+ value = db_row[1]
+ metadata[key] = value
+ return metadata
+
+ async def __add_tags(self, item_id, media_type, tags, db):
+ ''' add tags to db '''
+ for tag in tags:
+ sql_query = 'INSERT or IGNORE INTO tags (name) VALUES(?);'
+ async with db.execute(sql_query, (tag,)) as cursor:
+ tag_id = cursor.lastrowid
+ sql_query = 'INSERT or IGNORE INTO media_tags (item_id, media_type, tag_id) VALUES(?,?,?);'
+ await db.execute(sql_query, (item_id, media_type, tag_id))
+
+ async def __get_tags(self, item_id, media_type, db):
+ ''' get tags for media item '''
+ tags = []
+ sql_query = 'SELECT name FROM tags INNER JOIN media_tags on tags.tag_id = media_tags.tag_id WHERE item_id = ? AND media_type = ?'
+ async with db.execute(sql_query, (item_id, media_type)) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ tags.append(db_row[0])
+ return tags
+
+ async def __add_album_labels(self, album_id, labels, db):
+ ''' add labels to album in db '''
+ for label in labels:
+ sql_query = 'INSERT or IGNORE INTO labels (name) VALUES(?);'
+ async with db.execute(sql_query, (label,)) as cursor:
+ label_id = cursor.lastrowid
+ sql_query = 'INSERT or IGNORE INTO album_labels (album_id, label_id) VALUES(?,?);'
+ await db.execute(sql_query, (album_id, label_id))
+
+ async def __get_album_labels(self, album_id, db):
+ ''' get labels for album item '''
+ labels = []
+ sql_query = 'SELECT name FROM labels INNER JOIN album_labels on labels.label_id = album_labels.label_id WHERE album_id = ?'
+ async with db.execute(sql_query, (album_id,)) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ labels.append(db_row[0])
+ return labels
+
+ async def __get_track_artists(self, track_id, db, fulldata=False) -> List[Artist]:
+ ''' get artists for track '''
+ artists = []
+ sql_query = 'SELECT artist_id FROM track_artists WHERE track_id = ?'
+ async with db.execute(sql_query, (track_id,)) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ artist = await self.artist(db_row[0], fulldata=fulldata)
+ artists.append(artist)
+ return artists
+
+ async def __add_external_ids(self, item_id, media_type, external_ids, db):
+ ''' add or update external_ids'''
+ for external_id in external_ids:
+ for key, value in external_id.items():
+ sql_query = 'INSERT or REPLACE INTO external_ids (item_id, media_type, key, value) VALUES(?,?,?,?);'
+ await db.execute(sql_query, (item_id, media_type, key, value))
+
+ async def __get_external_ids(self, item_id, media_type, db):
+ ''' get external_ids for media item '''
+ external_ids = []
+ sql_query = 'SELECT key, value FROM external_ids WHERE item_id = ? AND media_type = ?'
+ async with db.execute(sql_query, (item_id, media_type)) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ external_id = {
+ db_row[0]: db_row[1]
+ }
+ external_ids.append(external_id)
+ return external_ids
+
+ async def __add_prov_ids(self, item_id, media_type, provider_ids, db):
+ ''' add provider ids for media item to db '''
+ for prov_mapping in provider_ids:
+ prov_id = prov_mapping['provider']
+ prov_item_id = prov_mapping['item_id']
+ quality = prov_mapping.get('quality',0)
+ details = prov_mapping.get('details','')
+ sql_query = 'INSERT OR REPLACE INTO provider_mappings (item_id, media_type, prov_item_id, provider, quality, details) VALUES(?,?,?,?,?,?);'
+ await db.execute(sql_query, (item_id, media_type, prov_item_id, prov_id, quality, details))
+
+ async def __get_prov_ids(self, item_id, media_type:MediaType, db):
+ ''' get all provider_ids for media item '''
+ provider_ids = []
+ sql_query = 'SELECT prov_item_id, provider, quality, details \
+ FROM provider_mappings \
+ WHERE item_id = ? AND media_type = ?'
+ async with db.execute(sql_query, (item_id, media_type)) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ prov_mapping = {
+ "provider": db_row[1],
+ "item_id": db_row[0],
+ "quality": db_row[2],
+ "details": db_row[3]
+ }
+ provider_ids.append(prov_mapping)
+ return provider_ids
+
+ async def __get_library_providers(self, item_id, media_type:MediaType, db):
+ ''' get the providers that have this media_item added to the library '''
+ providers = []
+ sql_query = 'SELECT provider FROM library_items WHERE item_id = ? AND media_type = ?'
+ async with db.execute(sql_query, (item_id, media_type)) as cursor:
+ db_rows = await cursor.fetchall()
+ for db_row in db_rows:
+ providers.append( db_row[0] )
+ return providers
+
+ async def __get_item_by_external_id(self, media_item, db):
+ ''' try to get existing item in db by matching the new item's external id's '''
+ item_id = None
+ for external_id in media_item.external_ids:
+ if item_id:
+ break
+ for key, value in external_id.items():
+ async with db.execute('SELECT (item_id) FROM external_ids WHERE media_type=? AND key=? AND value=?;', (media_item.media_type, key, value)) as cursor:
+ result = await cursor.fetchone()
+ if result:
+ item_id = result[0]
+ break
+ if item_id:
+ break
+ return item_id
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import sys
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+import re
+import uvloop
+import logging
+import os
+import shutil
+import slugify as unicode_slug
+import uuid
+import json
+import pkgutil
+import time
+
+from database import Database
+from metadata import MetaData
+from api import Api
+from utils import run_periodic, LOGGER
+from cache import Cache
+from music import Music
+from player import Player
+
+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(max_workers=5)
+ self.event_listeners = {}
+
+ import signal
+ signal.signal(signal.SIGINT, self.stop)
+ signal.signal(signal.SIGTERM, self.stop)
+
+ # init database and metadata modules
+ self.db = Database(self.event_loop)
+ # allow some time for the database to initialize
+ while not self.db.db_ready:
+ time.sleep(0.5)
+ self.cache = Cache()
+ self.metadata = MetaData(self.event_loop, self.db, self.cache)
+ self.music = Music(self)
+ self.player = Player(self)
+
+ # init web/api
+ self.api = Api(self)
+ asyncio.ensure_future(self.api.setup_web())
+
+ # start the event loop
+ self.event_loop.run_forever()
+
+ async def event(self, msg, msg_details=None):
+ ''' signal event '''
+ LOGGER.debug("Event: %s - %s" %(msg, msg_details))
+ for listener in self.event_listeners.values():
+ await listener(msg, msg_details)
+
+ async def add_event_listener(self, cb):
+ ''' add callback to our event listeners '''
+ cb_id = str(uuid.uuid4())
+ self.event_listeners[cb_id] = cb
+
+ async def remove_event_listener(self, cb_id):
+ ''' add callback to 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')
+ if os.path.isfile(conf_file):
+ shutil.move(conf_file, conf_file_backup)
+ with open(conf_file, 'w') as f:
+ f.write(json.dumps(self.config, indent=4))
+
+ def parse_config(self):
+ '''get config from config file'''
+ config = {
+ "musicproviders": {},
+ "playerproviders": {},
+ "player_settings":
+ {
+ "__desc__":
+ [
+ ("name", "", "Custom name for this player"),
+ ("group_parent", "<player>", "Group this player with another player"),
+ ("mute_as_power", False, "Use muting as power control"),
+ ("disable_volume", False, "Disable volume controls"),
+ ("apply_group_volume", False, "Apply group volume to childs (for group players only)"),
+ ("enabled", False, "Enable player")
+ ]
+ }
+ }
+ conf_file = os.path.join(self._datapath, 'config.json')
+ if os.path.isfile(conf_file):
+ with open(conf_file) as f:
+ data = f.read()
+ stored_config = json.loads(data)
+ for key in config.keys():
+ if stored_config.get(key):
+ config[key].update(stored_config[key])
+ self.config = config
+
+ def stop(self, signum=None, frame=None):
+ ''' properly close all connections'''
+ print('stop requested!')
+ self.save_config()
+ self.api.stop()
+ print('stopping event loop...')
+ self.event_loop.stop()
+ self.event_loop.close()
+
+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
+#!/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 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)
+ 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, 0.8]:
+ for item in result['releases']:
+ 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, 0.9, 0.8]:
+ for item in result['recordings']:
+ 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 = 'https://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)
+ self.throttler = Throttler(rate_limit=5, 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 -*-
+
+from enum import Enum, IntEnum
+from typing import List
+import sys
+sys.path.append("..")
+from utils import run_periodic, LOGGER, parse_track_title
+from difflib import SequenceMatcher as Matcher
+import asyncio
+from cache import use_cache
+
+
+class MediaType(IntEnum):
+ Artist = 1
+ Album = 2
+ Track = 3
+ Playlist = 4
+
+def media_type_from_string(media_type_str):
+ media_type_str = media_type_str.lower()
+ if 'artist' in media_type_str or media_type_str == '1':
+ return MediaType.Artist
+ elif 'album' in media_type_str or media_type_str == '2':
+ return MediaType.Album
+ elif 'track' in media_type_str or media_type_str == '3':
+ return MediaType.Track
+ elif 'playlist' in media_type_str or media_type_str == '4':
+ return MediaType.Playlist
+ else:
+ return None
+
+class ContributorRole(IntEnum):
+ Artist = 1
+ Writer = 2
+ Producer = 3
+
+class AlbumType(IntEnum):
+ Album = 1
+ Single = 2
+ Compilation = 3
+
+class TrackQuality(IntEnum):
+ LOSSY_MP3 = 0
+ LOSSY_OGG = 1
+ LOSSY_AAC = 2
+ FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES
+ FLAC_LOSSLES_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES
+ FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES
+ 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 '''
+ def __init__(self):
+ self.item_id = None
+ 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
+
+class Album(object):
+ ''' representation of an album '''
+ def __init__(self):
+ self.item_id = None
+ self.name = ''
+ self.metadata = {}
+ 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):
+ ''' representation of a track '''
+ def __init__(self):
+ self.item_id = None
+ self.name = ''
+ self.duration = 0
+ self.version = ''
+ self.external_ids = []
+ self.metadata = { }
+ self.tags = []
+ self.artists = []
+ self.provider_ids = []
+ self.album = None
+ self.disc_number = 0
+ self.track_number = 0
+ self.media_type = MediaType.Track
+ 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.version == other.version and
+ self.item_id == other.item_id)
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+class Playlist(object):
+ ''' representation of a playlist '''
+ def __init__(self):
+ self.item_id = None
+ self.name = ''
+ self.owner = ''
+ self.provider_ids = []
+ self.metadata = {}
+ self.media_type = MediaType.Playlist
+ self.in_library = []
+
+
+class MusicProvider():
+ '''
+ Model for a Musicprovider
+ Common methods usable for every provider
+ Provider specific get methods shoud be overriden in the provider specific implementation
+ Uses a form of lazy provisioning to local db as cache
+ '''
+
+ name = 'My great Music provider' # display name
+ prov_id = 'my_provider' # used as id
+ icon = ''
+
+ def __init__(self, mass):
+ self.mass = mass
+ self.cache = mass.cache
+
+ ### Common methods and properties ####
+
+ async def artist(self, prov_item_id, lazy=True) -> Artist:
+ ''' return artist details for the given provider artist id '''
+ item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Artist)
+ if not item_id:
+ # artist not yet in local database so fetch details
+ artist_details = await self.get_artist(prov_item_id)
+ if not artist_details:
+ LOGGER.warning('artist not found: %s' % prov_item_id)
+ return None
+ if lazy:
+ asyncio.create_task(self.add_artist(artist_details))
+ artist_details.is_lazy = True
+ return artist_details
+ item_id = await self.add_artist(artist_details)
+ return await self.mass.db.artist(item_id)
+
+ async def add_artist(self, artist_details, skip_match=False) -> int:
+ ''' add artist to local db and return the new database id'''
+ musicbrainz_id = None
+ for item in artist_details.external_ids:
+ if item.get("musicbrainz"):
+ musicbrainz_id = item["musicbrainz"]
+ if not musicbrainz_id:
+ musicbrainz_id = await self.get_artist_musicbrainz_id(artist_details, allow_fallback=not skip_match)
+ if not musicbrainz_id:
+ return
+ # grab additional metadata
+ if musicbrainz_id:
+ artist_details.external_ids.append({"musicbrainz": musicbrainz_id})
+ artist_details.metadata = await self.mass.metadata.get_artist_metadata(musicbrainz_id, artist_details.metadata)
+ item_id = await self.mass.db.add_artist(artist_details)
+ # also fetch same artist on all providers
+ if not skip_match:
+ new_artist = await self.mass.db.artist(item_id)
+ item_provider_keys = [item['provider'] for item in new_artist.provider_ids]
+ for prov_id, provider in self.mass.music.providers.items():
+ if not prov_id in item_provider_keys:
+ await provider.match_artist(new_artist)
+ return item_id
+
+ async def get_artist_musicbrainz_id(self, artist_details:Artist, allow_fallback=False):
+ ''' fetch musicbrainz id by performing search with both the artist and one of it's albums or tracks '''
+ musicbrainz_id = ""
+ # try with album first
+ lookup_albums = await self.get_artist_albums(artist_details.item_id)
+ for lookup_album in lookup_albums[:10]:
+ lookup_album_upc = None
+ for item in lookup_album.external_ids:
+ if item.get("upc"):
+ lookup_album_upc = item["upc"]
+ break
+ musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name,
+ albumname=lookup_album.name, album_upc=lookup_album_upc)
+ if musicbrainz_id:
+ break
+ # fallback to track
+ lookup_tracks = await self.get_artist_toptracks(artist_details.item_id)
+ for lookup_track in lookup_tracks[:10]:
+ lookup_track_isrc = None
+ for item in lookup_track.external_ids:
+ if item.get("isrc"):
+ lookup_track_isrc = item["isrc"]
+ break
+ musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name,
+ trackname=lookup_track.name, track_isrc=lookup_track_isrc)
+ if musicbrainz_id:
+ break
+ if not musicbrainz_id:
+ LOGGER.warning("Unable to get musicbrainz ID for artist %s !" % artist_details.name)
+ if allow_fallback:
+ musicbrainz_id = artist_details.name
+ return musicbrainz_id
+
+ async def album(self, prov_item_id, lazy=True) -> Album:
+ ''' return album details for the given provider album id'''
+ item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Album)
+ if not item_id:
+ # album not yet in local database so fetch details
+ album_details = await self.get_album(prov_item_id)
+ if not album_details:
+ LOGGER.warning('album not found: %s' % prov_item_id)
+ return album_details
+ if lazy:
+ asyncio.create_task(self.add_album(album_details))
+ album_details.is_lazy = True
+ return album_details
+ item_id = await self.add_album(album_details)
+ return await self.mass.db.album(item_id)
+
+ async def add_album(self, album_details, skip_match=False) -> int:
+ ''' add album to local db and return the new database id'''
+ # we need to fetch album artist too
+ db_album_artist = await self.artist(album_details.artist.item_id, lazy=False)
+ album_details.artist = db_album_artist
+ item_id = await self.mass.db.add_album(album_details)
+ # also fetch same album on all providers
+ if not skip_match:
+ new_album = await self.mass.db.album(item_id)
+ item_provider_keys = [item['provider'] for item in new_album.provider_ids]
+ for prov_id, provider in self.mass.music.providers.items():
+ if not prov_id in item_provider_keys:
+ await provider.match_album(new_album)
+ return item_id
+
+ async def track(self, prov_item_id, lazy=True, track_details=None) -> Track:
+ ''' return track details for the given provider track id '''
+ item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Track)
+ if not item_id:
+ # album not yet in local database so fetch details
+ if not track_details:
+ track_details = await self.get_track(prov_item_id)
+ if not track_details:
+ LOGGER.warning('track not found: %s' % prov_item_id)
+ return None
+ if lazy:
+ asyncio.ensure_future(self.add_track(track_details))
+ track_details.is_lazy = True
+ return track_details
+ item_id = await self.add_track(track_details)
+ return await self.mass.db.track(item_id)
+
+ async def add_track(self, track_details, prov_album_id=None, skip_match=False) -> int:
+ ''' add track to local db and return the new database id'''
+ track_artists = []
+ assert(track_details)
+ # we need to fetch track artists too
+ for track_artist in track_details.artists:
+ prov_item_id = track_artist.item_id
+ db_track_artist = await self.artist(prov_item_id, lazy=False)
+ assert(db_track_artist)
+ track_artists.append(db_track_artist)
+ track_details.artists = track_artists
+ if not prov_album_id:
+ prov_album_id = track_details.album.item_id
+ track_details.album = await self.album(prov_album_id, lazy=False)
+ item_id = await self.mass.db.add_track(track_details)
+ # also fetch same track on all providers
+ if not skip_match:
+ new_track = await self.mass.db.track(item_id)
+ item_provider_keys = [item['provider'] for item in new_track.provider_ids]
+ for prov_id, provider in self.mass.music.providers.items():
+ if not prov_id in item_provider_keys:
+ await provider.match_track(new_track)
+ return item_id
+
+ async def playlist(self, prov_item_id) -> Playlist:
+ ''' return playlist details for the given provider playlist id '''
+ item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Playlist)
+ if item_id:
+ return await self.mass.db.playlist(item_id)
+ else:
+ return await self.get_playlist(prov_item_id)
+
+ async def add_playlist(self, playlist_details) -> int:
+ ''' add playlist to local db and return the (new) database id'''
+ item_id = await self.mass.db.add_playlist(playlist_details)
+ return item_id
+
+ async def album_tracks(self, prov_album_id) -> List[Track]:
+ ''' return album tracks for the given provider album id'''
+ items = []
+ album = await self.get_album(prov_album_id)
+ for prov_track in await self.get_album_tracks(prov_album_id):
+ prov_track.album = album
+ track = await self.track(prov_track.item_id, track_details=prov_track)
+ items.append(track)
+ return items
+
+ async def playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]:
+ ''' return playlist tracks for the given provider playlist id'''
+ items = []
+ for prov_track in await self.get_playlist_tracks(prov_playlist_id, limit=limit, offset=offset):
+ for prov_mapping in prov_track.provider_ids:
+ item_prov_id = prov_mapping["provider"]
+ prov_item_id = prov_mapping["item_id"]
+ db_id = await self.mass.db.get_database_id(item_prov_id, prov_item_id, MediaType.Track)
+ if db_id:
+ items.append( await self.mass.db.track(db_id) )
+ else:
+ items.append(prov_track)
+ asyncio.create_task(self.add_track(prov_track))
+ return items
+
+ async def artist_toptracks(self, prov_item_id) -> List[Track]:
+ ''' return top tracks for an artist '''
+ items = []
+ for prov_track in await self.get_artist_toptracks(prov_item_id):
+ db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track)
+ if db_id:
+ items.append( await self.mass.db.track(db_id) )
+ else:
+ items.append(prov_track)
+ asyncio.create_task(self.add_track(prov_track))
+ return items
+
+ async def artist_albums(self, prov_item_id) -> List[Track]:
+ ''' return (all) albums for an artist '''
+ items = []
+ for prov_album in await self.get_artist_albums(prov_item_id):
+ db_id = await self.mass.db.get_database_id(self.prov_id, prov_album.item_id, MediaType.Album)
+ if db_id:
+ items.append( await self.mass.db.album(db_id) )
+ else:
+ items.append(prov_album)
+ asyncio.create_task(self.add_album(prov_album))
+ return items
+
+ async def match_artist(self, searchartist:Artist):
+ ''' try to match artist in this provider by supplying db artist '''
+ for prov_mapping in searchartist.provider_ids:
+ if prov_mapping["provider"] == self.prov_id:
+ return # we already have a mapping on this provider
+ search_results = await self.search(searchartist.name, [MediaType.Artist], limit=2)
+ for item in search_results["artists"]:
+ if item.name.lower() == searchartist.name.lower():
+ # just lazy load this item in the database, it will be matched automagically ;-)
+ db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Artist)
+ if not db_id:
+ asyncio.create_task(self.add_artist(item, skip_match=True))
+
+ async def match_album(self, searchalbum:Album):
+ ''' try to match album in this provider by supplying db album '''
+ for prov_mapping in searchalbum.provider_ids:
+ if prov_mapping["provider"] == self.prov_id:
+ return # we already have a mapping on this provider
+ searchstr = "%s - %s %s" %(searchalbum.artist.name, searchalbum.name, searchalbum.version)
+ search_results = await self.search(searchstr, [MediaType.Album], limit=5)
+ for item in search_results["albums"]:
+ if item.name == searchalbum.name and item.version == searchalbum.version and item.artist.name == searchalbum.artist.name:
+ # just lazy load this item in the database, it will be matched automagically ;-)
+ db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Album)
+ if not db_id:
+ asyncio.create_task(self.add_album(item, skip_match=True))
+
+ async def match_track(self, searchtrack:Album):
+ ''' try to match track in this provider by supplying db track '''
+ for prov_mapping in searchtrack.provider_ids:
+ if prov_mapping["provider"] == self.prov_id:
+ return # we already have a mapping on this provider
+ searchstr = "%s - %s" %(searchtrack.artists[0].name, searchtrack.name)
+ search_results = await self.search(searchstr, [MediaType.Track], limit=5)
+ for item in search_results["tracks"]:
+ if item.name == searchtrack.name and item.version == searchtrack.version and item.album.name == searchtrack.album.name:
+ # just lazy load this item in the database, it will be matched automagically ;-)
+ db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Track)
+ if not db_id:
+ asyncio.create_task(self.add_track(item, skip_match=True))
+
+ ### Provider specific implementation #####
+
+ async def search(self, searchstring, media_types=List[MediaType], limit=5):
+ ''' perform search on the provider '''
+ raise NotImplementedError
+
+ async def get_library_artists(self) -> List[Artist]:
+ ''' retrieve library artists from the provider '''
+ raise NotImplementedError
+
+ async def get_library_albums(self) -> List[Album]:
+ ''' retrieve library albums from the provider '''
+ raise NotImplementedError
+
+ async def get_library_tracks(self) -> List[Track]:
+ ''' retrieve library tracks from the provider '''
+ raise NotImplementedError
+
+ async def get_library_playlists(self) -> List[Playlist]:
+ ''' retrieve library/subscribed playlists from the provider '''
+ raise NotImplementedError
+
+ async def get_artist(self, prov_item_id) -> Artist:
+ ''' get full artist details by id '''
+ raise NotImplementedError
+
+ async def get_artist_albums(self, prov_item_id) -> List[Album]:
+ ''' get a list of albums for the given artist '''
+ raise NotImplementedError
+
+ async def get_artist_toptracks(self, prov_item_id) -> List[Track]:
+ ''' get a list of most popular tracks for the given artist '''
+ raise NotImplementedError
+
+ async def get_album(self, prov_item_id) -> Album:
+ ''' get full album details by id '''
+ raise NotImplementedError
+
+ async def get_track(self, prov_item_id) -> Track:
+ ''' get full track details by id '''
+ raise NotImplementedError
+
+ async def get_playlist(self, prov_item_id) -> Playlist:
+ ''' get full playlist details by id '''
+ raise NotImplementedError
+
+ async def get_album_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]:
+ ''' get album tracks for given album id '''
+ raise NotImplementedError
+
+ async def get_playlist_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]:
+ ''' get playlist tracks for given playlist id '''
+ raise NotImplementedError
+
+ async def add_library(self, prov_item_id, media_type:MediaType):
+ ''' add item to library '''
+ raise NotImplementedError
+
+ async def remove_library(self, prov_item_id, media_type:MediaType):
+ ''' remove item from library '''
+ raise NotImplementedError
+
+class PlayerState(str, Enum):
+ Off = "off"
+ Stopped = "stopped"
+ Paused = "paused"
+ Playing = "playing"
+
+class MusicPlayer():
+ ''' representation of a musicplayer '''
+ def __init__(self):
+ self.player_id = None
+ self.player_provider = None
+ self.name = ''
+ self.state = PlayerState.Off
+ self.powered = False
+ self.cur_item = Track()
+ self.cur_item_time = 0
+ self.volume_level = 0
+ self.shuffle_enabled = False
+ self.repeat_enabled = False
+ self.muted = False
+ self.group_parent = None # set to id of REAL group/parent player
+ self.is_group = False # is this player a group player ?
+ self.disable_volume = False
+ self.mute_as_power = False
+ self.apply_group_volume = False
+ self.enabled = False
+
+class PlayerProvider():
+ '''
+ Model for a Playerprovider
+ Common methods usable for every provider
+ Provider specific __get methods shoud be overriden in the provider specific implementation
+ '''
+ name = 'My great Musicplayer provider' # display name
+ prov_id = 'my_provider' # used as id
+ icon = ''
+ supports_queue = True # whether this provider has native support for a queue
+ supports_http_stream = True # whether we can fallback to http streaming
+ supported_musicproviders = [ # list with tuples of supported provider_id and media_types this playerprovider supports NATIVELY, order by preference/quality
+ ('qobuz', [MediaType.Track]),
+ ('file', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]),
+ ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist])
+ ]
+
+ def __init__(self, mass):
+ self.mass = mass
+
+ ### Common methods and properties ####
+
+
+ async def play_media(self, player_id, uri, queue_opt='play'):
+ '''
+ play media on a player
+ params:
+ - player_id: id of the player
+ - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream)
+ - queue_opt:
+ replace: replace whatever is currently playing with this media
+ next: the given media will be played after the currently playing track
+ add: add to the end of the queue
+ play: keep existing queue but play the given item now
+ '''
+ raise NotImplementedError
+
+
+ ### Provider specific implementation #####
+
+ async def player_command(self, player_id, cmd:str, cmd_args=None):
+ ''' issue command on player (play, pause, next, previous, stop, power, volume) '''
+ raise NotImplementedError
+
+ async def player_queue(self, player_id, offset=0, limit=50):
+ ''' return the items in the player's queue '''
+ raise NotImplementedError
+
+
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import sys
+import time
+sys.path.append("..")
+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 cache import use_cache
+
+
+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", "", "Path to music files"),
+ ("playlists_dir", "", "Path to playlists")
+ ]
+
+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_library_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.path.isdir(prov_item_id):
+ LOGGER.error("artist path does not exist: %s" % prov_item_id)
+ return None
+ if "\\" in prov_item_id:
+ name = prov_item_id.split("\\")[-1]
+ else:
+ name = prov_item_id.split("/")[-1]
+ artist = Artist()
+ artist.item_id = prov_item_id # temporary id
+ artist.name = name
+ artist.provider_ids.append({
+ "provider": self.prov_id,
+ "item_id": prov_item_id
+ })
+ return artist
+
+ async def get_album(self, prov_item_id) -> Album:
+ ''' get full album details by id '''
+ if not os.path.isdir(prov_item_id):
+ LOGGER.error("album path does not exist: %s" % prov_item_id)
+ return None
+ if "\\" in prov_item_id:
+ name = prov_item_id.split("\\")[-1]
+ artistpath = prov_item_id.rsplit("\\", 1)[0]
+ else:
+ name = prov_item_id.split("/")[-1]
+ artistpath = prov_item_id.rsplit("/", 1)[0]
+ album = Album()
+ album.item_id = prov_item_id # temporary 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.path.isfile(prov_item_id):
+ LOGGER.error("track path does not exist: %s" % prov_item_id)
+ return None
+ return await self.__parse_track(prov_item_id)
+
+ async def get_playlist(self, prov_item_id) -> Playlist:
+ ''' get full playlist details by id '''
+ if not os.path.isfile(prov_item_id):
+ LOGGER.error("playlist path does not exist: %s" % prov_item_id)
+ return None
+ filepath = prov_item_id
+ playlist = Playlist()
+ playlist.item_id = filepath # temporary id
+ playlist.name = filepath.split('\\')[-1].split('/')[-1].replace('.m3u', '')
+ playlist.provider_ids.append({
+ "provider": self.prov_id,
+ "item_id": filepath
+ })
+ playlist.owner = 'disk'
+ return playlist
+
+ async def get_album_tracks(self, prov_album_id) -> List[Track]:
+ ''' get album tracks for given album id '''
+ result = []
+ 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.path.isfile(prov_playlist_id):
+ LOGGER.error("playlist path does not exist: %s" % prov_playlist_id)
+ return []
+ counter = 0
+ with open(prov_playlist_id) 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 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 = []
+ 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_details(self, track_id):
+ ''' returns the stream details for the given track '''
+ track = await self.track(track_id)
+ import socket
+ host = socket.gethostbyname(socket.gethostname())
+ return {
+ 'mime_type': 'audio/flac',
+ 'duration': track.duration,
+ 'sampling_rate': 44100,
+ 'bit_depth': 16,
+ 'url': 'http://%s/stream/file/%s' % (host, track_id)
+ }
+
+ async def get_stream(self, track_id):
+ ''' get audio stream for a track '''
+ 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 ?
+ track.duration = song.length
+ track.item_id = filename # temporary id
+ name = song.tags['TITLE'][0]
+ track.name, track.version = parse_track_title(name)
+ if "\\" in filename:
+ albumpath = filename.rsplit("\\",1)[0]
+ else:
+ albumpath = filename.rsplit("/",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": fake_artistpath
+ })
+ 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])
+ if filename.endswith('.flac'):
+ # TODO: try to get more quality info
+ quality = TrackQuality.FLAC_LOSSLESS
+ elif filename.endswith('.ogg'):
+ quality = TrackQuality.LOSSY_OGG
+ elif filename.endswith('.m4a'):
+ quality = TrackQuality.LOSSY_AAC
+ else:
+ quality = TrackQuality.LOSSY_MP3
+ track.provider_ids.append({
+ "provider": self.prov_id,
+ "item_id": filename,
+ "quality": quality
+ })
+ 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 sys
+sys.path.append("..")
+from utils import run_periodic, LOGGER, parse_track_title
+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 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:
+ spotify_provider = QobuzProvider(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 QobuzProvider(MusicProvider):
+
+
+ def __init__(self, mass, username, password):
+ self.name = 'Qobuz'
+ self.prov_id = 'qobuz'
+ self._cur_user = None
+ self.mass = mass
+ self.cache = mass.cache
+ self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+ self.__username = username
+ self.__password = password
+ self.__user_auth_token = None
+ self.__app_id = "285473059"
+ self.__app_secret = "47249d0eaefa6bf43a959c09aacdbce8"
+ self.__logged_in = False
+ self.throttler = Throttler(rate_limit=1, period=0.5)
+
+ 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_library_playlists(self) -> List[Playlist]:
+ ''' retrieve playlists from the provider '''
+ result = []
+ for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists'):
+ 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)
+ 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 item["streamable"] and item['artist']['id'] == int(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
+ return []
+
+ 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 get_stream_details(self, track_id):
+ ''' returns the stream details for the provider '''
+ params = {'format_id': 27, 'track_id': track_id, 'intent': 'stream'}
+ return await self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True)
+
+ async def get_stream(self, track_id):
+ ''' get audio stream for a track '''
+ track_details = await self.get_stream_details(track_id)
+ url = track_details['url']
+ async with self.http_session.get(url) as response:
+ while True:
+ chunk = await response.content.read(262144)
+ if not chunk:
+ LOGGER.debug('end of stream')
+ break
+ yield chunk
+
+ async def __parse_artist(self, artist_obj):
+ ''' parse spotify artist object to generic layout '''
+ artist = Artist()
+ if not artist_obj.get('id'):
+ return None
+ artist.item_id = artist_obj['id'] # temporary 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 spotify 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'] # temporary 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 spotify 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" % track_obj.get('id'))
+ return None
+ track.item_id = track_obj['id'] # temporary id
+ if track_obj.get('performer') and not 'Various ' in track_obj['performer']:
+ artist = await self.__parse_artist(track_obj['performer'])
+ 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_LOSSLES_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 spotify playlist object to generic layout '''
+ playlist = Playlist()
+ if not playlist_obj.get('id'):
+ return None
+ playlist.item_id = playlist_obj['id'] # temporary 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']
+ 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_token:
+ return self.__user_auth_token
+ params = { "username": self.__username, "password": self.__password}
+ details = await self.__get_data("user/login", params, ignore_cache=True)
+ self.__user_auth_token = details["user_auth_token"]
+ 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": self.__app_id}
+ 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 + self.__app_secret
+ request_sig = str(hashlib.md5(request_sig.encode()).hexdigest())
+ params["request_ts"] = request_ts
+ params["request_sig"] = request_sig
+ params["app_id"] = self.__app_id
+ params["user_auth_token"] = self.__user_auth_token
+
+ async with self.http_session.get(url, headers=headers, params=params) as response:
+ result = await response.json()
+ if 'error' in result:
+ LOGGER.error(url)
+ LOGGER.error(params)
+ LOGGER.error(result)
+ result = None
+ result = await response.json()
+ return result
+
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import sys
+import time
+sys.path.append("..")
+from utils import run_periodic, LOGGER, parse_track_title
+from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist
+from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED
+import json
+import aiohttp
+from cache import use_cache
+
+
+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)
+ 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_library_playlists(self) -> List[Playlist]:
+ ''' retrieve playlists from the provider '''
+ result = []
+ for item in await self.__get_all_items("me/playlists"):
+ 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 '''
+ 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:
+ 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):
+ ''' returns the stream details for the provider '''
+ track = await self.track(track_id)
+ import socket
+ host = socket.gethostbyname(socket.gethostname())
+ return {
+ 'mime_type': 'audio/ogg',
+ 'duration': track.duration,
+ 'sampling_rate': 44100,
+ 'bit_depth': 16,
+ 'url': 'http://%s/stream/spotify/%s' % (host, track_id)
+ }
+
+ async def get_stream(self, track_id):
+ ''' get audio stream for a track '''
+ import subprocess
+ spotty = self.get_spotty_binary()
+ cmd = [spotty, '-n', 'temp', '-u', self._username, '-p', self._password, '--pass-through', '--single-track', track_id]
+ process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE)
+ while not process.stdout.at_eof():
+ line = await process.stdout.readline()
+ if line:
+ yield line
+ await process.wait()
+
+ async def __parse_artist(self, artist_obj):
+ ''' parse spotify artist object to generic layout '''
+ artist = Artist()
+ artist.item_id = artist_obj['id'] # temporary 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'] # temporary 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']:
+ return None
+ track = Track()
+ track.item_id = track_obj['id'] # temporary 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'] # temporary 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']
+ 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)
+ clientid = '2eb96f9b37494be1824999d58028a305'
+ args = [self.get_spotty_binary(), "-t", "--client-id", clientid, "--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.http_session.get(url, headers=headers, params=params) as response:
+ result = await response.json()
+ if 'error' in result:
+ LOGGER.error(url)
+ LOGGER.error(params)
+ raise Exception(result['error'])
+ 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 random
+import sys
+sys.path.append("..")
+from utils import run_periodic, run_background_task, 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 cache import use_cache
+import copy
+import pychromecast
+from pychromecast.controllers.multizone import MultizoneController
+from pychromecast.controllers import BaseController
+from pychromecast.controllers.spotify import SpotifyController
+import logging
+logging.getLogger("pychromecast").setLevel(logging.WARNING)
+
+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 ChromecastProvider(PlayerProvider):
+ ''' support for Home Assistant '''
+
+ def __init__(self, mass):
+ self.prov_id = 'chromecast'
+ self.name = 'Chromecast'
+ self.icon = ''
+ self.mass = mass
+ self._players = {}
+ self.supports_queue = False
+ self.supports_http_stream = True
+ self.supported_musicproviders = [
+ ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]),
+ ('http', [MediaType.Track])
+ ]
+ self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+ asyncio.ensure_future(self.__discover_chromecasts())
+
+
+ ### Provider specific implementation #####
+
+ 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':
+ self._players[player_id].cast.media_controller.play()
+ elif cmd == 'pause':
+ self._players[player_id].cast.media_controller.pause()
+ elif cmd == 'stop':
+ self._players[player_id].cast.media_controller.stop()
+ elif cmd == 'next':
+ self._players[player_id].cast.media_controller.queue_next()
+ elif cmd == 'previous':
+ self._players[player_id].cast.media_controller.queue_previous()
+ elif cmd == 'power' and cmd_args in ['on', '1', 1]:
+ # power is not supported
+ self._players[player_id].state = PlayerState.Stopped
+ self._players[player_id].cast.media_controller.play()
+ elif cmd == 'power' and cmd_args in ['off', '0', 0]:
+ # power is not supported
+ self._players[player_id].state = PlayerState.Off
+ self._players[player_id].cast.media_controller.stop()
+ elif cmd == 'volume':
+ self._players[player_id].cast.set_volume(try_parse_int(cmd_args)/100)
+ elif cmd == 'mute' and cmd_args in ['on', '1', 1]:
+ self._players[player_id].cast.set_volume_muted(True)
+ elif cmd == 'mute' and cmd_args in ['off', '0', 0]:
+ self._players[player_id].cast.set_volume_muted(False)
+
+ async def play_media(self, player_id, uri, queue_opt='play'):
+ '''
+ play media on a player
+ params:
+ - player_id: id of the player
+ - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream)
+ - queue_opt:
+ replace: replace whatever is currently playing with this media
+ next: the given media will be played after the currently playing track
+ add: add to the end of the queue
+ play: keep existing queue but play the given item now
+ '''
+ if uri.startswith('spotify:'):
+ # native spotify playback
+ uri = uri.replace('spotify://', '')
+ from pychromecast.controllers.spotify import SpotifyController
+ spotify = self.mass.music.providers['spotify']
+ token = await spotify.get_token()
+ sp = SpController(token['accessToken'], token['expiresIn'])
+ self._players[player_id].cast.register_handler(sp)
+ sp.launch_app()
+ spotify_player_id = sp.device
+ if spotify_player_id:
+ return await spotify.play_media(spotify_player_id, uri)
+ else:
+ LOGGER.error('player not found in spotify! %s' % player_id)
+ elif uri.startswith('http'):
+ self._players[player_id].cast.media_controller.play_media(uri, 'audio/flac')
+ else:
+ raise Exception("Not supported media_type or uri")
+
+ ### Provider specific (helper) methods #####
+
+ 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._players[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
+ player.powered = not caststatus.is_stand_by
+ if mediastatus:
+ 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_item = await self.__parse_track(mediastatus)
+ player.cur_item_time = try_parse_int(mediastatus.current_time)
+ await self.mass.player.update_player(player)
+
+ async def __parse_track(self, mediastatus):
+ ''' parse track in CC to our internal format '''
+ if mediastatus.content_type == 'application/x-spotify.track':
+ track_id = mediastatus.content_id.replace('spotify:track:','')
+ track = await self.mass.music.providers['spotify'].track(track_id)
+ else:
+ # TODO: match this info manually in the DB!!
+ track = Track()
+ artist = mediastatus.artist
+ album = mediastatus.album_name
+ title = mediastatus.title
+ track.name = "%s - %s" %(artist, title)
+ track.duration = try_parse_int(mediastatus.duration)
+ if mediastatus.media_metadata and mediastatus.media_metadata.get('images'):
+ track.metadata.image = mediastatus.media_metadata['images'][-1]['url']
+ return track
+
+ async def __handle_group_members_update(self, mz, added_player=None, removed_player=None):
+ ''' callback when cast group members update '''
+ if added_player:
+ if added_player in self._players:
+ self._players[added_player].group_parent = str(mz._uuid)
+ elif removed_player:
+ if removed_player in self._players:
+ self._players[removed_player].group_parent = None
+ else:
+ for member in mz.members:
+ if member in self._players:
+ self._players[member].group_parent = str(mz._uuid)
+
+ @run_periodic(600)
+ async def __discover_chromecasts(self):
+ ''' discover chromecasts on the network '''
+ LOGGER.info('Starting Chromecast discovery...')
+ bg_task = run_background_task(self.mass.bg_executor, pychromecast.get_chromecasts)
+ chromecasts = await asyncio.gather(bg_task)
+ for chromecast in chromecasts[0]:
+ player_id = str(chromecast.uuid)
+ if not player_id in self._players:
+ player = MusicPlayer()
+ player.player_id = player_id
+ player.name = chromecast.name
+ chromecast.start()
+ 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)
+ 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.wait()
+ player.cast = chromecast
+ player.player_provider = self.prov_id
+ self._players[player_id] = player
+ LOGGER.info('Chromecast discovery done...')
+
+
+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)
+
+
+class SpController(SpotifyController):
+ """ Controller to interact with Spotify namespace. """
+ def receive_message(self, message, data):
+ """ handle the auth flow and active player selection """
+ if data['type'] == 'setCredentialsResponse':
+ self.send_message({'type': 'getInfo', 'payload': {}})
+ if data['type'] == 'setCredentialsError':
+ self.device = None
+ if data['type'] == 'getInfoResponse':
+ self.device = data['payload']['deviceID']
+ self.is_launched = True
+ return True
--- /dev/null
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+
+import asyncio
+import os
+from typing import List
+import random
+import sys
+sys.path.append("..")
+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 cache import use_cache
+import copy
+
+def setup(mass):
+ ''' setup the provider'''
+ enabled = mass.config["playerproviders"]['homeassistant'].get(CONF_ENABLED)
+ token = mass.config["playerproviders"]['homeassistant'].get('token')
+ hostname = mass.config["playerproviders"]['homeassistant'].get(CONF_HOSTNAME)
+ if enabled and hostname and token:
+ provider = HassProvider(mass, hostname, token)
+ 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),
+ ('token', '<password>', 'Long Lived Access Token')
+ ]
+
+class HassProvider(PlayerProvider):
+ ''' support for Home Assistant '''
+
+ def __init__(self, mass, hostname, token):
+ self.prov_id = 'homeassistant'
+ self.name = 'Home Assistant'
+ self.icon = ''
+ self.mass = mass
+ self._players = {}
+ self._token = token
+ self._host = hostname
+ self.supports_queue = False
+ self.supports_http_stream = True # whether we can fallback to http streaming
+ self.supported_musicproviders = [] # we have no idea about the mediaplayers attached to hass so assume we can only do http playback
+ self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+ self.__send_ws = None
+ self.__last_id = 10
+ asyncio.ensure_future(self.__hass_connect())
+
+
+ ### Provider specific implementation #####
+
+ async def player_command(self, player_id, cmd:str, cmd_args=None):
+ ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) '''
+ service_data = {"entity_id": player_id}
+ service = None
+ if cmd == 'play':
+ service = 'media_play'
+ elif cmd == 'pause':
+ service = 'media_pause'
+ elif cmd == 'stop':
+ service = 'media_stop'
+ elif cmd == 'next':
+ service = 'media_next_track'
+ elif cmd == 'previous':
+ service = 'media_previous_track'
+ elif cmd == 'power' and cmd_args in ['on', '1', 1]:
+ service = 'turn_on'
+ elif cmd == 'power' and cmd_args in ['off', '0', 0]:
+ service = 'turn_off'
+ elif cmd == 'volume' and cmd_args == 'up':
+ service = 'volume_up'
+ elif cmd == 'volume' and cmd_args == 'down':
+ service = 'volume_down'
+ elif cmd == 'volume':
+ service = 'volume_set'
+ service_data['volume_level'] = try_parse_int(cmd_args) / 100
+ self._players[player_id].volume_level = try_parse_int(cmd_args)
+ elif cmd == 'mute' and cmd_args in ['on', '1', 1]:
+ service = 'volume_mute'
+ service_data['is_volume_muted'] = True
+ elif cmd == 'mute' and cmd_args in ['off', '0', 0]:
+ service = 'volume_mute'
+ service_data['is_volume_muted'] = False
+ return await self.__call_service(service, service_data)
+
+ async def play_media(self, player_id, uri, queue_opt='play'):
+ '''
+ play media on a player
+ params:
+ - player_id: id of the player
+ - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream)
+ - queue_opt:
+ replace: replace whatever is currently playing with this media
+ next: the given media will be played after the currently playing track
+ add: add to the end of the queue
+ play: keep existing queue but play the given item now
+ '''
+ service = "play_media"
+ service_data = {
+ "entity_id": player_id,
+ "media_content_id": uri,
+ "media_content_type": "music"
+ }
+ return await self.__call_service(service, service_data)
+
+ async def __call_service(self, service, service_data=None, domain='media_player'):
+ ''' 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)
+
+ ### Provider specific (helper) methods #####
+
+ async def __handle_player_state(self, data):
+ ''' handle a player state message from the websockets '''
+ player_id = data['entity_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 = data['attributes']['friendly_name']
+ player.powered = not data['state'] == 'off'
+ if data['state'] == 'playing':
+ player.state == PlayerState.Playing
+ elif data['state'] == 'paused':
+ player.state == PlayerState.Paused
+ else:
+ player.state = PlayerState.Stopped
+ if 'is_volume_muted' in data['attributes']:
+ player.muted = data['attributes']['is_volume_muted']
+ if 'volume_level' in data['attributes']:
+ player.volume_level = float(data['attributes']['volume_level']) * 100
+ if 'media_position' in data['attributes']:
+ player.cur_item_time = try_parse_int(data['attributes']['media_position'])
+ player.cur_item = await self.__parse_track(data)
+ await self.mass.player.update_player(player)
+
+ async def __parse_track(self, data):
+ ''' parse track in hass to our internal format '''
+ track = Track()
+ # TODO: match this info in the DB!
+ if 'media_content_id' in data['attributes']:
+ artist = data['attributes'].get('media_artist')
+ album = data['attributes'].get('media_album')
+ title = data['attributes'].get('media_title')
+ track.name = "%s - %s" %(artist, title)
+ if 'entity_picture' in data['attributes']:
+ img = "https://%s%s" %(self._host, data['attributes']['entity_picture'])
+ track.metadata['image'] = img
+ track.duration = try_parse_int(data['attributes'].get('media_duration',0))
+ return track
+
+ async def __hass_connect(self):
+ ''' Receive events from Hass through websockets '''
+ while True:
+ try:
+ async with self.http_session.ws_connect('wss://%s/api/websocket' % 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": "get_states"}
+ await send_msg(subscribe_msg)
+ elif data['type'] == 'event' and data['event']['event_type'] == 'state_changed':
+ if data['event']['data']['entity_id'].startswith('media_player'):
+ asyncio.ensure_future(self.__handle_player_state(data['event']['data']['new_state']))
+ elif data['type'] == 'result' and data.get('result'):
+ # reply to our get_states request
+ for item in data['result']:
+ if item['entity_id'].startswith('media_player'):
+ asyncio.ensure_future(self.__handle_player_state(item))
+ else:
+ LOGGER.info(data)
+ elif msg.type == aiohttp.WSMsgType.ERROR:
+ break
+ except Exception as exc:
+ LOGGER.exception(exc)
+ asyncio.sleep(10)
\ 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
+sys.path.append("..")
+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 cache import use_cache
+import copy
+
+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, True, 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._players = {}
+ self.last_msg_received = 0
+ self.supports_queue = True # whether this provider has native support for a queue
+ self.supports_http_stream = True # whether we can fallback to http streaming
+ self.supported_musicproviders = [
+ ('qobuz', [MediaType.Track]),
+ ('file', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]),
+ ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]),
+ ('http', [MediaType.Track])
+ ]
+ 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_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 in ['on', '1', 1]:
+ lms_commands = ['power', '1']
+ elif cmd == 'power' and cmd_args in ['off', '0', 0]:
+ lms_commands = ['power', '0']
+ elif cmd == 'volume' and cmd_args == 'up':
+ lms_commands = ['mixer', 'volume', '+2']
+ elif cmd == 'volume' and cmd_args == 'down':
+ lms_commands = ['mixer', 'volume', '-2']
+ elif cmd == 'volume':
+ lms_commands = ['mixer', 'volume', cmd_args]
+ elif cmd == 'mute' and cmd_args in ['on', '1', 1]:
+ lms_commands = ['mixer', 'muting', '1']
+ elif cmd == 'mute' and cmd_args in ['off', '0', 0]:
+ lms_commands = ['mixer', 'muting', '0']
+ return await self.__get_data(lms_commands, player_id=player_id)
+
+ async def play_media(self, player_id, uri, queue_opt='play'):
+ '''
+ play media on a player
+ params:
+ - player_id: id of the player
+ - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream)
+ - queue_opt:
+ replace: replace whatever is currently playing with this media
+ next: the given media will be played after the currently playing track
+ add: add to the end of the queue
+ play: keep existing queue but play the given item now
+ '''
+ if queue_opt == 'play':
+ cmd = ['playlist', 'insert', uri]
+ await self.__get_data(cmd, player_id=player_id)
+ cmd2 = ['playlist', 'index', '+1']
+ return await self.__get_data(cmd2, player_id=player_id)
+ elif queue_opt == 'replace':
+ cmd = ['playlist', 'play', uri]
+ return await self.__get_data(cmd, player_id=player_id)
+ elif queue_opt == 'next':
+ cmd = ['playlist', 'insert', uri]
+ return await self.__get_data(cmd, player_id=player_id)
+ else:
+ cmd = ['playlist', 'add', uri]
+ return 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 = []
+ cur_index = await self.__get_data(["playlist", "index", "?"], player_id=player_id)
+ cur_index = int(cur_index['_index'])
+ offset += cur_index # we do not care about already played tracks
+ player_details = await self.__get_data(["status", offset, limit, "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id)
+ 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
+ player.powered = player_details['power'] == 1
+ 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:
+ 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)
+ # fallback to a generic track
+ track = Track()
+ track.name = track_details['title']
+ track.duration = int(track_details['duration'])
+ 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 True:
+ 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
+from utils import run_periodic, run_async_background_task, LOGGER
+import aiohttp
+from difflib import SequenceMatcher as Matcher
+from models import MediaType
+from typing import List
+import toolz
+import operator
+
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MODULES_PATH = os.path.join(BASE_DIR, "modules", "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, lazy=True):
+ ''' get single music item by id and media type'''
+ if media_type == MediaType.Artist:
+ return await self.artist(item_id, lazy=lazy)
+ elif media_type == MediaType.Album:
+ return await self.album(item_id, lazy=lazy)
+ elif media_type == MediaType.Track:
+ return await self.track(item_id, lazy=lazy)
+ elif media_type == MediaType.Playlist:
+ return await self.playlist(item_id)
+ else:
+ return None
+
+ async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None):
+ ''' 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):
+ ''' 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):
+ ''' 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 library_playlists(self, limit=0, offset=0, orderby='name', provider_filter=None):
+ ''' return all library playlists, optionally filtered by provider '''
+ return await self.mass.db.library_playlists(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):
+ ''' 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.library_playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
+
+ async def artist(self, item_id, lazy=True):
+ ''' get artist by id '''
+ artist = await self.mass.db.artist(item_id)
+ if artist:
+ return artist
+ # not a database id, probably a provider id
+ for provider in self.providers.values():
+ artist = await provider.artist(item_id, lazy=lazy)
+ if artist:
+ return artist
+ raise Exception("Artist %s is not found" % item_id)
+
+ async def album(self, item_id, lazy=True):
+ ''' get album by id '''
+ album = await self.mass.db.album(item_id)
+ if album:
+ return album
+ # not a database id, probably a provider id
+ for provider in self.providers.values():
+ album = await provider.album(item_id, lazy=lazy)
+ if album:
+ return album
+ raise Exception("Album %s is not found" % item_id)
+
+ async def track(self, item_id, lazy=True):
+ ''' get track by id '''
+ track = await self.mass.db.track(item_id)
+ if track:
+ return track
+ # not a database id, probably a provider id
+ for provider in self.providers.values():
+ track = await provider.track(item_id, lazy=lazy)
+ if track:
+ return track
+ raise Exception("Track %s is not found" % item_id)
+
+ async def playlist(self, item_id):
+ ''' get playlist by id '''
+ playlist = await self.mass.db.playlist(item_id)
+ if playlist:
+ return playlist
+ # not a database id, probably a provider id
+ for provider in self.providers.values():
+ playlist = await provider.playlist(item_id)
+ if playlist:
+ return playlist
+ raise Exception("Playlist %s is not found" % item_id)
+
+ async def artist_toptracks(self, artist_id):
+ ''' get top tracks for given artist '''
+ items = []
+ artist = await self.artist(artist_id)
+ # 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):
+ ''' get (all) albums for given artist '''
+ items = []
+ artist = await self.artist(artist_id)
+ # 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):
+ ''' get the album tracks for given album '''
+ items = []
+ album = await self.album(album_id)
+ 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')))
+ return items
+
+ async def playlist_tracks(self, playlist_id, offset=0, limit=50):
+ ''' get the tracks for given playlist '''
+ items = []
+ playlist = await self.playlist(playlist_id)
+ for prov_mapping in playlist.provider_ids:
+ prov_id = prov_mapping['provider']
+ prov_item_id = prov_mapping['item_id']
+ prov_obj = self.providers[prov_id]
+ items += await prov_obj.playlist_tracks(prov_item_id, offset=offset, limit=limit)
+ items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
+ return items
+
+ async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False):
+ ''' 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():
+ 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, action=None):
+ ''' perform action on item (such as library add/remove) '''
+ result = None
+ item = await self.item(item_id, media_type)
+ if item and action in ['add', '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
+
+ def get_music_provider(self, item_id):
+ ''' get musicprovider object by id '''
+ prov_obj = None
+ if isinstance(item_id,int) or not '_' in item_id:
+ prov_obj = self.mass.db
+ else:
+ prov_id = item_id.split('_')[0]
+ item_id = item_id.split('_')[1]
+ prov_obj = self.providers[prov_id]
+ return item_id, prov_obj
+
+ @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 self.sync_library_artists(prov_id)
+ await self.sync_library_albums(prov_id)
+ await self.sync_library_tracks(prov_id)
+ await self.sync_library_playlists(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_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Artist)
+ if db_id == None:
+ db_id = await music_provider.add_artist(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.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_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Album)
+ if db_id == None:
+ db_id = await music_provider.add_album(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.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_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Track)
+ if db_id == None:
+ db_id = await music_provider.add_track(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.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_library_playlists(self, prov_id):
+ ''' sync library playlists for given provider'''
+ music_provider = self.providers[prov_id]
+ prev_items = await self.library_playlists(provider_filter=prov_id)
+ prev_db_ids = [item.item_id for item in prev_items]
+ cur_items = await music_provider.get_library_playlists()
+ cur_db_ids = []
+ for item in cur_items:
+ db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Playlist)
+ if db_id == None:
+ db_id = await music_provider.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)
+ # playlist tracks
+ #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_prov_id = prov_mapping['provider']
+ prov_item_id = prov_mapping['item_id']
+ db_id = await self.mass.db.get_database_id(item_prov_id, prov_item_id, MediaType.Track)
+ if db_id == None:
+ db_id = await self.providers[item_prov_id].add_track(item)
+ if not db_id in cur_db_ids:
+ cur_db_ids.append(db_id)
+ await self.mass.db.add_playlist_track(db_playlist_id, db_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))
+
+ 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 utils import run_periodic, LOGGER, try_parse_int
+import aiohttp
+from difflib import SequenceMatcher as Matcher
+from models import MediaType, PlayerState, MusicPlayer
+from typing import List
+import toolz
+import operator
+
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+MODULES_PATH = os.path.join(BASE_DIR, "modules", "playerproviders" )
+
+class Player():
+ ''' 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()
+
+ async def players(self):
+ ''' return all players '''
+ items = list(self._players.values())
+ items.sort(key=lambda x: x.name, reverse=False)
+ return items
+
+ async def player(self, player_id):
+ ''' return players by id '''
+ return self._players[player_id]
+
+ async def player_command(self, player_id, cmd, cmd_args=None):
+ ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) '''
+ if not player_id in self._players:
+ LOGGER.warning('Player %s not found' % player_id)
+ return False
+ player = self._players[player_id]
+ if player.group_parent and cmd not in ['power', 'volume', 'mute']:
+ # redirect playlist related commands to parent player
+ return await self.player_command(player.group_parent, cmd, cmd_args)
+ if cmd == 'power' and player.mute_as_power:
+ cmd = 'mute'
+ cmd_args = 'on' if cmd_args == 'off' else 'off' # invert logic (power ON is mute OFF)
+ if cmd == 'volume' and player.apply_group_volume:
+ # group volume, apply to childs (if any)
+ cur_volume = player.volume_level
+ if cmd_args == 'up':
+ new_volume = cur_volume + 2
+ elif cmd_args == 'down':
+ new_volume = cur_volume - 2
+ else:
+ new_volume = try_parse_int(cmd_args)
+ if new_volume < cur_volume:
+ volume_dif = new_volume - cur_volume
+ else:
+ volume_dif = cur_volume - new_volume
+ for child_player in await self.players():
+ if child_player.group_parent == player_id:
+ LOGGER.debug("%s - %s - %s" % (child_player.name, child_player.state, child_player.muted))
+ if child_player.group_parent == player_id and child_player.state != PlayerState.Off:
+ cur_child_volume = child_player.volume_level
+ new_child_volume = cur_child_volume + volume_dif
+ LOGGER.debug('apply group volume %s to child %s' %(new_child_volume, child_player.name))
+ await self.player_command(child_player.player_id, 'volume', new_child_volume)
+ player.volume_level = new_volume
+ return True
+ else:
+ prov_id = self._players[player_id].player_provider
+ prov = self.providers[prov_id]
+ return await prov.player_command(player_id, cmd, cmd_args)
+
+ async def remove_player(self, player_id):
+ ''' handle a player remove '''
+ self._players.pop(player_id, None)
+ asyncio.ensure_future(self.mass.event('player removed', player_id))
+
+ async def update_player(self, player_details):
+ ''' update (or add) player '''
+ LOGGER.debug('Incoming msg from %s' % player_details.name)
+ player_id = player_details.player_id
+ player_settings = await self.get_player_config(player_details)
+ player_changed = False
+ if not player_id in self._players:
+ self._players[player_id] = MusicPlayer()
+ player = self._players[player_id]
+ player.player_id = player_id
+ player.player_provider = player_details.player_provider
+ player_changed = True
+ else:
+ player = self._players[player_id]
+
+ # handle basic player settings
+ player_details.enabled = player_settings['enabled']
+ player_details.name = player_settings['name']
+ player_details.disable_volume = player_settings['disable_volume']
+ player_details.mute_as_power = player_settings['mute_as_power']
+ player_details.apply_group_volume = player_settings['apply_group_volume']
+
+ # handle mute as power setting
+ if player_details.mute_as_power:
+ player_details.powered = not player_details.muted
+ # combine state of group parent
+ if player_settings['group_parent']:
+ player_details.group_parent = player_settings['group_parent']
+ if player_details.group_parent and player_details.group_parent in self._players:
+ parent_player = self._players[player_details.group_parent]
+ if player_details.powered and player_details.state != PlayerState.Playing:
+ player_details.cur_item_time = parent_player.cur_item_time
+ player_details.cur_item = parent_player.cur_item
+ # handle group volume setting
+ if player_details.is_group and player_details.apply_group_volume:
+ group_volume = 0
+ active_players = 0
+ for child_player in self._players.values():
+ if child_player.group_parent == player_id and child_player.enabled and child_player.powered:
+ group_volume += child_player.volume_level
+ active_players += 1
+ group_volume = group_volume / active_players if active_players else 0
+ player_details.volume_level = group_volume
+ # compare values to detect changes
+ for key, cur_value in player.__dict__.items():
+ new_value = getattr(player_details, key)
+ if new_value != cur_value:
+ player_changed = True
+ setattr(player, key, new_value)
+ LOGGER.debug('key changed: %s for player %s - new value: %s' % (key, player.name, new_value))
+ if player_changed:
+ # player is added or updated!
+ asyncio.ensure_future(self.mass.event('player updated', player))
+
+ async def get_player_config(self, player_details):
+ ''' get or create player config '''
+ player_id = player_details.player_id
+ if player_id in self.mass.config['player_settings']:
+ return self.mass.config['player_settings'][player_id]
+ new_config = {
+ "name": player_details.name,
+ "group_parent": player_details.group_parent,
+ "mute_as_power": False,
+ "disable_volume": False,
+ "apply_group_volume": False,
+ "enabled": False
+ }
+ self.mass.config['player_settings'][player_id] = new_config
+ return new_config
+
+ async def play_media(self, player_id, media_item, queue_opt='replace'):
+ '''
+ play media on a player
+ player_id: id of the player
+ media_item: media item that should be played (Track, Album, Artist, Playlist)
+ queue_opt: replace, next or add
+ '''
+ if not player_id in self._players:
+ LOGGER.warning('Player %s not found' % player_id)
+ return False
+ prov_id = self._players[player_id].player_provider
+ prov = self.providers[prov_id]
+ # check supported music providers by this player and work out how to handle playback...
+ musicprovider = None
+ item_id = None
+ for prov_id, supported_types in prov.supported_musicproviders:
+ if media_item.provider_ids.get(prov_id):
+ musicprovider = prov_id
+ prov_item_id = media_item.provider_ids[prov_id]
+ if media_item.media_type in supported_types:
+ # the provider can handle this media_type directly !
+ uri = await self.get_item_uri(media_item.media_type, prov_item_id, prov_id)
+ return await prov.play_media(player_id, uri, queue_opt)
+ else:
+ # manually enqueue the tracks of this listing
+ return await self.queue_items(player_id, media_item, queue_opt)
+ elif prov_id == 'http':
+ # fallback to http streaming
+ if media_item.media_type == MediaType.Track:
+ for media_prov_id, media_prov_item_id in media_item.provider_ids.items():
+ stream_details = await self.mass.music.providers[media_prov_id].get_stream_details(media_prov_item_id)
+ return await prov.play_media(player_id, stream_details['url'], queue_opt)
+ else:
+ return await self.queue_items(player_id, media_item, queue_opt)
+ raise Exception("Musicprovider %s and/or mediatype %s not supported by player %s !" % ("/".join(media_item.provider_ids.keys()), media_item.media_type, player_id) )
+
+ async def queue_items(self, player_id, media_item, queue_opt):
+ ''' extract a list of items and manually enqueue the tracks '''
+ tracks = []
+ #TODO: respect shuffle
+ if media_item.media_type == MediaType.Artist:
+ tracks = await self.mass.music.artist_toptracks(media_item.item_id)
+ elif media_item.media_type == MediaType.Album:
+ tracks = await self.mass.music.album_tracks(media_item.item_id)
+ elif media_item.media_type == MediaType.Playlist:
+ tracks = await self.mass.music.playlist_tracks(media_item.item_id, offset=0, limit=0)
+ if queue_opt == 'replace':
+ await self.play_media(player_id, tracks[0], 'replace')
+ tracks = tracks[1:]
+ queue_opt = 'add'
+ for track in tracks:
+ await self.play_media(player_id, track, queue_opt)
+
+ async def player_queue(self, player_id, offset=0, limit=50):
+ ''' return the items in the player's queue '''
+ player = self._players[player_id]
+ player_prov = self.providers[player.player_provider]
+ if player_prov.supports_queue:
+ return await player_prov.player_queue(player_id, offset=offset, limit=limit)
+ else:
+ # TODO: Implement 'fake' queue
+ raise NotImplementedError
+
+ async def get_item_uri(self, media_type, item_id, provider):
+ ''' generate the URL/URI for a media item '''
+ uri = ""
+ if provider == "spotify" and media_type == MediaType.Track:
+ uri = 'spotify://spotify:track:%s' % item_id
+ elif provider == "spotify" and media_type == MediaType.Album:
+ uri = 'spotify://spotify:album:%s' % item_id
+ elif provider == "spotify" and media_type == MediaType.Artist:
+ uri = 'spotify://spotify:artist:%s' % item_id
+ elif provider == "spotify" and media_type == MediaType.Playlist:
+ uri = 'spotify://spotify:playlist:%s' % item_id
+ elif provider == "qobuz" and media_type == MediaType.Track:
+ uri = 'qobuz://%s.flac' % item_id
+ elif provider == "file":
+ uri = 'file://%s' % item_id
+ return uri
+
+ 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 logging
+from concurrent.futures import ThreadPoolExecutor
+
+logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(module)s -- %(message)s')
+LOGGER = logging.getLogger("music_assistant")
+consolehandler = logging.StreamHandler()
+consolehandler.setFormatter(logformat)
+LOGGER.addHandler(consolehandler)
+LOGGER.setLevel(logging.DEBUG)
+
+def run_periodic(period):
+ def scheduler(fcn):
+ async def wrapper(*args, **kwargs):
+ while True:
+ asyncio.create_task(fcn(*args, **kwargs))
+ await asyncio.sleep(period)
+ return wrapper
+ return scheduler
+
+def run_background_task(executor, corofn, *args):
+ ''' run non-async task in background '''
+ return asyncio.get_event_loop().run_in_executor(executor, corofn, *args)
+
+def run_async_background_task(executor, corofn, *args):
+ ''' run async task in background '''
+ def run_task(corofn, *args):
+ loop = asyncio.new_event_loop()
+ try:
+ coro = corofn(*args)
+ asyncio.set_event_loop(loop)
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+ return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args)
+
+def get_sort_name(name):
+ ''' create a sort name for an artist/title '''
+ sort_name = name
+ for item in ["The ", "De ", "de ", "Les "]:
+ if name.startswith(item):
+ sort_name = "".join(name.split(item)[1:])
+ return sort_name
+
+def try_parse_int(possible_int):
+ try:
+ return int(possible_int)
+ except:
+ return 0
+
+def parse_track_title(track_title):
+ ''' try to parse clean track title and version from the title '''
+ track_title = track_title.lower()
+ title = track_title
+ version = ''
+ for splitter in [" (", " [", " - ", " (", " [", "-"]:
+ if splitter in title:
+ title_parts = title.split(splitter)
+ for title_part in title_parts:
+ # look for the end splitter
+ for end_splitter in [")", "]"]:
+ if end_splitter in title_part:
+ title_part = title_part.split(end_splitter)[0]
+ for ignore_str in ["feat.", "featuring", "ft.", "with ", " & "]:
+ if ignore_str in title_part:
+ title = title.split(splitter+title_part)[0]
+ for version_str in ["version", "live", "edit", "remix", "mix",
+ "acoustic", " instrumental", "karaoke", "remaster", "versie", "explicit", "radio", "unplugged", "disco"]:
+ if version_str in title_part:
+ version = title_part
+ title = title.split(splitter+version)[0]
+ title = title.strip().title()
+ # version substitues
+
+ version = version.strip().title()
+ return title, version
+
--- /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 fixed flat dense dark color="transparent" scroll-off-screen >
+ <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-icon>search</v-icon>
+ </v-btn>
+ </v-layout>
+ </v-toolbar>
+</div>`,
+ props: [],
+ $_veeValidate: {
+ validator: "new"
+ },
+ data() {
+ return {
+ menu: false,
+ items: [
+ { title: "Home", icon: "home", path: "/" },
+ { title: "Artists", icon: "person", path: "/artists" },
+ { title: "Albums", icon: "album", path: "/albums" },
+ { title: "Tracks", icon: "audiotrack", path: "/tracks" },
+ { title: "Playlists", icon: "playlist_play", path: "/playlists" },
+ { title: "Search", icon: "search", path: "/search" },
+ { title: "Config", 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="370"\r
+ position="center top" \r
+ :src="getFanartImage()"\r
+ gradient="to top right, rgba(100,115,201,.33), rgba(25,32,72,.7)"\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
+ <a v-for="(value, key) in info.provider_ids" :href="info.metadata[key + '_url']" target="_blank" :key="key">\r
+ <img height="30" :src="'/images/icons/' + key + '.png'" style="padding-right:5px" />\r
+ </a>\r
+ <div style="text-shadow: 1px 1px #000000;vertical-align:top;margin-left:30px;margin-top:-35px;">\r
+ <qualityicon v-if="info.media_type == 3" v-bind:item="info" :height="30" :compact="false"/>\r
+ </div>\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(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>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>Add to 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>Remove from 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() ? 200 : 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.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
+ return img;\r
+ },\r
+ getThumb() {\r
+ var img = '';\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.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(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>
+
+ <qualityicon v-if="item.media_type == 3" v-bind:item="item" :height="25" :compact="true" :dark="true" :hiresonly="true"/>
+
+ <v-list-tile-action v-if="!hideproviders" v-for="provider in item.provider_ids" :key="provider.provider + provider.item_id">
+ <v-tooltip bottom>
+ <template v-slot:activator="{ on }">
+ <img v-on="on" height="20" :src="'images/icons/' + provider.provider + '.png'"/>
+ </template>
+ <span v-if="provider.details">{{ provider.details }}</span>
+ <span v-if="!provider.details">{{ provider.quality }}</span>
+ </v-tooltip>
+ </v-list-tile-action>
+
+ <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">Item is added to the library</span>
+ <span v-if="item.in_library.length == 0">Add item to the 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 {
+ selected: [2],
+ items: [],
+ offset: 0,
+ }
+ },
+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;">
+ <v-list-tile avatar ripple style="margin-bottom:15px;">
+
+ <v-list-tile-avatar v-if="!isMobile() && 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 v-if="!isMobile()" 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>
+
+
+ <!-- player controls -->
+ <v-list-tile-content>
+ <v-layout row style="content-align: center;vertical-align: middle; margin-top:10px;">
+ <v-btn icon style="padding:5px;" @click="playerCommand('previous')"><v-icon color="rgba(0,0,0,.54)">skip_previous</v-icon></v-btn>
+ <v-btn 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 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 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:30px;" v-if="!isMobile() && active_player_id">
+ <v-btn flat icon @click="$router.push('/queue/' + active_player_id)">
+ <v-flex xs12 class="vertical-btn">
+ <v-icon large>queue_music</v-icon>
+ <span class="caption">Queue</span>
+ </v-flex>
+ </v-btn>
+ </v-list-tile-action>
+
+ <!-- active player volume -->
+ <v-list-tile-action style="padding:30px;" 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 flat icon v-on="on">
+ <v-flex xs12 class="vertical-btn">
+ <v-icon large>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 flat icon @click="menu = !menu">
+ <v-flex xs12 class="vertical-btn">
+ <v-icon large>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>
+
+ <!-- progress bar -->
+ <div style="color:rgba(0,0,0,.65); height:35px;width:100%; vertical-align: middle; left:15px; right:0; bottom:0" v-if="!isMobile()">
+ <v-layout row style="vertical-align: middle">
+ <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>
+
+ </v-card>
+ </v-footer>
+
+ <!-- players side menu -->
+ <v-navigation-drawer right app clipped temporary v-model="menu">
+ <v-card-title class="headline">
+ <b>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">
+ {{ 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"/>
+ </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);
+ var cmd = 'players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt;
+ console.log(cmd);
+ this.ws.send(cmd);
+ },
+ 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 == '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)
+ for (var player_id in this.players)
+ if (this.players[player_id].state == 'playing' && this.players[player_id].enabled) {
+ // prefer the first playing player
+ this.active_player_id = player_id;
+ break;
+ }
+ if (!this.active_player_id)
+ for (var player_id in this.players) {
+ // fallback to just the first player
+ if (this.players[player_id].enabled)
+ {
+ 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>{{ !!$globals.playmenuitem ? $globals.playmenuitem.name : 'nix' }}</v-subheader>\r
+ <v-subheader>Play on: beneden</v-subheader>\r
+ \r
+ <v-list-tile avatar @click="$emit('playItem', $globals.playmenuitem, '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>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="$emit('playItem', $globals.playmenuitem, '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>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="$emit('playItem', $globals.playmenuitem, '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>Add to 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="" v-if="$globals.playmenuitem.media_type != 3">\r
+ <v-list-tile-avatar>\r
+ <v-icon>shuffle</v-icon>\r
+ </v-list-tile-avatar>\r
+ <v-list-tile-content>\r
+ <v-list-tile-title>Play now (shuffle)</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="" 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>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>\r
+ </v-card>\r
+ </v-dialog>\r
+`,\r
+ props: ['value'],\r
+ data (){\r
+ return{\r
+ fav: true,\r
+ message: false,\r
+ hints: true,\r
+ }\r
+ },\r
+ mounted() { },\r
+ created() { },\r
+ methods: { }\r
+ })\r
--- /dev/null
+Vue.component("qualityicon", {\r
+ template: `\r
+ <div :style="'height:' + height + 'px;'">\r
+ \r
+ <v-tooltip bottom>\r
+ <template v-slot:activator="{ on }">\r
+ <img height="100%" v-on="on" v-if="item.metadata && item.metadata.hires" src="images/icons/hires.png" style="align:center;vertical-align: middle;padding-right:10px;padding-left:10px;"/>\r
+ <img height="100%" v-on="on" v-if="!dark && !hiresonly" :src="getFileFormatLogo()" style="align:center;vertical-align: middle;"/>\r
+ <img height="100%" v-on="on" v-if="dark && !hiresonly" :src="getFileFormatLogo()" style="filter: invert(1);align:center;vertical-align: middle;"/>\r
+ </template>\r
+ <span>{{ getFileFormatDesc() }}</span>\r
+ </v-tooltip>\r
+ <span v-if="!compact" class="body-2" style="vertical-align: middle;">{{ getFileFormatDesc() }}</span>\r
+ </div>\r
+`,\r
+ props: ['item','height','compact', 'dark', 'hiresonly'],\r
+ data (){\r
+ return{}\r
+ },\r
+ mounted() { },\r
+ created() { },\r
+ methods: { \r
+\r
+ getFileFormatLogo() {\r
+ if (this.item.quality == 0)\r
+ return 'images/icons/mp3.png'\r
+ else if (this.item.quality == 1)\r
+ return 'images/icons/vorbis.png'\r
+ else if (this.item.quality == 2)\r
+ return 'images/icons/aac.png'\r
+ else if (this.item.quality > 2)\r
+ return 'images/icons/flac.png'\r
+ },\r
+ getFileFormatDesc() {\r
+ var desc = '';\r
+ if (this.item && this.item.quality)\r
+ {\r
+ if (this.item.quality == 0)\r
+ desc = 'MP3';\r
+ else if (this.item.quality == 1)\r
+ desc = 'Ogg Vorbis';\r
+ else if (this.item.quality == 2)\r
+ desc = 'AAC';\r
+ else if (this.item.quality > 2)\r
+ desc = 'FLAC';\r
+ else\r
+ desc = 'unknown';\r
+ }\r
+ // append details\r
+ if (this.item && this.item.metadata)\r
+ {\r
+ if (!!this.item.metadata && this.item.metadata.maximum_technical_specifications)\r
+ desc += ' ' + this.item.metadata.maximum_technical_specifications;\r
+ if (!!this.item.metadata && this.item.metadata.sample_rate && this.item.metadata.bit_depth)\r
+ desc += ' ' + this.item.metadata.sample_rate + 'kHz ' + this.item.metadata.bit_depth + 'bit';\r
+ }\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("searchbar", {
+ template: `
+ <section class="section searchbar">
+ <div class="container">
+ <b-field>
+ <b-autocomplete size="is-medium"
+ expanded
+ v-model="searchQuery"
+ :data="filteredDataArray"
+ placeholder="e.g. Eminem"
+ icon="magnify"
+ @select="option => selected = option"
+ @keyup.enter="onClickSearch"
+ ></b-autocomplete>
+ <p class="control" v-if="searchQuery">
+ <button @click="onClickClearSearch" class="button is-medium "><i class="fas fa-times"></i></button>
+ </p>
+ </b-field>
+ </div>
+ </section>
+ `,
+ data () {
+ return {
+ data: [],
+ searchQuery: '',
+ selected: null
+ }
+ },
+ props: {
+ recentSearch: {
+ type: Array,
+ required: true
+ },
+ newSearchQuery: {
+ type: String,
+ required: true
+ },
+ settings: {
+ type: Object,
+ required: true
+ }
+ },
+ mounted () {
+ this.searchQuery = this.settings.initialSearchQuery
+ this.onClickSearch()
+ },
+ watch: {
+ searchQuery: {
+ handler: _.debounce(function (val) {
+ if (val === '') {
+ this.$store.commit('CLEAR_SEARCH')
+ } else {
+ if (val !== this.newSearchQuery) {
+ this.onClickSearch()
+ }
+ }
+ }, 1000)
+ },
+ newSearchQuery (val) {
+ this.searchQuery = val
+ }
+ },
+ computed: {
+ filteredDataArray () {
+ return this.recentSearch.filter(option => {
+ return (
+ option
+ .toString()
+ .toLowerCase()
+ .indexOf(this.searchQuery.toLowerCase()) >= 0
+ )
+ })
+ }
+ },
+ methods: {
+ onClickSearch () {
+ this.$emit('clickSearch', this.searchQuery)
+ },
+ onClickClearSearch () {
+ this.searchQuery = ''
+ this.$emit('clickClearSearch')
+ }
+ }
+})
+/* <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>{{ 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
+ ></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
+<!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/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-128x128.png">
+ <link rel="manifest" href="./manifest.json">
+ <link rel="apple-touch-icon" sizes="180x180" href="./images/icons/icon-192x192.png">
+ <link href="./css/site.css" rel="stylesheet">
+ </head>
+
+ <body>
+
+ <div id="app">
+ <v-app light>
+ <v-content>
+ <headermenu></headermenu>
+ <player></player>
+ <router-view :key="$route.path"></router-view>
+ </v-content>
+ <v-dialog
+ v-model="$globals.loading"
+ persistent
+ width="300"
+ >
+ <v-card
+ color="primary"
+ dark
+ >
+ <v-card-text>
+ Please stand by
+ <v-progress-linear
+ indeterminate
+ color="white"
+ class="mb-0"
+ ></v-progress-linear>
+ </v-card-text>
+ </v-card>
+ </v-dialog>
+
+ </v-app>
+ </div>
+
+
+ <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script>
+ <script src="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.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="https://unpkg.com/http-vue-loader"></script>
+
+ <script>
+ function isMobile() {
+ return document.body.clientWidth < 800;
+ }
+ 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)
+ {
+ 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});
+ }
+
+ 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 = "/remove"
+ if (item.in_library.length == 0)
+ action = "/add"
+ var url = endpoint + item_id + action;
+ console.log('loading ' + url);
+ axios
+ .get(url)
+ .then(result => {
+ data = result.data;
+ console.log(data);
+ if (action == "/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='./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/qualityicon.vue.js'></script>
+ <script src='./pages/config.vue.js'></script>
+
+ <script>
+ Vue.use(VueRouter);
+ Vue.use(VeeValidate);
+ Vue.use(Vuetify);
+
+
+ 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,
+ playmenuitem: null
+ }
+ })
+ Vue.prototype.$globals = globalStore;
+ Vue.prototype.isMobile = isMobile;
+ Vue.prototype.toggleLibrary = toggleLibrary;
+ Vue.prototype.showPlayMenu = showPlayMenu;
+ Vue.prototype.clickItem= clickItem;
+
+ var app = new Vue({
+ el: '#app',
+ watch: {},
+ mounted() {
+
+ },
+ data: { },
+ methods: {},
+ router
+ })
+ </script>
+ </body>
+
+</html>
\ 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-72x72.png",
+ "sizes": "72x72",
+ "type": "image/png"
+ },
+ {
+ "src": "images/icons/icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ },
+ {
+ "src": "images/icons/icon-128x128.png",
+ "sizes": "128x128",
+ "type": "image/png"
+ },
+ {
+ "src": "images/icons/icon-144x144.png",
+ "sizes": "144x144",
+ "type": "image/png"
+ },
+ {
+ "src": "images/icons/icon-152x152.png",
+ "sizes": "152x152",
+ "type": "image/png"
+ },
+ {
+ "src": "images/icons/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "images/icons/icon-384x384.png",
+ "sizes": "384x384",
+ "type": "image/png"
+ },
+ {
+ "src": "images/icons/icon-512x512.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 = "Album info"
+ 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 = "Artist info"
+ 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
+ axios
+ .get(api_url, { params: { lazy: lazy }})
+ .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-flex xs12>
+ <v-card class="flex" tile style="background-color:rgba(0,0,0,.54);color:#ffffff;">
+ <v-card-title class="title justify-center">
+ {{ $globals.windowtitle }}
+ </v-card-title>
+ </v-card>
+ </v-flex>
+ <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'],
+ data() {
+ return {
+ selected: [2],
+ items: [],
+ offset: 0
+ }
+ },
+ created() {
+ this.showavatar = true;
+ mediatitle =
+ this.$globals.windowtitle = this.mediatype.charAt(0).toUpperCase() + this.mediatype.slice(1);
+ 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 }})
+ .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-flex xs12>
+ <v-card class="flex" tile style="background-color:rgba(0,0,0,.54);color:#ffffff;">
+ <v-card-title class="title justify-center">
+ {{ $globals.windowtitle }}
+ </v-card-title>
+ </v-card>
+ </v-flex>
+
+ <v-list two-line>
+
+ <!-- music providers -->
+ <v-list-group prepend-icon="library_music" no-action>
+ <template v-slot:activator>
+ <v-list-tile>
+ <v-list-tile-content>
+ <v-list-tile-title>Music Providers</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+ </template>
+ <template v-for="(conf_value, conf_key) in conf.musicproviders">
+ <v-list-tile>
+ <v-list-tile-avatar>
+ <img :src="'images/icons/' + conf_key + '.png'"/>
+ </v-list-tile-avatar>
+ <v-list-tile-content>
+ <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+
+ <div v-for="conf_item_key in conf.musicproviders[conf_key].__desc__">
+ <v-list-tile>
+ <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]"></v-switch>
+ <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-text-field>
+ <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-select>
+ <v-text-field v-else v-model="conf.musicproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box></v-text-field>
+ </v-list-tile>
+ </div>
+ <v-divider></v-divider>
+ </template>
+ </v-list-group>
+
+ <!-- player providers -->
+ <v-list-group prepend-icon="speaker_group" no-action>
+ <template v-slot:activator>
+ <v-list-tile>
+ <v-list-tile-content>
+ <v-list-tile-title>Player Providers</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+ </template>
+ <template v-for="(conf_value, conf_key) in conf.playerproviders">
+ <v-list-tile>
+ <v-list-tile-avatar>
+ <img :src="'images/icons/' + conf_key + '.png'"/>
+ </v-list-tile-avatar>
+ <v-list-tile-content>
+ <v-list-tile-title class="title">{{ conf_key }}</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+
+ <div v-for="conf_item_key in conf.playerproviders[conf_key].__desc__">
+ <v-list-tile>
+ <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]"></v-switch>
+ <v-text-field v-else-if="conf_item_key[1] == '<password>'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-text-field>
+ <v-select v-else-if="conf_item_key[1] == '<player>'" v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-select>
+ <v-text-field v-else v-model="conf.playerproviders[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box></v-text-field>
+ </v-list-tile>
+ </div>
+ <v-divider></v-divider>
+ </template>
+ </v-list-group>
+
+ <!-- player settings -->
+ <v-list-group prepend-icon="speaker" no-action>
+ <template v-slot:activator>
+ <v-list-tile>
+ <v-list-tile-content>
+ <v-list-tile-title>Player settings</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+ </template>
+ <template v-for="(player, key) in players" v-if="key != '__desc__' && key in players">
+ <v-list-tile>
+ <v-list-tile-content>
+ <v-list-tile-title class="title">{{ players[key].name }}</v-list-tile-title>
+ <v-list-tile-sub-title class="title">ID: {{ key }} Provider: {{ players[key].player_provider }}</v-list-tile-sub-title>
+ </v-list-tile-content>
+ </v-list-tile>
+
+ <div v-for="conf_item_key in conf.player_settings.__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="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="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="conf_item_key[2]"
+ :items="playersLst"
+ item-text="name"
+ item-value="id" box>
+ </v-select>
+ <v-text-field v-else v-model="conf.player_settings[key][conf_item_key[0]]" :label="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="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="Enabled"></v-switch>
+ </v-list-tile>
+ </div>
+ <v-divider></v-divider>
+ </template>
+ </v-list-group>
+
+ <v-btn @click="saveConfig()">Save</v-btn>
+ </v-list>
+ </section>
+ `,
+ props: [],
+ data() {
+ return {
+ conf: {},
+ players: {}
+ }
+ },
+ computed: {
+ playersLst()
+ {
+ var playersLst = [];
+ for (player_id in this.conf.player_settings)
+ if (player_id != '__desc__')
+ playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name})
+ return playersLst;
+ }
+
+ },
+ created() {
+ this.$globals.windowtitle = "Configuration";
+ 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: `
+ <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>
+`,
+ props: ["title"],
+ $_veeValidate: {
+ validator: "new"
+ },
+ data() {
+ return {
+ result: null,
+ showProgress: false
+ };
+ },
+ created() {
+ this.$globals.windowtitle = "Home"
+ this.items= [
+ { title: 'Artists', path: '/browse/library/artists', icon: "person" },
+ { title: 'Albums', path: '/browse/library/albums', icon: "album" },
+ { title: 'Tracks', path: '/browse/library/tracks', icon: "audiotrack" },
+ { title: 'Playlists', path: '/browse/library/playlists', icon: "playlist_play" }
+ ]
+ },
+ 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,
+ }
+ },
+ created() {
+ this.$globals.windowtitle = "Playlist info"
+ 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>
+ <infoheader v-bind:info="info"/>
+ <v-tabs
+ v-model="active"
+ color="transparent"
+ light
+ slider-color="black"
+ >
+ <v-tab ripple>Queue</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: ['player_id'],
+ data() {
+ return {
+ selected: [0],
+ info: {},
+ items: [],
+ offset: 0,
+ }
+ },
+ created() {
+ this.$globals.windowtitle = "Queue"
+ //this.getInfo();
+ this.getQueueTracks();
+ this.scroll(this.Queue);
+ },
+ methods: {
+ getInfo () {
+ const api_url = '/api/players/' + 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);
+ });
+ },
+ getQueueTracks () {
+ this.$globals.loading = true
+ const api_url = '/api/players/' + this.player_id + '/queue'
+ axios
+ .get(api_url, { params: { offset: this.offset, limit: 50}})
+ .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.getQueueTracks();
+ }
+ };
+ }
+ }
+})
--- /dev/null
+var Search = Vue.component('Search', {
+ template: `
+ <section>
+ <v-flex xs12 justify-center>
+ <v-card color="cyan darken-2" class="white--text" img="../images/info_gradient.jpg">
+
+ <div class="text-xs-center" style="height:40px" id="whitespace_top"/>
+ <v-card-title class="display-1 justify-center" style="text-shadow: 1px 1px #000000;">
+ {{ $globals.windowtitle }}
+ </v-card-title>
+ </v-card>
+ </v-flex>
+ <v-text-field
+ solo
+ clearable
+ label="Type here to search..."
+ prepend-inner-icon="search"
+ v-on:input="onSearchBoxInput"
+ v-model="searchquery">
+ </v-text-field>
+
+ <v-tabs
+ v-model="active"
+ color="transparent"
+ light
+ slider-color="black"
+ >
+
+ <v-tab ripple v-if="tracks.length">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">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">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">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,
+ searchquery: ''
+ }
+ },
+ created() {
+ this.$globals.windowtitle = "Search";
+ },
+ 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);
+ }
+ },
+ onSearchBoxInput (index) {
+ clearTimeout(this.timeout);
+ this.timeout = setTimeout(this.Search, 600);
+ },
+ Search () {
+ if (!this.searchquery) {
+ this.artists = [];
+ this.albums = [];
+ this.tracks = [];
+ this.playlists = [];
+ }
+ else {
+ 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 = "Track info"
+ 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
+cytoolz
+aiohttp
+spotify_token
+pychromecast
\ No newline at end of file