From: marcelveldt Date: Mon, 13 May 2019 23:38:46 +0000 (+0200) Subject: hass integration X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=a4af1364e453965976a24eaecab07dfa799d1b16;p=music-assistant-server.git hass integration --- diff --git a/.gitignore b/.gitignore index 1eb6c375..452d444d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ *.db *.pyc music_assistant/config.json +*.cert +*.pem diff --git a/music_assistant/api.py b/music_assistant/api.py index 02e84379..61552888 100755 --- a/music_assistant/api.py +++ b/music_assistant/api.py @@ -10,14 +10,17 @@ from aiohttp import web from models import MediaType, media_type_from_string from functools import partial json_serializer = partial(json.dumps, default=lambda x: x.__dict__) - +import ssl 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() @@ -48,8 +51,12 @@ class Api(): 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''' @@ -71,22 +78,25 @@ class Api(): 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): @@ -94,13 +104,15 @@ class Api(): 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): @@ -142,7 +154,8 @@ class Api(): 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) @@ -185,16 +198,16 @@ class Api(): 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} @@ -231,10 +244,13 @@ class Api(): ''' 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) diff --git a/music_assistant/database.py b/music_assistant/database.py index 302137a3..7fc884cd 100755 --- a/music_assistant/database.py +++ b/music_assistant/database.py @@ -38,7 +38,7 @@ class Database(): 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() @@ -100,13 +100,66 @@ class Database(): 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!) ''' @@ -195,7 +248,7 @@ class Database(): 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]: @@ -273,7 +326,7 @@ class Database(): 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]: @@ -354,59 +407,9 @@ class Database(): 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) diff --git a/music_assistant/main.py b/music_assistant/main.py index 63ef2e35..ad3835da 100755 --- a/music_assistant/main.py +++ b/music_assistant/main.py @@ -6,7 +6,6 @@ import asyncio from concurrent.futures import ThreadPoolExecutor import re import uvloop -import logging import os import shutil import slugify as unicode_slug @@ -21,10 +20,11 @@ from utils import run_periodic, LOGGER 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() @@ -43,20 +43,21 @@ class Main(): 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): @@ -81,18 +82,19 @@ class Main(): 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", "", "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)") ] } } @@ -119,5 +121,7 @@ if __name__ == "__main__": 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 diff --git a/music_assistant/metadata.py b/music_assistant/metadata.py index e3e3a35c..e3c14d2e 100755 --- a/music_assistant/metadata.py +++ b/music_assistant/metadata.py @@ -60,7 +60,7 @@ class MusicBrainz(): def __init__(self, event_loop, cache): self.event_loop = event_loop self.cache = cache - self.http_session = aiohttp.ClientSession(loop=event_loop) + self.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): @@ -76,15 +76,16 @@ class MusicBrainz(): 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): @@ -101,21 +102,22 @@ class MusicBrainz(): 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: @@ -134,8 +136,8 @@ class FanartTv(): def __init__(self, event_loop, cache): self.event_loop = event_loop self.cache = cache - self.http_session = aiohttp.ClientSession(loop=event_loop) - self.throttler = Throttler(rate_limit=5, period=1) + 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 ''' diff --git a/music_assistant/models.py b/music_assistant/models.py index e40f87e7..421ccbe1 100755 --- a/music_assistant/models.py +++ b/music_assistant/models.py @@ -55,6 +55,7 @@ class Artist(object): ''' representation of an artist ''' def __init__(self): self.item_id = None + self.provider = 'database' self.name = '' self.sort_name = '' self.metadata = {} @@ -69,6 +70,7 @@ class Album(object): ''' representation of an album ''' def __init__(self): self.item_id = None + self.provider = 'database' self.name = '' self.metadata = {} self.version = '' @@ -87,6 +89,7 @@ class Track(object): ''' representation of a track ''' def __init__(self): self.item_id = None + self.provider = 'database' self.name = '' self.duration = 0 self.version = '' @@ -114,13 +117,14 @@ class Playlist(object): ''' 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(): ''' @@ -147,8 +151,7 @@ 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 @@ -156,14 +159,14 @@ class MusicProvider(): 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 @@ -172,20 +175,21 @@ class MusicProvider(): 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"): @@ -196,21 +200,21 @@ class MusicProvider(): 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: @@ -220,8 +224,7 @@ class MusicProvider(): # 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 @@ -229,19 +232,18 @@ class MusicProvider(): 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: @@ -252,60 +254,55 @@ class MusicProvider(): 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]: @@ -320,7 +317,6 @@ class MusicProvider(): 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]: @@ -332,7 +328,6 @@ class MusicProvider(): 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]: @@ -344,49 +339,42 @@ class MusicProvider(): 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 ##### @@ -406,7 +394,7 @@ class MusicProvider(): ''' 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 @@ -462,7 +450,7 @@ class MusicPlayer(): 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 @@ -486,13 +474,7 @@ class PlayerProvider(): 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 @@ -500,17 +482,17 @@ class PlayerProvider(): ### 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 diff --git a/music_assistant/modules/homeassistant.py b/music_assistant/modules/homeassistant.py new file mode 100644 index 00000000..ad553937 --- /dev/null +++ b/music_assistant/modules/homeassistant.py @@ -0,0 +1,262 @@ +#!/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', '', '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 diff --git a/music_assistant/modules/musicproviders/file.py b/music_assistant/modules/musicproviders/file.py index 149ff177..5342ee60 100644 --- a/music_assistant/modules/musicproviders/file.py +++ b/music_assistant/modules/musicproviders/file.py @@ -90,7 +90,7 @@ class FileProvider(MusicProvider): 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 [] @@ -113,7 +113,8 @@ class FileProvider(MusicProvider): 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, @@ -133,7 +134,8 @@ class FileProvider(MusicProvider): 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: @@ -158,8 +160,10 @@ class FileProvider(MusicProvider): 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 @@ -257,7 +261,8 @@ class FileProvider(MusicProvider): 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: diff --git a/music_assistant/modules/musicproviders/qobuz.py b/music_assistant/modules/musicproviders/qobuz.py index da5dd2ef..84c613d6 100644 --- a/music_assistant/modules/musicproviders/qobuz.py +++ b/music_assistant/modules/musicproviders/qobuz.py @@ -45,14 +45,14 @@ class QobuzProvider(MusicProvider): 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 ''' @@ -125,7 +125,7 @@ class QobuzProvider(MusicProvider): 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'): @@ -188,7 +188,7 @@ class QobuzProvider(MusicProvider): 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) @@ -196,8 +196,16 @@ class QobuzProvider(MusicProvider): 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 ''' @@ -234,22 +242,27 @@ class QobuzProvider(MusicProvider): 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'] @@ -272,9 +285,10 @@ class QobuzProvider(MusicProvider): 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'], @@ -317,12 +331,16 @@ class QobuzProvider(MusicProvider): 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']: @@ -388,13 +406,15 @@ class QobuzProvider(MusicProvider): 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'): @@ -407,8 +427,9 @@ class QobuzProvider(MusicProvider): 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): @@ -464,14 +485,14 @@ class QobuzProvider(MusicProvider): 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 diff --git a/music_assistant/modules/musicproviders/spotify.py b/music_assistant/modules/musicproviders/spotify.py index 679da482..cae6aae1 100644 --- a/music_assistant/modules/musicproviders/spotify.py +++ b/music_assistant/modules/musicproviders/spotify.py @@ -10,6 +10,7 @@ sys.path.append("..") from utils import run_periodic, LOGGER, parse_track_title from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED +from asyncio_throttle import Throttler import json import aiohttp from cache import use_cache @@ -42,7 +43,8 @@ class SpotifyProvider(MusicProvider): 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 = {} @@ -119,7 +121,7 @@ class SpotifyProvider(MusicProvider): 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"): @@ -183,11 +185,13 @@ class SpotifyProvider(MusicProvider): 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 @@ -245,29 +249,46 @@ class SpotifyProvider(MusicProvider): 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'] @@ -292,7 +313,8 @@ class SpotifyProvider(MusicProvider): 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) @@ -336,7 +358,8 @@ class SpotifyProvider(MusicProvider): 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: @@ -369,13 +392,15 @@ class SpotifyProvider(MusicProvider): 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'): @@ -472,13 +497,14 @@ class SpotifyProvider(MusicProvider): 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''' diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/modules/playerproviders/chromecast.py index 569a68f1..29ff1dc5 100644 --- a/music_assistant/modules/playerproviders/chromecast.py +++ b/music_assistant/modules/playerproviders/chromecast.py @@ -23,8 +23,8 @@ import pychromecast from pychromecast.controllers.multizone import MultizoneController from pychromecast.controllers import BaseController from pychromecast.controllers.spotify import SpotifyController -import logging -logging.getLogger("pychromecast").setLevel(logging.WARNING) +from pychromecast.controllers.media import MediaController +import types def setup(mass): ''' setup the provider''' @@ -49,12 +49,8 @@ class ChromecastProvider(PlayerProvider): 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()) @@ -64,63 +60,152 @@ class ChromecastProvider(PlayerProvider): 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) @@ -144,10 +229,8 @@ class ChromecastProvider(PlayerProvider): 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 @@ -159,6 +242,23 @@ class ChromecastProvider(PlayerProvider): 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: @@ -184,7 +284,13 @@ class ChromecastProvider(PlayerProvider): 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) @@ -196,11 +302,14 @@ class ChromecastProvider(PlayerProvider): 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): @@ -211,7 +320,6 @@ class StatusListener: 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 @@ -221,7 +329,6 @@ class StatusMediaListener: 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 @@ -248,7 +355,6 @@ class MZListener: 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): @@ -261,3 +367,19 @@ class SpController(SpotifyController): 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 diff --git a/music_assistant/modules/playerproviders/homeassistant.py b/music_assistant/modules/playerproviders/homeassistant.py deleted file mode 100644 index a3f2dbff..00000000 --- a/music_assistant/modules/playerproviders/homeassistant.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/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', '', '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 diff --git a/music_assistant/modules/playerproviders/lms.py b/music_assistant/modules/playerproviders/lms.py index 16c977f3..ab086a72 100644 --- a/music_assistant/modules/playerproviders/lms.py +++ b/music_assistant/modules/playerproviders/lms.py @@ -51,14 +51,7 @@ class LMSProvider(PlayerProvider): 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()) @@ -81,49 +74,45 @@ class LMSProvider(PlayerProvider): 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 = [] diff --git a/music_assistant/music.py b/music_assistant/music.py index 73a980c6..4d3753ed 100755 --- a/music_assistant/music.py +++ b/music_assistant/music.py @@ -3,7 +3,7 @@ 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 @@ -27,16 +27,16 @@ class Music(): # 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 @@ -52,9 +52,9 @@ class Music(): ''' 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''' @@ -65,62 +65,37 @@ class Music(): 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'] @@ -130,12 +105,11 @@ class Music(): 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'] @@ -145,33 +119,39 @@ class Music(): 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 ''' @@ -188,10 +168,10 @@ class Music(): 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: @@ -205,17 +185,6 @@ class Music(): 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 ''' @@ -227,7 +196,7 @@ class Music(): 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): @@ -238,12 +207,10 @@ class Music(): 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: @@ -258,12 +225,13 @@ class Music(): 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: @@ -278,34 +246,32 @@ class Music(): 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: @@ -323,14 +289,12 @@ class Music(): 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: diff --git a/music_assistant/player.py b/music_assistant/player.py index 829a3981..444ba1b6 100755 --- a/music_assistant/player.py +++ b/music_assistant/player.py @@ -3,14 +3,17 @@ 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" ) @@ -37,33 +40,46 @@ class Player(): 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)) @@ -80,13 +96,19 @@ class Player(): 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 @@ -101,6 +123,27 @@ class Player(): 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: @@ -110,15 +153,17 @@ class Player(): 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 @@ -133,6 +178,8 @@ class Player(): 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 ''' @@ -155,81 +202,71 @@ class Player(): 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): diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 40877518..d6f2c1b3 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -4,13 +4,14 @@ 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): @@ -51,6 +52,12 @@ def try_parse_int(possible_int): 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() @@ -73,8 +80,15 @@ def parse_track_title(track_title): 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 diff --git a/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js index 7b87ac31..9bbfc1d7 100755 --- a/music_assistant/web/components/player.vue.js +++ b/music_assistant/web/components/player.vue.js @@ -191,9 +191,19 @@ Vue.component("player", { }, 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; diff --git a/music_assistant/web/components/playmenu.vue.js b/music_assistant/web/components/playmenu.vue.js index 54fae32f..6b3bacd9 100644 --- a/music_assistant/web/components/playmenu.vue.js +++ b/music_assistant/web/components/playmenu.vue.js @@ -16,6 +16,16 @@ Vue.component("playmenu", { + + + play_circle_outline + + + Replace + + + + queue_play_next diff --git a/music_assistant/web/index.html b/music_assistant/web/index.html index 2b1b5a45..3157ab83 100755 --- a/music_assistant/web/index.html +++ b/music_assistant/web/index.html @@ -79,7 +79,7 @@ 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 () { diff --git a/music_assistant/web/pages/artistdetails.vue.js b/music_assistant/web/pages/artistdetails.vue.js index 785e94b3..961430c6 100755 --- a/music_assistant/web/pages/artistdetails.vue.js +++ b/music_assistant/web/pages/artistdetails.vue.js @@ -75,7 +75,7 @@ var ArtistDetails = Vue.component('ArtistDetails', { 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; diff --git a/music_assistant/web/pages/browse.vue.js b/music_assistant/web/pages/browse.vue.js index fae6b299..d23a86c7 100755 --- a/music_assistant/web/pages/browse.vue.js +++ b/music_assistant/web/pages/browse.vue.js @@ -23,7 +23,7 @@ var Browse = Vue.component('Browse', { `, - props: ['mediatype'], + props: ['mediatype', 'provider'], data() { return { selected: [2], @@ -43,7 +43,7 @@ var Browse = Vue.component('Browse', { 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); diff --git a/music_assistant/web/pages/config.vue.js b/music_assistant/web/pages/config.vue.js index e9b72111..ff2e8c36 100755 --- a/music_assistant/web/pages/config.vue.js +++ b/music_assistant/web/pages/config.vue.js @@ -11,6 +11,38 @@ var Config = Vue.component('Config', { + + + + + + +