*.db
*.pyc
music_assistant/config.json
+*.cert
+*.pem
from models import MediaType, media_type_from_string
from functools import partial
json_serializer = partial(json.dumps, default=lambda x: x.__dict__)
-
+import ssl
class Api():
''' expose our data through json api '''
- def __init__(self, mass):
+ def __init__(self, mass, ssl_cert, ssl_key):
self.mass = mass
+ self._ssl_cert = ssl_cert
+ self._ssl_key = ssl_key
self.http_session = aiohttp.ClientSession()
+ mass.event_loop.create_task(self.setup_web())
def stop(self):
self.runner.cleanup()
self.runner = web.AppRunner(app)
await self.runner.setup()
- site = web.TCPSite(self.runner, '0.0.0.0', 8095)
- await site.start()
+ ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+ ssl_context.load_cert_chain(self._ssl_cert, self._ssl_key)
+ http_site = web.TCPSite(self.runner, '0.0.0.0', 8095)
+ https_site = web.TCPSite(self.runner, '0.0.0.0', 8096, ssl_context=ssl_context)
+ await http_site.start()
+ await https_site.start()
async def get_items(self, request):
''' get multiple library items'''
media_id = request.match_info.get('media_id')
action = request.match_info.get('action','')
lazy = request.rel_url.query.get('lazy', '') != 'false'
+ provider = request.rel_url.query.get('provider')
if action:
- result = await self.mass.music.item_action(media_id, media_type, action)
+ result = await self.mass.music.item_action(media_id, media_type, provider, action)
else:
- result = await self.mass.music.item(media_id, media_type, lazy=lazy)
+ result = await self.mass.music.item(media_id, media_type, provider, lazy=lazy)
return web.json_response(result, dumps=json_serializer)
async def artist_toptracks(self, request):
''' get top tracks for given artist '''
artist_id = request.match_info.get('artist_id')
- result = await self.mass.music.artist_toptracks(artist_id)
+ provider = request.rel_url.query.get('provider')
+ result = await self.mass.music.artist_toptracks(artist_id, provider)
return web.json_response(result, dumps=json_serializer)
async def artist_albums(self, request):
''' get (all) albums for given artist '''
artist_id = request.match_info.get('artist_id')
- result = await self.mass.music.artist_albums(artist_id)
+ provider = request.rel_url.query.get('provider')
+ result = await self.mass.music.artist_albums(artist_id, provider)
return web.json_response(result, dumps=json_serializer)
async def playlist_tracks(self, request):
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)
+ provider = request.rel_url.query.get('provider')
+ result = await self.mass.music.playlist_tracks(playlist_id, provider, offset=offset, limit=limit)
return web.json_response(result, dumps=json_serializer)
async def album_tracks(self, request):
''' get album tracks from provider'''
album_id = request.match_info.get('album_id')
- result = await self.mass.music.album_tracks(album_id)
+ provider = request.rel_url.query.get('provider')
+ result = await self.mass.music.album_tracks(album_id, provider)
return web.json_response(result, dumps=json_serializer)
async def search(self, request):
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)
+ provider = request.rel_url.query.get('provider')
+ media_item = await self.mass.music.item(media_id, media_type, provider, lazy=True)
result = await self.mass.player.play_media(player_id, media_item, queue_opt)
return web.json_response(result, dumps=json_serializer)
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 '/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}
''' 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)
+ #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']})
resp = web.StreamResponse(status=200,
- reason='OK',
- headers={'Content-Type': stream_details['mime_type']})
+ reason='OK',
+ headers={'Content-Type': 'audio/flac'})
await resp.prepare(request)
async for chunk in self.mass.music.providers[provider].get_stream(track_id):
await resp.write(chunk)
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 playlists(playlist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner TEXT NOT NULL, is_editable BOOLEAN 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()
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 playlists(self, filter_query=None, provider=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
+ elif provider != None:
+ sql_query += ' WHERE playlist_id in (SELECT item_id FROM provider_mappings WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Playlist)
+ 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.is_editable = db_row[3]
+ 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:
+ async with db.execute('SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;', (playlist.name, playlist.owner)) as cursor:
+ result = await cursor.fetchone()
+ if result:
+ playlist_id = result[0]
+ # update existing
+ sql_query = 'UPDATE playlists SET is_editable=? WHERE playlist_id=?;'
+ await db.execute(sql_query, (playlist.is_editable, playlist_id))
+ else:
+ # insert playlist
+ sql_query = 'INSERT OR REPLACE INTO playlists (name, owner, is_editable) VALUES(?,?,?);'
+ await db.execute(sql_query, (playlist.name, playlist.owner, playlist.is_editable))
+ # 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]
+ LOGGER.info('added playlist %s to database: %s' %(playlist.name, playlist_id))
+ # add/update 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()
+ return playlist_id
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!) '''
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))
+ LOGGER.info('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]:
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))
+ LOGGER.info('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]:
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))
+ LOGGER.info('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)
from concurrent.futures import ThreadPoolExecutor
import re
import uvloop
-import logging
import os
import shutil
import slugify as unicode_slug
from cache import Cache
from music import Music
from player import Player
+from modules.homeassistant import setup as hass_setup
class Main():
- def __init__(self, datapath):
+ def __init__(self, datapath, ssl_cert, ssl_key):
uvloop.install()
self._datapath = datapath
self.parse_config()
time.sleep(0.5)
self.cache = Cache(datapath)
self.metadata = MetaData(self.event_loop, self.db, self.cache)
+
+ # init modules
+ self.api = Api(self, ssl_cert, ssl_key)
+ self.hass = hass_setup(self)
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():
+ listeners = list(self.event_listeners.values())
+ for listener in listeners:
await listener(msg, msg_details)
async def add_event_listener(self, cb):
def parse_config(self):
'''get config from config file'''
config = {
+ "base": {},
"musicproviders": {},
"playerproviders": {},
"player_settings":
{
"__desc__":
[
+ ("enabled", False, "Enable player"),
("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")
+ ("apply_group_volume", False, "Apply group volume to childs (for group players only)")
]
}
}
datapath = sys.argv[1:]
if not datapath:
datapath = os.path.dirname(os.path.abspath(__file__))
- Main(datapath)
+ ssl_cert = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'certificate.cert')
+ ssl_key = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'privkey.pem')
+ Main(datapath, ssl_cert, ssl_key)
\ No newline at end of file
def __init__(self, event_loop, cache):
self.event_loop = event_loop
self.cache = cache
- self.http_session = aiohttp.ClientSession(loop=event_loop)
+ self.http_session = aiohttp.ClientSession(loop=event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
self.throttler = Throttler(rate_limit=1, period=1)
async def search_artist_by_album(self, artistname, albumname=None, album_upc=None):
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 strictness in [1, 0.95, 0.9]:
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():
+ if album_upc or Matcher(None, item['title'].lower(), albumname.lower()).ratio() >= strictness:
+ for artist in item['artist-credit']:
+ artist = artist['artist']
+ if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
return artist['id']
+ for item in artist.get('aliases',[]):
+ if item['name'].lower() == artistname.lower():
+ return artist['id']
return ''
async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None):
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 strictness in [1, 0.95]:
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():
+ if track_isrc or Matcher(None, item['title'].lower(), trackname.lower()).ratio() >= strictness:
+ for artist in item['artist-credit']:
+ artist = artist['artist']
+ if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness:
return artist['id']
+ for item in artist.get('aliases',[]):
+ if item['name'].lower() == artistname.lower():
+ return artist['id']
return ''
@use_cache(30)
async def get_data(self, endpoint, params={}):
''' get data from api'''
- url = 'https://musicbrainz.org/ws/2/%s' % endpoint
+ url = 'http://musicbrainz.org/ws/2/%s' % endpoint
headers = {'User-Agent': 'Music Assistant/1.0.0 https://github.com/marcelveldt'}
params['fmt'] = 'json'
async with self.throttler:
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)
+ self.http_session = aiohttp.ClientSession(loop=event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+ self.throttler = Throttler(rate_limit=1, period=1)
async def artist_images(self, mb_artist_id):
''' retrieve images by musicbrainz artist id '''
''' representation of an artist '''
def __init__(self):
self.item_id = None
+ self.provider = 'database'
self.name = ''
self.sort_name = ''
self.metadata = {}
''' representation of an album '''
def __init__(self):
self.item_id = None
+ self.provider = 'database'
self.name = ''
self.metadata = {}
self.version = ''
''' representation of a track '''
def __init__(self):
self.item_id = None
+ self.provider = 'database'
self.name = ''
self.duration = 0
self.version = ''
''' representation of a playlist '''
def __init__(self):
self.item_id = None
+ self.provider = 'database'
self.name = ''
self.owner = ''
self.provider_ids = []
self.metadata = {}
self.media_type = MediaType.Playlist
self.in_library = []
-
+ self.is_editable = False
class MusicProvider():
'''
# 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
+ raise Exception('artist not found: %s' % prov_item_id)
if lazy:
asyncio.create_task(self.add_artist(artist_details))
artist_details.is_lazy = True
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:
+ async def add_artist(self, artist_details) -> 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)
+ musicbrainz_id = await self.get_artist_musicbrainz_id(artist_details)
if not musicbrainz_id:
return
# grab additional metadata
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)
+ new_artist = await self.mass.db.artist(item_id)
+ new_artist_toptracks = await self.get_artist_toptracks(artist_details.item_id)
+ if new_artist_toptracks:
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)
+ await provider.match_artist(new_artist, new_artist_toptracks)
return item_id
- async def get_artist_musicbrainz_id(self, artist_details:Artist, allow_fallback=False):
+ async def get_artist_musicbrainz_id(self, artist_details:Artist):
''' 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]:
+ for lookup_album in lookup_albums[:5]:
lookup_album_upc = None
for item in lookup_album.external_ids:
if item.get("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"]
+ if not musicbrainz_id:
+ lookup_tracks = await self.get_artist_toptracks(artist_details.item_id)
+ for lookup_track in lookup_tracks:
+ 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
- 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
+ musicbrainz_id = artist_details.name
return musicbrainz_id
async def album(self, prov_item_id, lazy=True) -> Album:
# 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
+ raise Exception('album not found: %s' % prov_item_id)
if lazy:
asyncio.create_task(self.add_album(album_details))
album_details.is_lazy = True
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:
+ async def add_album(self, album_details) -> 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)
+ 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:
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
+ raise Exception('track not found: %s' % prov_item_id)
if lazy:
- asyncio.ensure_future(self.add_track(track_details))
+ asyncio.create_task(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:
+ async def add_track(self, track_details, prov_album_id=None) -> 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)
+ db_track_artist = await self.artist(track_artist.item_id, lazy=False)
+ if 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)
+ 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:
+ async def playlist(self, prov_playlist_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)
+ db_id = await self.mass.db.get_database_id(self.prov_id, prov_playlist_id, MediaType.Playlist)
+ if db_id:
+ # synced playlist, return database details
+ return await self.mass.db.playlist(db_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
+ return await self.get_playlist(prov_playlist_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)
+ 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:
+ prov_track.album = album
+ items.append(prov_track)
return items
async def playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]:
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]:
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]:
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):
+ async def match_artist(self, searchartist:Artist, searchtracks:List[Track]):
''' 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))
+ for searchtrack in searchtracks:
+ searchstr = "%s - %s" %(searchartist.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:
+ # double safety check - artist must match exactly !
+ for artist in item.artists:
+ if artist.name == searchartist.name:
+ # just load this item in the database, it will be matched automagically ;-)
+ return await self.artist(artist.item_id, lazy=False)
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))
+ # just load this item in the database, it will be matched automagically ;-)
+ await self.album(item.item_id, lazy=False)
- async def match_track(self, searchtrack:Album):
+ async def match_track(self, searchtrack:Track):
''' 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)
+ searchartists = [item.name for item in searchtrack.artists]
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))
+ # double safety check - artist must match exactly !
+ for artist in item.artists:
+ if artist.name in searchartists:
+ # just load this item in the database, it will be matched automagically ;-)
+ await self.track(item.item_id, lazy=False)
### Provider specific implementation #####
''' retrieve library tracks from the provider '''
raise NotImplementedError
- async def get_library_playlists(self) -> List[Playlist]:
+ async def get_playlists(self) -> List[Playlist]:
''' retrieve library/subscribed playlists from the provider '''
raise NotImplementedError
self.player_id = None
self.player_provider = None
self.name = ''
- self.state = PlayerState.Off
+ self.state = PlayerState.Stopped
self.powered = False
self.cur_item = Track()
self.cur_item_time = 0
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])
- ]
+ supported_musicproviders = ['qobuz', 'file', 'spotify', 'http'] # list of supported music provider uri's this playerprovider supports NATIVELY
def __init__(self, mass):
self.mass = mass
### Common methods and properties ####
- async def play_media(self, player_id, uri, queue_opt='play'):
+ async def play_media(self, player_id, media_items:List[Track], 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)
+ - media_items: List of Tracks to play, each Track will contain uri attribute (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
+ play: keep existing queue but play the given item(s) now first
'''
raise NotImplementedError
--- /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
+import slugify as slug
+
+'''
+ Homeassistant integration
+ allows publishing of our players to hass
+ allows using hass entities (like switches, media_players or gui inputs) to be triggered
+'''
+
+def setup(mass):
+ ''' setup the module and read/apply config'''
+ if not mass.config['base'].get('homeassistant'):
+ mass.config['base']['homeassistant'] = {}
+ conf = mass.config['base']['homeassistant']
+ conf['__desc__'] = config_entries()
+ for key, def_value, desc in config_entries():
+ if not key in conf:
+ conf[key] = def_value
+ enabled = conf.get(CONF_ENABLED)
+ token = conf.get('token')
+ url = conf.get('url')
+ if enabled and url and token:
+ # append hass player config settings
+ hass_player_conf = [("hass_power_entity", "", "Attach player power to homeassistant entity"),
+ ("hass_power_entity_source", "", "Source on the homeassistant entity (optional)"),
+ ("hass_volume_entity", "", "Attach player volume to homeassistant entity")]
+ for key, default, desc in hass_player_conf:
+ entry_found = False
+ for value in mass.config['player_settings']['__desc__']:
+ if value[0] == key:
+ entry_found = True
+ break
+ if not entry_found:
+ mass.config['player_settings']['__desc__'].append((key, default, desc))
+ return HomeAssistant(mass, url, token)
+ return None
+
+def config_entries():
+ ''' get the config entries for this module (list with key/value pairs)'''
+ return [
+ (CONF_ENABLED, False, CONF_ENABLED),
+ ('url', 'localhost', 'URL to homeassistant (e.g. https://homeassistant:8123)'),
+ ('token', '<password>', 'Long Lived Access Token'),
+ ('publish_players', True, 'Publish players to Home Assistant')
+ ]
+
+class HomeAssistant():
+ ''' HomeAssistant integration '''
+
+ def __init__(self, mass, url, token):
+ self.mass = mass
+ self._published_players = {}
+ self._tracked_states = {}
+ self._state_listeners = []
+ self._token = token
+ if url.startswith('https://'):
+ self._use_ssl = True
+ self._host = url.replace('https://','').split('/')[0]
+ else:
+ self._use_ssl = False
+ self._host = url.replace('http://','').split('/')[0]
+ self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+ self.__send_ws = None
+ self.__last_id = 10
+ LOGGER.info('Homeassistant integration is enabled')
+ mass.event_loop.create_task(self.__hass_websocket())
+ mass.event_loop.create_task(self.mass.add_event_listener(self.mass_event))
+
+ async def get_state(self, entity_id, attribute='state', register_listener=None):
+ ''' get state of a hass entity'''
+ if entity_id in self._tracked_states:
+ state_obj = self._tracked_states[entity_id]
+ else:
+ # first request
+ state_obj = await self.__get_data('states/%s' % entity_id)
+ if register_listener:
+ # register state listener
+ self._state_listeners.append( (entity_id, register_listener) )
+ self._tracked_states[entity_id] = state_obj
+ if attribute == 'state':
+ return state_obj['state']
+ elif not attribute:
+ return state_obj
+ else:
+ return state_obj['attributes'].get(attribute)
+
+ async def mass_event(self, msg, msg_details):
+ ''' received event from mass '''
+ if msg == "player updated":
+ await self.publish_player(msg_details)
+
+ async def hass_event(self, event_type, event_data):
+ ''' received event from hass '''
+ if event_type == 'state_changed':
+ if event_data['entity_id'] in self._tracked_states:
+ self._tracked_states[event_data['entity_id']] = event_data['new_state']
+ for entity_id, handler in self._state_listeners:
+ if entity_id == event_data['entity_id']:
+ asyncio.create_task(handler())
+ elif event_type == 'call_service' and event_data['domain'] == 'media_player':
+ await self.__handle_player_command(event_data['service'], event_data['service_data'])
+
+ async def __handle_player_command(self, service, service_data):
+ ''' handle forwarded service call for one of our players '''
+ if isinstance(service_data['entity_id'], list):
+ # can be a list of entity ids if action fired on multiple items
+ entity_ids = service_data['entity_id']
+ else:
+ entity_ids = [service_data['entity_id']]
+ for entity_id in entity_ids:
+ if entity_id in self._published_players:
+ # call is for one of our players so handle it
+ player_id = self._published_players[entity_id]
+ if service == 'turn_on':
+ await self.mass.player.player_command(player_id, 'power', 'on')
+ elif service == 'turn_off':
+ await self.mass.player.player_command(player_id, 'power', 'off')
+ elif service == 'volume_mute':
+ await self.mass.player.player_command(player_id, 'mute', service_data['is_volume_muted'])
+ elif service == 'volume_set':
+ volume_level = service_data['volume_level']*100
+ await self.mass.player.player_command(player_id, 'volume', volume_level)
+ elif service == 'media_play':
+ await self.mass.player.player_command(player_id, 'play')
+ elif service == 'media_pause':
+ await self.mass.player.player_command(player_id, 'pause')
+ elif service == 'media_stop':
+ await self.mass.player.player_command(player_id, 'stop')
+ elif service == 'media_next_track':
+ await self.mass.player.player_command(player_id, 'next')
+ elif service == 'media_play_pause':
+ await self.mass.player.player_command(player_id, 'pause', 'toggle')
+ # TODO: handle media play !
+
+ async def publish_player(self, player):
+ ''' publish player details to hass'''
+ if not self.mass.config['base']['homeassistant']['publish_players']:
+ return False
+ player_id = player.player_id
+ entity_id = 'media_player.mass_' + slug.slugify(player.name, separator='_').lower()
+ state = player.state if player.powered else 'off'
+ state_attributes = {
+ "supported_features": 58303,
+ "friendly_name": player.name,
+ "volume_level": player.volume_level/100,
+ "is_volume_muted": player.muted,
+ "media_duration": player.cur_item.duration if player.cur_item else 0,
+ "media_position": player.cur_item_time,
+ "media_title": player.cur_item.name if player.cur_item else "",
+ "media_artist": player.cur_item.artists[0].name if player.cur_item and player.cur_item.artists else "",
+ "media_album_name": player.cur_item.album.name if player.cur_item and player.cur_item.album else "",
+ "entity_picture": player.cur_item.album.metadata.get('image') if player.cur_item and player.cur_item.album else ""
+ }
+ self._published_players[entity_id] = player_id
+ await self.__set_state(entity_id, state, state_attributes)
+
+ async def call_service(self, domain, service, service_data=None):
+ ''' call service on hass '''
+ if not self.__send_ws:
+ return False
+ msg = {
+ "type": "call_service",
+ "domain": domain,
+ "service": service,
+ }
+ if service_data:
+ msg['service_data'] = service_data
+ return await self.__send_ws(msg)
+
+ async def __set_state(self, entity_id, new_state, state_attributes={}):
+ ''' set state to hass entity '''
+ data = {
+ "state": new_state,
+ "entity_id": entity_id,
+ "attributes": state_attributes
+ }
+ return await self.__post_data('states/%s' % entity_id, data)
+
+ async def __hass_websocket(self):
+ ''' Receive events from Hass through websockets '''
+ while True:
+ try:
+ protocol = 'wss' if self._use_ssl else 'ws'
+ async with self.http_session.ws_connect('%s://%s/api/websocket' % (protocol, self._host)) as ws:
+
+ async def send_msg(msg):
+ ''' callback to send message to the websockets client'''
+ self.__last_id += 1
+ msg['id'] = self.__last_id
+ await ws.send_json(msg)
+
+ async for msg in ws:
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ if msg.data == 'close cmd':
+ await ws.close()
+ break
+ else:
+ data = msg.json()
+ if data['type'] == 'auth_required':
+ # send auth token
+ auth_msg = {"type": "auth", "access_token": self._token}
+ await ws.send_json(auth_msg)
+ elif data['type'] == 'auth_invalid':
+ raise Exception(data)
+ elif data['type'] == 'auth_ok':
+ # register callback
+ self.__send_ws = send_msg
+ # subscribe to events
+ subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"}
+ await send_msg(subscribe_msg)
+ subscribe_msg = {"type": "subscribe_events", "event_type": "call_service"}
+ await send_msg(subscribe_msg)
+ elif data['type'] == 'event':
+ asyncio.create_task(self.hass_event(data['event']['event_type'], data['event']['data']))
+ elif data['type'] == 'result' and data.get('result'):
+ # reply to our get_states request
+ asyncio.create_task(self.hass_event('all_states', data['result']))
+ else:
+ LOGGER.info(data)
+ elif msg.type == aiohttp.WSMsgType.ERROR:
+ break
+ except Exception as exc:
+ LOGGER.exception(exc)
+ asyncio.sleep(10)
+
+ async def __get_data(self, endpoint):
+ ''' get data from hass rest api'''
+ url = "http://%s/api/%s" % (self._host, endpoint)
+ if self._use_ssl:
+ url = "https://%s/api/%s" % (self._host, endpoint)
+ headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"}
+ async with self.http_session.get(url, headers=headers) as response:
+ return await response.json()
+
+ async def __post_data(self, endpoint, data):
+ ''' post data to hass rest api'''
+ url = "http://%s/api/%s" % (self._host, endpoint)
+ if self._use_ssl:
+ url = "https://%s/api/%s" % (self._host, endpoint)
+ headers = {"Authorization": "Bearer %s" % self._token, "Content-Type": "application/json"}
+ async with self.http_session.post(url, headers=headers, json=data) as response:
+ return await response.json()
\ No newline at end of file
result += await self.get_album_tracks(album.item_id)
return result
- async def get_library_playlists(self) -> List[Playlist]:
+ async def get_playlists(self) -> List[Playlist]:
''' retrieve playlists from disk '''
if not self._playlists_dir:
return []
else:
name = prov_item_id.split("/")[-1]
artist = Artist()
- artist.item_id = prov_item_id # temporary id
+ artist.item_id = prov_item_id
+ artist.provider = self.prov_id
artist.name = name
artist.provider_ids.append({
"provider": self.prov_id,
name = prov_item_id.split("/")[-1]
artistpath = prov_item_id.rsplit("/", 1)[0]
album = Album()
- album.item_id = prov_item_id # temporary id
+ album.item_id = prov_item_id
+ album.provider = self.prov_id
album.name, album.version = parse_track_title(name)
album.artist = await self.get_artist(artistpath)
if not album.artist:
return None
filepath = prov_item_id
playlist = Playlist()
- playlist.item_id = filepath # temporary id
+ playlist.item_id = filepath
+ playlist.provider = self.prov_id
playlist.name = filepath.split('\\')[-1].split('/')[-1].replace('.m3u', '')
+ playlist.is_editable = True
playlist.provider_ids.append({
"provider": self.prov_id,
"item_id": filepath
except:
return None # not a media file ?
track.duration = song.length
- track.item_id = filename # temporary id
+ track.item_id = filename
+ track.provider = self.prov_id
name = song.tags['TITLE'][0]
track.name, track.version = parse_track_title(name)
if "\\" in filename:
self._cur_user = None
self.mass = mass
self.cache = mass.cache
- self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+ self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
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)
+ self.throttler = Throttler(rate_limit=1, period=1)
async def search(self, searchstring, media_types=List[MediaType], limit=5):
''' perform search on the provider '''
result.append(track)
return result
- async def get_library_playlists(self) -> List[Playlist]:
+ async def get_playlists(self) -> List[Playlist]:
''' retrieve playlists from the provider '''
result = []
for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists'):
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):
+ if str(item['artist']['id']) == str(prov_artist_id):
album = await self.__parse_album(item)
if album:
albums.append(album)
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 []
+ # artist toptracks not supported on Qobuz, so use search instead
+ items = []
+ artist = await self.get_artist(prov_artist_id)
+ params = {"query": artist.name, "limit": 10, "type": "tracks" }
+ searchresult = await self.__get_data("catalog/search", params)
+ for item in searchresult["tracks"]["items"]:
+ if "performer" in item and str(item["performer"]["id"]) == str(prov_artist_id):
+ track = await self.__parse_track(item)
+ items.append(track)
+ return items
async def add_library(self, prov_item_id, media_type:MediaType):
''' add item to library '''
async def get_stream(self, track_id):
''' get audio stream for a track '''
+ sox_effects='vol -12 dB'
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
+ env = os.environ.copy()
+ env["SOX_OPTS"] = "−−multi−threaded −−replay−gain track"
+ cmd = 'curl -s -X GET "%s" | sox -t flac - -t flac - %s' % (url, sox_effects)
+ process = await asyncio.create_subprocess_shell(cmd, stdout=asyncio.subprocess.PIPE, env=env)
+ while not process.stdout.at_eof():
+ chunk = await process.stdout.readline()
+ if chunk:
yield chunk
+ else:
+ break
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.item_id = artist_obj['id']
+ artist.provider = self.prov_id
artist.provider_ids.append({
"provider": self.prov_id,
"item_id": artist_obj['id']
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'))
+ LOGGER.debug("invalid/unavailable album found: %s" % album_obj.get('id'))
return None
- album.item_id = album_obj['id'] # temporary id
+ album.item_id = album_obj['id']
+ album.provider = self.prov_id
album.provider_ids.append({
"provider": self.prov_id,
"item_id": album_obj['id'],
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'))
+ LOGGER.debug("invalid/unavailable track found: %s" % track_obj.get('id'))
return None
- track.item_id = track_obj['id'] # temporary id
+ track.item_id = track_obj['id']
+ track.provider = self.prov_id
if track_obj.get('performer') and not 'Various ' in track_obj['performer']:
artist = await self.__parse_artist(track_obj['performer'])
- track.artists.append(artist)
+ if not artist:
+ artist = self.get_artist(track_obj['performer']['id'])
+ if artist:
+ track.artists.append(artist)
if not track.artists:
# try to grab artist from album
if track_obj.get('album') and track_obj['album'].get('artist') and not 'Various ' in track_obj['album']['artist']:
playlist = Playlist()
if not playlist_obj.get('id'):
return None
- playlist.item_id = playlist_obj['id'] # temporary id
+ playlist.item_id = playlist_obj['id']
+ playlist.provider = self.prov_id
playlist.provider_ids.append({
"provider": self.prov_id,
"item_id": playlist_obj['id']
})
playlist.name = playlist_obj['name']
playlist.owner = playlist_obj['owner']['name']
+ playlist.is_editable = playlist_obj['owner']['id'] == self._cur_user["id"] or playlist_obj['is_collaborative']
if playlist_obj.get('images300'):
playlist.metadata["image"] = playlist_obj['images300'][0]
if playlist_obj.get('url'):
return self.__user_auth_token
params = { "username": self.__username, "password": self.__password}
details = await self.__get_data("user/login", params, ignore_cache=True)
+ self._cur_user = details["user"]
self.__user_auth_token = details["user_auth_token"]
- LOGGER.info("Succesfully logged in to Qobuz as %s" % (details["user"]["display_name"]))
+ LOGGER.info("Succesfully logged in to Qobuz as %s" % (self._cur_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):
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
+ async with self.throttler:
+ 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
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
+from asyncio_throttle import Throttler
import json
import aiohttp
from cache import use_cache
self._cur_user = None
self.mass = mass
self.cache = mass.cache
- self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
+ self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False))
+ self.throttler = Throttler(rate_limit=1, period=1)
self._username = username
self._password = password
self.__auth_token = {}
result.append(track)
return result
- async def get_library_playlists(self) -> List[Playlist]:
+ async def get_playlists(self) -> List[Playlist]:
''' retrieve playlists from the provider '''
result = []
for item in await self.__get_all_items("me/playlists"):
async def get_artist_toptracks(self, prov_artist_id) -> List[Track]:
''' get a list of 10 most popular tracks for the given artist '''
+ artist = await self.get_artist(prov_artist_id)
items = await self.__get_data('artists/%s/top-tracks' % prov_artist_id)
tracks = []
for item in items['tracks']:
track = await self.__parse_track(item)
if track:
+ track.artists = [artist]
tracks.append(track)
return tracks
import socket
host = socket.gethostbyname(socket.gethostname())
return {
- 'mime_type': 'audio/ogg',
+ 'mime_type': 'audio/flac',
'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 '''
+ sox_effects='vol -12 dB'
+ import subprocess
+ spotty = self.get_spotty_binary()
+ env = os.environ.copy()
+ env["SOX_OPTS"] = "−−multi−threaded −−replay−gain track"
+ cmd = spotty + ' -n temp -u ' + self._username + ' -p ' + self._password + ' --pass-through --single-track ' + track_id
+ cmd += ' | sox -t ogg - -t flac - %s' % sox_effects
+ process = await asyncio.create_subprocess_shell(cmd, stdout=asyncio.subprocess.PIPE, env=env)
+ while not process.stdout.at_eof():
+ chunk = await process.stdout.readline()
+ if chunk:
+ yield chunk
+ await process.wait()
+
+ async def get_stream__old(self, track_id, sox_effects='vol -12 dB'):
''' 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)
+ os.environ["SOX_OPTS"] = "−−multi−threaded −−replay−gain track"
+ cmd = spotty + ' -n temp -u ' + self._username + ' -p ' + self._password + ' --pass-through --single-track ' + track_id + ' | sox -t ogg -- - -t flac - %s' % sox_effects
+ process = await asyncio.create_subprocess_shell(cmd, stdout=asyncio.subprocess.PIPE)
while not process.stdout.at_eof():
- line = await process.stdout.readline()
- if line:
- yield line
+ chunk = await process.stdout.readline()
+ if chunk:
+ yield chunk
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.item_id = artist_obj['id']
+ artist.provider = self.prov_id
artist.provider_ids.append({
"provider": self.prov_id,
"item_id": artist_obj['id']
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.item_id = album_obj['id']
+ album.provider = self.prov_id
album.name, album.version = parse_track_title(album_obj['name'])
for artist in album_obj['artists']:
album.artist = await self.__parse_artist(artist)
if 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
+ track.item_id = track_obj['id']
+ track.provider = self.prov_id
for track_artist in track_obj['artists']:
artist = await self.__parse_artist(track_artist)
if artist:
playlist = Playlist()
if not playlist_obj.get('id'):
return None
- playlist.item_id = playlist_obj['id'] # temporary id
+ playlist.item_id = playlist_obj['id']
+ playlist.provider = self.prov_id
playlist.provider_ids.append({
"provider": self.prov_id,
"item_id": playlist_obj['id']
})
playlist.name = playlist_obj['name']
playlist.owner = playlist_obj['owner']['display_name']
+ playlist.is_editable = playlist_obj['owner']['id'] == self.sp_user["id"] or playlist_obj['collaborative']
if playlist_obj.get('images'):
playlist.metadata["image"] = playlist_obj['images'][0]['url']
if playlist_obj.get('external_urls'):
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 with self.throttler:
+ 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)
+ return None
+ return result
async def __delete_data(self, endpoint, params={}):
''' get data from api'''
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)
+from pychromecast.controllers.media import MediaController
+import types
def setup(mass):
''' setup the provider'''
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._chromecasts = {}
+ self.supported_musicproviders = ['http']
self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
asyncio.ensure_future(self.__discover_chromecasts())
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()
+ self._chromecasts[player_id].media_controller.play()
elif cmd == 'pause':
- self._players[player_id].cast.media_controller.pause()
+ self._chromecasts[player_id].media_controller.pause()
elif cmd == 'stop':
- self._players[player_id].cast.media_controller.stop()
+ self._chromecasts[player_id].media_controller.stop()
elif cmd == 'next':
- self._players[player_id].cast.media_controller.queue_next()
+ self._chromecasts[player_id].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()
+ self._chromecasts[player_id].media_controller.queue_previous()
+ elif cmd == 'power' and cmd_args == 'off':
+ self._players[player_id].powered = False # power is not supported
+ await self.mass.player.update_player(self._players[player_id])
+ elif cmd == 'power':
+ self._players[player_id].powered = True # power is not supported
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)
+ self._chromecasts[player_id].set_volume(try_parse_int(cmd_args)/100)
+ elif cmd == 'mute' and cmd_args == 'off':
+ self._chromecasts[player_id].set_volume_muted(False)
+ elif cmd == 'mute':
+ self._chromecasts[player_id].set_volume_muted(True)
+ elif cmd == 'power':
+ pass # power is not supported on chromecast
- async def play_media(self, player_id, uri, queue_opt='play'):
+ async def player_queue(self, player_id, offset=0, limit=50):
+ ''' return the items in the player's queue '''
+ items = []
+ for item in self._chromecasts[player_id].queue[offset:limit]:
+ track = await self.__track_from_uri(item['media']['contentId'])
+ if track:
+ items.append(track)
+ return items
+
+ async def create_queue_item(self, track):
+ '''create queue item from track info '''
+ return {
+ 'autoplay' : True,
+ 'preloadTime' : 10,
+ 'playbackDuration': int(track.duration),
+ 'startTime' : 0,
+ 'activeTrackIds' : [],
+ 'media': {
+ 'contentId': track.uri,
+ 'customData': {'provider': track.provider},
+ 'contentType': "audio/flac",
+ 'streamType': 'BUFFERED',
+ 'metadata': {
+ 'title': track.name,
+ 'artist': track.artists[0].name,
+ },
+ 'duration': int(track.duration)
+ }
+ }
+
+ async def play_media(self, player_id, media_items, 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")
+ player = self._chromecasts[player_id]
+ media_controller = player.media_controller
+ receiver_ctrl = media_controller._socket_client.receiver_controller
+ cur_queue_index = 0
+ if media_controller.queue_cur_id != None:
+ for item in media_controller.queue_items:
+ # status queue may contain at max 3 tracks (previous, current and next)
+ if item['itemId'] == media_controller.queue_cur_id:
+ cur_queue_item = item
+ # find out the current index
+ for counter, value in enumerate(player.queue):
+ if value['media']['contentId'] == cur_queue_item['media']['contentId']:
+ cur_queue_index = counter
+ break
+ break
+ if (not media_controller.queue_cur_id or not media_controller.status.media_session_id or not player.queue):
+ queue_opt = 'replace'
+
+ new_queue_items = []
+ for track in media_items:
+ queue_item = await self.create_queue_item(track)
+ new_queue_items.append(queue_item)
+ if (queue_opt in ['replace', 'play'] or not media_controller.queue_cur_id or
+ not media_controller.status.media_session_id or not player.queue):
+ # load new Chromecast queue with items
+ if queue_opt == 'add':
+ # append items to queue
+ player.queue = player.queue + new_queue_items
+ startindex = cur_queue_index
+ elif queue_opt == 'play':
+ # keep current queue but append new items at begin and start playing first item
+ player.queue = new_queue_items + player.queue[cur_queue_index:] + player.queue[:cur_queue_index]
+ startindex = 0
+ elif queue_opt == 'next':
+ # play the new items after the current playing item (insert before current next item)
+ player.queue = new_queue_items + player.queue[cur_queue_index:] + player.queue[:cur_queue_index]
+ startindex = cur_queue_index
+ else:
+ # overwrite the whole queue with new item(s)
+ player.queue = new_queue_items
+ startindex = 0
+ # load first 10 items as soon as possible
+ queuedata = {
+ "type": 'QUEUE_LOAD',
+ "repeatMode": "REPEAT_ALL" if player.repeat_enabled else "REPEAT_OFF",
+ "shuffle": player.shuffle_enabled,
+ "queueType": "PLAYLIST",
+ "startIndex": startindex, # Item index to play after this request or keep same item if undefined
+ "items": player.queue[:10]
+ }
+ await self.__send_player_queue(receiver_ctrl, media_controller, queuedata)
+ # append the rest of the items in the queue in chunks
+ for chunk in chunks(player.queue[10:], 100):
+ await asyncio.sleep(1)
+ queuedata = { "type": 'QUEUE_INSERT', "items": chunk }
+ await self.__send_player_queue(receiver_ctrl, media_controller, queuedata)
+ elif queue_opt == 'add':
+ # existing queue is playing: simply append items to the end of the queue (in small chunks)
+ player.queue = player.queue + new_queue_items
+ insertbefore = None
+ for chunk in chunks(new_queue_items, 100):
+ queuedata = { "type": 'QUEUE_INSERT', "items": chunk }
+ await self.__send_player_queue(receiver_ctrl, media_controller, queuedata)
+ await asyncio.sleep(1)
+ elif queue_opt == 'next':
+ # play the new items after the current playing item (insert before current next item)
+ player.queue = player.queue[:cur_queue_index] + new_queue_items + player.queue[cur_queue_index:]
+ queuedata = {
+ "type": 'QUEUE_INSERT',
+ "insertBefore": media_controller.queue_cur_id+1,
+ "items": new_queue_items[:200] # limit of the queue message
+ }
+ await self.__send_player_queue(receiver_ctrl, media_controller, queuedata)
+
### Provider specific (helper) methods #####
-
+
+ async def __send_player_queue(self, receiver_ctrl, media_controller, queuedata):
+ '''send new data to the CC queue'''
+ def app_launched_callback():
+ LOGGER.info("app_launched_callback")
+ """Plays media after chromecast has switched to requested app."""
+ queuedata['mediaSessionId'] = media_controller.status.media_session_id
+ LOGGER.info('')
+ LOGGER.info('')
+ media_controller.send_message(queuedata, inc_session_id=False)
+ receiver_ctrl.launch_app(media_controller.app_id,
+ callback_function=app_launched_callback)
+
async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None):
''' handle a player state message from the socket '''
player_id = str(chromecast.uuid)
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:
+ track = await self.__track_from_uri(mediastatus.content_id)
+ if not track:
# TODO: match this info manually in the DB!!
track = Track()
artist = mediastatus.artist
track.metadata.image = mediastatus.media_metadata['images'][-1]['url']
return track
+ async def __track_from_uri(self, uri):
+ ''' try to parse uri loaded in CC to a track we understand '''
+ track = None
+ if uri.startswith('spotify://track:') and 'spotify' in self.mass.music.providers:
+ track_id = uri.replace('spotify:track:','')
+ track = await self.mass.music.providers['spotify'].track(track_id)
+ elif uri.startswith('qobuz://') and 'qobuz' in self.mass.music.providers:
+ track_id = uri.replace('qobuz://','').replace('.flac','')
+ track = await self.mass.music.providers['qobuz'].track(track_id)
+ elif uri.startswith('http') and '/stream' in uri:
+ try:
+ item_id = uri.split('/')[-1]
+ provider = uri.split('/')[-2]
+ track = await self.mass.music.providers[provider].track(item_id)
+ except: pass
+ 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:
player = MusicPlayer()
player.player_id = player_id
player.name = chromecast.name
+ player.player_provider = self.prov_id
chromecast.start()
+ # patch the receive message method for handling queue status updates
+ chromecast.queue = []
+ chromecast.media_controller.queue_items = []
+ chromecast.media_controller.queue_cur_id = None
+ chromecast.media_controller.receive_message = types.MethodType(receive_message, chromecast.media_controller)
listenerCast = StatusListener(chromecast, self.__handle_player_state, self.mass.event_loop)
chromecast.register_status_listener(listenerCast)
listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop)
chromecast.register_handler(mz)
chromecast.register_connection_listener(MZConnListener(mz))
chromecast.wait()
- player.cast = chromecast
- player.player_provider = self.prov_id
+ self._chromecasts[player_id] = chromecast
self._players[player_id] = player
LOGGER.info('Chromecast discovery done...')
+def chunks(l, n):
+ """Yield successive n-sized chunks from l."""
+ for i in range(0, len(l), n):
+ yield l[i:i + n]
class StatusListener:
def __init__(self, chromecast, callback, loop):
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
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
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):
self.device = data['payload']['deviceID']
self.is_launched = True
return True
+
+def receive_message(self, message, data):
+ """ Called when a media message is received. """
+ #LOGGER.info('message: %s - data: %s'%(message, data))
+ if data['type'] == 'MEDIA_STATUS':
+ try:
+ self.queue_items = data['status'][0]['items']
+ except:
+ pass
+ try:
+ self.queue_cur_id = data['status'][0]['currentItemId']
+ except:
+ pass
+ self._process_media_status(data)
+ return True
+ return False
\ No newline at end of file
+++ /dev/null
-#!/usr/bin/env python3
-# -*- coding:utf-8 -*-
-
-import asyncio
-import os
-from typing import List
-import random
-import sys
-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
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.supported_musicproviders = ['qobuz', 'file', 'spotify', 'http']
self.http_session = aiohttp.ClientSession(loop=mass.event_loop)
# we use a combi of active polling and subscriptions because the cometd implementation of LMS is somewhat unreliable
asyncio.ensure_future(self.__lms_events())
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]:
+ elif cmd == 'power' and cmd_args == 'off':
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 == 'power':
+ lms_commands = ['power', '1']
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]:
+ elif cmd == 'mute' and cmd_args == 'off':
lms_commands = ['mixer', 'muting', '0']
+ elif cmd == 'mute':
+ lms_commands = ['mixer', 'muting', '1']
return await self.__get_data(lms_commands, player_id=player_id)
- async def play_media(self, player_id, uri, queue_opt='play'):
+ async def play_media(self, player_id, media_items, 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]
+ cmd = ['playlist', 'insert', media_items[0].uri]
await self.__get_data(cmd, player_id=player_id)
- cmd2 = ['playlist', 'index', '+1']
- return await self.__get_data(cmd2, player_id=player_id)
+ cmd = ['playlist', 'index', '+1']
+ await self.__get_data(cmd, player_id=player_id)
+ for track in media_items[1:]:
+ cmd = ['playlist', 'insert', track.uri]
+ await self.__get_data(cmd, player_id=player_id)
elif queue_opt == 'replace':
- cmd = ['playlist', 'play', uri]
- return await self.__get_data(cmd, player_id=player_id)
+ cmd = ['playlist', 'play', media_items[0].uri]
+ await self.__get_data(cmd, player_id=player_id)
+ for track in media_items[1:]:
+ cmd = ['playlist', 'add', track.uri]
+ await self.__get_data(cmd, player_id=player_id)
elif queue_opt == 'next':
- cmd = ['playlist', 'insert', uri]
- return await self.__get_data(cmd, player_id=player_id)
+ for track in media_items:
+ cmd = ['playlist', 'insert', track.uri]
+ await self.__get_data(cmd, player_id=player_id)
else:
- cmd = ['playlist', 'add', uri]
- return await self.__get_data(cmd, player_id=player_id)
-
+ for track in media_items:
+ cmd = ['playlist', 'add', track.uri]
+ await self.__get_data(cmd, player_id=player_id)
+
async def player_queue(self, player_id, offset=0, limit=50):
''' return the items in the player's queue '''
items = []
import asyncio
import os
-from utils import run_periodic, run_async_background_task, LOGGER
+from utils import run_periodic, run_async_background_task, LOGGER, try_parse_int
import aiohttp
from difflib import SequenceMatcher as Matcher
from models import MediaType
# schedule sync task
mass.event_loop.create_task(self.sync_music_providers())
- async def item(self, item_id, media_type:MediaType, lazy=True):
+ async def item(self, item_id, media_type:MediaType, provider='database', lazy=True):
''' get single music item by id and media type'''
if media_type == MediaType.Artist:
- return await self.artist(item_id, lazy=lazy)
+ return await self.artist(item_id, provider, lazy=lazy)
elif media_type == MediaType.Album:
- return await self.album(item_id, lazy=lazy)
+ return await self.album(item_id, provider, lazy=lazy)
elif media_type == MediaType.Track:
- return await self.track(item_id, lazy=lazy)
+ return await self.track(item_id, provider, lazy=lazy)
elif media_type == MediaType.Playlist:
- return await self.playlist(item_id)
+ return await self.playlist(item_id, provider)
else:
return 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):
+ async def 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)
+ return await self.mass.db.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'''
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)
+ return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter)
- async def artist(self, item_id, lazy=True):
+ async def artist(self, item_id, provider='database', 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)
+ if not provider or provider == 'database':
+ return await self.mass.db.artist(item_id)
+ return await self.providers[provider].artist(item_id, lazy=lazy)
- async def album(self, item_id, lazy=True):
+ async def album(self, item_id, provider='database', 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)
+ if not provider or provider == 'database':
+ return await self.mass.db.album(item_id)
+ return await self.providers[provider].album(item_id, lazy=lazy)
- async def track(self, item_id, lazy=True):
+ async def track(self, item_id, provider='database', 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)
+ if not provider or provider == 'database':
+ return await self.mass.db.track(item_id)
+ return await self.providers[provider].track(item_id, lazy=lazy)
- async def playlist(self, item_id):
+ async def playlist(self, item_id, provider='database'):
''' 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)
+ if not provider or provider == 'database':
+ return await self.mass.db.playlist(item_id)
+ return await self.providers[provider].playlist(item_id)
- async def artist_toptracks(self, artist_id):
+ async def artist_toptracks(self, artist_id, provider='database'):
''' get top tracks for given artist '''
- items = []
- artist = await self.artist(artist_id)
+ artist = await self.artist(artist_id, provider)
# always append database tracks
- items += await self.mass.db.artist_tracks(artist.item_id)
+ 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']
items.sort(key=lambda x: x.name, reverse=False)
return items
- async def artist_albums(self, artist_id):
+ async def artist_albums(self, artist_id, provider='database'):
''' get (all) albums for given artist '''
- items = []
- artist = await self.artist(artist_id)
+ artist = await self.artist(artist_id, provider)
# always append database tracks
- items += await self.mass.db.artist_albums(artist.item_id)
+ 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']
items.sort(key=lambda x: x.name, reverse=False)
return items
- async def album_tracks(self, album_id):
+ async def album_tracks(self, album_id, provider='database'):
''' get the album tracks for given album '''
items = []
- album = await self.album(album_id)
+ album = await self.album(album_id, provider)
for prov_mapping in album.provider_ids:
prov_id = prov_mapping['provider']
prov_item_id = prov_mapping['item_id']
prov_obj = self.providers[prov_id]
items += await prov_obj.album_tracks(prov_item_id)
- if items:
- break # no need to pull in dups
items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
return items
- async def playlist_tracks(self, playlist_id, offset=0, limit=50):
+ async def playlist_tracks(self, playlist_id, provider='database', 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)
- if items:
- break
- items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
- return items
+ playlist = None
+ if not provider or provider == 'database':
+ playlist = await self.mass.db.playlist(playlist_id)
+ if playlist and playlist.is_editable:
+ # database synced playlist, return tracks from db...
+ return await self.mass.db.playlist_tracks(playlist.item_id, offset=offset, limit=limit)
+ else:
+ # return playlist tracks from provider
+ 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)
+ if items:
+ break
+ 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 '''
items = list(toolz.unique(items, key=operator.attrgetter('item_id')))
return result
- async def item_action(self, item_id, media_type, action=None):
+ async def item_action(self, item_id, media_type, provider='database', action=None):
''' perform action on item (such as library add/remove) '''
result = None
- item = await self.item(item_id, media_type)
+ item = await self.item(item_id, media_type, provider)
if item and action in ['add', 'remove']:
# remove or add item to the library
for prov_mapping in result.provider_ids:
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 '''
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)
+ await self.sync_playlists(prov_id)
self.sync_running = False
async def sync_library_artists(self, prov_id):
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)
+ db_item = await music_provider.artist(item.item_id, lazy=False)
+ cur_db_ids.append(db_item.item_id)
+ if not db_item.item_id in prev_db_ids:
+ await self.mass.db.add_to_library(db_item.item_id, MediaType.Artist, prov_id)
# process deletions
for db_id in prev_db_ids:
if db_id not in cur_db_ids:
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)
+ db_item = await music_provider.album(item.item_id, lazy=False)
+ cur_db_ids.append(db_item.item_id)
+ # precache album tracks...
+ for album_track in await music_provider.get_album_tracks(item.item_id):
+ await music_provider.track(album_track.item_id)
+ if not db_item.item_id in prev_db_ids:
+ await self.mass.db.add_to_library(db_item.item_id, MediaType.Album, prov_id)
# process deletions
for db_id in prev_db_ids:
if db_id not in cur_db_ids:
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)
+ db_item = await music_provider.track(item.item_id, lazy=False)
+ cur_db_ids.append(db_item.item_id)
+ if not db_item.item_id in prev_db_ids:
+ await self.mass.db.add_to_library(db_item.item_id, MediaType.Track, prov_id)
# process deletions
for db_id in prev_db_ids:
if db_id not in cur_db_ids:
await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id)
LOGGER.info("Finished syncing Tracks for provider %s" % prov_id)
- async def sync_library_playlists(self, prov_id):
+ async def sync_playlists(self, prov_id):
''' sync library playlists for given provider'''
music_provider = self.providers[prov_id]
- prev_items = await self.library_playlists(provider_filter=prov_id)
+ prev_items = await self.playlists(provider_filter=prov_id)
prev_db_ids = [item.item_id for item in prev_items]
- cur_items = await music_provider.get_library_playlists()
+ cur_items = await music_provider.get_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)
+ # always add to db because playlist attributes could have changed
+ db_id = await self.mass.db.add_playlist(item)
cur_db_ids.append(db_id)
if not db_id in prev_db_ids:
await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id)
- # playlist tracks
- #asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) )
+ if item.is_editable:
+ # precache/sync playlist tracks (user owned playlists only)
+ asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) )
# process playlist deletions
for db_id in prev_db_ids:
if db_id not in cur_db_ids:
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']
+ item_provider = 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)
+ db_item = await self.providers[item_provider].track(prov_item_id, lazy=False)
+ cur_db_ids.append(db_item.item_id)
+ if not db_item.item_id in prev_db_ids:
+ await self.mass.db.add_playlist_track(db_playlist_id, db_item.item_id, pos)
pos += 1
# process playlist track deletions
for db_id in prev_db_ids:
import asyncio
import os
-from utils import run_periodic, LOGGER, try_parse_int
+from utils import run_periodic, LOGGER, try_parse_int, try_parse_float
import aiohttp
from difflib import SequenceMatcher as Matcher
from models import MediaType, PlayerState, MusicPlayer
from typing import List
import toolz
import operator
-
+import socket
+import random
+from copy import deepcopy
+import functools
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODULES_PATH = os.path.join(BASE_DIR, "modules", "playerproviders" )
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]
+ player_settings = await self.get_player_config(player)
+ # handle some common workarounds
+ if cmd in ['pause', 'play'] and cmd_args == 'toggle':
+ cmd = 'pause' if player.state == PlayerState.Playing else 'play'
+ if cmd == 'volume' and cmd_args == 'up':
+ cmd_args = try_parse_int(cmd_args) + 2
+ elif cmd == 'volume' and cmd_args == 'down':
+ cmd_args = try_parse_int(cmd_args) - 2
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)
+ # handle hass integration
+ if self.mass.hass:
+ if cmd == 'power' and cmd_args == 'on' and player_settings.get('hass_power_entity') and player_settings.get('hass_power_entity_source'):
+ service_data = { 'entity_id': player_settings['hass_power_entity'], 'source':player_settings['hass_power_entity_source'] }
+ await self.mass.hass.call_service('media_player', 'select_source', service_data)
+ elif cmd == 'power' and player_settings.get('hass_power_entity'):
+ domain = player_settings['hass_power_entity'].split('.')[0]
+ service_data = { 'entity_id': player_settings['hass_power_entity']}
+ await self.mass.hass.call_service(domain, 'turn_%s' % cmd_args, service_data)
+ if cmd == 'volume' and player_settings.get('hass_volume_entity'):
+ service_data = { 'entity_id': player_settings['hass_power_entity'], 'volume_level': int(cmd_args)/100}
+ await self.mass.hass.call_service('media_player', 'volume_set', service_data)
+ cmd_args = 100 # just force full volume on actual player if volume is outsourced to hass
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:
+ player_childs = [item for item in self._players.values() if item.group_parent == player_id]
+ is_group = len(player_childs) > 0
+ if is_group and 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)
+ 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:
+ for child_player in player_childs:
+ if child_player.enabled and child_player.powered:
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))
self._players.pop(player_id, None)
asyncio.ensure_future(self.mass.event('player removed', player_id))
+ async def trigger_update(self, player_id):
+ ''' manually trigger update for a player '''
+ await self.update_player(self._players[player_id])
+
async def update_player(self, player_details):
''' update (or add) player '''
+ player_details = deepcopy(player_details)
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:
+ # first message from player
self._players[player_id] = MusicPlayer()
player = self._players[player_id]
player.player_id = player_id
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 hass integration
+ if self.mass.hass:
+ if player_settings.get('hass_power_entity') and player_settings.get('hass_power_entity_source'):
+ hass_state = await self.mass.hass.get_state(
+ player_settings['hass_power_entity'],
+ attribute='source',
+ register_listener=functools.partial(self.trigger_update, player_id))
+ player_details.powered = hass_state == player_settings['hass_power_entity_source']
+ elif player_settings.get('hass_power_entity'):
+ hass_state = await self.mass.hass.get_state(
+ player_settings['hass_power_entity'],
+ attribute='state',
+ register_listener=functools.partial(self.trigger_update, player_id))
+ player_details.powered = hass_state != 'off'
+ if player_settings.get('hass_volume_entity'):
+ hass_state = await self.mass.hass.get_state(
+ player_settings['hass_volume_entity'],
+ attribute='volume_level',
+ register_listener=functools.partial(self.trigger_update, player_id))
+ player_details.volume_level = int(try_parse_float(hass_state)*100)
# handle mute as power setting
if player_details.mute_as_power:
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
+ player_details.cur_item_time = parent_player.cur_item_time
+ player_details.cur_item = parent_player.cur_item
+ player_details.state = parent_player.state
# handle group volume setting
+ player_childs = [item for item in self._players.values() if item.group_parent == player_id]
+ player_details.is_group = len(player_childs) > 0
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:
+ for child_player in player_childs:
+ if 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
if player_changed:
# player is added or updated!
asyncio.ensure_future(self.mass.event('player updated', player))
+ for child in player_childs:
+ asyncio.create_task(self.trigger_update(child.player_id))
async def get_player_config(self, player_details):
''' get or create player config '''
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
+ queue_opt: play, 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
+ player_prov = self.providers[self._players[player_id].player_provider]
+ # collect tracks to play
if media_item.media_type == MediaType.Artist:
- tracks = await self.mass.music.artist_toptracks(media_item.item_id)
+ tracks = await self.mass.music.artist_toptracks(media_item.item_id, provider=media_item.provider)
elif media_item.media_type == MediaType.Album:
- tracks = await self.mass.music.album_tracks(media_item.item_id)
+ tracks = await self.mass.music.album_tracks(media_item.item_id, provider=media_item.provider)
elif media_item.media_type == MediaType.Playlist:
- tracks = await self.mass.music.playlist_tracks(media_item.item_id, offset=0, limit=0)
- if queue_opt == 'replace':
- await self.play_media(player_id, tracks[0], 'replace')
- tracks = tracks[1:]
- queue_opt = 'add'
+ tracks = await self.mass.music.playlist_tracks(media_item.item_id, provider=media_item.provider, offset=0, limit=0)
+ else:
+ tracks = [media_item] # single track
+ # check supported music providers by this player and work out how to handle playback...
+ playable_tracks = []
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)
+ # sort by quality
+ match_found = False
+ for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True):
+ media_provider = prov_media['provider']
+ media_item_id = prov_media['item_id']
+ player_supported_provs = player_prov.supported_musicproviders
+ if media_provider in player_supported_provs:
+ # the provider can handle this media_type directly !
+ track.uri = await self.get_track_uri(media_item_id, media_provider)
+ playable_tracks.append(track)
+ match_found = True
+ elif 'http' in player_prov.supported_musicproviders:
+ # fallback to http streaming if supported
+ track.uri = await self.get_track_uri(media_item_id, media_provider, True)
+ playable_tracks.append(track)
+ match_found = True
+ if match_found:
+ break
+ if playable_tracks:
+ if self._players[player_id].shuffle_enabled:
+ random.shuffle(playable_tracks)
+ if queue_opt in ['next', 'play'] and len(playable_tracks) > 1:
+ queue_opt = 'replace' # always assume playback of multiple items as new queue
+ return await player_prov.play_media(player_id, playable_tracks, queue_opt)
else:
- # TODO: Implement 'fake' queue
- raise NotImplementedError
+ raise Exception("Musicprovider %s and/or mediatype %s not supported by player %s !" % ("/".join(media_item.provider_ids), media_item.media_type, player_id) )
- async def get_item_uri(self, media_type, item_id, provider):
+ async def get_track_uri(self, item_id, provider, http_stream=False):
''' generate the URL/URI for a media item '''
uri = ""
- if provider == "spotify" and media_type == MediaType.Track:
+ if http_stream:
+ host = socket.gethostbyname(socket.gethostname())
+ uri = 'http://%s:8095/stream/%s/%s'% (host, provider, item_id)
+ elif provider == "spotify":
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:
+ elif provider == "qobuz":
uri = 'qobuz://%s.flac' % item_id
elif provider == "file":
uri = 'file://%s' % item_id
return uri
+ 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]
+ return await player_prov.player_queue(player_id, offset=offset, limit=limit)
+
def load_providers(self):
''' dynamically load providers '''
for item in os.listdir(MODULES_PATH):
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")
+logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(name)s.%(module)s -- %(message)s')
consolehandler = logging.StreamHandler()
consolehandler.setFormatter(logformat)
+LOGGER = logging.getLogger(__package__)
+LOGGER.setLevel(logging.INFO)
LOGGER.addHandler(consolehandler)
-LOGGER.setLevel(logging.DEBUG)
+
+
def run_periodic(period):
def scheduler(fcn):
except:
return 0
+def try_parse_float(possible_float):
+ try:
+ return float(possible_float)
+ 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()
version = title_part
title = title.split(splitter+version)[0]
title = title.strip().title()
- # version substitues
-
+ # version substitutes
+ if "radio" in version:
+ version = "radio version"
+ elif "album" in version:
+ version = "album version"
+ elif "single" in version:
+ version = "single version"
+ elif "remaster" in version:
+ version = "remaster"
version = version.strip().title()
return title, version
},
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);
+ var api_url = 'api/players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt;
+ axios
+ .get(api_url, {
+ params: {
+ provider: item.provider
+ }
+ })
+ .then(result => {
+ console.log(result.data);
+ })
+ .catch(error => {
+ console.log("error", error);
+ });
},
switchPlayer (new_player_id) {
this.active_player_id = new_player_id;
</v-list-tile>\r
<v-divider></v-divider>\r
\r
+ <v-list-tile avatar @click="$emit('playItem', $globals.playmenuitem, 'replace')">\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>Replace</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
endpoint = "/playlists/"
item_id = item.item_id.toString();
var url = endpoint + item_id;
- router.push({ path: url});
+ router.push({ path: url, params: {provider: item.provider}});
}
String.prototype.formatDuration = function () {
this.$globals.loading = true;
const api_url = '/api/artists/' + this.media_id
axios
- .get(api_url, { params: { lazy: lazy }})
+ .get(api_url, { params: { lazy: lazy, provider: this.provider }})
.then(result => {
data = result.data;
this.info = data;
</v-list>
</section>
`,
- props: ['mediatype'],
+ props: ['mediatype', 'provider'],
data() {
return {
selected: [2],
this.$globals.loading = true
const api_url = '/api/' + this.mediatype;
axios
- .get(api_url, { params: { offset: this.offset, limit: 50 }})
+ .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider }})
.then(result => {
data = result.data;
this.items.push(...data);
<v-list two-line>
+ <!-- base/generic config -->
+ <v-list-group prepend-icon="settings" no-action>
+ <template v-slot:activator>
+ <v-list-tile>
+ <v-list-tile-content>
+ <v-list-tile-title>Generic settings</v-list-tile-title>
+ </v-list-tile-content>
+ </v-list-tile>
+ </template>
+ <template v-for="(conf_value, conf_key) in conf.base">
+ <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.base[conf_key].__desc__">
+ <v-list-tile>
+ <v-switch v-if="typeof(conf_item_key[1]) == 'boolean'" v-model="conf.base[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.base[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.base[conf_key][conf_item_key[0]]" :label="conf_item_key[2]" box type="password"></v-select>
+ <v-text-field v-else v-model="conf.base[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>
+
+
<!-- music providers -->
<v-list-group prepend-icon="library_music" no-action>
<template v-slot:activator>
asyncio_throttle
aiocometd
aiosqlite
-pytaglib
\ No newline at end of file
+pytaglib
+python-slugify
\ No newline at end of file