From 11ad4c2fa334661d9b9518a9afa9bb7d8feb1a1f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 16 Apr 2020 10:19:56 +0200 Subject: [PATCH] more refactoring/cleanup --- .vscode/.ropeproject/config.py | 53 +- music_assistant/__init__.py | 2 +- music_assistant/__main__.py | 8 +- music_assistant/cache.py | 67 +- music_assistant/config.py | 41 +- music_assistant/constants.py | 3 +- music_assistant/database.py | 827 ++++++++++-------- music_assistant/homeassistant.py | 335 ++++--- music_assistant/http_streamer.py | 474 +++++----- music_assistant/mass.py | 44 +- music_assistant/metadata.py | 267 +++--- music_assistant/models/media_types.py | 77 +- music_assistant/models/musicprovider.py | 329 +++---- music_assistant/models/player.py | 190 ++-- music_assistant/models/player_queue.py | 140 +-- music_assistant/models/playerprovider.py | 42 +- music_assistant/models/playerstate.py | 1 + music_assistant/music_manager.py | 439 +++++----- music_assistant/musicproviders/file.py | 226 ++--- music_assistant/musicproviders/qobuz.py | 619 ++++++------- music_assistant/musicproviders/spotify.py | 498 ++++++----- music_assistant/musicproviders/tunein.py | 104 ++- music_assistant/player_manager.py | 87 +- music_assistant/playerproviders/chromecast.py | 283 +++--- music_assistant/playerproviders/sonos.py | 131 +-- music_assistant/playerproviders/squeezebox.py | 508 +++++++---- music_assistant/playerproviders/webplayer.py | 139 +-- music_assistant/utils.py | 108 ++- music_assistant/web.py | 510 ++++++----- pylintrc | 33 +- setup.cfg | 38 +- setup.py | 17 +- tox.ini | 35 + 33 files changed, 3688 insertions(+), 2987 deletions(-) create mode 100644 tox.ini diff --git a/.vscode/.ropeproject/config.py b/.vscode/.ropeproject/config.py index dee2d1ae..0b2a3169 100644 --- a/.vscode/.ropeproject/config.py +++ b/.vscode/.ropeproject/config.py @@ -14,8 +14,16 @@ def set_prefs(prefs): # '.svn': matches 'pkg/.svn' and all of its children # 'build/*.o': matches 'build/lib.o' but not 'build/sub/lib.o' # 'build//*.o': matches 'build/lib.o' and 'build/sub/lib.o' - prefs['ignored_resources'] = ['*.pyc', '*~', '.ropeproject', - '.hg', '.svn', '_svn', '.git', '.tox'] + prefs["ignored_resources"] = [ + "*.pyc", + "*~", + ".ropeproject", + ".hg", + ".svn", + "_svn", + ".git", + ".tox", + ] # Specifies which files should be considered python files. It is # useful when you have scripts inside your project. Only files @@ -37,66 +45,66 @@ def set_prefs(prefs): # prefs.add('python_path', '~/python/') # Should rope save object information or not. - prefs['save_objectdb'] = True - prefs['compress_objectdb'] = False + prefs["save_objectdb"] = True + prefs["compress_objectdb"] = False # If `True`, rope analyzes each module when it is being saved. - prefs['automatic_soa'] = True + prefs["automatic_soa"] = True # The depth of calls to follow in static object analysis - prefs['soa_followed_calls'] = 0 + prefs["soa_followed_calls"] = 0 # If `False` when running modules or unit tests "dynamic object # analysis" is turned off. This makes them much faster. - prefs['perform_doa'] = True + prefs["perform_doa"] = True # Rope can check the validity of its object DB when running. - prefs['validate_objectdb'] = True + prefs["validate_objectdb"] = True # How many undos to hold? - prefs['max_history_items'] = 32 + prefs["max_history_items"] = 32 # Shows whether to save history across sessions. - prefs['save_history'] = True - prefs['compress_history'] = False + prefs["save_history"] = True + prefs["compress_history"] = False # Set the number spaces used for indenting. According to # :PEP:`8`, it is best to use 4 spaces. Since most of rope's # unit-tests use 4 spaces it is more reliable, too. - prefs['indent_size'] = 4 + prefs["indent_size"] = 4 # Builtin and c-extension modules that are allowed to be imported # and inspected by rope. - prefs['extension_modules'] = [] + prefs["extension_modules"] = [] # Add all standard c-extensions to extension_modules list. - prefs['import_dynload_stdmods'] = True + prefs["import_dynload_stdmods"] = True # If `True` modules with syntax errors are considered to be empty. # The default value is `False`; When `False` syntax errors raise # `rope.base.exceptions.ModuleSyntaxError` exception. - prefs['ignore_syntax_errors'] = False + prefs["ignore_syntax_errors"] = False # If `True`, rope ignores unresolvable imports. Otherwise, they # appear in the importing namespace. - prefs['ignore_bad_imports'] = False + prefs["ignore_bad_imports"] = False # If `True`, rope will insert new module imports as # `from import ` by default. - prefs['prefer_module_from_imports'] = False + prefs["prefer_module_from_imports"] = False # If `True`, rope will transform a comma list of imports into # multiple separate import statements when organizing # imports. - prefs['split_imports'] = False + prefs["split_imports"] = False # If `True`, rope will remove all top-level import statements and # reinsert them at the top of the module when making changes. - prefs['pull_imports_to_top'] = True + prefs["pull_imports_to_top"] = True # If `True`, rope will sort imports alphabetically by module name instead # of alphabetically by import statement, with from imports after normal # imports. - prefs['sort_imports_alphabetically'] = False + prefs["sort_imports_alphabetically"] = False # Location of implementation of # rope.base.oi.type_hinting.interfaces.ITypeHintingFactory In general @@ -105,8 +113,9 @@ def set_prefs(prefs): # listed in module rope.base.oi.type_hinting.providers.interfaces # For example, you can add you own providers for Django Models, or disable # the search type-hinting in a class hierarchy, etc. - prefs['type_hinting_factory'] = ( - 'rope.base.oi.type_hinting.factory.default_type_hinting_factory') + prefs[ + "type_hinting_factory" + ] = "rope.base.oi.type_hinting.factory.default_type_hinting_factory" def project_opened(project): diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index feeeef76..a6634771 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -1 +1 @@ -"""Init file for Music Assistant.""" \ No newline at end of file +"""Init file for Music Assistant.""" diff --git a/music_assistant/__main__.py b/music_assistant/__main__.py index 1639fb7c..a8102e4b 100755 --- a/music_assistant/__main__.py +++ b/music_assistant/__main__.py @@ -1,12 +1,12 @@ """Start Music Assistant.""" import argparse +import asyncio +import logging +import os import platform import sys -import os -import logging -import asyncio -from aiorun import run +from aiorun import run from music_assistant.mass import MusicAssistant diff --git a/music_assistant/cache.py b/music_assistant/cache.py index 341123b6..6616e4b9 100644 --- a/music_assistant/cache.py +++ b/music_assistant/cache.py @@ -2,14 +2,14 @@ # -*- coding: utf-8 -*- """provides a simple stateless caching system.""" -import os import functools -import time -import pickle from functools import reduce -import aiosqlite +import os +import pickle +import time -from music_assistant.utils import run_periodic, LOGGER +import aiosqlite +from music_assistant.utils import LOGGER, run_periodic class Cache(object): @@ -21,16 +21,17 @@ class Cache(object): """Initialize our caching class.""" self.mass = mass if not os.path.isdir(mass.datapath): - raise FileNotFoundError( - f"data directory {mass.datapath} does not exist!") + raise FileNotFoundError(f"data directory {mass.datapath} does not exist!") self._dbfile = os.path.join(mass.datapath, "cache.db") async def setup(self): """Async initialize of cache module.""" self._db = await aiosqlite.connect(self._dbfile, timeout=30) self._db.row_factory = aiosqlite.Row - await self._db.execute("""CREATE TABLE IF NOT EXISTS simplecache( - id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""") + await self._db.execute( + """CREATE TABLE IF NOT EXISTS simplecache( + id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""" + ) await self._db.commit() self.mass.event_loop.create_task(self.auto_cleanup()) @@ -50,25 +51,21 @@ class Cache(object): cur_time = int(time.time()) checksum = self._get_checksum(checksum) sql_query = "SELECT expires, data, checksum FROM simplecache WHERE id = ?" - async with self._db.execute(sql_query, (cache_key, )) as cursor: + async with self._db.execute(sql_query, (cache_key,)) as cursor: cache_data = await cursor.fetchone() if not cache_data: - LOGGER.debug('no cache data for %s', cache_key) - elif cache_data['expires'] < cur_time: - LOGGER.debug('cache expired for %s', cache_key) - elif checksum and cache_data['checksum'] != checksum: - LOGGER.debug('cache checksum mismatch for %s', cache_key) - if cache_data and cache_data['expires'] > cur_time: - if checksum is None or cache_data['checksum'] == checksum: - LOGGER.debug('return cache data for %s', cache_key) + LOGGER.debug("no cache data for %s", cache_key) + elif cache_data["expires"] < cur_time: + LOGGER.debug("cache expired for %s", cache_key) + elif checksum and cache_data["checksum"] != checksum: + LOGGER.debug("cache checksum mismatch for %s", cache_key) + if cache_data and cache_data["expires"] > cur_time: + if checksum is None or cache_data["checksum"] == checksum: + LOGGER.debug("return cache data for %s", cache_key) result = pickle.loads(cache_data[1]) return result - async def set(self, - cache_key, - data, - checksum="", - expiration=(86400*30)): + async def set(self, cache_key, data, checksum="", expiration=(86400 * 30)): """ set data in cache """ @@ -79,7 +76,7 @@ class Cache(object): (id, expires, data, checksum) VALUES (?, ?, ?, ?)""" await self._db.execute(sql_query, (cache_key, expires, data, checksum)) await self._db.commit() - + @run_periodic(3600) async def auto_cleanup(self): """ (scheduled) auto cleanup task """ @@ -89,11 +86,11 @@ class Cache(object): async with self._db.execute(sql_query) as cursor: cache_objects = await cursor.fetchall() for cache_data in cache_objects: - cache_id = cache_data['id'] + cache_id = cache_data["id"] # clean up db cache object only if expired - if cache_data['expires'] < cur_timestamp: + if cache_data["expires"] < cur_timestamp: sql_query = "DELETE FROM simplecache WHERE id = ?" - await self._db.execute(sql_query, (cache_id, )) + await self._db.execute(sql_query, (cache_id,)) LOGGER.debug("delete from db %s", cache_id) # compact db await self._db.commit() @@ -111,7 +108,9 @@ class Cache(object): return reduce(lambda x, y: x + y, map(ord, stringinput)) -async def cached_iterator(cache, iter_func, cache_key, expires=(86400*30), checksum=None): +async def cached_iterator( + cache, iter_func, cache_key, expires=(86400 * 30), checksum=None +): """Helper method to store results of an iterator in the cache.""" cache_result = await cache.get(cache_key, checksum) if cache_result: @@ -125,6 +124,7 @@ async def cached_iterator(cache, iter_func, cache_key, expires=(86400*30), check cache_result.append(item) await cache.set(cache_key, cache_result, checksum, expires) + async def cached(cache, cache_key, coro_func, *args, **kwargs): """Helper method to store results of a coroutine in the cache.""" cache_result = await cache.get(cache_key) @@ -134,8 +134,10 @@ async def cached(cache, cache_key, coro_func, *args, **kwargs): await cache.set(cache_key, result) return result + def use_cache(cache_days=14, cache_checksum=None): """ decorator that can be used to cache a method's result.""" + def wrapper(func): @functools.wraps(func) async def wrapped(*args, **kwargs): @@ -153,15 +155,18 @@ def use_cache(cache_days=14, cache_checksum=None): cache_str, result, checksum=cache_checksum, - expiration=(86400*cache_days), + expiration=(86400 * cache_days), ) return result + return wrapped + return wrapper + def __cache_id_from_args(*args, **kwargs): - ''' parse arguments to build cache id ''' - cache_str = '' + """ parse arguments to build cache id """ + cache_str = "" # append args to cache identifier for item in args[1:]: if isinstance(item, dict): diff --git a/music_assistant/config.py b/music_assistant/config.py index 5af415fa..b5758787 100755 --- a/music_assistant/config.py +++ b/music_assistant/config.py @@ -4,13 +4,18 @@ import os import shutil -from music_assistant.utils import try_load_json_file, json, LOGGER -from music_assistant.constants import CONF_KEY_BASE, CONF_KEY_PLAYERSETTINGS, \ - CONF_KEY_MUSICPROVIDERS, CONF_KEY_PLAYERPROVIDERS, EVENT_CONFIG_CHANGED +from music_assistant.constants import ( + CONF_KEY_BASE, + CONF_KEY_MUSICPROVIDERS, + CONF_KEY_PLAYERPROVIDERS, + CONF_KEY_PLAYERSETTINGS, + EVENT_CONFIG_CHANGED, +) +from music_assistant.utils import LOGGER, json, try_load_json_file class MassConfig(dict): - ''' Class which holds our configuration ''' + """ Class which holds our configuration """ def __init__(self, mass): self.loading = False @@ -23,26 +28,26 @@ class MassConfig(dict): @property def base(self): - ''' return base config ''' + """ return base config """ return self[CONF_KEY_BASE] @property def players(self): - ''' return player settings ''' + """ return player settings """ return self[CONF_KEY_PLAYERSETTINGS] @property def playerproviders(self): - ''' return playerprovider settings ''' + """ return playerprovider settings """ return self[CONF_KEY_PLAYERPROVIDERS] @property def musicproviders(self): - ''' return musicprovider settings ''' + """ return musicprovider settings """ return self[CONF_KEY_MUSICPROVIDERS] def create_module_config(self, conf_key, conf_entries, base_key=CONF_KEY_BASE): - ''' create (or update) module configuration ''' + """ create (or update) module configuration """ cur_conf = self[base_key].get(conf_key) new_conf = {} for key, def_value, desc in conf_entries: @@ -50,19 +55,19 @@ class MassConfig(dict): new_conf[key] = def_value else: new_conf[key] = cur_conf[key] - new_conf['__desc__'] = conf_entries + new_conf["__desc__"] = conf_entries self[base_key][conf_key] = new_conf return self[base_key][conf_key] def save(self): - ''' save config to file ''' + """ save config to file """ if self.loading: LOGGER.warning("save already running") return self.loading = True # backup existing file - conf_file = os.path.join(self.mass.datapath, 'config.json') - conf_file_backup = os.path.join(self.mass.datapath, 'config.json.backup') + conf_file = os.path.join(self.mass.datapath, "config.json") + conf_file_backup = os.path.join(self.mass.datapath, "config.json.backup") if os.path.isfile(conf_file): shutil.move(conf_file, conf_file_backup) # remove description keys from config @@ -72,19 +77,19 @@ class MassConfig(dict): for subkey, subvalue in value.items(): if subkey != "__desc__": final_conf[key][subkey] = subvalue - with open(conf_file, 'w') as f: + with open(conf_file, "w") as f: f.write(json.dumps(final_conf, indent=4)) LOGGER.info("Config saved!") self.loading = False - + def __load(self): - '''load config from file''' + """load config from file""" self.loading = True - conf_file = os.path.join(self.mass.datapath, 'config.json') + conf_file = os.path.join(self.mass.datapath, "config.json") data = try_load_json_file(conf_file) if not data: # might be a corrupt config file, retry with backup file - conf_file_backup = os.path.join(self.mass.datapath, 'config.json.backup') + conf_file_backup = os.path.join(self.mass.datapath, "config.json.backup") data = try_load_json_file(conf_file_backup) if data: for key, value in data.items(): diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 7e12c51e..8d698a30 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -9,7 +9,7 @@ CONF_PORT = "port" CONF_TOKEN = "token" CONF_URL = "url" -CONF_TYPE_PASSWORD = '' +CONF_TYPE_PASSWORD = "" CONF_KEY_BASE = "base" CONF_KEY_PLAYERSETTINGS = "player_settings" @@ -28,4 +28,3 @@ EVENT_HASS_ENTITY_CHANGED = "hass entity changed" EVENT_MUSIC_SYNC_STATUS = "music sync status" EVENT_QUEUE_UPDATED = "queue updated" EVENT_QUEUE_ITEMS_UPDATED = "queue items updated" - diff --git a/music_assistant/database.py b/music_assistant/database.py index 0194743e..e408e079 100755 --- a/music_assistant/database.py +++ b/music_assistant/database.py @@ -5,14 +5,22 @@ import asyncio import logging import os from typing import List -import aiosqlite +import aiosqlite +from music_assistant.models.media_types import ( + Album, + Artist, + MediaType, + Playlist, + Radio, + Track, +) from music_assistant.utils import LOGGER, get_sort_name, try_parse_int -from music_assistant.models.media_types import MediaType, Artist, Album, Track, Playlist, Radio def commit_guard(func): """ decorator to guard against multiple db writes """ + async def wrapped(*args, **kwargs): method_class = args[0] while method_class.commit_guard_active: @@ -25,121 +33,145 @@ def commit_guard(func): return wrapped -class Database(): +class Database: commit_guard_active = False def __init__(self, mass): self.mass = mass if not os.path.isdir(mass.datapath): - raise FileNotFoundError( - f"data directory {mass.datapath} does not exist!") + raise FileNotFoundError(f"data directory {mass.datapath} does not exist!") self._dbfile = os.path.join(mass.datapath, "database.db") self._db = None - logging.getLogger('aiosqlite').setLevel(logging.INFO) + logging.getLogger("aiosqlite").setLevel(logging.INFO) async def close(self): - ''' handle shutdown event, close db connection ''' + """ handle shutdown event, close db connection """ await self._db.close() LOGGER.info("db connection closed") async def setup(self): - ''' init database ''' + """ init database """ self._db = await aiosqlite.connect(self._dbfile) self._db.row_factory = aiosqlite.Row - await self._db.execute('''CREATE TABLE IF NOT EXISTS library_items( + await self._db.execute( + """CREATE TABLE IF NOT EXISTS library_items( item_id INTEGER NOT NULL, provider TEXT NOT NULL, media_type INTEGER NOT NULL, UNIQUE(item_id, provider, media_type) - );''') + );""" + ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS artists( + await self._db.execute( + """CREATE TABLE IF NOT EXISTS artists( artist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - sort_name TEXT, musicbrainz_id TEXT NOT NULL UNIQUE);''') + sort_name TEXT, musicbrainz_id TEXT NOT NULL UNIQUE);""" + ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS albums( + await self._db.execute( + """CREATE TABLE IF NOT EXISTS albums( album_id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL, name TEXT NOT NULL, albumtype TEXT, year INTEGER, version TEXT, UNIQUE(artist_id, name, version, year) - );''') - - await self._db.execute('''CREATE TABLE IF NOT EXISTS labels( - label_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);''' - ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS album_labels( - album_id INTEGER, label_id INTEGER, UNIQUE(album_id, label_id));''' - ) - - await self._db.execute('''CREATE TABLE IF NOT EXISTS tracks( + );""" + ) + + await self._db.execute( + """CREATE TABLE IF NOT EXISTS labels( + label_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);""" + ) + await self._db.execute( + """CREATE TABLE IF NOT EXISTS album_labels( + album_id INTEGER, label_id INTEGER, UNIQUE(album_id, label_id));""" + ) + + await self._db.execute( + """CREATE TABLE IF NOT EXISTS tracks( track_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, album_id INTEGER, version TEXT, duration INTEGER, UNIQUE(name, version, album_id, duration) - );''') - await self._db.execute('''CREATE TABLE IF NOT EXISTS track_artists( - track_id INTEGER, artist_id INTEGER, UNIQUE(track_id, artist_id));''' - ) - - await self._db.execute('''CREATE TABLE IF NOT EXISTS tags( - tag_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);''' - ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS media_tags( + );""" + ) + await self._db.execute( + """CREATE TABLE IF NOT EXISTS track_artists( + track_id INTEGER, artist_id INTEGER, UNIQUE(track_id, artist_id));""" + ) + + await self._db.execute( + """CREATE TABLE IF NOT EXISTS tags( + tag_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);""" + ) + await self._db.execute( + """CREATE TABLE IF NOT EXISTS media_tags( item_id INTEGER, media_type INTEGER, tag_id, UNIQUE(item_id, media_type, tag_id) - );''') + );""" + ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS provider_mappings( + await self._db.execute( + """CREATE TABLE IF NOT EXISTS provider_mappings( item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, prov_item_id TEXT NOT NULL, provider TEXT NOT NULL, quality INTEGER NOT NULL, details TEXT NULL, UNIQUE(item_id, media_type, prov_item_id, provider, quality) - );''') + );""" + ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS metadata( + await self._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));''') + value TEXT, UNIQUE(item_id, media_type, key));""" + ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS external_ids( + await self._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));''') + value TEXT, UNIQUE(item_id, media_type, key, value));""" + ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS playlists( + await self._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, checksum TEXT NOT NULL, UNIQUE(name, owner) - );''') + );""" + ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS radios( - radio_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE);''' - ) + await self._db.execute( + """CREATE TABLE IF NOT EXISTS radios( + radio_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE);""" + ) - await self._db.execute('''CREATE TABLE IF NOT EXISTS track_loudness( + await self._db.execute( + """CREATE TABLE IF NOT EXISTS track_loudness( provider_track_id INTEGER NOT NULL, provider TEXT NOT NULL, loudness REAL, - UNIQUE(provider_track_id, provider));''') + UNIQUE(provider_track_id, provider));""" + ) await self._db.commit() - await self._db.execute('VACUUM;') + await self._db.execute("VACUUM;") - async def get_database_id(self, provider: str, prov_item_id: str, - media_type: MediaType): - ''' get the database id for the given prov_id ''' - if provider == 'database': + async def get_database_id( + self, provider: str, prov_item_id: str, media_type: MediaType + ): + """ get the database id for the given prov_id """ + if provider == "database": return prov_item_id - sql_query = '''SELECT item_id FROM provider_mappings - WHERE prov_item_id = ? AND provider = ? AND media_type = ?;''' + sql_query = """SELECT item_id FROM provider_mappings + WHERE prov_item_id = ? AND provider = ? AND media_type = ?;""" async with self._db.execute( - sql_query, (prov_item_id, provider, media_type)) as cursor: + sql_query, (prov_item_id, provider, media_type) + ) as cursor: item_id = await cursor.fetchone() if item_id: return item_id[0] return None async def search(self, searchquery, media_types: List[MediaType]): - ''' search library for the given searchphrase ''' + """ search library for the given searchphrase """ result = {"artists": [], "albums": [], "tracks": [], "playlists": []} searchquery = "%" + searchquery + "%" if MediaType.Artist in media_types: sql_query = ' WHERE name LIKE "%s"' % searchquery - result["artists"] = [ - item async for item in self.artists(sql_query) - ] + result["artists"] = [item async for item in self.artists(sql_query)] if MediaType.Album in media_types: sql_query = ' WHERE name LIKE "%s"' % searchquery result["albums"] = [item async for item in self.albums(sql_query)] @@ -153,231 +185,263 @@ class Database(): ] return result - async def library_artists(self, provider=None, - orderby='name') -> List[Artist]: - ''' get all library artists, optionally filtered by provider''' + async def library_artists(self, provider=None, orderby="name") -> List[Artist]: + """ get all library artists, optionally filtered by provider""" if provider is not None: - sql_query = 'WHERE artist_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % ( - provider, MediaType.Artist) + sql_query = ( + 'WHERE artist_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' + % (provider, MediaType.Artist) + ) else: - sql_query = 'WHERE artist_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Artist + sql_query = ( + "WHERE artist_id in (SELECT item_id FROM library_items WHERE media_type = %d)" + % MediaType.Artist + ) async for item in self.artists(sql_query, orderby=orderby): yield item - async def library_albums(self, provider=None, - orderby='name') -> List[Album]: - ''' get all library albums, optionally filtered by provider''' + async def library_albums(self, provider=None, orderby="name") -> List[Album]: + """ get all library albums, optionally filtered by provider""" if provider is not None: - sql_query = ' WHERE album_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % ( - provider, MediaType.Album) + sql_query = ( + ' WHERE album_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' + % (provider, MediaType.Album) + ) else: - sql_query = ' WHERE album_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Album + sql_query = ( + " WHERE album_id in (SELECT item_id FROM library_items WHERE media_type = %d)" + % MediaType.Album + ) async for item in self.albums(sql_query, orderby=orderby): yield item - async def library_tracks(self, provider=None, - orderby='name') -> List[Track]: - ''' get all library tracks, optionally filtered by provider''' + async def library_tracks(self, provider=None, orderby="name") -> List[Track]: + """ get all library tracks, optionally filtered by provider""" if provider is not None: - sql_query = '''SELECT * FROM tracks + sql_query = """SELECT * FROM tracks WHERE track_id in (SELECT item_id FROM library_items WHERE provider = "%s" - AND media_type = %d)''' % (provider, MediaType.Track) + AND media_type = %d)""" % ( + provider, + MediaType.Track, + ) else: - sql_query = '''SELECT * FROM tracks + sql_query = ( + """SELECT * FROM tracks WHERE track_id in - (SELECT item_id FROM library_items WHERE media_type = %d)''' % MediaType.Track + (SELECT item_id FROM library_items WHERE media_type = %d)""" + % MediaType.Track + ) async for item in self.tracks(sql_query, orderby=orderby): yield item - async def library_playlists(self, provider=None, - orderby='name') -> List[Playlist]: - ''' fetch all playlist records from table''' + async def library_playlists(self, provider=None, orderby="name") -> List[Playlist]: + """ fetch all playlist records from table""" if provider is not None: - sql_query = '''WHERE playlist_id in + sql_query = """WHERE playlist_id in (SELECT item_id FROM library_items WHERE provider = "%s" - AND media_type = %d)''' % (provider, MediaType.Playlist) + 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 + sql_query = ( + """WHERE playlist_id in + (SELECT item_id FROM library_items WHERE media_type = %d)""" + % MediaType.Playlist + ) async for item in self.playlists(sql_query, orderby=orderby): yield item - async def library_radios(self, provider=None, - orderby='name') -> List[Radio]: - ''' fetch all radio records from table''' + async def library_radios(self, provider=None, orderby="name") -> List[Radio]: + """ fetch all radio records from table""" if provider is not None: - sql_query = '''WHERE radio_id in + sql_query = """WHERE radio_id in (SELECT item_id FROM library_items WHERE provider = "%s" - AND media_type = %d)''' % (provider, MediaType.Radio) + AND media_type = %d)""" % ( + provider, + MediaType.Radio, + ) else: - sql_query = '''WHERE radio_id in - (SELECT item_id FROM library_items WHERE media_type = %d)''' % MediaType.Radio + sql_query = ( + """WHERE radio_id in + (SELECT item_id FROM library_items WHERE media_type = %d)""" + % MediaType.Radio + ) async for item in self.radios(sql_query, orderby=orderby): yield item - async def playlists(self, filter_query=None, - orderby='name') -> List[Playlist]: - ''' fetch playlist records from table''' - sql_query = 'SELECT * FROM playlists' + async def playlists(self, filter_query=None, orderby="name") -> List[Playlist]: + """ fetch playlist records from table""" + sql_query = "SELECT * FROM playlists" if filter_query: - sql_query += ' ' + filter_query - sql_query += ' ORDER BY %s' % orderby + sql_query += " " + filter_query + sql_query += " ORDER BY %s" % orderby async with self._db.execute(sql_query) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: playlist = Playlist() - playlist.item_id = db_row['playlist_id'] - playlist.name = db_row['name'] - playlist.owner = db_row['owner'] - playlist.is_editable = db_row['is_editable'] - playlist.checksum = db_row['checksum'] + playlist.item_id = db_row["playlist_id"] + playlist.name = db_row["name"] + playlist.owner = db_row["owner"] + playlist.is_editable = db_row["is_editable"] + playlist.checksum = db_row["checksum"] playlist.metadata = await self.__get_metadata( - playlist.item_id, MediaType.Playlist) + playlist.item_id, MediaType.Playlist + ) playlist.provider_ids = await self.__get_prov_ids( - playlist.item_id, MediaType.Playlist) + playlist.item_id, MediaType.Playlist + ) playlist.in_library = await self.__get_library_providers( - playlist.item_id, MediaType.Playlist) + playlist.item_id, MediaType.Playlist + ) yield playlist async def playlist(self, playlist_id: int) -> Playlist: - ''' get playlist record by id ''' + """ get playlist record by id """ playlist_id = try_parse_int(playlist_id) - async for item in self.playlists('WHERE playlist_id = %s' % - playlist_id): + async for item in self.playlists("WHERE playlist_id = %s" % playlist_id): return item return None - async def radios(self, filter_query=None, - orderby='name') -> List[Playlist]: - ''' fetch radio records from table''' - sql_query = 'SELECT * FROM radios' + async def radios(self, filter_query=None, orderby="name") -> List[Playlist]: + """ fetch radio records from table""" + sql_query = "SELECT * FROM radios" if filter_query: - sql_query += ' ' + filter_query - sql_query += ' ORDER BY %s' % orderby + sql_query += " " + filter_query + sql_query += " ORDER BY %s" % orderby async with self._db.execute(sql_query) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: radio = Radio() radio.item_id = db_row[0] radio.name = db_row[1] - radio.metadata = await self.__get_metadata(radio.item_id, - MediaType.Radio) + radio.metadata = await self.__get_metadata(radio.item_id, MediaType.Radio) radio.provider_ids = await self.__get_prov_ids( - radio.item_id, MediaType.Radio) + radio.item_id, MediaType.Radio + ) radio.in_library = await self.__get_library_providers( - radio.item_id, MediaType.Radio) + radio.item_id, MediaType.Radio + ) yield radio async def radio(self, radio_id: int) -> Playlist: - ''' get radio record by id ''' + """ get radio record by id """ radio_id = try_parse_int(radio_id) - async for item in self.radios('WHERE radio_id = %s' % radio_id): + async for item in self.radios("WHERE radio_id = %s" % radio_id): return item return None @commit_guard async def add_playlist(self, playlist: Playlist): - ''' add a new playlist record into table''' - assert (playlist.name) + """ add a new playlist record into table""" + assert playlist.name async with self._db.execute( - 'SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;', - (playlist.name, playlist.owner)) as cursor: + "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=?, checksum=? WHERE playlist_id=?;' + sql_query = "UPDATE playlists SET is_editable=?, checksum=? WHERE playlist_id=?;" await self._db.execute( - sql_query, - (playlist.is_editable, playlist.checksum, playlist_id)) + sql_query, (playlist.is_editable, playlist.checksum, playlist_id) + ) else: # insert playlist - sql_query = 'INSERT INTO playlists (name, owner, is_editable, checksum) VALUES(?,?,?,?);' + sql_query = "INSERT INTO playlists (name, owner, is_editable, checksum) VALUES(?,?,?,?);" async with self._db.execute( - sql_query, - (playlist.name, playlist.owner, playlist.is_editable, - playlist.checksum)) as cursor: + sql_query, + ( + playlist.name, + playlist.owner, + playlist.is_editable, + playlist.checksum, + ), + ) as cursor: last_row_id = cursor.lastrowid await self._db.commit() # get id from newly created item - sql_query = 'SELECT (playlist_id) FROM playlists WHERE ROWID=?' - async with self._db.execute(sql_query, - (last_row_id, )) as cursor: + sql_query = "SELECT (playlist_id) FROM playlists WHERE ROWID=?" + async with self._db.execute(sql_query, (last_row_id,)) as cursor: playlist_id = await cursor.fetchone() playlist_id = playlist_id[0] - LOGGER.debug('added playlist %s to database: %s', - playlist.name, playlist_id) + LOGGER.debug( + "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) - await self.__add_metadata(playlist_id, MediaType.Playlist, - playlist.metadata) + await self.__add_prov_ids( + playlist_id, MediaType.Playlist, playlist.provider_ids + ) + await self.__add_metadata( + playlist_id, MediaType.Playlist, playlist.metadata + ) # save await self._db.commit() return playlist_id @commit_guard async def add_radio(self, radio: Radio): - ''' add a new radio record into table''' - assert (radio.name) + """ add a new radio record into table""" + assert radio.name async with self._db.execute( - 'SELECT (radio_id) FROM radios WHERE name=?;', - (radio.name, )) as cursor: + "SELECT (radio_id) FROM radios WHERE name=?;", (radio.name,) + ) as cursor: result = await cursor.fetchone() if result: radio_id = result[0] else: # insert radio - sql_query = 'INSERT INTO radios (name) VALUES(?);' - async with self._db.execute(sql_query, - (radio.name, )) as cursor: + sql_query = "INSERT INTO radios (name) VALUES(?);" + async with self._db.execute(sql_query, (radio.name,)) as cursor: last_row_id = cursor.lastrowid await self._db.commit() # get id from newly created item - sql_query = 'SELECT (radio_id) FROM radios WHERE ROWID=?' - async with self._db.execute(sql_query, - (last_row_id, )) as cursor: + sql_query = "SELECT (radio_id) FROM radios WHERE ROWID=?" + async with self._db.execute(sql_query, (last_row_id,)) as cursor: radio_id = await cursor.fetchone() radio_id = radio_id[0] - LOGGER.debug('added radio station %s to database: %s', - radio.name, radio_id) + LOGGER.debug( + "added radio station %s to database: %s", radio.name, radio_id + ) # add/update metadata - await self.__add_prov_ids(radio_id, MediaType.Radio, - radio.provider_ids) - await self.__add_metadata(radio_id, MediaType.Radio, - radio.metadata) + await self.__add_prov_ids(radio_id, MediaType.Radio, radio.provider_ids) + await self.__add_metadata(radio_id, MediaType.Radio, radio.metadata) # save await self._db.commit() return radio_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!) ''' + async def add_to_library(self, item_id: int, media_type: MediaType, provider: str): + """ add an item to the library (item must already be present in the db!) """ item_id = try_parse_int(item_id) - sql_query = 'INSERT or REPLACE INTO library_items (item_id, provider, media_type) VALUES(?,?,?);' + sql_query = "INSERT or REPLACE INTO library_items (item_id, provider, media_type) VALUES(?,?,?);" await self._db.execute(sql_query, (item_id, provider, media_type)) await self._db.commit() - async def remove_from_library(self, item_id: int, media_type: MediaType, - provider: str): - ''' remove item from the library ''' + async def remove_from_library( + self, item_id: int, media_type: MediaType, provider: str + ): + """ remove item from the library """ item_id = try_parse_int(item_id) - sql_query = 'DELETE FROM library_items WHERE item_id=? AND provider=? AND media_type=?;' + sql_query = ( + "DELETE FROM library_items WHERE item_id=? AND provider=? AND media_type=?;" + ) await self._db.execute(sql_query, (item_id, provider, media_type)) if media_type == MediaType.Playlist: - sql_query = 'DELETE FROM playlists WHERE playlist_id=?;' - await self._db.execute(sql_query, (item_id, )) - sql_query = 'DELETE FROM provider_mappings WHERE item_id=? AND media_type=? AND provider=?;' + sql_query = "DELETE FROM playlists WHERE playlist_id=?;" + await self._db.execute(sql_query, (item_id,)) + sql_query = "DELETE FROM provider_mappings WHERE item_id=? AND media_type=? AND provider=?;" await self._db.execute(sql_query, (item_id, media_type, provider)) await self._db.commit() - async def artists(self, filter_query=None, orderby='name', - fulldata=False) -> List[Artist]: - ''' fetch artist records from table''' - sql_query = 'SELECT * FROM artists' + async def artists( + self, filter_query=None, orderby="name", fulldata=False + ) -> List[Artist]: + """ fetch artist records from table""" + sql_query = "SELECT * FROM artists" if filter_query: - sql_query += ' ' + filter_query - sql_query += ' ORDER BY %s' % orderby + sql_query += " " + filter_query + sql_query += " ORDER BY %s" % orderby async with self._db.execute(sql_query) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: @@ -386,31 +450,36 @@ class Database(): artist.name = db_row[1] artist.sort_name = db_row[2] artist.provider_ids = await self.__get_prov_ids( - artist.item_id, MediaType.Artist) + artist.item_id, MediaType.Artist + ) artist.in_library = await self.__get_library_providers( - artist.item_id, MediaType.Artist) + artist.item_id, MediaType.Artist + ) if fulldata: artist.external_ids = await self.__get_external_ids( - artist.item_id, MediaType.Artist) + artist.item_id, MediaType.Artist + ) artist.metadata = await self.__get_metadata( - artist.item_id, MediaType.Artist) - artist.tags = await self.__get_tags(artist.item_id, - MediaType.Artist) + artist.item_id, MediaType.Artist + ) + artist.tags = await self.__get_tags(artist.item_id, MediaType.Artist) artist.metadata = await self.__get_metadata( - artist.item_id, MediaType.Artist) + artist.item_id, MediaType.Artist + ) yield artist async def artist(self, artist_id: int, fulldata=True) -> Artist: - ''' get artist record by id ''' + """ get artist record by id """ artist_id = try_parse_int(artist_id) - async for item in self.artists('WHERE artist_id = %s' % artist_id, - fulldata=fulldata): + async for item in self.artists( + "WHERE artist_id = %s" % artist_id, fulldata=fulldata + ): return item return None @commit_guard async def add_artist(self, artist: Artist): - ''' add a new artist record into table''' + """ add a new artist record into table""" artist_id = None # always prefer to grab existing artist with external_id (=musicbrainz_id) artist_id = await self.__get_item_by_external_id(artist) @@ -418,44 +487,49 @@ class Database(): # insert artist musicbrainz_id = None for item in artist.external_ids: - if item.get('musicbrainz'): - musicbrainz_id = item['musicbrainz'] + if item.get("musicbrainz"): + musicbrainz_id = item["musicbrainz"] break - assert (musicbrainz_id) # musicbrainz id is required + assert musicbrainz_id # musicbrainz id is required if not artist.sort_name: artist.sort_name = get_sort_name(artist.name) - sql_query = 'INSERT INTO artists (name, sort_name, musicbrainz_id) VALUES(?,?,?);' + sql_query = ( + "INSERT INTO artists (name, sort_name, musicbrainz_id) VALUES(?,?,?);" + ) async with self._db.execute( - sql_query, - (artist.name, artist.sort_name, musicbrainz_id)) as cursor: + sql_query, (artist.name, artist.sort_name, musicbrainz_id) + ) as cursor: last_row_id = cursor.lastrowid # get id from (newly created) item async with self._db.execute( - 'SELECT artist_id FROM artists WHERE ROWID=?;', - (last_row_id, )) as cursor: + "SELECT artist_id FROM artists WHERE ROWID=?;", (last_row_id,) + ) as cursor: artist_id = await cursor.fetchone() artist_id = artist_id[0] # always add metadata and tags etc. because we might have received # additional info or a match from other provider - await self.__add_prov_ids(artist_id, MediaType.Artist, - artist.provider_ids) + await self.__add_prov_ids(artist_id, MediaType.Artist, artist.provider_ids) await self.__add_metadata(artist_id, MediaType.Artist, artist.metadata) await self.__add_tags(artist_id, MediaType.Artist, artist.tags) - await self.__add_external_ids(artist_id, MediaType.Artist, - artist.external_ids) + await self.__add_external_ids(artist_id, MediaType.Artist, artist.external_ids) # save await self._db.commit() - LOGGER.debug('added artist %s (%s) to database: %s', artist.name, - artist.provider_ids, artist_id) + LOGGER.debug( + "added artist %s (%s) to database: %s", + artist.name, + artist.provider_ids, + artist_id, + ) return artist_id - async def albums(self, filter_query=None, orderby='name', - fulldata=False) -> List[Album]: - ''' fetch all album records from table''' - sql_query = 'SELECT * FROM albums' + async def albums( + self, filter_query=None, orderby="name", fulldata=False + ) -> List[Album]: + """ fetch all album records from table""" + sql_query = "SELECT * FROM albums" if filter_query: - sql_query += ' ' + filter_query - sql_query += ' ORDER BY %s' % orderby + sql_query += " " + filter_query + sql_query += " ORDER BY %s" % orderby async with self._db.execute(sql_query) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: @@ -466,101 +540,120 @@ class Database(): album.year = db_row[4] album.version = db_row[5] album.provider_ids = await self.__get_prov_ids( - album.item_id, MediaType.Album) + album.item_id, MediaType.Album + ) album.in_library = await self.__get_library_providers( - album.item_id, MediaType.Album) + album.item_id, MediaType.Album + ) album.artist = await self.artist(db_row[1], fulldata=fulldata) if fulldata: album.external_ids = await self.__get_external_ids( - album.item_id, MediaType.Album) + album.item_id, MediaType.Album + ) album.metadata = await self.__get_metadata( - album.item_id, MediaType.Album) - album.tags = await self.__get_tags(album.item_id, - MediaType.Album) + album.item_id, MediaType.Album + ) + album.tags = await self.__get_tags(album.item_id, MediaType.Album) album.labels = await self.__get_album_labels(album.item_id) yield album async def album(self, album_id: int, fulldata=True) -> Album: - ''' get album record by id ''' + """ get album record by id """ album_id = try_parse_int(album_id) - async for item in self.albums('WHERE album_id = %s' % album_id, - fulldata=fulldata): + async for item in self.albums( + "WHERE album_id = %s" % album_id, fulldata=fulldata + ): return item return None @commit_guard async def add_album(self, album: Album): - ''' add a new album record into table''' - assert (album.name and album.artist) + """ add a new album record into table""" + assert album.name and album.artist album_id = None - assert (album.artist.provider == 'database') + assert album.artist.provider == "database" # always try to grab existing album with external_id album_id = await self.__get_item_by_external_id(album) # fallback to matching on artist_id, name and version if not album_id: # search exact match first - sql_query = 'SELECT album_id FROM albums WHERE artist_id=? AND name=? AND version=? AND year=? AND albumtype=?' + sql_query = "SELECT album_id FROM albums WHERE artist_id=? AND name=? AND version=? AND year=? AND albumtype=?" async with self._db.execute( - sql_query, - (album.artist.item_id, album.name, album.version, album.year, - album.albumtype)) as cursor: + sql_query, + ( + album.artist.item_id, + album.name, + album.version, + album.year, + album.albumtype, + ), + ) as cursor: album_id = await cursor.fetchone() if album_id: - album_id = album_id['album_id'] + album_id = album_id["album_id"] # fallback to almost exact match - sql_query = 'SELECT album_id, year, version, albumtype FROM albums WHERE artist_id=? AND name=?' + sql_query = "SELECT album_id, year, version, albumtype FROM albums WHERE artist_id=? AND name=?" async with self._db.execute( - sql_query, (album.artist.item_id, album.name)) as cursor: + sql_query, (album.artist.item_id, album.name) + ) as cursor: albums = await cursor.fetchall() for result in albums: - if ((not album.version and result['year'] == album.year) - or (album.version - and result['version'] == album.version)): - album_id = result['album_id'] + if (not album.version and result["year"] == album.year) or ( + album.version and result["version"] == album.version + ): + album_id = result["album_id"] break if not album_id: # insert album - sql_query = 'INSERT INTO albums (artist_id, name, albumtype, year, version) VALUES(?,?,?,?,?);' - query_params = (album.artist.item_id, album.name, album.albumtype, - album.year, album.version) + sql_query = "INSERT INTO albums (artist_id, name, albumtype, year, version) VALUES(?,?,?,?,?);" + query_params = ( + album.artist.item_id, + album.name, + album.albumtype, + album.year, + album.version, + ) async with self._db.execute(sql_query, query_params) as cursor: last_row_id = cursor.lastrowid # get id from newly created item - sql_query = 'SELECT (album_id) FROM albums WHERE ROWID=?' - async with self._db.execute(sql_query, (last_row_id, )) as cursor: + sql_query = "SELECT (album_id) FROM albums WHERE ROWID=?" + async with self._db.execute(sql_query, (last_row_id,)) as cursor: album_id = await cursor.fetchone() album_id = album_id[0] # always add metadata and tags etc. because we might have received # additional info or a match from other provider - await self.__add_prov_ids(album_id, MediaType.Album, - album.provider_ids) + await self.__add_prov_ids(album_id, MediaType.Album, album.provider_ids) await self.__add_metadata(album_id, MediaType.Album, album.metadata) await self.__add_tags(album_id, MediaType.Album, album.tags) await self.__add_album_labels(album_id, album.labels) - await self.__add_external_ids(album_id, MediaType.Album, - album.external_ids) + await self.__add_external_ids(album_id, MediaType.Album, album.external_ids) # save await self._db.commit() - LOGGER.debug('added album %s (%s) to database: %s', album.name, - album.provider_ids, album_id) + LOGGER.debug( + "added album %s (%s) to database: %s", + album.name, + album.provider_ids, + album_id, + ) return album_id - async def tracks(self, custom_query=None, orderby='name', - fulldata=False) -> List[Track]: - ''' fetch all track records from table''' - sql_query = 'SELECT * FROM tracks' + async def tracks( + self, custom_query=None, orderby="name", fulldata=False + ) -> List[Track]: + """ fetch all track records from table""" + sql_query = "SELECT * FROM tracks" if custom_query: sql_query = custom_query - sql_query += ' ORDER BY %s' % orderby + sql_query += " ORDER BY %s" % orderby async with self._db.execute(sql_query) as cursor: for db_row in await cursor.fetchall(): track = Track() track.item_id = db_row["track_id"] track.name = db_row["name"] - track.album = await self.album(db_row["album_id"], - fulldata=fulldata) + track.album = await self.album(db_row["album_id"], fulldata=fulldata) track.artists = await self.__get_track_artists( - track.item_id, fulldata=fulldata) + track.item_id, fulldata=fulldata + ) track.duration = db_row["duration"] track.version = db_row["version"] try: # album tracks only @@ -573,20 +666,23 @@ class Database(): except IndexError: pass track.in_library = await self.__get_library_providers( - track.item_id, MediaType.Track) + track.item_id, MediaType.Track + ) track.external_ids = await self.__get_external_ids( - track.item_id, MediaType.Track) + track.item_id, MediaType.Track + ) track.provider_ids = await self.__get_prov_ids( - track.item_id, MediaType.Track) + track.item_id, MediaType.Track + ) if fulldata: track.metadata = await self.__get_metadata( - track.item_id, MediaType.Track) - track.tags = await self.__get_tags(track.item_id, - MediaType.Track) + track.item_id, MediaType.Track + ) + track.tags = await self.__get_tags(track.item_id, MediaType.Track) yield track async def track(self, track_id: int, fulldata=True) -> Track: - ''' get track record by id ''' + """ get track record by id """ track_id = try_parse_int(track_id) sql_query = "SELECT * FROM tracks WHERE track_id = %s" % track_id async for item in self.tracks(sql_query, fulldata=fulldata): @@ -595,101 +691,107 @@ class Database(): @commit_guard async def add_track(self, track: Track): - ''' add a new track record into table''' - assert (track.name and track.album) - assert (track.album.provider == 'database') - assert (track.artists) + """ add a new track record into table""" + assert track.name and track.album + assert track.album.provider == "database" + assert track.artists for artist in track.artists: - assert (artist.provider == 'database') + assert artist.provider == "database" # always try to grab existing track with external_id track_id = await self.__get_item_by_external_id(track) # fallback to matching on album_id, name and version if not track_id: - sql_query = 'SELECT track_id, duration, version FROM tracks WHERE album_id=? AND name=?' + sql_query = "SELECT track_id, duration, version FROM tracks WHERE album_id=? AND name=?" async with self._db.execute( - sql_query, (track.album.item_id, track.name)) as cursor: + sql_query, (track.album.item_id, track.name) + ) as cursor: results = await cursor.fetchall() for result in results: # we perform an additional safety check on the duration or version - if ((track.version and result['version'] == track.version) - or - (not track.version - and abs(result['duration'] - track.duration) < 3)): - track_id = result['track_id'] + if (track.version and result["version"] == track.version) or ( + not track.version + and abs(result["duration"] - track.duration) < 3 + ): + track_id = result["track_id"] break if not track_id: # insert track - assert (track.name and track.album.item_id) - sql_query = 'INSERT INTO tracks (name, album_id, duration, version) VALUES(?,?,?,?);' - query_params = (track.name, track.album.item_id, track.duration, - track.version) + assert track.name and track.album.item_id + sql_query = "INSERT INTO tracks (name, album_id, duration, version) VALUES(?,?,?,?);" + query_params = ( + track.name, + track.album.item_id, + track.duration, + track.version, + ) async with self._db.execute(sql_query, query_params) as cursor: last_row_id = cursor.lastrowid # get id from newly created item (the safe way) async with self._db.execute( - 'SELECT track_id FROM tracks WHERE ROWID=?', - (last_row_id, )) as cursor: + "SELECT track_id FROM tracks WHERE ROWID=?", (last_row_id,) + ) as cursor: track_id = await cursor.fetchone() track_id = track_id[0] # add track artists for artist in track.artists: - sql_query = 'INSERT or IGNORE INTO track_artists (track_id, artist_id) VALUES(?,?);' + sql_query = ( + "INSERT or IGNORE INTO track_artists (track_id, artist_id) VALUES(?,?);" + ) await self._db.execute(sql_query, (track_id, artist.item_id)) # always add metadata and tags etc. because we might have received # additional info or a match from other provider - await self.__add_prov_ids(track_id, MediaType.Track, - track.provider_ids) + await self.__add_prov_ids(track_id, MediaType.Track, track.provider_ids) await self.__add_metadata(track_id, MediaType.Track, track.metadata) await self.__add_tags(track_id, MediaType.Track, track.tags) - await self.__add_external_ids(track_id, MediaType.Track, - track.external_ids) + await self.__add_external_ids(track_id, MediaType.Track, track.external_ids) # save to db await self._db.commit() - LOGGER.debug('added track %s (%s) to database: %s', track.name, - track.provider_ids, track_id) + LOGGER.debug( + "added track %s (%s) to database: %s", + track.name, + track.provider_ids, + track_id, + ) return track_id async def update_track(self, track_id, column_key, column_value): - ''' update column of existing track ''' - sql_query = 'UPDATE tracks SET %s=? WHERE track_id=?;' % column_key + """ update column of existing track """ + sql_query = "UPDATE tracks SET %s=? WHERE track_id=?;" % column_key await self._db.execute(sql_query, (column_value, track_id)) await self._db.commit() async def update_playlist(self, playlist_id, column_key, column_value): - ''' update column of existing playlist ''' - sql_query = 'UPDATE playlists SET %s=? WHERE playlist_id=?;' % column_key + """ update column of existing playlist """ + sql_query = "UPDATE playlists SET %s=? WHERE playlist_id=?;" % column_key await self._db.execute(sql_query, (column_value, playlist_id)) await self._db.commit() - async def artist_tracks(self, artist_id, orderby='name') -> List[Track]: - ''' get all library tracks for the given artist ''' + async def artist_tracks(self, artist_id, orderby="name") -> List[Track]: + """ get all library tracks for the given artist """ artist_id = try_parse_int(artist_id) - sql_query = 'SELECT * FROM tracks WHERE track_id in (SELECT track_id FROM track_artists WHERE artist_id = %s)' % artist_id - async for item in self.tracks(sql_query, - orderby=orderby, - fulldata=False): + sql_query = ( + "SELECT * FROM tracks WHERE track_id in (SELECT track_id FROM track_artists WHERE artist_id = %s)" + % artist_id + ) + async for item in self.tracks(sql_query, orderby=orderby, fulldata=False): yield item - async def artist_albums(self, artist_id, orderby='name') -> List[Album]: - ''' get all library albums for the given artist ''' - sql_query = ' WHERE artist_id = %s' % artist_id - async for item in self.albums(sql_query, - orderby=orderby, - fulldata=False): + async def artist_albums(self, artist_id, orderby="name") -> List[Album]: + """ get all library albums for the given artist """ + sql_query = " WHERE artist_id = %s" % artist_id + async for item in self.albums(sql_query, orderby=orderby, fulldata=False): yield item async def set_track_loudness(self, provider_track_id, provider, loudness): - ''' set integrated loudness for a track in db ''' - sql_query = 'INSERT or REPLACE INTO track_loudness (provider_track_id, provider, loudness) VALUES(?,?,?);' - await self._db.execute(sql_query, - (provider_track_id, provider, loudness)) + """ set integrated loudness for a track in db """ + sql_query = "INSERT or REPLACE INTO track_loudness (provider_track_id, provider, loudness) VALUES(?,?,?);" + await self._db.execute(sql_query, (provider_track_id, provider, loudness)) await self._db.commit() async def get_track_loudness(self, provider_track_id, provider): - ''' get integrated loudness for a track in db ''' - sql_query = 'SELECT loudness FROM track_loudness WHERE provider_track_id = ? AND provider = ?' - async with self._db.execute(sql_query, - (provider_track_id, provider)) as cursor: + """ get integrated loudness for a track in db """ + sql_query = "SELECT loudness FROM track_loudness WHERE provider_track_id = ? AND provider = ?" + async with self._db.execute(sql_query, (provider_track_id, provider)) as cursor: result = await cursor.fetchone() if result: return result[0] @@ -697,21 +799,21 @@ class Database(): return None async def __add_metadata(self, item_id, media_type, metadata): - ''' add or update metadata''' + """ add or update metadata""" for key, value in metadata.items(): if value: - sql_query = 'INSERT or REPLACE INTO metadata (item_id, media_type, key, value) VALUES(?,?,?,?);' - await self._db.execute(sql_query, - (item_id, media_type, key, value)) + sql_query = "INSERT or REPLACE INTO metadata (item_id, media_type, key, value) VALUES(?,?,?,?);" + await self._db.execute(sql_query, (item_id, media_type, key, value)) async def __get_metadata(self, item_id, media_type, filter_key=None): - ''' get metadata for media item ''' + """ get metadata for media item """ metadata = {} - sql_query = 'SELECT key, value FROM metadata WHERE item_id = ? AND media_type = ?' + sql_query = ( + "SELECT key, value FROM metadata WHERE item_id = ? AND media_type = ?" + ) if filter_key: sql_query += ' AND key = "%s"' % filter_key - async with self._db.execute(sql_query, - (item_id, media_type)) as cursor: + async with self._db.execute(sql_query, (item_id, media_type)) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: key = db_row[0] @@ -720,66 +822,67 @@ class Database(): return metadata async def __add_tags(self, item_id, media_type, tags): - ''' add tags to db ''' + """ add tags to db """ for tag in tags: - sql_query = 'INSERT or IGNORE INTO tags (name) VALUES(?);' - async with self._db.execute(sql_query, (tag, )) as cursor: + sql_query = "INSERT or IGNORE INTO tags (name) VALUES(?);" + async with self._db.execute(sql_query, (tag,)) as cursor: tag_id = cursor.lastrowid - sql_query = 'INSERT or IGNORE INTO media_tags (item_id, media_type, tag_id) VALUES(?,?,?);' + sql_query = "INSERT or IGNORE INTO media_tags (item_id, media_type, tag_id) VALUES(?,?,?);" await self._db.execute(sql_query, (item_id, media_type, tag_id)) async def __get_tags(self, item_id, media_type): - ''' get tags for media item ''' + """ get tags for media item """ tags = [] - sql_query = 'SELECT name FROM tags INNER JOIN media_tags on tags.tag_id = media_tags.tag_id WHERE item_id = ? AND media_type = ?' - async with self._db.execute(sql_query, - (item_id, media_type)) as cursor: + sql_query = "SELECT name FROM tags INNER JOIN media_tags on tags.tag_id = media_tags.tag_id WHERE item_id = ? AND media_type = ?" + async with self._db.execute(sql_query, (item_id, media_type)) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: tags.append(db_row[0]) return tags async def __add_album_labels(self, album_id, labels): - ''' add labels to album in db ''' + """ add labels to album in db """ for label in labels: - sql_query = 'INSERT or IGNORE INTO labels (name) VALUES(?);' - async with self._db.execute(sql_query, (label, )) as cursor: + sql_query = "INSERT or IGNORE INTO labels (name) VALUES(?);" + async with self._db.execute(sql_query, (label,)) as cursor: label_id = cursor.lastrowid - sql_query = 'INSERT or IGNORE INTO album_labels (album_id, label_id) VALUES(?,?);' + sql_query = ( + "INSERT or IGNORE INTO album_labels (album_id, label_id) VALUES(?,?);" + ) await self._db.execute(sql_query, (album_id, label_id)) async def __get_album_labels(self, album_id): - ''' get labels for album item ''' + """ get labels for album item """ labels = [] - sql_query = 'SELECT name FROM labels INNER JOIN album_labels on labels.label_id = album_labels.label_id WHERE album_id = ?' - async with self._db.execute(sql_query, (album_id, )) as cursor: + sql_query = "SELECT name FROM labels INNER JOIN album_labels on labels.label_id = album_labels.label_id WHERE album_id = ?" + async with self._db.execute(sql_query, (album_id,)) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: labels.append(db_row[0]) return labels - async def __get_track_artists(self, track_id, - fulldata=False) -> List[Artist]: - ''' get artists for track ''' - sql_query = 'WHERE artist_id in (SELECT artist_id FROM track_artists WHERE track_id = %s)' % track_id - return [ - item async for item in self.artists(sql_query, fulldata=fulldata) - ] + async def __get_track_artists(self, track_id, fulldata=False) -> List[Artist]: + """ get artists for track """ + sql_query = ( + "WHERE artist_id in (SELECT artist_id FROM track_artists WHERE track_id = %s)" + % track_id + ) + return [item async for item in self.artists(sql_query, fulldata=fulldata)] async def __add_external_ids(self, item_id, media_type, external_ids): - ''' add or update external_ids''' + """ add or update external_ids""" for external_id in external_ids: for key, value in external_id.items(): - sql_query = 'INSERT or REPLACE INTO external_ids (item_id, media_type, key, value) VALUES(?,?,?,?);' - await self._db.execute(sql_query, - (item_id, media_type, key, value)) + sql_query = "INSERT or REPLACE INTO external_ids (item_id, media_type, key, value) VALUES(?,?,?,?);" + await self._db.execute(sql_query, (item_id, media_type, key, value)) async def __get_external_ids(self, item_id, media_type): - ''' get external_ids for media item ''' + """ get external_ids for media item """ external_ids = [] - sql_query = 'SELECT key, value FROM external_ids WHERE item_id = ? AND media_type = ?' - async with self._db.execute(sql_query, - (item_id, media_type)) as cursor: + sql_query = ( + "SELECT key, value FROM external_ids WHERE item_id = ? AND media_type = ?" + ) + async with self._db.execute(sql_query, (item_id, media_type)) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: external_id = {db_row[0]: db_row[1]} @@ -787,62 +890,64 @@ class Database(): return external_ids async def __add_prov_ids(self, item_id, media_type, provider_ids): - ''' add provider ids for media item to db ''' + """ add provider ids for media item to db """ for prov_mapping in provider_ids: - prov_id = prov_mapping['provider'] - prov_item_id = prov_mapping['item_id'] - quality = prov_mapping.get('quality', 0) - details = prov_mapping.get('details', '') - sql_query = 'INSERT OR REPLACE INTO provider_mappings (item_id, media_type, prov_item_id, provider, quality, details) VALUES(?,?,?,?,?,?);' + prov_id = prov_mapping["provider"] + prov_item_id = prov_mapping["item_id"] + quality = prov_mapping.get("quality", 0) + details = prov_mapping.get("details", "") + sql_query = "INSERT OR REPLACE INTO provider_mappings (item_id, media_type, prov_item_id, provider, quality, details) VALUES(?,?,?,?,?,?);" await self._db.execute( sql_query, - (item_id, media_type, prov_item_id, prov_id, quality, details)) + (item_id, media_type, prov_item_id, prov_id, quality, details), + ) async def __get_prov_ids(self, item_id, media_type: MediaType): - ''' get all provider_ids for media item ''' + """ get all provider_ids for media item """ provider_ids = [] - sql_query = 'SELECT prov_item_id, provider, quality, details \ + sql_query = "SELECT prov_item_id, provider, quality, details \ FROM provider_mappings \ - WHERE item_id = ? AND media_type = ?' + WHERE item_id = ? AND media_type = ?" - async with self._db.execute(sql_query, - (item_id, media_type)) as cursor: + async with self._db.execute(sql_query, (item_id, media_type)) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: prov_mapping = { "provider": db_row[1], "item_id": db_row[0], "quality": db_row[2], - "details": db_row[3] + "details": db_row[3], } provider_ids.append(prov_mapping) return provider_ids async def __get_library_providers(self, item_id, media_type: MediaType): - ''' get the providers that have this media_item added to the library ''' + """ get the providers that have this media_item added to the library """ providers = [] - sql_query = 'SELECT provider FROM library_items WHERE item_id = ? AND media_type = ?' - async with self._db.execute(sql_query, - (item_id, media_type)) as cursor: + sql_query = ( + "SELECT provider FROM library_items WHERE item_id = ? AND media_type = ?" + ) + async with self._db.execute(sql_query, (item_id, media_type)) as cursor: db_rows = await cursor.fetchall() for db_row in db_rows: providers.append(db_row[0]) return providers async def __get_item_by_external_id(self, media_item): - ''' try to get existing item in db by matching the new item's external id's ''' + """ try to get existing item in db by matching the new item's external id's """ item_id = None for external_id in media_item.external_ids: if item_id: break for key, value in external_id.items(): async with self._db.execute( - 'SELECT (item_id) FROM external_ids WHERE media_type=? AND key=? AND value=?;', - (media_item.media_type, key, value)) as cursor: + "SELECT (item_id) FROM external_ids WHERE media_type=? AND key=? AND value=?;", + (media_item.media_type, key, value), + ) as cursor: result = await cursor.fetchone() if result: item_id = result[0] break if item_id: break - return item_id \ No newline at end of file + return item_id diff --git a/music_assistant/homeassistant.py b/music_assistant/homeassistant.py index d9fb3e61..205ec46d 100644 --- a/music_assistant/homeassistant.py +++ b/music_assistant/homeassistant.py @@ -2,44 +2,54 @@ # -*- coding:utf-8 -*- import asyncio +import copy +import datetime +import hashlib +import json import os -from typing import List import random -import aiohttp import time -import datetime -import hashlib -from asyncio_throttle import Throttler +from typing import List + from aiocometd import Client, ConnectionType, Extension -import copy -import slugify as slug -import json -from music_assistant.utils import run_periodic, LOGGER, IS_HASSIO, try_parse_int +import aiohttp +from asyncio_throttle import Throttler +from music_assistant.constants import ( + CONF_ENABLED, + CONF_TOKEN, + CONF_URL, + EVENT_HASS_ENTITY_CHANGED, + EVENT_PLAYER_ADDED, + EVENT_PLAYER_CHANGED, +) from music_assistant.models.media_types import Track -from music_assistant.constants import CONF_ENABLED, CONF_URL, CONF_TOKEN, EVENT_PLAYER_CHANGED, EVENT_PLAYER_ADDED, EVENT_HASS_ENTITY_CHANGED +from music_assistant.utils import IS_HASSIO, LOGGER, run_periodic, try_parse_int +import slugify as slug -CONF_KEY = 'homeassistant' +CONF_KEY = "homeassistant" CONF_PUBLISH_PLAYERS = "publish_players" ### auto detect hassio for auto config #### if IS_HASSIO: CONFIG_ENTRIES = [ (CONF_ENABLED, False, CONF_ENABLED), - (CONF_PUBLISH_PLAYERS, True, 'hass_publish')] + (CONF_PUBLISH_PLAYERS, True, "hass_publish"), + ] else: CONFIG_ENTRIES = [ (CONF_ENABLED, False, CONF_ENABLED), - (CONF_URL, 'localhost', 'hass_url'), - (CONF_TOKEN, '', 'hass_token'), - (CONF_PUBLISH_PLAYERS, True, 'hass_publish')] - + (CONF_URL, "localhost", "hass_url"), + (CONF_TOKEN, "", "hass_token"), + (CONF_PUBLISH_PLAYERS, True, "hass_publish"), + ] + -class HomeAssistant(): - ''' +class HomeAssistant: + """ Homeassistant integration allows publishing of our players to hass allows using hass entities (like switches, media_players or gui inputs) to be triggered - ''' + """ def __init__(self, mass): self.mass = mass @@ -52,53 +62,57 @@ class HomeAssistant(): # load/create/update config config = self.mass.config.create_module_config(CONF_KEY, CONFIG_ENTRIES) self.enabled = config[CONF_ENABLED] - if (self.enabled and not IS_HASSIO and not - (config[CONF_URL] or config[CONF_TOKEN])): + if ( + self.enabled + and not IS_HASSIO + and not (config[CONF_URL] or config[CONF_TOKEN]) + ): LOGGER.warning("Invalid configuration for Home Assistant") self.enabled = False if IS_HASSIO: - self._token = os.environ['HASSIO_TOKEN'] + self._token = os.environ["HASSIO_TOKEN"] self._use_ssl = False - self._host = 'hassio/homeassistant' + self._host = "hassio/homeassistant" else: self._token = config[CONF_TOKEN] url = config[CONF_URL] - if url.startswith('https://'): + if url.startswith("https://"): self._use_ssl = True - self._host = url.replace('https://','').split('/')[0] + self._host = url.replace("https://", "").split("/")[0] else: self._use_ssl = False - self._host = url.replace('http://','').split('/')[0] + self._host = url.replace("http://", "").split("/")[0] if self.enabled: - LOGGER.info('Homeassistant integration is enabled') + LOGGER.info("Homeassistant integration is enabled") async def setup(self): - ''' perform async setup ''' + """ perform async setup """ if not self.enabled: return self.http_session = aiohttp.ClientSession( - loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) + loop=self.mass.event_loop, connector=aiohttp.TCPConnector() + ) self.mass.event_loop.create_task(self.__hass_websocket()) await self.mass.add_event_listener(self.mass_event, EVENT_PLAYER_CHANGED) await self.mass.add_event_listener(self.mass_event, EVENT_PLAYER_ADDED) self.mass.event_loop.create_task(self.__get_sources()) - async def get_state_async(self, entity_id, attribute='state'): - ''' get state of a hass entity (async)''' + async def get_state_async(self, entity_id, attribute="state"): + """ get state of a hass entity (async)""" state = self.get_state(entity_id, attribute) if not state: await self.__request_state(entity_id) state = self.get_state(entity_id, attribute) return state - def get_state(self, entity_id, attribute='state'): - ''' get state of a hass entity''' + def get_state(self, entity_id, attribute="state"): + """ get state of a hass entity""" state_obj = self._tracked_entities.get(entity_id) if state_obj: - if attribute == 'state': - return state_obj['state'] + if attribute == "state": + return state_obj["state"] elif attribute: - return state_obj['attributes'].get(attribute) + return state_obj["attributes"].get(attribute) else: return state_obj else: @@ -106,34 +120,39 @@ class HomeAssistant(): return None async def __request_state(self, entity_id): - ''' get state of a hass entity''' - state_obj = await self.__get_data('states/%s' % entity_id) - if 'state' in state_obj: + """ get state of a hass entity""" + state_obj = await self.__get_data("states/%s" % entity_id) + if "state" in state_obj: self._tracked_entities[entity_id] = state_obj await self.mass.signal_event(EVENT_HASS_ENTITY_CHANGED, state_obj) - + async def mass_event(self, msg, msg_details): - ''' received event from mass ''' + """ received event from mass """ if msg in [EVENT_PLAYER_CHANGED, EVENT_PLAYER_ADDED]: 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_entities: - self._tracked_entities[event_data['entity_id']] = event_data['new_state'] + """ received event from hass """ + if event_type == "state_changed": + if event_data["entity_id"] in self._tracked_entities: + self._tracked_entities[event_data["entity_id"]] = event_data[ + "new_state" + ] self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_HASS_ENTITY_CHANGED, event_data)) - elif event_type == 'call_service' and event_data['domain'] == 'media_player': - await self.__handle_player_command(event_data['service'], event_data['service_data']) + self.mass.signal_event(EVENT_HASS_ENTITY_CHANGED, event_data) + ) + 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): + """ 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'] + entity_ids = service_data["entity_id"] else: - entity_ids = [service_data['entity_id']] + 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 @@ -141,162 +160,194 @@ class HomeAssistant(): player = await self.mass.players.get_player(player_id) if not player: return - if service == 'turn_on': + if service == "turn_on": await player.power_on() - elif service == 'turn_off': + elif service == "turn_off": await player.power_off() - elif service == 'toggle': + elif service == "toggle": await player.power_toggle() - elif service == 'volume_mute': - await player.volume_mute(service_data['is_volume_muted']) - elif service == 'volume_up': + elif service == "volume_mute": + await player.volume_mute(service_data["is_volume_muted"]) + elif service == "volume_up": await player.volume_up() - elif service == 'volume_down': + elif service == "volume_down": await player.volume_down() - elif service == 'volume_set': - volume_level = service_data['volume_level']*100 + elif service == "volume_set": + volume_level = service_data["volume_level"] * 100 await player.volume_set(volume_level) - elif service == 'media_play': + elif service == "media_play": await player.play() - elif service == 'media_pause': + elif service == "media_pause": await player.pause() - elif service == 'media_stop': + elif service == "media_stop": await player.stop() - elif service == 'media_next_track': + elif service == "media_next_track": await player.next() - elif service == 'media_play_pause': + elif service == "media_play_pause": await player.play_pause() - elif service == 'play_media': + elif service == "play_media": return await self.__handle_play_media(player_id, service_data) async def __handle_play_media(self, player_id, service_data): - ''' handle play_media request from homeassistant''' - media_content_type = service_data['media_content_type'].lower() - media_content_id = service_data['media_content_id'] - queue_opt = 'add' if service_data.get('enqueue') else 'play' - if media_content_type == 'playlist' and not '://' in media_content_id: + """ handle play_media request from homeassistant""" + media_content_type = service_data["media_content_type"].lower() + media_content_id = service_data["media_content_id"] + queue_opt = "add" if service_data.get("enqueue") else "play" + if media_content_type == "playlist" and not "://" in media_content_id: media_items = [] - for playlist_str in media_content_id.split(','): + for playlist_str in media_content_id.split(","): playlist_str = playlist_str.strip() playlist = await self.mass.music.playlist_by_name(playlist_str) if playlist: media_items.append(playlist) return await self.mass.players.play_media(player_id, media_items, queue_opt) - elif media_content_type == 'playlist' and 'spotify://playlist' in media_content_id: + elif ( + media_content_type == "playlist" + and "spotify://playlist" in media_content_id + ): # TODO: handle parsing of other uri's here - playlist = self.mass.music.providers['spotify'].playlist(media_content_id.split(':')[-1]) + playlist = self.mass.music.providers["spotify"].playlist( + media_content_id.split(":")[-1] + ) return await self.mass.players.play_media(player_id, playlist, queue_opt) - elif media_content_id.startswith('http'): + elif media_content_id.startswith("http"): track = Track() track.uri = media_content_id - track.provider = 'http' + track.provider = "http" return await self.mass.players.play_media(player_id, track, queue_opt) - + async def publish_player(self, player_info): - ''' publish player details to hass''' - if not self.mass.config['base']['homeassistant']['publish_players']: + """ publish player details to hass""" + if not self.mass.config["base"]["homeassistant"]["publish_players"]: return False if not player_info["name"]: return # TODO: throttle updates to home assistant ? player_id = player_info["player_id"] - entity_id = 'media_player.mass_' + slug.slugify(player_info["name"], separator='_').lower() + entity_id = ( + "media_player.mass_" + + slug.slugify(player_info["name"], separator="_").lower() + ) state = player_info["state"] state_attributes = { - "supported_features": 65471, - "friendly_name": player_info["name"], - "source_list": self._sources, - "source": 'unknown', - "volume_level": player_info["volume_level"]/100, - "is_volume_muted": player_info["muted"], - "media_position_updated_at": player_info["media_position_updated_at"], - "media_duration": None, - "media_position": player_info["cur_time"], - "media_title": None, - "media_artist": None, - "media_album_name": None, - "entity_picture": None - } + "supported_features": 65471, + "friendly_name": player_info["name"], + "source_list": self._sources, + "source": "unknown", + "volume_level": player_info["volume_level"] / 100, + "is_volume_muted": player_info["muted"], + "media_position_updated_at": player_info["media_position_updated_at"], + "media_duration": None, + "media_position": player_info["cur_time"], + "media_title": None, + "media_artist": None, + "media_album_name": None, + "entity_picture": None, + } if state != "off": player = await self.mass.players.get_player(player_id) if player.queue.cur_item: state_attributes["media_duration"] = player.queue.cur_item.duration state_attributes["media_title"] = player.queue.cur_item.name if player.queue.cur_item.artists: - state_attributes["media_artist"] = player.queue.cur_item.artists[0].name + state_attributes["media_artist"] = player.queue.cur_item.artists[ + 0 + ].name if player.queue.cur_item.album: - state_attributes["media_album_name"] = player.queue.cur_item.album.name - state_attributes["entity_picture"] = player.queue.cur_item.album.metadata.get("image") + state_attributes[ + "media_album_name" + ] = player.queue.cur_item.album.name + state_attributes[ + "entity_picture" + ] = player.queue.cur_item.album.metadata.get("image") 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 ''' + """ call service on hass """ if not self.__send_ws: return False - msg = { - "type": "call_service", - "domain": domain, - "service": service, - } + msg = {"type": "call_service", "domain": domain, "service": service} if service_data: - msg['service_data'] = service_data + msg["service_data"] = service_data return await self.__send_ws(msg) @run_periodic(120) async def __get_sources(self): - ''' we build a list of all playlists to use as player sources ''' - self._sources = [playlist.name async for playlist in self.mass.music.library_playlists()] - self._sources += [playlist.name async for playlist in self.mass.music.library_radios()] + """ we build a list of all playlists to use as player sources """ + self._sources = [ + playlist.name async for playlist in self.mass.music.library_playlists() + ] + self._sources += [ + playlist.name async for playlist in self.mass.music.library_radios() + ] async def __set_state(self, entity_id, new_state, state_attributes={}): - ''' set state to hass entity ''' + """ 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) - + "attributes": state_attributes, + } + return await self.__post_data("states/%s" % entity_id, data) + async def __hass_websocket(self): - ''' Receive events from Hass through websockets ''' + """ Receive events from Hass through websockets """ while self.mass.event_loop.is_running(): try: - protocol = 'wss' if self._use_ssl else 'ws' - async with self.http_session.ws_connect('%s://%s/api/websocket' % (protocol, self._host), verify_ssl=False) as ws: - + protocol = "wss" if self._use_ssl else "ws" + async with self.http_session.ws_connect( + "%s://%s/api/websocket" % (protocol, self._host), verify_ssl=False + ) as ws: + async def send_msg(msg): - ''' callback to send message to the websockets client''' + """ callback to send message to the websockets client""" self.__last_id += 1 - msg['id'] = self.__last_id + 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': + if msg.data == "close cmd": await ws.close() break else: data = msg.json() - if data['type'] == 'auth_required': + if data["type"] == "auth_required": # send auth token - auth_msg = {"type": "auth", "access_token": self._token} + auth_msg = { + "type": "auth", + "access_token": self._token, + } await ws.send_json(auth_msg) - elif data['type'] == 'auth_invalid': + elif data["type"] == "auth_invalid": raise Exception(data) - elif data['type'] == 'auth_ok': + elif data["type"] == "auth_ok": # register callback self.__send_ws = send_msg # subscribe to events - subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"} + subscribe_msg = { + "type": "subscribe_events", + "event_type": "state_changed", + } await send_msg(subscribe_msg) - subscribe_msg = {"type": "subscribe_events", "event_type": "call_service"} + 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'): + 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'])) + asyncio.create_task( + self.hass_event("all_states", data["result"]) + ) # else: # LOGGER.info(data) elif msg.type == aiohttp.WSMsgType.ERROR: @@ -308,19 +359,29 @@ class HomeAssistant(): await asyncio.sleep(10) async def __get_data(self, endpoint): - ''' get data from hass rest api''' + """ 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, verify_ssl=False) as response: + headers = { + "Authorization": "Bearer %s" % self._token, + "Content-Type": "application/json", + } + async with self.http_session.get( + url, headers=headers, verify_ssl=False + ) as response: return await response.json() async def __post_data(self, endpoint, data): - ''' post data to hass rest api''' + """ 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, verify_ssl=False) as response: - return await response.json() \ No newline at end of file + headers = { + "Authorization": "Bearer %s" % self._token, + "Content-Type": "application/json", + } + async with self.http_session.post( + url, headers=headers, json=data, verify_ssl=False + ) as response: + return await response.json() diff --git a/music_assistant/http_streamer.py b/music_assistant/http_streamer.py index ace731c7..11892e97 100755 --- a/music_assistant/http_streamer.py +++ b/music_assistant/http_streamer.py @@ -2,29 +2,37 @@ # -*- coding:utf-8 -*- import asyncio -import os -import operator import concurrent -from aiohttp import web +import gc +import io +import operator +import os +import shlex +import subprocess import threading import urllib + +import aiohttp +from aiohttp import web from memory_tempfile import MemoryTempfile -import soundfile +from music_assistant.constants import EVENT_STREAM_ENDED, EVENT_STREAM_STARTED +from music_assistant.models.media_types import MediaType, TrackQuality +from music_assistant.models.playerstate import PlayerState +from music_assistant.utils import ( + LOGGER, + get_folder_size, + get_ip, + run_async_background_task, + run_periodic, + try_parse_int, +) import pyloudnorm -import io -import aiohttp -import subprocess -import gc -import shlex +import soundfile -from music_assistant.constants import EVENT_STREAM_STARTED, EVENT_STREAM_ENDED -from music_assistant.utils import LOGGER, try_parse_int, get_ip, run_async_background_task, run_periodic, get_folder_size -from music_assistant.models.media_types import TrackQuality, MediaType -from music_assistant.models.playerstate import PlayerState +class HTTPStreamer: + """ Built-in streamer using sox and webserver """ -class HTTPStreamer(): - ''' Built-in streamer using sox and webserver ''' def __init__(self, mass): self.mass = mass self.local_ip = get_ip() @@ -32,37 +40,38 @@ class HTTPStreamer(): self.stream_clients = [] async def setup(self): - ''' async initialize of module ''' + """ async initialize of module """ pass # we have nothing to initialize async def stream(self, http_request): - ''' + """ start stream for a player - ''' + """ # make sure we have valid params - player_id = http_request.match_info.get('player_id', '') + player_id = http_request.match_info.get("player_id", "") player = await self.mass.players.get_player(player_id) if not player: return web.Response(status=404, reason="Player not found") if not player.queue.use_queue_stream: - queue_item_id = http_request.match_info.get('queue_item_id') + queue_item_id = http_request.match_info.get("queue_item_id") queue_item = await player.queue.by_item_id(queue_item_id) if not queue_item: return web.Response(status=404, reason="Invalid Queue item Id") # prepare headers as audio/flac content - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'audio/flac'}) + resp = web.StreamResponse( + status=200, reason="OK", headers={"Content-Type": "audio/flac"} + ) await resp.prepare(http_request) # run the streamer in executor to prevent the subprocess locking up our eventloop cancelled = threading.Event() if player.queue.use_queue_stream: bg_task = self.mass.event_loop.run_in_executor( - None, self.__get_queue_stream, player, resp, cancelled) + None, self.__get_queue_stream, player, resp, cancelled + ) else: bg_task = self.mass.event_loop.run_in_executor( - None, self.__get_queue_item_stream, player, queue_item, resp, - cancelled) + None, self.__get_queue_item_stream, player, queue_item, resp, cancelled + ) # let the streaming begin! try: await asyncio.gather(bg_task) @@ -72,39 +81,45 @@ class HTTPStreamer(): return resp def __get_queue_item_stream(self, player, queue_item, buffer, cancelled): - ''' start streaming single queue track ''' + """ start streaming single queue track """ LOGGER.debug( - "stream single queue track started for track %s on player %s" % - (queue_item.name, player.name)) + "stream single queue track started for track %s on player %s" + % (queue_item.name, player.name) + ) for is_last_chunk, audio_chunk in self.__get_audio_stream( - player, queue_item, cancelled): + player, queue_item, cancelled + ): if cancelled.is_set(): # http session ended # we must consume the data to prevent hanging subprocess instances continue # put chunk in buffer - self.mass.run_task(buffer.write(audio_chunk), - wait_for_result=True, - ignore_exception=(BrokenPipeError, - ConnectionResetError)) + self.mass.run_task( + buffer.write(audio_chunk), + wait_for_result=True, + ignore_exception=(BrokenPipeError, ConnectionResetError), + ) # all chunks received: streaming finished if cancelled.is_set(): LOGGER.debug( - "stream single track interrupted for track %s on player %s" % - (queue_item.name, player.name)) + "stream single track interrupted for track %s on player %s" + % (queue_item.name, player.name) + ) else: # indicate EOF if no more data - self.mass.run_task(buffer.write_eof(), - wait_for_result=True, - ignore_exception=(BrokenPipeError, - ConnectionResetError)) + self.mass.run_task( + buffer.write_eof(), + wait_for_result=True, + ignore_exception=(BrokenPipeError, ConnectionResetError), + ) LOGGER.debug( - "stream single track finished for track %s on player %s" % - (queue_item.name, player.name)) + "stream single track finished for track %s on player %s" + % (queue_item.name, player.name) + ) def __get_queue_stream(self, player, buffer, cancelled): - ''' start streaming all queue tracks ''' - sample_rate = try_parse_int(player.settings['max_sample_rate']) + """ start streaming all queue tracks """ + sample_rate = try_parse_int(player.settings["max_sample_rate"]) fade_length = try_parse_int(player.settings["crossfade_duration"]) if not sample_rate or sample_rate < 44100 or sample_rate > 384000: sample_rate = 96000 @@ -112,14 +127,13 @@ class HTTPStreamer(): fade_bytes = int(sample_rate * 4 * 2 * fade_length) else: fade_bytes = int(sample_rate * 4 * 2 * 6) - pcm_args = 'raw -b 32 -c 2 -e signed-integer -r %s' % sample_rate - args = 'sox -t %s - -t flac -C 0 -' % pcm_args + pcm_args = "raw -b 32 -c 2 -e signed-integer -r %s" % sample_rate + args = "sox -t %s - -t flac -C 0 -" % pcm_args # start sox process args = shlex.split(args) - sox_proc = subprocess.Popen(args, - shell=False, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE) + sox_proc = subprocess.Popen( + args, shell=False, stdout=subprocess.PIPE, stdin=subprocess.PIPE + ) def fill_buffer(): while True: @@ -131,16 +145,23 @@ class HTTPStreamer(): buffer.write(chunk), wait_for_result=True, ignore_exception=( - BrokenPipeError, ConnectionResetError, - concurrent.futures._base.CancelledError)) + BrokenPipeError, + ConnectionResetError, + concurrent.futures._base.CancelledError, + ), + ) del chunk # indicate EOF if no more data if not cancelled.is_set(): self.mass.run_task( buffer.write_eof(), wait_for_result=True, - ignore_exception=(BrokenPipeError, ConnectionResetError, - concurrent.futures._base.CancelledError)) + ignore_exception=( + BrokenPipeError, + ConnectionResetError, + concurrent.futures._base.CancelledError, + ), + ) # start fill buffer task in background fill_buffer_thread = threading.Thread(target=fill_buffer) @@ -148,7 +169,7 @@ class HTTPStreamer(): LOGGER.info("Start Queue Stream for player %s " % (player.name)) is_start = True - last_fadeout_data = b'' + last_fadeout_data = b"" while True: if cancelled.is_set(): break @@ -156,33 +177,35 @@ class HTTPStreamer(): if is_start: # report start of queue playback so we can calculate current track/duration etc. queue_track = asyncio.run_coroutine_threadsafe( - player.queue.start_queue_stream(), - self.mass.event_loop).result() + player.queue.start_queue_stream(), self.mass.event_loop + ).result() is_start = False else: queue_track = player.queue.next_item if not queue_track: LOGGER.debug("no (more) tracks left in queue") break - LOGGER.debug("Start Streaming queue track: %s (%s) on player %s" % - (queue_track.item_id, queue_track.name, player.name)) - fade_in_part = b'' + LOGGER.debug( + "Start Streaming queue track: %s (%s) on player %s" + % (queue_track.item_id, queue_track.name, player.name) + ) + fade_in_part = b"" cur_chunk = 0 prev_chunk = None bytes_written = 0 # handle incoming audio chunks for is_last_chunk, chunk in self.__get_audio_stream( - player, - queue_track, - cancelled, - chunksize=fade_bytes, - resample=sample_rate): + player, + queue_track, + cancelled, + chunksize=fade_bytes, + resample=sample_rate, + ): cur_chunk += 1 ### HANDLE FIRST PART OF TRACK if cur_chunk == 1 and is_last_chunk: - LOGGER.warning("Stream error, skip track %s", - queue_track.item_id) + LOGGER.warning("Stream error, skip track %s", queue_track.item_id) break if cur_chunk <= 2 and not last_fadeout_data: # no fadeout_part available so just pass it to the output directly @@ -195,13 +218,13 @@ class HTTPStreamer(): ### HANDLE CROSSFADE OF PREVIOUS TRACK FADE_OUT AND THIS TRACK FADE_IN elif cur_chunk == 2 and last_fadeout_data: # combine the first 2 chunks and strip off silence - args = 'sox --ignore-length -t %s - -t %s - silence 1 0.1 1%%' % ( - pcm_args, pcm_args) + args = "sox --ignore-length -t %s - -t %s - silence 1 0.1 1%%" % ( + pcm_args, + pcm_args, + ) first_part, std_err = subprocess.Popen( - args, - shell=True, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE).communicate(prev_chunk + chunk) + args, shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE + ).communicate(prev_chunk + chunk) if len(first_part) < fade_bytes: # part is too short after the strip action?! # so we just use the full first part @@ -211,12 +234,13 @@ class HTTPStreamer(): del first_part # do crossfade crossfade_part = self.__crossfade_pcm_parts( - fade_in_part, last_fadeout_data, pcm_args, fade_length) + fade_in_part, last_fadeout_data, pcm_args, fade_length + ) sox_proc.stdin.write(crossfade_part) bytes_written += len(crossfade_part) del crossfade_part del fade_in_part - last_fadeout_data = b'' + last_fadeout_data = b"" # also write the leftover bytes from the strip action sox_proc.stdin.write(remaining_bytes) bytes_written += len(remaining_bytes) @@ -227,22 +251,25 @@ class HTTPStreamer(): elif prev_chunk and is_last_chunk: # last chunk received so create the last_part with the previous chunk and this chunk # and strip off silence - args = 'sox --ignore-length -t %s - -t %s - reverse silence 1 0.1 1%% reverse' % ( - pcm_args, pcm_args) + args = ( + "sox --ignore-length -t %s - -t %s - reverse silence 1 0.1 1%% reverse" + % (pcm_args, pcm_args) + ) last_part, stderr = subprocess.Popen( - args, - shell=True, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE).communicate(prev_chunk + chunk) + args, shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE + ).communicate(prev_chunk + chunk) if len(last_part) < fade_bytes: # part is too short after the strip action # so we just use the entire original data last_part = prev_chunk + chunk if len(last_part) < fade_bytes: - LOGGER.warning("Not enough data for crossfade: %s", - len(last_part)) - if not player.queue.crossfade_enabled or len( - last_part) < fade_bytes: + LOGGER.warning( + "Not enough data for crossfade: %s", len(last_part) + ) + if ( + not player.queue.crossfade_enabled + or len(last_part) < fade_bytes + ): # crossfading is not enabled so just pass the (stripped) audio data sox_proc.stdin.write(last_part) bytes_written += len(last_part) @@ -279,8 +306,9 @@ class HTTPStreamer(): accurate_duration = bytes_written / int(sample_rate * 4 * 2) queue_track.duration = accurate_duration LOGGER.debug( - "Finished Streaming queue track: %s (%s) on player %s" % - (queue_track.item_id, queue_track.name, player.name)) + "Finished Streaming queue track: %s (%s) on player %s" + % (queue_track.item_id, queue_track.name, player.name) + ) # run garbage collect manually to avoid too much memory fragmentation gc.collect() # end of queue reached, pass last fadeout bits to final output @@ -295,94 +323,100 @@ class HTTPStreamer(): # run garbage collect manually to avoid too much memory fragmentation gc.collect() if cancelled.is_set(): - LOGGER.info("streaming of queue for player %s interrupted" % - player.name) + LOGGER.info("streaming of queue for player %s interrupted" % player.name) else: - LOGGER.info("streaming of queue for player %s completed" % - player.name) + LOGGER.info("streaming of queue for player %s completed" % player.name) - def __get_audio_stream(self, - player, - queue_item, - cancelled, - chunksize=128000, - resample=None): - ''' get audio stream from provider and apply additional effects/processing where/if needed''' + def __get_audio_stream( + self, player, queue_item, cancelled, chunksize=128000, resample=None + ): + """ get audio stream from provider and apply additional effects/processing where/if needed""" streamdetails = None # always request the full db track as there might be other qualities available - full_track = self.mass.run_task(self.mass.music.track( - queue_item.item_id, - queue_item.provider, - lazy=True, - track_details=queue_item), wait_for_result=True) + full_track = self.mass.run_task( + self.mass.music.track( + queue_item.item_id, + queue_item.provider, + lazy=True, + track_details=queue_item, + ), + wait_for_result=True, + ) # sort by quality and check track availability - for prov_media in sorted(full_track.provider_ids, - key=operator.itemgetter('quality'), - reverse=True): - if not prov_media['provider'] in self.mass.music.providers: + for prov_media in sorted( + full_track.provider_ids, key=operator.itemgetter("quality"), reverse=True + ): + if not prov_media["provider"] in self.mass.music.providers: continue # get stream details from provider - streamdetails = self.mass.run_task(self.mass.music.providers[ - prov_media['provider']].get_stream_details( - prov_media['item_id']), - wait_for_result=True) + streamdetails = self.mass.run_task( + self.mass.music.providers[prov_media["provider"]].get_stream_details( + prov_media["item_id"] + ), + wait_for_result=True, + ) if streamdetails: - streamdetails['player_id'] = player.player_id - if not 'item_id' in streamdetails: - streamdetails['item_id'] = prov_media['item_id'] - if not 'provider' in streamdetails: - streamdetails['provider'] = prov_media['provider'] - if not 'quality' in streamdetails: - streamdetails['quality'] = prov_media['quality'] + streamdetails["player_id"] = player.player_id + if not "item_id" in streamdetails: + streamdetails["item_id"] = prov_media["item_id"] + if not "provider" in streamdetails: + streamdetails["provider"] = prov_media["provider"] + if not "quality" in streamdetails: + streamdetails["quality"] = prov_media["quality"] queue_item.streamdetails = streamdetails break if not streamdetails: LOGGER.warning("no stream details for %s", queue_item.name) - yield (True, b'') + yield (True, b"") return # get sox effects and resample options sox_options = self.__get_player_sox_options(player, streamdetails) - outputfmt = 'flac -C 0' + outputfmt = "flac -C 0" if resample: - outputfmt = 'raw -b 32 -c 2 -e signed-integer' - sox_options += ' rate -v %s' % resample - streamdetails['sox_options'] = sox_options + outputfmt = "raw -b 32 -c 2 -e signed-integer" + sox_options += " rate -v %s" % resample + streamdetails["sox_options"] = sox_options # determine how to proceed based on input file type - if streamdetails["content_type"] == 'aac': + if streamdetails["content_type"] == "aac": # support for AAC created with ffmpeg in between args = 'ffmpeg -v quiet -i "%s" -f flac - | sox -t flac - -t %s - %s' % ( - streamdetails["path"], outputfmt, sox_options) - process = subprocess.Popen(args, - shell=True, - stdout=subprocess.PIPE, - bufsize=chunksize) - elif streamdetails['type'] in ['url', 'file']: + streamdetails["path"], + outputfmt, + sox_options, + ) + process = subprocess.Popen( + args, shell=True, stdout=subprocess.PIPE, bufsize=chunksize + ) + elif streamdetails["type"] in ["url", "file"]: args = 'sox -t %s "%s" -t %s - %s' % ( - streamdetails["content_type"], streamdetails["path"], - outputfmt, sox_options) + streamdetails["content_type"], + streamdetails["path"], + outputfmt, + sox_options, + ) args = shlex.split(args) - process = subprocess.Popen(args, - shell=False, - stdout=subprocess.PIPE, - bufsize=chunksize) - elif streamdetails['type'] == 'executable': - args = '%s | sox -t %s - -t %s - %s' % ( - streamdetails["path"], streamdetails["content_type"], - outputfmt, sox_options) - process = subprocess.Popen(args, - shell=True, - stdout=subprocess.PIPE, - bufsize=chunksize) + process = subprocess.Popen( + args, shell=False, stdout=subprocess.PIPE, bufsize=chunksize + ) + elif streamdetails["type"] == "executable": + args = "%s | sox -t %s - -t %s - %s" % ( + streamdetails["path"], + streamdetails["content_type"], + outputfmt, + sox_options, + ) + process = subprocess.Popen( + args, shell=True, stdout=subprocess.PIPE, bufsize=chunksize + ) else: LOGGER.warning("no streaming options for %s", queue_item.name) - yield (True, b'') + yield (True, b"") return # fire event that streaming has started for this track - self.mass.run_task( - self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails)) + self.mass.run_task(self.mass.signal_event(EVENT_STREAM_STARTED, streamdetails)) # yield chunks from stdout # we keep 1 chunk behind to detect end of stream properly - prev_chunk = b'' + prev_chunk = b"" while True: if cancelled.is_set(): # http session ended @@ -399,60 +433,72 @@ class HTTPStreamer(): yield (False, prev_chunk) prev_chunk = chunk # fire event that streaming has ended - self.mass.run_task( - self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails)) + self.mass.run_task(self.mass.signal_event(EVENT_STREAM_ENDED, streamdetails)) # send task to background to analyse the audio if queue_item.media_type == MediaType.Track: - self.mass.event_loop.run_in_executor(None, self.__analyze_audio, - streamdetails) + self.mass.event_loop.run_in_executor( + None, self.__analyze_audio, streamdetails + ) def __get_player_sox_options(self, player, streamdetails): - ''' get player specific sox effect options ''' + """ get player specific sox effect options """ sox_options = [] # volume normalisation - gain_correct = self.mass.run_task(self.mass.players.get_gain_correct( - player.player_id, streamdetails["item_id"], - streamdetails["provider"]), - wait_for_result=True) + gain_correct = self.mass.run_task( + self.mass.players.get_gain_correct( + player.player_id, streamdetails["item_id"], streamdetails["provider"] + ), + wait_for_result=True, + ) if gain_correct != 0: - sox_options.append('vol %s dB ' % gain_correct) + sox_options.append("vol %s dB " % gain_correct) # downsample if needed - if player.settings['max_sample_rate']: - max_sample_rate = try_parse_int(player.settings['max_sample_rate']) + if player.settings["max_sample_rate"]: + max_sample_rate = try_parse_int(player.settings["max_sample_rate"]) if max_sample_rate: quality = streamdetails["quality"] - if quality > TrackQuality.FLAC_LOSSLESS_HI_RES_3 and max_sample_rate == 192000: - sox_options.append('rate -v 192000') - elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_2 and max_sample_rate == 96000: - sox_options.append('rate -v 96000') - elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_1 and max_sample_rate == 48000: - sox_options.append('rate -v 48000') - if player.settings.get('sox_options'): - sox_options.append(player.settings['sox_options']) + if ( + quality > TrackQuality.FLAC_LOSSLESS_HI_RES_3 + and max_sample_rate == 192000 + ): + sox_options.append("rate -v 192000") + elif ( + quality > TrackQuality.FLAC_LOSSLESS_HI_RES_2 + and max_sample_rate == 96000 + ): + sox_options.append("rate -v 96000") + elif ( + quality > TrackQuality.FLAC_LOSSLESS_HI_RES_1 + and max_sample_rate == 48000 + ): + sox_options.append("rate -v 48000") + if player.settings.get("sox_options"): + sox_options.append(player.settings["sox_options"]) return " ".join(sox_options) def __analyze_audio(self, streamdetails): - ''' analyze track audio, for now we only calculate EBU R128 loudness ''' - item_key = '%s%s' % (streamdetails["item_id"], - streamdetails["provider"]) + """ analyze track audio, for now we only calculate EBU R128 loudness """ + item_key = "%s%s" % (streamdetails["item_id"], streamdetails["provider"]) if item_key in self.analyze_jobs: return # prevent multiple analyze jobs for same track self.analyze_jobs[item_key] = True - track_loudness = self.mass.run_task(self.mass.db.get_track_loudness( - streamdetails["item_id"], streamdetails["provider"]), - wait_for_result=True) + track_loudness = self.mass.run_task( + self.mass.db.get_track_loudness( + streamdetails["item_id"], streamdetails["provider"] + ), + wait_for_result=True, + ) if track_loudness == None: # only when needed we do the analyze stuff - LOGGER.debug('Start analyzing track %s' % item_key) - if streamdetails['type'] == 'url': + LOGGER.debug("Start analyzing track %s" % item_key) + if streamdetails["type"] == "url": import urllib - audio_data = urllib.request.urlopen( - streamdetails["path"]).read() - elif streamdetails['type'] == 'executable': - audio_data = subprocess.check_output(streamdetails["path"], - shell=True) - elif streamdetails['type'] == 'file': - with open(streamdetails['path'], 'rb') as f: + + audio_data = urllib.request.urlopen(streamdetails["path"]).read() + elif streamdetails["type"] == "executable": + audio_data = subprocess.check_output(streamdetails["path"], shell=True) + elif streamdetails["type"] == "file": + with open(streamdetails["path"], "rb") as f: audio_data = f.read() # calculate BS.1770 R128 integrated loudness with io.BytesIO(audio_data) as tmpfile: @@ -461,46 +507,56 @@ class HTTPStreamer(): loudness = meter.integrated_loudness(data) # measure loudness del data self.mass.run_task( - self.mass.db.set_track_loudness(streamdetails["item_id"], - streamdetails["provider"], - loudness)) + self.mass.db.set_track_loudness( + streamdetails["item_id"], streamdetails["provider"], loudness + ) + ) del audio_data - LOGGER.debug("Integrated loudness of track %s is: %s" % - (item_key, loudness)) + LOGGER.debug( + "Integrated loudness of track %s is: %s" % (item_key, loudness) + ) self.analyze_jobs.pop(item_key, None) @staticmethod - def __crossfade_pcm_parts(fade_in_part, fade_out_part, pcm_args, - fade_length): - ''' crossfade two chunks of audio using sox ''' + def __crossfade_pcm_parts(fade_in_part, fade_out_part, pcm_args, fade_length): + """ crossfade two chunks of audio using sox """ # create fade-in part - fadeinfile = MemoryTempfile(fallback=True).NamedTemporaryFile( - buffering=0) - args = 'sox --ignore-length -t %s - -t %s %s fade t %s' % ( - pcm_args, pcm_args, fadeinfile.name, fade_length) + fadeinfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0) + args = "sox --ignore-length -t %s - -t %s %s fade t %s" % ( + pcm_args, + pcm_args, + fadeinfile.name, + fade_length, + ) args = shlex.split(args) process = subprocess.Popen(args, shell=False, stdin=subprocess.PIPE) process.communicate(fade_in_part) # create fade-out part - fadeoutfile = MemoryTempfile(fallback=True).NamedTemporaryFile( - buffering=0) - args = 'sox --ignore-length -t %s - -t %s %s reverse fade t %s reverse' % ( - pcm_args, pcm_args, fadeoutfile.name, fade_length) + fadeoutfile = MemoryTempfile(fallback=True).NamedTemporaryFile(buffering=0) + args = "sox --ignore-length -t %s - -t %s %s reverse fade t %s reverse" % ( + pcm_args, + pcm_args, + fadeoutfile.name, + fade_length, + ) args = shlex.split(args) - process = subprocess.Popen(args, - shell=False, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE) + process = subprocess.Popen( + args, shell=False, stdout=subprocess.PIPE, stdin=subprocess.PIPE + ) process.communicate(fade_out_part) # create crossfade using sox and some temp files # TODO: figure out how to make this less complex and without the tempfiles - args = 'sox -m -v 1.0 -t %s %s -v 1.0 -t %s %s -t %s -' % ( - pcm_args, fadeoutfile.name, pcm_args, fadeinfile.name, pcm_args) + args = "sox -m -v 1.0 -t %s %s -v 1.0 -t %s %s -t %s -" % ( + pcm_args, + fadeoutfile.name, + pcm_args, + fadeinfile.name, + pcm_args, + ) args = shlex.split(args) - process = subprocess.Popen(args, - shell=False, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE) + process = subprocess.Popen( + args, shell=False, stdout=subprocess.PIPE, stdin=subprocess.PIPE + ) crossfade_part, stderr = process.communicate() fadeinfile.close() fadeoutfile.close() diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 628faf69..886c6cd2 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -2,36 +2,36 @@ # -*- coding:utf-8 -*- import asyncio -import re -import os -import shutil -import slugify as unicode_slug -import uuid import json -import time import logging +import os +import re +import shutil import threading +import time +import uuid -from .database import Database +import slugify as unicode_slug + +from .cache import Cache from .config import MassConfig -from .utils import run_periodic, LOGGER, try_parse_bool, serialize_values +from .database import Database +from .homeassistant import HomeAssistant +from .http_streamer import HTTPStreamer from .metadata import MetaData -from .cache import Cache from .music_manager import MusicManager from .player_manager import PlayerManager -from .http_streamer import HTTPStreamer -from .homeassistant import HomeAssistant +from .utils import LOGGER, run_periodic, serialize_values, try_parse_bool from .web import Web -class MusicAssistant(): - +class MusicAssistant: def __init__(self, datapath, event_loop): - ''' + """ Create an instance of MusicAssistant :param datapath: file location to store the data :param event_loop: asyncio event_loop - ''' + """ self.event_loop = event_loop self.event_loop.set_exception_handler(self.handle_exception) self.datapath = datapath @@ -48,7 +48,7 @@ class MusicAssistant(): self.http_streamer = HTTPStreamer(self) async def start(self): - ''' start running the music assistant server ''' + """ start running the music assistant server """ await self.db.setup() await self.cache.setup() await self.metadata.setup() @@ -69,35 +69,35 @@ class MusicAssistant(): await self.cache.close() def handle_exception(self, loop, context): - ''' global exception handler ''' + """ global exception handler """ LOGGER.debug(f"Caught exception: {context}") loop.default_exception_handler(context) async def signal_event(self, msg, msg_details=None): - ''' signal (systemwide) event ''' + """ signal (systemwide) event """ if not (msg_details == None or isinstance(msg_details, (str, dict))): msg_details = serialize_values(msg_details) listeners = list(self.event_listeners.values()) for callback, eventfilter in listeners: if not eventfilter or eventfilter in msg: - if msg == 'shutdown': + if msg == "shutdown": # the shutdown event should be awaited await callback(msg, msg_details) else: self.event_loop.create_task(callback(msg, msg_details)) async def add_event_listener(self, cb, eventfilter=None): - ''' add callback to our event listeners ''' + """ add callback to our event listeners """ cb_id = str(uuid.uuid4()) self.event_listeners[cb_id] = (cb, eventfilter) return cb_id async def remove_event_listener(self, cb_id): - ''' remove callback from our event listeners ''' + """ remove callback from our event listeners """ self.event_listeners.pop(cb_id, None) def run_task(self, corofcn, wait_for_result=False, ignore_exception=None): - ''' helper to run a task on the main event loop from another thread ''' + """ helper to run a task on the main event loop from another thread """ if threading.current_thread() is threading.main_thread(): raise Exception("Can not be called from main event loop!") future = asyncio.run_coroutine_threadsafe(corofcn, self.event_loop) diff --git a/music_assistant/metadata.py b/music_assistant/metadata.py index 1f127e3e..9d28795a 100755 --- a/music_assistant/metadata.py +++ b/music_assistant/metadata.py @@ -1,19 +1,20 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -import aiohttp -from asyncio_throttle import Throttler -from yarl import URL import re -from music_assistant.utils import LOGGER, compare_strings, get_compare_string +import aiohttp +from asyncio_throttle import Throttler from music_assistant.cache import use_cache +from music_assistant.utils import LOGGER, compare_strings, get_compare_string +from yarl import URL LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' -class MetaData(): - ''' several helpers to search and store mediadata for mediaitems ''' +class MetaData: + """ several helpers to search and store mediadata for mediaitems """ + # TODO: create periodic task to search for missing metadata def __init__(self, mass): self.mass = mass @@ -21,163 +22,180 @@ class MetaData(): self.fanarttv = FanartTv(mass) async def setup(self): - ''' async initialize of metadata module ''' + """ async initialize of metadata module """ await self.musicbrainz.setup() await self.fanarttv.setup() async def get_artist_metadata(self, mb_artist_id, cur_metadata): - ''' get/update rich metadata for an artist by providing the musicbrainz artist id ''' + """ get/update rich metadata for an artist by providing the musicbrainz artist id """ metadata = cur_metadata - if not 'fanart' in metadata: + if not "fanart" in metadata: res = await self.fanarttv.artist_images(mb_artist_id) if res: self.merge_metadata(cur_metadata, res) return metadata - async def get_mb_artist_id(self, - artistname, - albumname=None, - album_upc=None, - trackname=None, - track_isrc=None): - ''' retrieve musicbrainz artist id for the given details ''' + async def get_mb_artist_id( + self, + artistname, + albumname=None, + album_upc=None, + trackname=None, + track_isrc=None, + ): + """ retrieve musicbrainz artist id for the given details """ LOGGER.debug( - 'searching musicbrainz for %s (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)', - artistname, albumname, album_upc, trackname, track_isrc) + "searching musicbrainz for %s (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)", + artistname, + albumname, + album_upc, + trackname, + track_isrc, + ) mb_artist_id = None if album_upc: mb_artist_id = await self.musicbrainz.search_artist_by_album( - artistname, None, album_upc) + artistname, None, album_upc + ) if mb_artist_id: LOGGER.debug( - 'Got MusicbrainzArtistId for %s after search on upc %s --> %s', - artistname, album_upc, mb_artist_id) + "Got MusicbrainzArtistId for %s after search on upc %s --> %s", + artistname, + album_upc, + mb_artist_id, + ) if not mb_artist_id and track_isrc: mb_artist_id = await self.musicbrainz.search_artist_by_track( - artistname, None, track_isrc) + artistname, None, track_isrc + ) if mb_artist_id: LOGGER.debug( - 'Got MusicbrainzArtistId for %s after search on isrc %s --> %s', - artistname, track_isrc, mb_artist_id) + "Got MusicbrainzArtistId for %s after search on isrc %s --> %s", + artistname, + track_isrc, + mb_artist_id, + ) if not mb_artist_id and albumname: mb_artist_id = await self.musicbrainz.search_artist_by_album( - artistname, albumname) + artistname, albumname + ) if mb_artist_id: LOGGER.debug( - 'Got MusicbrainzArtistId for %s after search on albumname %s --> %s', - artistname, albumname, mb_artist_id) + "Got MusicbrainzArtistId for %s after search on albumname %s --> %s", + artistname, + albumname, + mb_artist_id, + ) if not mb_artist_id and trackname: mb_artist_id = await self.musicbrainz.search_artist_by_track( - artistname, trackname) + artistname, trackname + ) if mb_artist_id: LOGGER.debug( - 'Got MusicbrainzArtistId for %s after search on trackname %s --> %s', - artistname, trackname, mb_artist_id) + "Got MusicbrainzArtistId for %s after search on trackname %s --> %s", + artistname, + trackname, + mb_artist_id, + ) return mb_artist_id @staticmethod def merge_metadata(cur_metadata, new_values): - ''' merge new info into the metadata dict without overwiteing existing values ''' + """ merge new info into the metadata dict without overwiteing existing values """ for key, value in new_values.items(): if not cur_metadata.get(key): cur_metadata[key] = value return cur_metadata -class MusicBrainz(): +class MusicBrainz: def __init__(self, mass): self.mass = mass self.cache = mass.cache async def setup(self): - ''' perform async setup ''' + """ perform async setup """ self.http_session = aiohttp.ClientSession( - loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) + loop=self.mass.event_loop, connector=aiohttp.TCPConnector() + ) self.throttler = Throttler(rate_limit=1, period=1) - async def search_artist_by_album(self, - artistname, - albumname=None, - album_upc=None): - ''' retrieve musicbrainz artist id by providing the artist name and albumname or upc ''' + async def search_artist_by_album(self, artistname, albumname=None, album_upc=None): + """ retrieve musicbrainz artist id by providing the artist name and albumname or upc """ for searchartist in [ - re.sub(LUCENE_SPECIAL, r'\\\1', artistname), - get_compare_string(artistname) + re.sub(LUCENE_SPECIAL, r"\\\1", artistname), + get_compare_string(artistname), ]: if album_upc: - endpoint = 'release' - params = {'query': 'barcode:%s' % album_upc} + endpoint = "release" + params = {"query": "barcode:%s" % album_upc} else: - searchalbum = re.sub(LUCENE_SPECIAL, r'\\\1', albumname) - endpoint = 'release' + searchalbum = re.sub(LUCENE_SPECIAL, r"\\\1", albumname) + endpoint = "release" params = { - 'query': - 'artist:"%s" AND release:"%s"' % - (searchartist, searchalbum) + "query": 'artist:"%s" AND release:"%s"' + % (searchartist, searchalbum) } result = await self.get_data(endpoint, params) - if result and 'releases' in result: + if result and "releases" in result: for strictness in [True, False]: - for item in result['releases']: + for item in result["releases"]: if album_upc or compare_strings( - item['title'], albumname, strictness): - for artist in item['artist-credit']: - if compare_strings(artist['artist']['name'], - artistname, strictness): - return artist['artist']['id'] - for item in artist.get('aliases', []): - if compare_strings(item['name'], - artistname, strictness): - return artist['id'] - return '' - - async def search_artist_by_track(self, - artistname, - trackname=None, - track_isrc=None): - ''' retrieve artist id by providing the artist name and trackname or track isrc ''' - endpoint = 'recording' - searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname) - #searchartist = searchartist.replace('/','').replace('\\','').replace('-', '') + item["title"], albumname, strictness + ): + for artist in item["artist-credit"]: + if compare_strings( + artist["artist"]["name"], artistname, strictness + ): + return artist["artist"]["id"] + for item in artist.get("aliases", []): + if compare_strings( + item["name"], artistname, strictness + ): + return artist["id"] + return "" + + async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None): + """ retrieve artist id by providing the artist name and trackname or track isrc """ + endpoint = "recording" + searchartist = re.sub(LUCENE_SPECIAL, r"\\\1", artistname) + # searchartist = searchartist.replace('/','').replace('\\','').replace('-', '') if track_isrc: - endpoint = 'isrc/%s' % track_isrc - params = {'inc': 'artist-credits'} + endpoint = "isrc/%s" % track_isrc + params = {"inc": "artist-credits"} else: - searchtrack = re.sub(LUCENE_SPECIAL, r'\\\1', trackname) - endpoint = 'recording' - params = { - 'query': '"%s" AND artist:"%s"' % (searchtrack, searchartist) - } + searchtrack = re.sub(LUCENE_SPECIAL, r"\\\1", trackname) + endpoint = "recording" + params = {"query": '"%s" AND artist:"%s"' % (searchtrack, searchartist)} result = await self.get_data(endpoint, params) - if result and 'recordings' in result: + if result and "recordings" in result: for strictness in [True, False]: - for item in result['recordings']: - if track_isrc or compare_strings(item['title'], trackname, - strictness): - for artist in item['artist-credit']: - if compare_strings(artist['artist']['name'], - artistname, strictness): - return artist['artist']['id'] - for item in artist.get('aliases', []): - if compare_strings(item['name'], artistname, - strictness): - return artist['id'] - return '' + for item in result["recordings"]: + if track_isrc or compare_strings( + item["title"], trackname, strictness + ): + for artist in item["artist-credit"]: + if compare_strings( + artist["artist"]["name"], artistname, strictness + ): + return artist["artist"]["id"] + for item in artist.get("aliases", []): + if compare_strings( + item["name"], artistname, strictness + ): + return artist["id"] + return "" @use_cache(2) async def get_data(self, endpoint, params={}): - ''' get data from api''' - url = 'http://musicbrainz.org/ws/2/%s' % endpoint - headers = { - 'User-Agent': - 'Music Assistant/1.0.0 https://github.com/marcelveldt' - } - params['fmt'] = 'json' + """ get data from api""" + url = "http://musicbrainz.org/ws/2/%s" % endpoint + headers = {"User-Agent": "Music Assistant/1.0.0 https://github.com/marcelveldt"} + params["fmt"] = "json" async with self.throttler: - async with self.http_session.get(url, - headers=headers, - params=params, - verify_ssl=False) as response: + async with self.http_session.get( + url, headers=headers, params=params, verify_ssl=False + ) as response: try: result = await response.json() except Exception as exc: @@ -187,48 +205,49 @@ class MusicBrainz(): return result -class FanartTv(): +class FanartTv: def __init__(self, mass): self.mass = mass self.cache = mass.cache async def setup(self): - ''' perform async setup ''' + """ perform async setup """ self.http_session = aiohttp.ClientSession( - loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) + loop=self.mass.event_loop, connector=aiohttp.TCPConnector() + ) self.throttler = Throttler(rate_limit=1, period=2) async def artist_images(self, mb_artist_id): - ''' retrieve images by musicbrainz artist id ''' + """ retrieve images by musicbrainz artist id """ metadata = {} data = await self.get_data("music/%s" % mb_artist_id) if data: - if data.get('hdmusiclogo'): - metadata['logo'] = data['hdmusiclogo'][0]["url"] - elif data.get('musiclogo'): - metadata['logo'] = data['musiclogo'][0]["url"] - if data.get('artistbackground'): + if data.get("hdmusiclogo"): + metadata["logo"] = data["hdmusiclogo"][0]["url"] + elif data.get("musiclogo"): + metadata["logo"] = data["musiclogo"][0]["url"] + if data.get("artistbackground"): count = 0 - for item in data['artistbackground']: + for item in data["artistbackground"]: key = "fanart" if count == 0 else "fanart.%s" % count metadata[key] = item["url"] - if data.get('artistthumb'): - url = data['artistthumb'][0]["url"] - if not '2a96cbd8b46e442fc41c2b86b821562f' in url: - metadata['image'] = url - if data.get('musicbanner'): - metadata['banner'] = data['musicbanner'][0]["url"] + if data.get("artistthumb"): + url = data["artistthumb"][0]["url"] + if not "2a96cbd8b46e442fc41c2b86b821562f" in url: + metadata["image"] = url + if data.get("musicbanner"): + metadata["banner"] = data["musicbanner"][0]["url"] return metadata @use_cache(30) async def get_data(self, endpoint, params={}): - ''' get data from api''' - url = 'http://webservice.fanart.tv/v3/%s' % endpoint - params['api_key'] = '639191cb0774661597f28a47e7e2bad5' + """ get data from api""" + url = "http://webservice.fanart.tv/v3/%s" % endpoint + params["api_key"] = "639191cb0774661597f28a47e7e2bad5" async with self.throttler: - async with self.http_session.get(url, - params=params, - verify_ssl=False) as response: + async with self.http_session.get( + url, params=params, verify_ssl=False + ) as response: try: result = await response.json() except aiohttp.client_exceptions.ContentTypeError: @@ -239,7 +258,7 @@ class FanartTv(): except aiohttp.client_exceptions.ClientConnectorError: LOGGER.error("Failed to retrieve %s", endpoint) return None - if 'error' in result and 'limit' in result['error']: - LOGGER.error(result['error']) + if "error" in result and "limit" in result["error"]: + LOGGER.error(result["error"]) return None return result diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py index 41d41767..803ff11d 100755 --- a/music_assistant/models/media_types.py +++ b/music_assistant/models/media_types.py @@ -3,6 +3,7 @@ from enum import Enum, IntEnum + class MediaType(IntEnum): Artist = 1 Album = 2 @@ -10,47 +11,53 @@ class MediaType(IntEnum): Playlist = 4 Radio = 5 + def media_type_from_string(media_type_str): media_type_str = media_type_str.lower() - if 'artist' in media_type_str or media_type_str == '1': + if "artist" in media_type_str or media_type_str == "1": return MediaType.Artist - elif 'album' in media_type_str or media_type_str == '2': + elif "album" in media_type_str or media_type_str == "2": return MediaType.Album - elif 'track' in media_type_str or media_type_str == '3': + elif "track" in media_type_str or media_type_str == "3": return MediaType.Track - elif 'playlist' in media_type_str or media_type_str == '4': + elif "playlist" in media_type_str or media_type_str == "4": return MediaType.Playlist - elif 'radio' in media_type_str or media_type_str == '5': + elif "radio" in media_type_str or media_type_str == "5": return MediaType.Radio else: return None + class ContributorRole(IntEnum): Artist = 1 Writer = 2 Producer = 3 + class AlbumType(IntEnum): Album = 1 Single = 2 Compilation = 3 + class TrackQuality(IntEnum): LOSSY_MP3 = 0 LOSSY_OGG = 1 LOSSY_AAC = 2 - FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES - FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES - FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES + FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES + FLAC_LOSSLESS_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES + class MediaItem(object): - ''' representation of a media item ''' + """ representation of a media item """ + def __init__(self): self.item_id = None - self.provider = 'database' - self.name = '' + self.provider = "database" + self.name = "" self.metadata = {} self.tags = [] self.external_ids = [] @@ -58,59 +65,71 @@ class MediaItem(object): self.in_library = [] self.is_lazy = False self.available = True - def __eq__(self, other): + + def __eq__(self, other): if not isinstance(other, self.__class__): return NotImplemented - return (self.name == other.name and - self.item_id == other.item_id and - self.provider == other.provider) + return ( + self.name == other.name + and self.item_id == other.item_id + and self.provider == other.provider + ) + def __ne__(self, other): return not self.__eq__(other) + class Artist(MediaItem): - ''' representation of an artist ''' + """ representation of an artist """ + def __init__(self): super().__init__() - self.sort_name = '' + self.sort_name = "" self.media_type = MediaType.Artist + class Album(MediaItem): - ''' representation of an album ''' + """ representation of an album """ + def __init__(self): super().__init__() - self.version = '' + self.version = "" self.albumtype = AlbumType.Album self.year = 0 self.artist = None self.labels = [] self.media_type = MediaType.Album + class Track(MediaItem): - ''' representation of a track ''' + """ representation of a track """ + def __init__(self): super().__init__() self.duration = 0 - self.version = '' + self.version = "" self.artists = [] self.album = None self.disc_number = 1 self.track_number = 1 self.media_type = MediaType.Track + class Playlist(MediaItem): - ''' representation of a playlist ''' + """ representation of a playlist """ + def __init__(self): super().__init__() - self.owner = '' + self.owner = "" self.media_type = MediaType.Playlist self.is_editable = False - self.checksum = '' # some value to detect playlist track changes + self.checksum = "" # some value to detect playlist track changes + class Radio(MediaItem): - ''' representation of a radio station ''' + """ representation of a radio station """ + def __init__(self): super().__init__() self.media_type = MediaType.Radio self.duration = 86400 - - diff --git a/music_assistant/models/musicprovider.py b/music_assistant/models/musicprovider.py index 40213874..7fd88f88 100755 --- a/music_assistant/models/musicprovider.py +++ b/music_assistant/models/musicprovider.py @@ -3,22 +3,31 @@ import asyncio from typing import List + +from music_assistant.cache import cached, cached_iterator +from music_assistant.models.media_types import ( + Album, + Artist, + MediaType, + Playlist, + Radio, + Track, +) from music_assistant.utils import LOGGER, compare_strings -from music_assistant.cache import cached_iterator, cached -from music_assistant.models.media_types import Album, Artist, Track, Playlist, MediaType, Radio -class MusicProvider(): +class MusicProvider: """ Model for a Musicprovider Common methods usable for every provider Provider specific get methods shoud be overriden in the provider specific implementation Uses a form of lazy provisioning to local db as cache """ + def __init__(self, mass): """[DO NOT OVERRIDE]""" - self.prov_id = '' - self.name = '' + self.prov_id = "" + self.name = "" self.mass = mass self.cache = mass.cache @@ -28,35 +37,33 @@ class MusicProvider(): ### Common methods and properties #### - async def artist(self, - prov_item_id, - lazy=True, - ref_album=None, - ref_track=None, - provider=None) -> Artist: + async def artist( + self, prov_item_id, lazy=True, ref_album=None, ref_track=None, provider=None + ) -> Artist: """ return artist details for the given provider artist id """ if not provider: provider = self.prov_id - item_id = await self.mass.db.get_database_id(provider, prov_item_id, - MediaType.Artist) + item_id = await self.mass.db.get_database_id( + provider, prov_item_id, MediaType.Artist + ) if item_id is not None: # artist not yet in local database so fetch details - cache_key = f'{self.prov_id}.get_artist.{prov_item_id}' - artist_details = await cached(self.cache, cache_key, - self.get_artist, prov_item_id) + cache_key = f"{self.prov_id}.get_artist.{prov_item_id}" + artist_details = await cached( + self.cache, cache_key, self.get_artist, prov_item_id + ) if not artist_details: - raise Exception('artist not found: %s' % prov_item_id) + raise Exception("artist not found: %s" % prov_item_id) if lazy: asyncio.create_task(self.add_artist(artist_details)) artist_details.is_lazy = True return artist_details - item_id = await self.add_artist(artist_details, - ref_album=ref_album, - ref_track=ref_track) + item_id = await self.add_artist( + artist_details, ref_album=ref_album, ref_track=ref_track + ) return await self.mass.db.artist(item_id) - async def add_artist(self, artist_details, ref_album=None, - ref_track=None) -> int: + async def add_artist(self, artist_details, ref_album=None, ref_track=None) -> int: """ add artist to local db and return the new database id""" musicbrainz_id = None for item in artist_details.external_ids: @@ -64,14 +71,16 @@ class MusicProvider(): musicbrainz_id = item["musicbrainz"] if not musicbrainz_id: musicbrainz_id = await self.get_artist_musicbrainz_id( - artist_details, ref_album=ref_album, ref_track=ref_track) + artist_details, ref_album=ref_album, ref_track=ref_track + ) if not musicbrainz_id: return # grab additional metadata if musicbrainz_id: artist_details.external_ids.append({"musicbrainz": musicbrainz_id}) artist_details.metadata = await self.mass.metadata.get_artist_metadata( - musicbrainz_id, artist_details.metadata) + musicbrainz_id, artist_details.metadata + ) item_id = await self.mass.db.add_artist(artist_details) # also fetch same artist on all providers new_artist = await self.mass.db.artist(item_id) @@ -79,30 +88,26 @@ class MusicProvider(): new_artist_toptracks = [ref_track] else: new_artist_toptracks = [ - item async for item in self.get_artist_toptracks( - artist_details.item_id) + item async for item in self.get_artist_toptracks(artist_details.item_id) ] if ref_album: new_artist_albums = [ref_album] else: new_artist_albums = [ - item async for item in self.get_artist_albums( - artist_details.item_id) + item async for item in self.get_artist_albums(artist_details.item_id) ] if new_artist_toptracks or new_artist_albums: - item_provider_keys = [ - item['provider'] for item in new_artist.provider_ids - ] + 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, new_artist_albums, - new_artist_toptracks) + await provider.match_artist( + new_artist, new_artist_albums, new_artist_toptracks + ) return item_id - async def get_artist_musicbrainz_id(self, - artist_details: Artist, - ref_album=None, - ref_track=None): + async def get_artist_musicbrainz_id( + self, artist_details: Artist, ref_album=None, ref_track=None + ): """ fetch musicbrainz id by performing search with both the artist and one of it's albums or tracks """ musicbrainz_id = "" # try with album first @@ -110,8 +115,7 @@ class MusicProvider(): lookup_albums = [ref_album] else: lookup_albums = [ - item async for item in self.get_artist_albums( - artist_details.item_id) + item async for item in self.get_artist_albums(artist_details.item_id) ] for lookup_album in lookup_albums[:10]: lookup_album_upc = None @@ -124,7 +128,8 @@ class MusicProvider(): musicbrainz_id = await self.mass.metadata.get_mb_artist_id( artist_details.name, albumname=lookup_album.name, - album_upc=lookup_album_upc) + album_upc=lookup_album_upc, + ) if musicbrainz_id: break # fallback to track @@ -133,8 +138,8 @@ class MusicProvider(): lookup_tracks = [ref_track] else: lookup_tracks = [ - item async for item in self.get_artist_toptracks( - artist_details.item_id) + item + async for item in self.get_artist_toptracks(artist_details.item_id) ] for lookup_track in lookup_tracks[:25]: if not lookup_track: @@ -147,33 +152,35 @@ class MusicProvider(): musicbrainz_id = await self.mass.metadata.get_mb_artist_id( artist_details.name, trackname=lookup_track.name, - track_isrc=lookup_track_isrc) + track_isrc=lookup_track_isrc, + ) if musicbrainz_id: break if not musicbrainz_id: - LOGGER.debug("Unable to get musicbrainz ID for artist %s !", - artist_details.name) + LOGGER.debug( + "Unable to get musicbrainz ID for artist %s !", artist_details.name + ) musicbrainz_id = artist_details.name return musicbrainz_id - async def album(self, - prov_item_id, - lazy=True, - album_details=None, - provider=None) -> Album: + async def album( + self, prov_item_id, lazy=True, album_details=None, provider=None + ) -> Album: """ return album details for the given provider album id""" if not provider: provider = self.prov_id - item_id = await self.mass.db.get_database_id(provider, prov_item_id, - MediaType.Album) + item_id = await self.mass.db.get_database_id( + provider, prov_item_id, MediaType.Album + ) if not item_id: # album not yet in local database so fetch details if not album_details: - cache_key = f'{self.prov_id}.get_album.{prov_item_id}' - album_details = await cached(self.cache, cache_key, - self.get_album, prov_item_id) + cache_key = f"{self.prov_id}.get_album.{prov_item_id}" + album_details = await cached( + self.cache, cache_key, self.get_album, prov_item_id + ) if not album_details: - raise Exception('album not found: %s' % prov_item_id) + raise Exception("album not found: %s" % prov_item_id) if lazy: asyncio.create_task(self.add_album(album_details)) album_details.is_lazy = True @@ -184,40 +191,40 @@ class MusicProvider(): 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, - ref_album=album_details, - provider=album_details.artist.provider) + db_album_artist = await self.artist( + album_details.artist.item_id, + lazy=False, + ref_album=album_details, + provider=album_details.artist.provider, + ) album_details.artist = db_album_artist item_id = await self.mass.db.add_album(album_details) # also fetch same album on all providers new_album = await self.mass.db.album(item_id) - item_provider_keys = [ - item['provider'] for item in new_album.provider_ids - ] + 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, - provider=None) -> Track: + async def track( + self, prov_item_id, lazy=True, track_details=None, provider=None + ) -> Track: """ return track details for the given provider track id """ if not provider: provider = self.prov_id - item_id = await self.mass.db.get_database_id(provider, prov_item_id, - MediaType.Track) + item_id = await self.mass.db.get_database_id( + provider, prov_item_id, MediaType.Track + ) if not item_id: # track not yet in local database so fetch details if not track_details: - cache_key = f'{self.prov_id}.get_track.{prov_item_id}' - track_details = await cached(self.cache, cache_key, - self.get_track, prov_item_id) + cache_key = f"{self.prov_id}.get_track.{prov_item_id}" + track_details = await cached( + self.cache, cache_key, self.get_track, prov_item_id + ) if not track_details: - LOGGER.error('track not found: %s', prov_item_id) + LOGGER.error("track not found: %s", prov_item_id) return None if lazy: asyncio.create_task(self.add_track(track_details)) @@ -231,10 +238,12 @@ class MusicProvider(): track_artists = [] # we need to fetch track artists too for track_artist in track_details.artists: - db_track_artist = await self.artist(track_artist.item_id, - lazy=False, - ref_track=track_details, - provider=track_artist.provider) + db_track_artist = await self.artist( + track_artist.item_id, + lazy=False, + ref_track=track_details, + provider=track_artist.provider, + ) if db_track_artist: track_artists.append(db_track_artist) track_details.artists = track_artists @@ -244,17 +253,16 @@ class MusicProvider(): if album_details: track_details.album = album_details # make sure we have a database album - if track_details.album and track_details.album.provider != 'database': + if track_details.album and track_details.album.provider != "database": track_details.album = await self.album( track_details.album.item_id, lazy=False, - provider=track_details.album.provider) + provider=track_details.album.provider, + ) item_id = await self.mass.db.add_track(track_details) # also fetch same track on all providers (will also get other quality versions) new_track = await self.mass.db.track(item_id) - item_provider_keys = [ - item['provider'] for item in new_track.provider_ids - ] + 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) @@ -264,8 +272,9 @@ class MusicProvider(): """ return playlist details for the given provider playlist id """ if not provider: provider = self.prov_id - db_id = await self.mass.db.get_database_id(provider, prov_playlist_id, - MediaType.Playlist) + db_id = await self.mass.db.get_database_id( + provider, prov_playlist_id, MediaType.Playlist + ) if not db_id: # item not yet in local database so fetch and store details item_details = await self.get_playlist(prov_playlist_id) @@ -276,8 +285,9 @@ class MusicProvider(): """ return radio details for the given provider playlist id """ if not provider: provider = self.prov_id - db_id = await self.mass.db.get_database_id(provider, prov_radio_id, - MediaType.Radio) + db_id = await self.mass.db.get_database_id( + provider, prov_radio_id, MediaType.Radio + ) if not db_id: # item not yet in local database so fetch and store details item_details = await self.get_radio(prov_radio_id) @@ -286,15 +296,15 @@ class MusicProvider(): async def album_tracks(self, prov_album_id) -> List[Track]: """ return album tracks for the given provider album id""" - cache_key = f'{self.prov_id}.album_tracks.{prov_album_id}' - async for item in cached_iterator(self.cache, - self.get_album_tracks(prov_album_id), - cache_key): + cache_key = f"{self.prov_id}.album_tracks.{prov_album_id}" + async for item in cached_iterator( + self.cache, self.get_album_tracks(prov_album_id), cache_key + ): if not item: continue - db_id = await self.mass.db.get_database_id(item.provider, - item.item_id, - MediaType.Track) + db_id = await self.mass.db.get_database_id( + item.provider, item.item_id, MediaType.Track + ) if db_id: # return database track instead if we have a match db_item = await self.mass.db.track(db_id, fulldata=False) @@ -308,18 +318,19 @@ class MusicProvider(): """ return playlist tracks for the given provider playlist id""" playlist = await self.playlist(prov_playlist_id) cache_checksum = playlist.checksum - cache_key = f'{self.prov_id}.playlist_tracks.{prov_playlist_id}' + cache_key = f"{self.prov_id}.playlist_tracks.{prov_playlist_id}" pos = 0 async for item in cached_iterator( - self.cache, - self.get_playlist_tracks(prov_playlist_id), - cache_key, - checksum=cache_checksum): + self.cache, + self.get_playlist_tracks(prov_playlist_id), + cache_key, + checksum=cache_checksum, + ): if not item: continue - db_id = await self.mass.db.get_database_id(item.provider, - item.item_id, - MediaType.Track) + db_id = await self.mass.db.get_database_id( + item.provider, item.item_id, MediaType.Track + ) if db_id: # return database track instead if we have a match item = await self.mass.db.track(db_id, fulldata=False) @@ -329,13 +340,14 @@ class MusicProvider(): async def artist_toptracks(self, prov_artist_id) -> List[Track]: """ return top tracks for an artist """ - cache_key = f'{self.prov_id}.artist_toptracks.{prov_artist_id}' + cache_key = f"{self.prov_id}.artist_toptracks.{prov_artist_id}" async for item in cached_iterator( - self.cache, self.get_artist_toptracks(prov_artist_id), - cache_key): + self.cache, self.get_artist_toptracks(prov_artist_id), cache_key + ): if item: db_id = await self.mass.db.get_database_id( - self.prov_id, item.item_id, MediaType.Track) + self.prov_id, item.item_id, MediaType.Track + ) if db_id: # return database track instead if we have a match yield await self.mass.db.track(db_id) @@ -344,51 +356,51 @@ class MusicProvider(): async def artist_albums(self, prov_artist_id) -> List[Track]: """ return (all) albums for an artist """ - cache_key = f'{self.prov_id}.artist_albums.{prov_artist_id}' + cache_key = f"{self.prov_id}.artist_albums.{prov_artist_id}" async for item in cached_iterator( - self.cache, self.get_artist_albums(prov_artist_id), cache_key): - db_id = await self.mass.db.get_database_id(self.prov_id, - item.item_id, - MediaType.Album) + self.cache, self.get_artist_albums(prov_artist_id), cache_key + ): + db_id = await self.mass.db.get_database_id( + self.prov_id, item.item_id, MediaType.Album + ) if db_id: # return database album instead if we have a match yield await self.mass.db.album(db_id) else: yield item - async def match_artist(self, searchartist: Artist, - searchalbums: List[Album], - searchtracks: List[Track]): + async def match_artist( + self, searchartist: Artist, searchalbums: List[Album], searchtracks: List[Track] + ): """ try to match artist in this provider by supplying db artist """ for searchalbum in searchalbums: searchstr = "%s - %s" % (searchartist.name, searchalbum.name) - search_results = await self.search(searchstr, [MediaType.Album], - limit=5) + search_results = await self.search(searchstr, [MediaType.Album], limit=5) for strictness in [True, False]: for item in search_results["albums"]: - if (item and compare_strings( - item.name, searchalbum.name, strict=strictness)): + if item and compare_strings( + item.name, searchalbum.name, strict=strictness + ): # double safety check - artist must match exactly ! - if compare_strings(item.artist.name, - searchartist.name, - strict=strictness): + if compare_strings( + item.artist.name, searchartist.name, strict=strictness + ): # just load this item in the database where it will be strictly matched - await self.artist(item.artist.item_id, - lazy=strictness) + await self.artist(item.artist.item_id, lazy=strictness) return for searchtrack in searchtracks: searchstr = "%s - %s" % (searchartist.name, searchtrack.name) - search_results = await self.search(searchstr, [MediaType.Track], - limit=5) + search_results = await self.search(searchstr, [MediaType.Track], limit=5) for strictness in [True, False]: for item in search_results["tracks"]: - if (item and compare_strings( - item.name, searchtrack.name, strict=strictness)): + if item and compare_strings( + item.name, searchtrack.name, strict=strictness + ): # double safety check - artist must match exactly ! for artist in item.artists: - if compare_strings(artist.name, - searchartist.name, - strict=strictness): + if compare_strings( + artist.name, searchartist.name, strict=strictness + ): # just load this item in the database where it will be strictly matched # we set skip matching to false to prevent endless recursive matching await self.artist(artist.item_id, lazy=False) @@ -398,18 +410,23 @@ class MusicProvider(): """ try to match album in this provider by supplying db album """ searchstr = "%s - %s" % (searchalbum.artist.name, searchalbum.name) if searchalbum.version: - searchstr += ' ' + searchalbum.version - search_results = await self.search(searchstr, [MediaType.Album], - limit=5) + searchstr += " " + searchalbum.version + search_results = await self.search(searchstr, [MediaType.Album], limit=5) for item in search_results["albums"]: - if (item and - (item.name in searchalbum.name - or searchalbum.name in item.name) and compare_strings( - item.artist.name, searchalbum.artist.name, strict=False)): + if ( + item + and (item.name in searchalbum.name or searchalbum.name in item.name) + and compare_strings( + item.artist.name, searchalbum.artist.name, strict=False + ) + ): # some providers mess up versions in the title, try to fix that situation - if (searchalbum.version and not item.version - and searchalbum.name in item.name - and searchalbum.version in item.name): + if ( + searchalbum.version + and not item.version + and searchalbum.name in item.name + and searchalbum.version in item.name + ): item.name = searchalbum.name item.version = searchalbum.version # just load this item in the database where it will be strictly matched @@ -420,32 +437,34 @@ class MusicProvider(): """ try to match track in this provider by supplying db track """ searchstr = "%s - %s" % (searchtrack.artists[0].name, searchtrack.name) if searchtrack.version: - searchstr += ' ' + searchtrack.version + searchstr += " " + searchtrack.version searchartists = [item.name for item in searchtrack.artists] - search_results = await self.search(searchstr, [MediaType.Track], - limit=5) + search_results = await self.search(searchstr, [MediaType.Track], limit=5) for item in search_results["tracks"]: if not item or not item.name or not item.album: continue - if ((item.name in searchtrack.name - or searchtrack.name in item.name) and item.album - and item.album.name == searchtrack.album.name): + if ( + (item.name in searchtrack.name or searchtrack.name in item.name) + and item.album + and item.album.name == searchtrack.album.name + ): # some providers mess up versions in the title, try to fix that situation - if (searchtrack.version and not item.version - and searchtrack.name in item.name - and searchtrack.version in item.name): + if ( + searchtrack.version + and not item.version + and searchtrack.name in item.name + and searchtrack.version in item.name + ): item.name = searchtrack.name item.version = searchtrack.version # double safety check - artist must match exactly ! for artist in item.artists: for searchartist in searchartists: - if compare_strings(artist.name, - searchartist, - strict=False): + if compare_strings(artist.name, searchartist, strict=False): # just load this item in the database where it will be strictly matched - await self.track(item.item_id, - lazy=False, - track_details=item) + await self.track( + item.item_id, lazy=False, track_details=item + ) break ### Provider specific implementation ##### diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index b8f45bb9..136c021a 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -6,16 +6,18 @@ """ import time -from music_assistant.utils import try_parse_int, try_parse_bool, try_parse_float + from music_assistant.constants import EVENT_PLAYER_CHANGED from music_assistant.models.player_queue import PlayerQueue from music_assistant.models.playerstate import PlayerState +from music_assistant.utils import try_parse_bool, try_parse_float, try_parse_int # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods # pylint: disable=too-few-public-methods -class Player(): + +class Player: """ Representation of a musicplayer. Should be subclassed/overriden with provider specific implementation. @@ -37,11 +39,11 @@ class Player(): async def cmd_next(self): """ [CAN OVERRIDE] send next track command to player """ - return await self.queue.play_index(self.queue.cur_index+1) + return await self.queue.play_index(self.queue.cur_index + 1) async def cmd_previous(self): """ [CAN OVERRIDE] send previous track command to player """ - return await self.queue.play_index(self.queue.cur_index-1) + return await self.queue.play_index(self.queue.cur_index - 1) async def cmd_power_on(self): """ [MUST OVERRIDE] send power ON command to player """ @@ -123,24 +125,24 @@ class Player(): def __init__(self, mass, player_id, prov_id): # private attributes self.mass = mass - self._player_id = player_id # unique id for this player - self._prov_id = prov_id # unique provider id for the player - self._name = '' + self._player_id = player_id # unique id for this player + self._prov_id = prov_id # unique provider id for the player + self._name = "" self._state = PlayerState.Stopped self._group_childs = [] self._powered = False self._cur_time = 0 self._media_position_updated_at = 0 - self._cur_uri = '' + self._cur_uri = "" self._volume_level = 0 self._muted = False self._queue = PlayerQueue(mass, self) self.__update_player_settings() self.initialized = False # public attributes - self.supports_queue = True # has native support for a queue - self.supports_gapless = False # has native gapless support - self.supports_crossfade = False # has native crossfading support + self.supports_queue = True # has native support for a queue + self.supports_gapless = False # has native gapless support + self.supports_crossfade = False # has native crossfading support @property def player_id(self): @@ -155,7 +157,7 @@ class Player(): @property def enabled(self): """ [PROTECTED] enabled state of this player """ - if self.settings.get('enabled'): + if self.settings.get("enabled"): return True else: return False @@ -163,8 +165,8 @@ class Player(): @property def name(self): """ [PROTECTED] name of this player """ - if self.settings.get('name'): - return self.settings['name'] + if self.settings.get("name"): + return self.settings["name"] else: return self._name @@ -190,7 +192,7 @@ class Player(): return player_ids @property - def group_childs(self)->list: + def group_childs(self) -> list: """ [PROTECTED] return all child player ids for this group player as list @@ -206,7 +208,8 @@ class Player(): self.mass.event_loop.create_task(self.update()) for child_player_id in group_childs: self.mass.event_loop.create_task( - self.mass.players.trigger_update(child_player_id)) + self.mass.players.trigger_update(child_player_id) + ) def add_group_child(self, child_player_id): """ add player as child to this group player """ @@ -214,7 +217,8 @@ class Player(): self._group_childs.append(child_player_id) self.mass.event_loop.create_task(self.update()) self.mass.event_loop.create_task( - self.mass.players.trigger_update(child_player_id)) + self.mass.players.trigger_update(child_player_id) + ) def remove_group_child(self, child_player_id): """ remove player as child from this group player """ @@ -222,7 +226,8 @@ class Player(): self._group_childs.remove(child_player_id) self.mass.event_loop.create_task(self.update()) self.mass.event_loop.create_task( - self.mass.players.trigger_update(child_player_id)) + self.mass.players.trigger_update(child_player_id) + ) @property def state(self): @@ -249,18 +254,20 @@ class Player(): if not self.enabled: return False # homeassistant integration - if (self.mass.hass.enabled and self.settings.get('hass_power_entity') and - self.settings.get('hass_power_entity_source')): - hass_state = self.mass.hass.get_state( - self.settings['hass_power_entity'], - attribute='source') - return hass_state == self.settings['hass_power_entity_source'] - elif self.mass.hass.enabled and self.settings.get('hass_power_entity'): + if ( + self.mass.hass.enabled + and self.settings.get("hass_power_entity") + and self.settings.get("hass_power_entity_source") + ): hass_state = self.mass.hass.get_state( - self.settings['hass_power_entity']) - return hass_state != 'off' + self.settings["hass_power_entity"], attribute="source" + ) + return hass_state == self.settings["hass_power_entity_source"] + elif self.mass.hass.enabled and self.settings.get("hass_power_entity"): + hass_state = self.mass.hass.get_state(self.settings["hass_power_entity"]) + return hass_state != "off" # mute as power - elif self.settings.get('mute_as_power'): + elif self.settings.get("mute_as_power"): return not self.muted else: return self._powered @@ -330,11 +337,11 @@ class Player(): group_volume = group_volume / active_players return group_volume # handle hass integration - elif self.mass.hass.enabled and self.settings.get('hass_volume_entity'): + elif self.mass.hass.enabled and self.settings.get("hass_volume_entity"): hass_state = self.mass.hass.get_state( - self.settings['hass_volume_entity'], - attribute='volume_level') - return int(try_parse_float(hass_state)*100) + self.settings["hass_volume_entity"], attribute="volume_level" + ) + return int(try_parse_float(hass_state) * 100) else: return self._volume_level @@ -348,7 +355,8 @@ class Player(): # trigger update on group player for group_parent_id in self.group_parents: self.mass.event_loop.create_task( - self.mass.players.trigger_update(group_parent_id)) + self.mass.players.trigger_update(group_parent_id) + ) @property def muted(self): @@ -440,26 +448,31 @@ class Player(): """ [PROTECTED] send power ON command to player """ await self.cmd_power_on() # handle mute as power - if self.settings.get('mute_as_power'): + if self.settings.get("mute_as_power"): await self.volume_mute(False) # handle hass integration - if (self.mass.hass.enabled and - self.settings.get('hass_power_entity') and - self.settings.get('hass_power_entity_source')): + if ( + self.mass.hass.enabled + and self.settings.get("hass_power_entity") + and self.settings.get("hass_power_entity_source") + ): cur_source = await self.mass.hass.get_state_async( - self.settings['hass_power_entity'], attribute='source') + self.settings["hass_power_entity"], attribute="source" + ) if not cur_source: service_data = { - 'entity_id': self.settings['hass_power_entity'], - 'source': self.settings['hass_power_entity_source'] + "entity_id": self.settings["hass_power_entity"], + "source": self.settings["hass_power_entity_source"], } - await self.mass.hass.call_service('media_player', 'select_source', service_data) - elif self.mass.hass.enabled and self.settings.get('hass_power_entity'): - domain = self.settings['hass_power_entity'].split('.')[0] - service_data = {'entity_id': self.settings['hass_power_entity']} - await self.mass.hass.call_service(domain, 'turn_on', service_data) + await self.mass.hass.call_service( + "media_player", "select_source", service_data + ) + elif self.mass.hass.enabled and self.settings.get("hass_power_entity"): + domain = self.settings["hass_power_entity"].split(".")[0] + service_data = {"entity_id": self.settings["hass_power_entity"]} + await self.mass.hass.call_service(domain, "turn_on", service_data) # handle play on power on - if self.settings.get('play_power_on'): + if self.settings.get("play_power_on"): # play player's own queue if it has items if self._queue.items: await self.play() @@ -477,21 +490,26 @@ class Player(): await self.stop() await self.cmd_power_off() # handle mute as power - if self.settings.get('mute_as_power'): + if self.settings.get("mute_as_power"): await self.volume_mute(True) # handle hass integration - if (self.mass.hass.enabled and - self.settings.get('hass_power_entity') and - self.settings.get('hass_power_entity_source')): + if ( + self.mass.hass.enabled + and self.settings.get("hass_power_entity") + and self.settings.get("hass_power_entity_source") + ): cur_source = await self.mass.hass.get_state_async( - self.settings['hass_power_entity'], attribute='source') - if cur_source == self.settings['hass_power_entity_source']: - service_data = {'entity_id': self.settings['hass_power_entity']} - await self.mass.hass.call_service('media_player', 'turn_off', service_data) - elif self.mass.hass.enabled and self.settings.get('hass_power_entity'): - domain = self.settings['hass_power_entity'].split('.')[0] - service_data = {'entity_id': self.settings['hass_power_entity']} - await self.mass.hass.call_service(domain, 'turn_off', service_data) + self.settings["hass_power_entity"], attribute="source" + ) + if cur_source == self.settings["hass_power_entity_source"]: + service_data = {"entity_id": self.settings["hass_power_entity"]} + await self.mass.hass.call_service( + "media_player", "turn_off", service_data + ) + elif self.mass.hass.enabled and self.settings.get("hass_power_entity"): + domain = self.settings["hass_power_entity"].split(".")[0] + service_data = {"entity_id": self.settings["hass_power_entity"]} + await self.mass.hass.call_service(domain, "turn_off", service_data) # handle group power if self.is_group: # player is group, turn off all childs @@ -534,22 +552,26 @@ class Player(): new_volume = volume_level volume_dif = new_volume - cur_volume if cur_volume == 0: - volume_dif_percent = 1+(new_volume/100) + volume_dif_percent = 1 + (new_volume / 100) else: - volume_dif_percent = volume_dif/cur_volume + volume_dif_percent = volume_dif / cur_volume for child_player_id in self.group_childs: child_player = await self.mass.players.get_player(child_player_id) if child_player and child_player.enabled and child_player.powered: cur_child_volume = child_player.volume_level - new_child_volume = cur_child_volume + (cur_child_volume * volume_dif_percent) + new_child_volume = cur_child_volume + ( + cur_child_volume * volume_dif_percent + ) await child_player.volume_set(new_child_volume) # handle hass integration - elif self.mass.hass.enabled and self.settings.get('hass_volume_entity'): + elif self.mass.hass.enabled and self.settings.get("hass_volume_entity"): service_data = { - 'entity_id': self.settings['hass_volume_entity'], - 'volume_level': volume_level/100 + "entity_id": self.settings["hass_volume_entity"], + "volume_level": volume_level / 100, } - await self.mass.hass.call_service('media_player', 'volume_set', service_data) + await self.mass.hass.call_service( + "media_player", "volume_set", service_data + ) # just force full volume on actual player if volume is outsourced to hass await self.cmd_volume_set(100) else: @@ -584,45 +606,49 @@ class Player(): @property def settings(self): """ [PROTECTED] get player config settings """ - if self.player_id in self.mass.config['player_settings']: - return self.mass.config['player_settings'][self.player_id] + if self.player_id in self.mass.config["player_settings"]: + return self.mass.config["player_settings"][self.player_id] else: self.__update_player_settings() - return self.mass.config['player_settings'][self.player_id] + return self.mass.config["player_settings"][self.player_id] def __update_player_settings(self): """ [PROTECTED] update player config settings """ - player_settings = self.mass.config['player_settings'].get(self.player_id, {}) + player_settings = self.mass.config["player_settings"].get(self.player_id, {}) # generate config for the player - config_entries = [ # default config entries for a player + config_entries = [ # default config entries for a player ("enabled", True, "player_enabled"), ("name", "", "player_name"), ("mute_as_power", False, "player_mute_power"), ("max_sample_rate", 96000, "max_sample_rate"), - ('volume_normalisation', True, 'enable_r128_volume_normalisation'), - ('target_volume', '-23', 'target_volume_lufs'), - ('fallback_gain_correct', '-12', 'fallback_gain_correct'), + ("volume_normalisation", True, "enable_r128_volume_normalisation"), + ("target_volume", "-23", "target_volume_lufs"), + ("fallback_gain_correct", "-12", "fallback_gain_correct"), ("crossfade_duration", 0, "crossfade_duration"), ("play_power_on", False, "player_power_play"), ] # append player specific settings - config_entries += self.mass.players.providers[self._prov_id].player_config_entries + config_entries += self.mass.players.providers[ + self._prov_id + ].player_config_entries # hass integration - if self.mass.config['base'].get('homeassistant', {}).get("enabled"): + if self.mass.config["base"].get("homeassistant", {}).get("enabled"): # append hass specific config entries - config_entries += [("hass_power_entity", "", "hass_player_power"), - ("hass_power_entity_source", "", "hass_player_source"), - ("hass_volume_entity", "", "hass_player_volume")] + config_entries += [ + ("hass_power_entity", "", "hass_player_power"), + ("hass_power_entity_source", "", "hass_player_source"), + ("hass_volume_entity", "", "hass_player_volume"), + ] # pylint: disable=unused-variable for key, def_value, desc in config_entries: if not key in player_settings: - if (isinstance(def_value, str) and def_value.startswith('<')): + if isinstance(def_value, str) and def_value.startswith("<"): player_settings[key] = None else: player_settings[key] = def_value # pylint: enable=unused-variable - self.mass.config['player_settings'][self.player_id] = player_settings - self.mass.config['player_settings'][self.player_id]['__desc__'] = config_entries + self.mass.config["player_settings"][self.player_id] = player_settings + self.mass.config["player_settings"][self.player_id]["__desc__"] = config_entries def to_dict(self): """ instance attributes as dict so it can be serialized to json """ @@ -642,5 +668,5 @@ class Player(): "group_childs": self.group_childs, "enabled": self.enabled, "supports_queue": self.supports_queue, - "supports_gapless": self.supports_gapless + "supports_gapless": self.supports_gapless, } diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 3bcd27a0..e8692511 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -6,23 +6,29 @@ """ import asyncio -from typing import List +from enum import Enum import random +from typing import List import uuid -from enum import Enum -from music_assistant.utils import LOGGER, serialize_values -from music_assistant.constants import EVENT_PLAYBACK_STARTED, EVENT_PLAYBACK_STOPPED, \ - EVENT_QUEUE_UPDATED, EVENT_QUEUE_ITEMS_UPDATED +from music_assistant.constants import ( + EVENT_PLAYBACK_STARTED, + EVENT_PLAYBACK_STOPPED, + EVENT_QUEUE_ITEMS_UPDATED, + EVENT_QUEUE_UPDATED, +) from music_assistant.models.media_types import Track from music_assistant.models.playerstate import PlayerState +from music_assistant.utils import LOGGER, serialize_values # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods # pylint: disable=too-few-public-methods + class QueueOption(str, Enum): """Enum representation of the queue (play) options""" + Play = "play" Replace = "replace" Next = "next" @@ -31,6 +37,7 @@ class QueueOption(str, Enum): class QueueItem(Track): """Representation of a queue item, extended version of track.""" + def __init__(self, media_item=None): super().__init__() self.streamdetails = {} @@ -41,12 +48,14 @@ class QueueItem(Track): for key, value in media_item.__dict__.items(): setattr(self, key, value) -class PlayerQueue(): + +class PlayerQueue: """ Model for a player's queue. Can be overriden by custom implementation, but will not be needed in most cases. """ + def __init__(self, mass, player): self.mass = mass self._player = player @@ -62,10 +71,12 @@ class PlayerQueue(): self._last_track = None asyncio.run_coroutine_threadsafe( self.mass.add_event_listener(self.on_shutdown, "shutdown"), - self.mass.event_loop) + self.mass.event_loop, + ) # load previous queue settings from disk - asyncio.run_coroutine_threadsafe(self.__restore_saved_state(), - self.mass.event_loop) + asyncio.run_coroutine_threadsafe( + self.__restore_saved_state(), self.mass.event_loop + ) @property def shuffle_enabled(self): @@ -79,17 +90,16 @@ class PlayerQueue(): # shuffle requested self._shuffle_enabled = True if self.cur_index is not None: - played_items = self.items[:self.cur_index] - next_items = self.__shuffle_items(self.items[self.cur_index + - 1:]) + played_items = self.items[: self.cur_index] + next_items = self.__shuffle_items(self.items[self.cur_index + 1 :]) items = played_items + [self.cur_item] + next_items self.mass.event_loop.create_task(self.update(items)) elif self._shuffle_enabled and not enable_shuffle: # unshuffle self._shuffle_enabled = False if self.cur_index is not None: - played_items = self.items[:self.cur_index] - next_items = self.items[self.cur_index + 1:] + played_items = self.items[: self.cur_index] + next_items = self.items[self.cur_index + 1 :] next_items.sort(key=lambda x: x.sort_index, reverse=False) items = played_items + [self.cur_item] + next_items self.mass.event_loop.create_task(self.update(items)) @@ -111,12 +121,12 @@ class PlayerQueue(): @property def crossfade_enabled(self): """Returns if crossfade is enabled for this player's queue.""" - return self._player.settings.get('crossfade_duration', 0) > 0 + return self._player.settings.get("crossfade_duration", 0) > 0 @property def gapless_enabled(self): """Returns if gapless support is enabled for this player.""" - return self._player.settings.get('gapless_enabled', True) + return self._player.settings.get("gapless_enabled", True) @property def cur_index(self): @@ -198,9 +208,9 @@ class PlayerQueue(): for example if crossfading is requested but a player doesn't natively support it it will send a constant stream of audio to the player and all tracks """ - return ((self.crossfade_enabled - and not self._player.supports_crossfade) or - (self.gapless_enabled and not self._player.supports_gapless)) + return (self.crossfade_enabled and not self._player.supports_crossfade) or ( + self.gapless_enabled and not self._player.supports_gapless + ) async def get_item(self, index): """get item by index from queue""" @@ -247,8 +257,9 @@ class PlayerQueue(): await self._player.cmd_queue_load(self.items) await self.play_index(prev_index) else: - LOGGER.warning("resume queue requested for %s but queue is empty", - self._player.name) + LOGGER.warning( + "resume queue requested for %s but queue is empty", self._player.name + ) async def play_index(self, index): """Play item at index X in queue.""" @@ -258,9 +269,11 @@ class PlayerQueue(): return if self.use_queue_stream: self._next_queue_startindex = index - queue_stream_uri = 'http://%s:%s/stream/%s' % ( - self.mass.web.local_ip, self.mass.web.http_port, - self._player.player_id) + queue_stream_uri = "http://%s:%s/stream/%s" % ( + self.mass.web.local_ip, + self.mass.web.http_port, + self._player.player_id, + ) return await self._player.cmd_play_uri(queue_stream_uri) elif self._player.supports_queue: return await self._player.cmd_queue_play_index(index) @@ -313,32 +326,36 @@ class PlayerQueue(): :param offset: offset from current queue position """ - if not self.items or self.cur_index is None or self.cur_index + offset > len( - self.items): + if ( + not self.items + or self.cur_index is None + or self.cur_index + offset > len(self.items) + ): return await self.load(queue_items) insert_at_index = self.cur_index + offset for index, item in enumerate(queue_items): item.sort_index = insert_at_index + index if self.shuffle_enabled: queue_items = self.__shuffle_items(queue_items) - self._items = self._items[:insert_at_index] + queue_items + self._items[ - insert_at_index:] + self._items = ( + self._items[:insert_at_index] + queue_items + self._items[insert_at_index:] + ) if self.use_queue_stream or not self._player.supports_queue: if offset == 0: await self.play_index(insert_at_index) else: try: - await self._player.cmd_queue_insert(queue_items, - insert_at_index) + await self._player.cmd_queue_insert(queue_items, insert_at_index) except NotImplementedError: # not supported by player, use load queue instead LOGGER.debug( "cmd_queue_insert not supported by player, fallback to cmd_queue_load " ) - self._items = self._items[self.cur_index:] + self._items = self._items[self.cur_index :] await self._player.cmd_queue_load(self._items) self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict())) + self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict()) + ) self.mass.event_loop.create_task(self.__save_state()) async def append(self, queue_items: List[QueueItem]): @@ -348,8 +365,8 @@ class PlayerQueue(): for index, item in enumerate(queue_items): item.sort_index = len(self.items) + index if self.shuffle_enabled: - played_items = self.items[:self.cur_index] - next_items = self.items[self.cur_index:] + queue_items + played_items = self.items[: self.cur_index] + next_items = self.items[self.cur_index :] + queue_items next_items = self.__shuffle_items(next_items) items = played_items + next_items return await self.update(items) @@ -362,10 +379,11 @@ class PlayerQueue(): LOGGER.debug( "cmd_queue_append not supported by player, fallback to cmd_queue_load " ) - self._items = self._items[self.cur_index:] + self._items = self._items[self.cur_index :] await self._player.cmd_queue_load(self._items) self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict())) + self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict()) + ) self.mass.event_loop.create_task(self.__save_state()) async def update(self, queue_items: List[QueueItem]): @@ -381,10 +399,11 @@ class PlayerQueue(): LOGGER.debug( "cmd_queue_update not supported by player, fallback to cmd_queue_load " ) - self._items = self._items[self.cur_index:] + self._items = self._items[self.cur_index :] await self._player.cmd_queue_load(self._items) self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict())) + self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict()) + ) self.mass.event_loop.create_task(self.__save_state()) async def clear(self): @@ -400,7 +419,8 @@ class PlayerQueue(): # not supported by player, ignore pass self.mass.event_loop.create_task( - self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict())) + self.mass.signal_event(EVENT_QUEUE_ITEMS_UPDATED, self.to_dict()) + ) async def update_state(self): """update queue details, called when player updates""" @@ -440,7 +460,7 @@ class PlayerQueue(): "cur_item": serialize_values(self.cur_item), "cur_item_time": self.cur_item_time, "next_item": serialize_values(self.next_item), - "queue_stream_enabled": self.use_queue_stream + "queue_stream_enabled": self.use_queue_stream, } async def __get_queue_stream_index(self): @@ -450,7 +470,9 @@ class PlayerQueue(): total_time = 0 track_time = 0 if self.items and len(self.items) > self._last_queue_startindex: - queue_index = self._last_queue_startindex # holds the last starting position + queue_index = ( + self._last_queue_startindex + ) # holds the last starting position queue_track = None while len(self.items) > queue_index: queue_track = self.items[queue_index] @@ -466,28 +488,30 @@ class PlayerQueue(): async def __process_queue_update(self, new_index, track_time): """compare the queue index to determine if playback changed""" new_track = await self.get_item(new_index) - if (not self._last_track - and new_track) or self._last_track != new_track: + if (not self._last_track and new_track) or self._last_track != new_track: # queue track updated # account for track changing state so trigger track change after 1 second if self._last_track and self._last_track.streamdetails: - self._last_track.streamdetails[ - "seconds_played"] = self._last_item_time - await self.mass.signal_event(EVENT_PLAYBACK_STOPPED, - self._last_track.streamdetails) + self._last_track.streamdetails["seconds_played"] = self._last_item_time + await self.mass.signal_event( + EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails + ) if new_track and new_track.streamdetails: - await self.mass.signal_event(EVENT_PLAYBACK_STARTED, - new_track.streamdetails) + await self.mass.signal_event( + EVENT_PLAYBACK_STARTED, new_track.streamdetails + ) self._last_track = new_track if self._last_player_state != self._player.state: self._last_player_state = self._player.state - if (self._player.cur_time == 0 and self._player.state in [ - PlayerState.Stopped, PlayerState.Off - ]): + if self._player.cur_time == 0 and self._player.state in [ + PlayerState.Stopped, + PlayerState.Off, + ]: # player stopped playing if self._last_track: await self.mass.signal_event( - EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails) + EVENT_PLAYBACK_STOPPED, self._last_track.streamdetails + ) # update vars if track_time > 2: # account for track changing state so keep this a few seconds behind @@ -512,7 +536,7 @@ class PlayerQueue(): async def __restore_saved_state(self): """try to load the saved queue for this player from cache file""" - cache_str = 'queue_%s' % self._player.player_id + cache_str = "queue_%s" % self._player.player_id cache_data = await self.mass.cache.get(cache_str) if cache_data: self._shuffle_enabled = cache_data["shuffle_enabled"] @@ -525,18 +549,18 @@ class PlayerQueue(): async def on_shutdown(self, msg, msg_details): """Handle shutdown event, save queue state.""" await self.__save_state() + # pylint: enable=unused-argument async def __save_state(self): """save current queue settings to file""" - cache_str = 'queue_%s' % self._player.player_id + cache_str = "queue_%s" % self._player.player_id cache_data = { "shuffle_enabled": self._shuffle_enabled, "repeat_enabled": self._repeat_enabled, "items": self._items, "cur_item": self._cur_index, - "next_queue_index": self._next_queue_startindex + "next_queue_index": self._next_queue_startindex, } await self.mass.cache.set(cache_str, cache_data) - LOGGER.info("queue state saved to file for player %s", - self._player.player_id) + LOGGER.info("queue state saved to file for player %s", self._player.player_id) diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py index a77f40e1..802002f1 100755 --- a/music_assistant/models/playerprovider.py +++ b/music_assistant/models/playerprovider.py @@ -4,24 +4,25 @@ import asyncio from enum import Enum from typing import List -from music_assistant.utils import run_periodic, LOGGER + from music_assistant.constants import CONF_ENABLED -from music_assistant.models.player_queue import PlayerQueue from music_assistant.models.media_types import Track from music_assistant.models.player import Player +from music_assistant.models.player_queue import PlayerQueue +from music_assistant.utils import LOGGER, run_periodic -class PlayerProvider(): - ''' +class PlayerProvider: + """ Model for a Playerprovider Common methods usable for every provider Provider specific methods should be overriden in the provider specific implementation - ''' + """ def __init__(self, mass): """[DO NOT OVERRIDE]""" - self.prov_id = '' - self.name = '' + self.prov_id = "" + self.name = "" self.mass = mass self.cache = mass.cache self.player_config_entries = [] @@ -34,24 +35,23 @@ class PlayerProvider(): @property def players(self): - ''' return all players for this provider ''' - return [item for item in self.mass.players.players if item.player_provider == self.prov_id] - - async def get_player(self, player_id:str): - ''' return player by id ''' + """ return all players for this provider """ + return [ + item + for item in self.mass.players.players + if item.player_provider == self.prov_id + ] + + async def get_player(self, player_id: str): + """ return player by id """ return await self.mass.players.get_player(player_id) - async def add_player(self, player:Player): - ''' register a new player ''' + async def add_player(self, player: Player): + """ register a new player """ return await self.mass.players.add_player(player) - async def remove_player(self, player_id:str): - ''' remove a player ''' + async def remove_player(self, player_id: str): + """ remove a player """ return await self.mass.players.remove_player(player_id) ### Provider specific implementation ##### - - - - - diff --git a/music_assistant/models/playerstate.py b/music_assistant/models/playerstate.py index 34336119..cebaad9f 100755 --- a/music_assistant/models/playerstate.py +++ b/music_assistant/models/playerstate.py @@ -3,6 +3,7 @@ from enum import Enum + class PlayerState(str, Enum): Off = "off" Stopped = "stopped" diff --git a/music_assistant/music_manager.py b/music_assistant/music_manager.py index 5befc5b1..3ddcef5f 100755 --- a/music_assistant/music_manager.py +++ b/music_assistant/music_manager.py @@ -1,23 +1,32 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -import operator -import os import base64 import functools +import operator +import os import time from typing import List -import toolz + from PIL import Image import aiohttp - -from music_assistant.utils import run_periodic, LOGGER, load_provider_modules -from music_assistant.models.media_types import MediaItem, MediaType, Track, Artist, Album, Playlist, Radio from music_assistant.constants import CONF_KEY_MUSICPROVIDERS, EVENT_MUSIC_SYNC_STATUS +from music_assistant.models.media_types import ( + Album, + Artist, + MediaItem, + MediaType, + Playlist, + Radio, + Track, +) +from music_assistant.utils import LOGGER, load_provider_modules, run_periodic +import toolz def sync_task(desc): """ decorator to report a sync task """ + def wrapper(func): @functools.wraps(func) async def wrapped(*args): @@ -27,33 +36,36 @@ def sync_task(desc): for sync_prov_id, sync_desc in method_class.running_sync_jobs: if sync_prov_id == prov_id and sync_desc == desc: LOGGER.warning( - "Syncjob %s for provider %s is already running!", desc, - prov_id) + "Syncjob %s for provider %s is already running!", desc, prov_id + ) return sync_job = (prov_id, desc) method_class.running_sync_jobs.append(sync_job) await method_class.mass.signal_event( - EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs) + EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs + ) await func(*args) LOGGER.info("Finished syncing %s for provider %s", desc, prov_id) method_class.running_sync_jobs.remove(sync_job) await method_class.mass.signal_event( - EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs) + EVENT_MUSIC_SYNC_STATUS, method_class.running_sync_jobs + ) return wrapped return wrapper -class MusicManager(): - ''' several helpers around the musicproviders ''' +class MusicManager: + """ several helpers around the musicproviders """ + def __init__(self, mass): self.running_sync_jobs = [] self.mass = mass self.providers = {} async def setup(self): - ''' async initialize of module ''' + """ async initialize of module """ # load providers await self.load_modules() # schedule sync task @@ -66,17 +78,14 @@ class MusicManager(): for player in self.providers[reload_module].players: await self.mass.players.remove_player(player.player_id) self.providers.pop(reload_module, None) - LOGGER.info('Unloaded %s module', reload_module) + LOGGER.info("Unloaded %s module", reload_module) # load all modules (that are not already loaded) - await load_provider_modules(self.mass, self.providers, - CONF_KEY_MUSICPROVIDERS) - - async def item(self, - item_id, - media_type: MediaType, - provider='database', - lazy=True): - ''' get single music item by id and media type''' + await load_provider_modules(self.mass, self.providers, CONF_KEY_MUSICPROVIDERS) + + async def item( + self, item_id, media_type: MediaType, provider="database", lazy=True + ): + """ get single music item by id and media type""" if media_type == MediaType.Artist: return await self.artist(item_id, provider, lazy=lazy) elif media_type == MediaType.Album: @@ -90,93 +99,97 @@ class MusicManager(): else: return None - async def library_artists(self, orderby='name', - provider_filter=None) -> List[Artist]: - ''' return all library artists, optionally filtered by provider ''' + async def library_artists( + self, orderby="name", provider_filter=None + ) -> List[Artist]: + """ return all library artists, optionally filtered by provider """ async for item in self.mass.db.library_artists( - provider=provider_filter, orderby=orderby): + provider=provider_filter, orderby=orderby + ): yield item - async def library_albums(self, orderby='name', - provider_filter=None) -> List[Album]: - ''' return all library albums, optionally filtered by provider ''' - async for item in self.mass.db.library_albums(provider=provider_filter, - orderby=orderby): + async def library_albums(self, orderby="name", provider_filter=None) -> List[Album]: + """ return all library albums, optionally filtered by provider """ + async for item in self.mass.db.library_albums( + provider=provider_filter, orderby=orderby + ): yield item - async def library_tracks(self, orderby='name', - provider_filter=None) -> List[Track]: - ''' return all library tracks, optionally filtered by provider ''' - async for item in self.mass.db.library_tracks(provider=provider_filter, - orderby=orderby): + async def library_tracks(self, orderby="name", provider_filter=None) -> List[Track]: + """ return all library tracks, optionally filtered by provider """ + async for item in self.mass.db.library_tracks( + provider=provider_filter, orderby=orderby + ): yield item - async def library_playlists(self, orderby='name', - provider_filter=None) -> List[Playlist]: - ''' return all library playlists, optionally filtered by provider ''' + async def library_playlists( + self, orderby="name", provider_filter=None + ) -> List[Playlist]: + """ return all library playlists, optionally filtered by provider """ async for item in self.mass.db.library_playlists( - provider=provider_filter, orderby=orderby): + provider=provider_filter, orderby=orderby + ): yield item - async def library_radios(self, orderby='name', - provider_filter=None) -> List[Playlist]: - ''' return all library radios, optionally filtered by provider ''' - async for item in self.mass.db.library_radios(provider=provider_filter, - orderby=orderby): + async def library_radios( + self, orderby="name", provider_filter=None + ) -> List[Playlist]: + """ return all library radios, optionally filtered by provider """ + async for item in self.mass.db.library_radios( + provider=provider_filter, orderby=orderby + ): yield item - async def artist(self, item_id, provider='database', lazy=True) -> Artist: - ''' get artist by id ''' - if not provider or provider == 'database': + async def artist(self, item_id, provider="database", lazy=True) -> Artist: + """ get artist by id """ + if not provider or provider == "database": return await self.mass.db.artist(item_id) return await self.providers[provider].artist(item_id, lazy=lazy) - async def album(self, item_id, provider='database', lazy=True) -> Album: - ''' get album by id ''' - if not provider or provider == 'database': + async def album(self, item_id, provider="database", lazy=True) -> Album: + """ get album by id """ + if not provider or provider == "database": return await self.mass.db.album(item_id) return await self.providers[provider].album(item_id, lazy=lazy) - async def track(self, - item_id, - provider='database', - lazy=True, - track_details=None) -> Track: - ''' get track by id ''' - if not provider or provider == 'database': + async def track( + self, item_id, provider="database", lazy=True, track_details=None + ) -> Track: + """ get track by id """ + if not provider or provider == "database": return await self.mass.db.track(item_id) return await self.providers[provider].track( - item_id, lazy=lazy, track_details=track_details) + item_id, lazy=lazy, track_details=track_details + ) - async def playlist(self, item_id, provider='database') -> Playlist: - ''' get playlist by id ''' - if not provider or provider == 'database': + async def playlist(self, item_id, provider="database") -> Playlist: + """ get playlist by id """ + if not provider or provider == "database": return await self.mass.db.playlist(item_id) return await self.providers[provider].playlist(item_id) - async def radio(self, item_id, provider='database') -> Radio: - ''' get radio by id ''' - if not provider or provider == 'database': + async def radio(self, item_id, provider="database") -> Radio: + """ get radio by id """ + if not provider or provider == "database": return await self.mass.db.radio(item_id) return await self.providers[provider].radio(item_id) async def playlist_by_name(self, name) -> Playlist: - ''' get playlist by name ''' + """ get playlist by name """ async for playlist in self.library_playlists(): if playlist.name == name: return playlist return None async def radio_by_name(self, name) -> Radio: - ''' get radio by name ''' + """ get radio by name """ async for radio in self.library_radios(): if radio.name == name: return radio return None - async def artist_toptracks(self, artist_id, - provider='database') -> List[Track]: - ''' get top tracks for given artist ''' + async def artist_toptracks(self, artist_id, provider="database") -> List[Track]: + """ get top tracks for given artist """ track_names = [] artist = await self.artist(artist_id, provider, lazy=False) # always append database tracks @@ -185,17 +198,16 @@ class MusicManager(): yield item track_names.append(item.name + item.version) for prov_mapping in artist.provider_ids: - prov_id = prov_mapping['provider'] - prov_item_id = prov_mapping['item_id'] + prov_id = prov_mapping["provider"] + prov_item_id = prov_mapping["item_id"] prov_obj = self.providers[prov_id] async for item in prov_obj.artist_toptracks(prov_item_id): if (item.name + item.version) not in track_names: yield item track_names.append(item.name + item.version) - async def artist_albums(self, artist_id, - provider='database') -> List[Album]: - ''' get (all) albums for given artist ''' + async def artist_albums(self, artist_id, provider="database") -> List[Album]: + """ get (all) albums for given artist """ album_names = [] artist = await self.artist(artist_id, provider, lazy=False) # always append database tracks (if db artist) @@ -204,46 +216,43 @@ class MusicManager(): yield item album_names.append(item.name + item.version) for prov_mapping in artist.provider_ids: - prov_id = prov_mapping['provider'] - prov_item_id = prov_mapping['item_id'] + prov_id = prov_mapping["provider"] + prov_item_id = prov_mapping["item_id"] prov_obj = self.providers[prov_id] async for item in prov_obj.artist_albums(prov_item_id): if (item.name + item.version) not in album_names: yield item album_names.append(item.name + item.version) - async def album_tracks(self, album_id, provider='database') -> List[Track]: - ''' get the album tracks for given album ''' + async def album_tracks(self, album_id, provider="database") -> List[Track]: + """ get the album tracks for given album """ album = await self.album(album_id, provider) # collect the tracks from the first provider prov = album.provider_ids[0] - prov_obj = self.providers[prov['provider']] - async for item in prov_obj.album_tracks(prov['item_id']): + prov_obj = self.providers[prov["provider"]] + async for item in prov_obj.album_tracks(prov["item_id"]): yield item - async def playlist_tracks(self, playlist_id, - provider='database') -> List[Track]: - ''' get the tracks for given playlist ''' + async def playlist_tracks(self, playlist_id, provider="database") -> List[Track]: + """ get the tracks for given playlist """ playlist = await self.playlist(playlist_id, provider) # return playlist tracks from provider prov = playlist.provider_ids[0] - async for item in self.providers[prov['provider']].playlist_tracks( - prov['item_id']): + async for item in self.providers[prov["provider"]].playlist_tracks( + prov["item_id"] + ): yield item - async def search(self, - searchquery, - media_types: List[MediaType], - limit=10, - online=False) -> dict: - ''' search database or providers ''' + async def search( + self, searchquery, media_types: List[MediaType], limit=10, online=False + ) -> dict: + """ search database or providers """ # get results from database result = await self.mass.db.search(searchquery, media_types) if online: # include results from music providers for prov in self.providers.values(): - prov_results = await prov.search(searchquery, media_types, - limit) + prov_results = await prov.search(searchquery, media_types, limit) for item_type, items in prov_results.items(): if not item_type in result: result[item_type] = items @@ -251,79 +260,78 @@ class MusicManager(): result[item_type] += items # filter out duplicates for item_type, items in result.items(): - items = list( - toolz.unique(items, key=operator.attrgetter('item_id'))) + items = list(toolz.unique(items, key=operator.attrgetter("item_id"))) return result async def library_add(self, media_items: List[MediaItem]): - '''Add media item(s) to the library''' + """Add media item(s) to the library""" result = False for item in media_items: # make sure we have a database item - media_item = await self.item(item.item_id, - item.media_type, - item.provider, - lazy=False) + media_item = await self.item( + item.item_id, item.media_type, item.provider, lazy=False + ) if not media_item: continue # add to provider's libraries for prov in item.provider_ids: - prov_id = prov['provider'] - prov_item_id = prov['item_id'] + prov_id = prov["provider"] + prov_item_id = prov["item_id"] if prov_id in self.providers: result = await self.providers[prov_id].add_library( - prov_item_id, media_item.media_type) + prov_item_id, media_item.media_type + ) # mark as library item in internal db - await self.mass.db.add_to_library(media_item.item_id, - media_item.media_type, - prov_id) + await self.mass.db.add_to_library( + media_item.item_id, media_item.media_type, prov_id + ) return result async def library_remove(self, media_items: List[MediaItem]): - '''Remove media item(s) from the library''' + """Remove media item(s) from the library""" result = False for item in media_items: # make sure we have a database item - media_item = await self.item(item.item_id, - item.media_type, - item.provider, - lazy=False) + media_item = await self.item( + item.item_id, item.media_type, item.provider, lazy=False + ) if not media_item: continue # remove from provider's libraries for prov in item.provider_ids: - prov_id = prov['provider'] - prov_item_id = prov['item_id'] + prov_id = prov["provider"] + prov_item_id = prov["item_id"] if prov_id in self.providers: result = await self.providers[prov_id].remove_library( - prov_item_id, media_item.media_type) + prov_item_id, media_item.media_type + ) # mark as library item in internal db - await self.mass.db.remove_from_library(media_item.item_id, - media_item.media_type, - prov_id) + await self.mass.db.remove_from_library( + media_item.item_id, media_item.media_type, prov_id + ) return result async def add_playlist_tracks(self, db_playlist_id, tracks: List[Track]): - ''' add tracks to playlist - make sure we dont add dupes ''' + """ add tracks to playlist - make sure we dont add dupes """ # we can only edit playlists that are in the database (marked as editable) - playlist = await self.playlist(db_playlist_id, 'database') + playlist = await self.playlist(db_playlist_id, "database") if not playlist or not playlist.is_editable: return False # playlist can only have one provider (for now) playlist_prov = playlist.provider_ids[0] # grab all existing track ids in the playlist so we can check for duplicates cur_playlist_track_ids = [] - async for item in self.providers[ - playlist_prov['provider']].playlist_tracks( - playlist_prov['item_id']): + async for item in self.providers[playlist_prov["provider"]].playlist_tracks( + playlist_prov["item_id"] + ): cur_playlist_track_ids.append(item.item_id) - cur_playlist_track_ids += [i['item_id'] for i in item.provider_ids] + cur_playlist_track_ids += [i["item_id"] for i in item.provider_ids] track_ids_to_add = [] for track in tracks: # check for duplicates already_exists = track.item_id in cur_playlist_track_ids for track_prov in track.provider_ids: - if track_prov['item_id'] in cur_playlist_track_ids: + if track_prov["item_id"] in cur_playlist_track_ids: already_exists = True if already_exists: continue @@ -331,13 +339,13 @@ class MusicManager(): # this should all be handled in the frontend but these checks are here just to be safe # a track can contain multiple versions on the same provider # simply sort by quality and just add the first one (assuming track is still available) - for track_version in sorted(track.provider_ids, - key=operator.itemgetter('quality'), - reverse=True): - if track_version['provider'] == playlist_prov['provider']: - track_ids_to_add.append(track_version['item_id']) + for track_version in sorted( + track.provider_ids, key=operator.itemgetter("quality"), reverse=True + ): + if track_version["provider"] == playlist_prov["provider"]: + track_ids_to_add.append(track_version["item_id"]) break - elif playlist_prov['provider'] == 'file': + elif playlist_prov["provider"] == "file": # the file provider can handle uri's from all providers so simply add the uri uri = f'{track_version["provider"]}://{track_version["item_id"]}' track_ids_to_add.append(uri) @@ -345,45 +353,44 @@ class MusicManager(): # actually add the tracks to the playlist on the provider if track_ids_to_add: # invalidate cache - await self.mass.db.update_playlist(playlist.item_id, 'checksum', - str(time.time())) + await self.mass.db.update_playlist( + playlist.item_id, "checksum", str(time.time()) + ) # return result of the action on the provioer - return await self.providers[playlist_prov['provider'] - ].add_playlist_tracks( - playlist_prov['item_id'], - track_ids_to_add) + return await self.providers[playlist_prov["provider"]].add_playlist_tracks( + playlist_prov["item_id"], track_ids_to_add + ) return False - async def remove_playlist_tracks(self, db_playlist_id, - tracks: List[Track]): - ''' remove tracks from playlist ''' + async def remove_playlist_tracks(self, db_playlist_id, tracks: List[Track]): + """ remove tracks from playlist """ # we can only edit playlists that are in the database (marked as editable) - playlist = await self.playlist(db_playlist_id, 'database') + playlist = await self.playlist(db_playlist_id, "database") if not playlist or not playlist.is_editable: return False # playlist can only have one provider (for now) prov_playlist = playlist.provider_ids[0] - prov_playlist_playlist_id = prov_playlist['item_id'] - prov_playlist_provider_id = prov_playlist['provider'] + prov_playlist_playlist_id = prov_playlist["item_id"] + prov_playlist_provider_id = prov_playlist["provider"] track_ids_to_remove = [] for track in tracks: # a track can contain multiple versions on the same provider, remove all for track_provider in track.provider_ids: - if track_provider['provider'] == prov_playlist_provider_id: - track_ids_to_remove.append(track_provider['item_id']) + if track_provider["provider"] == prov_playlist_provider_id: + track_ids_to_remove.append(track_provider["item_id"]) # actually remove the tracks from the playlist on the provider if track_ids_to_remove: # invalidate cache - await self.mass.db.update_playlist(playlist.item_id, 'checksum', - str(time.time())) - return await self.providers[prov_playlist_provider_id - ].remove_playlist_tracks( - prov_playlist_playlist_id, - track_ids_to_remove) + await self.mass.db.update_playlist( + playlist.item_id, "checksum", str(time.time()) + ) + return await self.providers[ + prov_playlist_provider_id + ].remove_playlist_tracks(prov_playlist_playlist_id, track_ids_to_remove) @run_periodic(3600 * 3) async def __sync_music_providers(self): - ''' periodic sync of all music providers ''' + """ periodic sync of all music providers """ for prov_id in self.providers: self.mass.event_loop.create_task(self.sync_music_provider(prov_id)) @@ -398,80 +405,77 @@ class MusicManager(): await self.sync_library_playlists(prov_id) await self.sync_library_radios(prov_id) - @sync_task('artists') + @sync_task("artists") async def sync_library_artists(self, prov_id): - ''' sync library artists for given provider''' + """ sync library artists for given provider""" music_provider = self.providers[prov_id] prev_db_ids = [ - item.item_id - async for item in self.library_artists(provider_filter=prov_id) + item.item_id async for item in self.library_artists(provider_filter=prov_id) ] cur_db_ids = [] async for item in music_provider.get_library_artists(): 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) + await self.mass.db.add_to_library( + db_item.item_id, MediaType.Artist, prov_id + ) # process deletions for db_id in prev_db_ids: if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, MediaType.Artist, - prov_id) + await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id) - @sync_task('albums') + @sync_task("albums") async def sync_library_albums(self, prov_id): - ''' sync library albums for given provider''' + """ sync library albums for given provider""" music_provider = self.providers[prov_id] prev_db_ids = [ - item.item_id - async for item in self.library_albums(provider_filter=prov_id) + item.item_id async for item in self.library_albums(provider_filter=prov_id) ] cur_db_ids = [] async for item in music_provider.get_library_albums(): - db_album = await music_provider.album(item.item_id, - album_details=item, - lazy=False) + db_album = await music_provider.album( + item.item_id, album_details=item, lazy=False + ) if not db_album: LOGGER.error("provider %s album: %s", prov_id, item.__dict__) cur_db_ids.append(db_album.item_id) if not db_album.item_id in prev_db_ids: - await self.mass.db.add_to_library(db_album.item_id, - MediaType.Album, prov_id) + await self.mass.db.add_to_library( + db_album.item_id, MediaType.Album, prov_id + ) # precache album tracks async for item in music_provider.album_tracks(item.item_id): pass # process deletions for db_id in prev_db_ids: if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, MediaType.Album, - prov_id) + await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id) - @sync_task('tracks') + @sync_task("tracks") async def sync_library_tracks(self, prov_id): - ''' sync library tracks for given provider''' + """ sync library tracks for given provider""" music_provider = self.providers[prov_id] prev_db_ids = [ - item.item_id - async for item in self.library_tracks(provider_filter=prov_id) + item.item_id async for item in self.library_tracks(provider_filter=prov_id) ] cur_db_ids = [] async for item in music_provider.get_library_tracks(): 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) + 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) + await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id) - @sync_task('playlists') + @sync_task("playlists") async def sync_library_playlists(self, prov_id): - ''' sync library playlists for given provider''' + """ sync library playlists for given provider""" music_provider = self.providers[prov_id] prev_db_ids = [ item.item_id @@ -483,76 +487,69 @@ class MusicManager(): 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) + await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id) # precache playlist tracks async for item in music_provider.playlist_tracks(item.item_id): pass # process playlist deletions for db_id in prev_db_ids: if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, - MediaType.Playlist, - prov_id) + await self.mass.db.remove_from_library( + db_id, MediaType.Playlist, prov_id + ) - @sync_task('radios') + @sync_task("radios") async def sync_library_radios(self, prov_id): - ''' sync library radios for given provider''' + """ sync library radios for given provider""" music_provider = self.providers[prov_id] prev_db_ids = [ - item.item_id - async for item in self.library_radios(provider_filter=prov_id) + item.item_id async for item in self.library_radios(provider_filter=prov_id) ] cur_db_ids = [] async for item in music_provider.get_radios(): - db_id = await self.mass.db.get_database_id(prov_id, item.item_id, - MediaType.Radio) + db_id = await self.mass.db.get_database_id( + prov_id, item.item_id, MediaType.Radio + ) if not db_id: db_id = await self.mass.db.add_radio(item) cur_db_ids.append(db_id) if not db_id in prev_db_ids: - await self.mass.db.add_to_library(db_id, MediaType.Radio, - prov_id) + await self.mass.db.add_to_library(db_id, MediaType.Radio, prov_id) # process deletions for db_id in prev_db_ids: if db_id not in cur_db_ids: - await self.mass.db.remove_from_library(db_id, MediaType.Radio, - prov_id) - - async def get_image_thumb(self, - item_id, - media_type: MediaType, - provider, - size=50): - ''' get path to (resized) thumb image for given media item ''' - cache_folder = os.path.join(self.mass.datapath, '.thumbs') - cache_id = f'{item_id}{media_type}{provider}' - cache_id = base64.b64encode(cache_id.encode('utf-8')).decode('utf-8') - cache_file_org = os.path.join(cache_folder, f'{cache_id}0.png') - cache_file_sized = os.path.join(cache_folder, f'{cache_id}{size}.png') + await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id) + + async def get_image_thumb(self, item_id, media_type: MediaType, provider, size=50): + """ get path to (resized) thumb image for given media item """ + cache_folder = os.path.join(self.mass.datapath, ".thumbs") + cache_id = f"{item_id}{media_type}{provider}" + cache_id = base64.b64encode(cache_id.encode("utf-8")).decode("utf-8") + cache_file_org = os.path.join(cache_folder, f"{cache_id}0.png") + cache_file_sized = os.path.join(cache_folder, f"{cache_id}{size}.png") if os.path.isfile(cache_file_sized): # return file from cache return cache_file_sized # no file in cache so we should get it - img_url = '' + img_url = "" # we only retrieve items that we already have in cache item = None if await self.mass.db.get_database_id(provider, item_id, media_type): item = await self.item(item_id, media_type, provider) if not item: - return '' - if item and item.metadata.get('image'): - img_url = item.metadata['image'] + return "" + if item and item.metadata.get("image"): + img_url = item.metadata["image"] elif media_type == MediaType.Track and item.album: # try album image instead for tracks - return await self.get_image_thumb(item.album.item_id, - MediaType.Album, - item.album.provider, size) + return await self.get_image_thumb( + item.album.item_id, MediaType.Album, item.album.provider, size + ) elif media_type == MediaType.Album and item.artist: # try artist image instead for albums - return await self.get_image_thumb(item.artist.item_id, - MediaType.Artist, - item.artist.provider, size) + return await self.get_image_thumb( + item.artist.item_id, MediaType.Artist, item.artist.provider, size + ) if not img_url: return None # fetch image and store in cache @@ -562,7 +559,7 @@ class MusicManager(): async with session.get(img_url, verify_ssl=False) as response: assert response.status == 200 img_data = await response.read() - with open(cache_file_org, 'wb') as img_file: + with open(cache_file_org, "wb") as img_file: img_file.write(img_data) if not size: # return base image @@ -570,7 +567,7 @@ class MusicManager(): # save resized image basewidth = size img = Image.open(cache_file_org) - wpercent = (basewidth / float(img.size[0])) + wpercent = basewidth / float(img.size[0]) hsize = int((float(img.size[1]) * float(wpercent))) img = img.resize((basewidth, hsize), Image.ANTIALIAS) img.save(cache_file_sized) diff --git a/music_assistant/musicproviders/file.py b/music_assistant/musicproviders/file.py index 864a7856..a72c4e64 100644 --- a/music_assistant/musicproviders/file.py +++ b/music_assistant/musicproviders/file.py @@ -2,36 +2,46 @@ # -*- coding:utf-8 -*- import asyncio +import base64 import os -from typing import List import sys import time -import base64 -import taglib +from typing import List -from music_assistant.utils import run_periodic, LOGGER, parse_title_and_version -from music_assistant.models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist from music_assistant.constants import CONF_ENABLED +from music_assistant.models.media_types import ( + Album, + AlbumType, + Artist, + MediaType, + Playlist, + Track, + TrackQuality, +) +from music_assistant.models.musicprovider import MusicProvider +from music_assistant.utils import LOGGER, parse_title_and_version +import taglib -PROV_NAME = 'Local files and playlists' -PROV_CLASS = 'FileProvider' +PROV_NAME = "Local files and playlists" +PROV_CLASS = "FileProvider" CONFIG_ENTRIES = [ (CONF_ENABLED, False, CONF_ENABLED), - ("music_dir", "", "file_prov_music_path"), - ("playlists_dir", "", "file_prov_playlists_path") - ] + ("music_dir", "", "file_prov_music_path"), + ("playlists_dir", "", "file_prov_playlists_path"), +] class FileProvider(MusicProvider): - ''' + """ Very basic implementation of a musicprovider for local files Assumes files are stored on disk in format // Reads ID3 tags from file and falls back to parsing filename Supports m3u files only for playlists Supports having URI's from streaming providers within m3u playlist Should be compatible with LMS - ''' + """ + _music_dir = None _playlists_dir = None @@ -46,60 +56,59 @@ class FileProvider(MusicProvider): self._playlists_dir = None async def search(self, searchstring, media_types=List[MediaType], limit=5): - ''' perform search on the provider ''' - result = { - "artists": [], - "albums": [], - "tracks": [], - "playlists": [] - } + """ perform search on the provider """ + result = {"artists": [], "albums": [], "tracks": [], "playlists": []} return result - + async def get_library_artists(self) -> List[Artist]: - ''' get artist folders in music directory ''' + """ get artist folders in music directory """ if not os.path.isdir(self._music_dir): LOGGER.error("music path does not exist: %s" % self._music_dir) return yield for dirname in os.listdir(self._music_dir): dirpath = os.path.join(self._music_dir, dirname) - if os.path.isdir(dirpath) and not dirpath.startswith('.'): + if os.path.isdir(dirpath) and not dirpath.startswith("."): artist = await self.get_artist(dirpath) if artist: yield artist - + async def get_library_albums(self) -> List[Album]: - ''' get album folders recursively ''' + """ get album folders recursively """ async for artist in self.get_library_artists(): async for album in self.get_artist_albums(artist.item_id): yield album async def get_library_tracks(self) -> List[Track]: - ''' get all tracks recursively ''' - #TODO: support disk subfolders + """ get all tracks recursively """ + # TODO: support disk subfolders async for album in self.get_library_albums(): async for track in self.get_album_tracks(album.item_id): yield track - + async def get_library_playlists(self) -> List[Playlist]: - ''' retrieve playlists from disk ''' + """ retrieve playlists from disk """ if not self._playlists_dir: return yield for filename in os.listdir(self._playlists_dir): filepath = os.path.join(self._playlists_dir, filename) - if os.path.isfile(filepath) and not filename.startswith('.') and filename.lower().endswith('.m3u'): + if ( + os.path.isfile(filepath) + and not filename.startswith(".") + and filename.lower().endswith(".m3u") + ): playlist = await self.get_playlist(filepath) if playlist: yield playlist async def get_artist(self, prov_item_id) -> Artist: - ''' get full artist details by id ''' + """ get full artist details by id """ if not os.sep in prov_item_id: - itempath = base64.b64decode(prov_item_id).decode('utf-8') + itempath = base64.b64decode(prov_item_id).decode("utf-8") else: itempath = prov_item_id - prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') + prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8") if not os.path.isdir(itempath): LOGGER.error("artist path does not exist: %s" % itempath) return None @@ -108,19 +117,18 @@ class FileProvider(MusicProvider): artist.item_id = prov_item_id artist.provider = self.prov_id artist.name = name - artist.provider_ids.append({ - "provider": self.prov_id, - "item_id": artist.item_id - }) + artist.provider_ids.append( + {"provider": self.prov_id, "item_id": artist.item_id} + ) return artist - + async def get_album(self, prov_item_id) -> Album: - ''' get full album details by id ''' + """ get full album details by id """ if not os.sep in prov_item_id: - itempath = base64.b64decode(prov_item_id).decode('utf-8') + itempath = base64.b64decode(prov_item_id).decode("utf-8") else: itempath = prov_item_id - prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') + prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8") if not os.path.isdir(itempath): LOGGER.error("album path does not exist: %s" % itempath) return None @@ -133,16 +141,13 @@ class FileProvider(MusicProvider): album.artist = await self.get_artist(artistpath) if not album.artist: raise Exception("No album artist ! %s" % artistpath) - album.provider_ids.append({ - "provider": self.prov_id, - "item_id": prov_item_id - }) + album.provider_ids.append({"provider": self.prov_id, "item_id": prov_item_id}) return album async def get_track(self, prov_item_id) -> Track: - ''' get full track details by id ''' + """ get full track details by id """ if not os.sep in prov_item_id: - itempath = base64.b64decode(prov_item_id).decode('utf-8') + itempath = base64.b64decode(prov_item_id).decode("utf-8") else: itempath = prov_item_id if not os.path.isfile(itempath): @@ -151,32 +156,31 @@ class FileProvider(MusicProvider): return await self.__parse_track(itempath) async def get_playlist(self, prov_item_id) -> Playlist: - ''' get full playlist details by id ''' + """ get full playlist details by id """ if not os.sep in prov_item_id: - itempath = base64.b64decode(prov_item_id).decode('utf-8') + itempath = base64.b64decode(prov_item_id).decode("utf-8") else: itempath = prov_item_id - prov_item_id = base64.b64encode(itempath.encode('utf-8')).decode('utf-8') + prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8") if not os.path.isfile(itempath): LOGGER.error("playlist path does not exist: %s" % itempath) return None playlist = Playlist() playlist.item_id = prov_item_id playlist.provider = self.prov_id - playlist.name = itempath.split(os.sep)[-1].replace('.m3u', '') + playlist.name = itempath.split(os.sep)[-1].replace(".m3u", "") playlist.is_editable = True - playlist.provider_ids.append({ - "provider": self.prov_id, - "item_id": prov_item_id - }) - playlist.owner = 'disk' + playlist.provider_ids.append( + {"provider": self.prov_id, "item_id": prov_item_id} + ) + playlist.owner = "disk" playlist.checksum = os.path.getmtime(itempath) return playlist - + async def get_album_tracks(self, prov_album_id) -> List[Track]: - ''' get album tracks for given album id ''' + """ get album tracks for given album id """ if not os.sep in prov_album_id: - albumpath = base64.b64decode(prov_album_id).decode('utf-8') + albumpath = base64.b64decode(prov_album_id).decode("utf-8") else: albumpath = prov_album_id if not os.path.isdir(albumpath): @@ -185,16 +189,18 @@ class FileProvider(MusicProvider): album = await self.get_album(albumpath) for filename in os.listdir(albumpath): filepath = os.path.join(albumpath, filename) - if os.path.isfile(filepath) and not filepath.startswith('.'): + if os.path.isfile(filepath) and not filepath.startswith("."): track = await self.__parse_track(filepath) if track: track.album = album yield track - async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]: - ''' get playlist tracks for given playlist id ''' + async def get_playlist_tracks( + self, prov_playlist_id, limit=50, offset=0 + ) -> List[Track]: + """ get playlist tracks for given playlist id """ if not os.sep in prov_playlist_id: - itempath = base64.b64decode(prov_playlist_id).decode('utf-8') + itempath = base64.b64decode(prov_playlist_id).decode("utf-8") else: itempath = prov_playlist_id if not os.path.isfile(itempath): @@ -205,7 +211,7 @@ class FileProvider(MusicProvider): with open(itempath) as f: for line in f.readlines(): line = line.strip() - if line and not line.startswith('#'): + if line and not line.startswith("#"): counter += 1 if counter > offset: track = await self.__parse_track_from_uri(line) @@ -216,9 +222,9 @@ class FileProvider(MusicProvider): break async def get_artist_albums(self, prov_artist_id) -> List[Album]: - ''' get a list of albums for the given artist ''' + """ get a list of albums for the given artist """ if not os.sep in prov_artist_id: - artistpath = base64.b64decode(prov_artist_id).decode('utf-8') + artistpath = base64.b64decode(prov_artist_id).decode("utf-8") else: artistpath = prov_artist_id if not os.path.isdir(artistpath): @@ -226,49 +232,49 @@ class FileProvider(MusicProvider): return for dirname in os.listdir(artistpath): dirpath = os.path.join(artistpath, dirname) - if os.path.isdir(dirpath) and not dirpath.startswith('.'): + if os.path.isdir(dirpath) and not dirpath.startswith("."): album = await self.get_album(dirpath) if album: yield album async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - ''' get a list of random tracks as we have no clue about preference ''' + """ get a list of random tracks as we have no clue about preference """ async for album in self.get_artist_albums(prov_artist_id): async for track in self.get_album_tracks(album.item_id): yield track async def get_stream_details(self, track_id): - ''' return the content details for the given track when it will be streamed''' + """ return the content details for the given track when it will be streamed""" if not os.sep in track_id: - track_id = base64.b64decode(track_id).decode('utf-8') + track_id = base64.b64decode(track_id).decode("utf-8") if not os.path.isfile(track_id): return None # TODO: retrieve sanple rate and bitdepth return { "type": "file", "path": track_id, - "content_type": track_id.split('.')[-1], + "content_type": track_id.split(".")[-1], "sample_rate": 44100, - "bit_depth": 16 + "bit_depth": 16, } - + async def __parse_track(self, filename): - ''' try to parse a track from a filename with taglib ''' + """ try to parse a track from a filename with taglib """ track = Track() try: song = taglib.File(filename) except: - return None # not a media file ? - prov_item_id = base64.b64encode(filename.encode('utf-8')).decode('utf-8') + return None # not a media file ? + prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8") track.duration = song.length track.item_id = prov_item_id track.provider = self.prov_id - name = song.tags['TITLE'][0] + name = song.tags["TITLE"][0] track.name, track.version = parse_title_and_version(name) - albumpath = filename.rsplit(os.sep,1)[0] + albumpath = filename.rsplit(os.sep, 1)[0] track.album = await self.get_album(albumpath) artists = [] - for artist_str in song.tags['ARTIST']: + for artist_str in song.tags["ARTIST"]: local_artist_path = os.path.join(self._music_dir, artist_str) if os.path.isfile(local_artist_path): artist = await self.get_artist(local_artist_path) @@ -276,23 +282,27 @@ class FileProvider(MusicProvider): artist = Artist() artist.name = artist_str fake_artistpath = os.path.join(self._music_dir, artist_str) - artist.item_id = fake_artistpath # temporary id - artist.provider_ids.append({ + artist.item_id = fake_artistpath # temporary id + artist.provider_ids.append( + { "provider": self.prov_id, - "item_id": base64.b64encode(fake_artistpath.encode('utf-8')).decode('utf-8') - }) + "item_id": base64.b64encode( + fake_artistpath.encode("utf-8") + ).decode("utf-8"), + } + ) artists.append(artist) track.artists = artists - if 'GENRE' in song.tags: - track.tags = song.tags['GENRE'] - if 'ISRC' in song.tags: - track.external_ids.append( {"isrc": song.tags['ISRC'][0]} ) - if 'DISCNUMBER' in song.tags: - track.disc_number = int(song.tags['DISCNUMBER'][0]) - if 'TRACKNUMBER' in song.tags: - track.track_number = int(song.tags['TRACKNUMBER'][0]) + if "GENRE" in song.tags: + track.tags = song.tags["GENRE"] + if "ISRC" in song.tags: + track.external_ids.append({"isrc": song.tags["ISRC"][0]}) + if "DISCNUMBER" in song.tags: + track.disc_number = int(song.tags["DISCNUMBER"][0]) + if "TRACKNUMBER" in song.tags: + track.track_number = int(song.tags["TRACKNUMBER"][0]) quality_details = "" - if filename.endswith('.flac'): + if filename.endswith(".flac"): # TODO: get bit depth quality = TrackQuality.FLAC_LOSSLESS if song.sampleRate > 192000: @@ -301,34 +311,38 @@ class FileProvider(MusicProvider): quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 elif song.sampleRate > 48000: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - quality_details = "%s Khz" % (song.sampleRate/1000) - elif filename.endswith('.ogg'): + quality_details = "%s Khz" % (song.sampleRate / 1000) + elif filename.endswith(".ogg"): quality = TrackQuality.LOSSY_OGG quality_details = "%s kbps" % (song.bitrate) - elif filename.endswith('.m4a'): + elif filename.endswith(".m4a"): quality = TrackQuality.LOSSY_AAC quality_details = "%s kbps" % (song.bitrate) else: quality = TrackQuality.LOSSY_MP3 quality_details = "%s kbps" % (song.bitrate) - track.provider_ids.append({ - "provider": self.prov_id, - "item_id": prov_item_id, - "quality": quality, - "details": quality_details - }) + track.provider_ids.append( + { + "provider": self.prov_id, + "item_id": prov_item_id, + "quality": quality, + "details": quality_details, + } + ) return track - + async def __parse_track_from_uri(self, uri): - ''' try to parse a track from an uri found in playlist ''' + """ try to parse a track from an uri found in playlist """ if "://" in uri: # track is uri from external provider? - prov_id = uri.split('://')[0] - prov_item_id = uri.split('/')[-1].split('.')[0].split(':')[-1] + prov_id = uri.split("://")[0] + prov_item_id = uri.split("/")[-1].split(".")[0].split(":")[-1] try: - return await self.mass.music.providers[prov_id].track(prov_item_id, lazy=False) + return await self.mass.music.providers[prov_id].track( + prov_item_id, lazy=False + ) except Exception as exc: - LOGGER.warning("Could not parse uri %s to track: %s" %(uri, str(exc))) + LOGGER.warning("Could not parse uri %s to track: %s" % (uri, str(exc))) return None # try to treat uri as filename # TODO: filename could be related to musicdir or full path diff --git a/music_assistant/musicproviders/qobuz.py b/music_assistant/musicproviders/qobuz.py index a961ef13..6393ab9c 100644 --- a/music_assistant/musicproviders/qobuz.py +++ b/music_assistant/musicproviders/qobuz.py @@ -1,26 +1,42 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -from typing import List import datetime import hashlib import time +from typing import List + import aiohttp from asyncio_throttle import Throttler - -from music_assistant.utils import LOGGER, parse_title_and_version from music_assistant.app_vars import get_app_var -from music_assistant.models.media_types import MediaType, AlbumType, Artist, Album, Track, Playlist, TrackQuality +from music_assistant.constants import ( + CONF_ENABLED, + CONF_PASSWORD, + CONF_TYPE_PASSWORD, + CONF_USERNAME, + EVENT_PLAYBACK_STOPPED, + EVENT_STREAM_STARTED, +) +from music_assistant.models.media_types import ( + Album, + AlbumType, + Artist, + MediaType, + Playlist, + Track, + TrackQuality, +) from music_assistant.models.musicprovider import MusicProvider -from music_assistant.constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, \ - CONF_TYPE_PASSWORD, EVENT_STREAM_STARTED, EVENT_PLAYBACK_STOPPED +from music_assistant.utils import LOGGER, parse_title_and_version -PROV_NAME = 'Qobuz' -PROV_CLASS = 'QobuzProvider' +PROV_NAME = "Qobuz" +PROV_CLASS = "QobuzProvider" -CONFIG_ENTRIES = [(CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD)] +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD), +] class QobuzProvider(MusicProvider): @@ -33,7 +49,7 @@ class QobuzProvider(MusicProvider): __logged_in = None async def setup(self, conf): - ''' perform async setup ''' + """ perform async setup """ self.__username = conf[CONF_USERNAME] self.__password = conf[CONF_PASSWORD] if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: @@ -41,15 +57,14 @@ class QobuzProvider(MusicProvider): self.__user_auth_info = None self.__logged_in = False self.http_session = aiohttp.ClientSession( - loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) + loop=self.mass.event_loop, connector=aiohttp.TCPConnector() + ) self.throttler = Throttler(rate_limit=4, period=1) - await self.mass.add_event_listener(self.mass_event, - EVENT_STREAM_STARTED) - await self.mass.add_event_listener(self.mass_event, - EVENT_PLAYBACK_STOPPED) + await self.mass.add_event_listener(self.mass_event, EVENT_STREAM_STARTED) + await self.mass.add_event_listener(self.mass_event, EVENT_PLAYBACK_STOPPED) async def search(self, searchstring, media_types=List[MediaType], limit=5): - ''' perform search on the provider ''' + """ perform search on the provider """ result = {"artists": [], "albums": [], "tracks": [], "playlists": []} params = {"query": searchstring, "limit": limit} if len(media_types) == 1: @@ -81,469 +96,483 @@ class QobuzProvider(MusicProvider): result["tracks"].append(track) if "playlists" in searchresult: for item in searchresult["playlists"]["items"]: - result["playlists"].append(await - self.__parse_playlist(item)) + result["playlists"].append(await self.__parse_playlist(item)) return result async def get_library_artists(self) -> List[Artist]: - ''' retrieve all library artists from qobuz ''' - params = {'type': 'artists'} - endpoint = 'favorite/getUserFavorites' - async for item in self.__get_all_items(endpoint, params, key='artists'): + """ retrieve all library artists from qobuz """ + params = {"type": "artists"} + endpoint = "favorite/getUserFavorites" + async for item in self.__get_all_items(endpoint, params, key="artists"): artist = await self.__parse_artist(item) if artist: yield artist async def get_library_albums(self) -> List[Album]: - ''' retrieve all library albums from qobuz ''' - params = {'type': 'albums'} - endpoint = 'favorite/getUserFavorites' - async for item in self.__get_all_items(endpoint, params, key='albums'): + """ retrieve all library albums from qobuz """ + params = {"type": "albums"} + endpoint = "favorite/getUserFavorites" + async for item in self.__get_all_items(endpoint, params, key="albums"): album = await self.__parse_album(item) if album: yield album async def get_library_tracks(self) -> List[Track]: - ''' retrieve library tracks from qobuz ''' - params = {'type': 'tracks'} - endpoint = 'favorite/getUserFavorites' - async for item in self.__get_all_items(endpoint, params, key='tracks'): + """ retrieve library tracks from qobuz """ + params = {"type": "tracks"} + endpoint = "favorite/getUserFavorites" + async for item in self.__get_all_items(endpoint, params, key="tracks"): track = await self.__parse_track(item) if track: yield track async def get_library_playlists(self) -> List[Playlist]: - ''' retrieve all library playlists from the provider ''' - endpoint = 'playlist/getUserPlaylists' - async for item in self.__get_all_items(endpoint, key='playlists'): + """ retrieve all library playlists from the provider """ + endpoint = "playlist/getUserPlaylists" + async for item in self.__get_all_items(endpoint, key="playlists"): playlist = await self.__parse_playlist(item) if playlist: yield playlist async def get_artist(self, prov_artist_id) -> Artist: - ''' get full artist details by id ''' - params = {'artist_id': prov_artist_id} + """ get full artist details by id """ + params = {"artist_id": prov_artist_id} artist_obj = await self.__get_data("artist/get", params) return await self.__parse_artist(artist_obj) async def get_album(self, prov_album_id) -> Album: - ''' get full album details by id ''' - params = {'album_id': prov_album_id} + """ get full album details by id """ + params = {"album_id": prov_album_id} album_obj = await self.__get_data("album/get", params) return await self.__parse_album(album_obj) async def get_track(self, prov_track_id) -> Track: - ''' get full track details by id ''' - params = {'track_id': prov_track_id} + """ get full track details by id """ + params = {"track_id": prov_track_id} track_obj = await self.__get_data("track/get", params) return await self.__parse_track(track_obj) async def get_playlist(self, prov_playlist_id) -> Playlist: - ''' get full playlist details by id ''' - params = {'playlist_id': prov_playlist_id} + """ get full playlist details by id """ + params = {"playlist_id": prov_playlist_id} playlist_obj = await self.__get_data("playlist/get", params) return await self.__parse_playlist(playlist_obj) async def get_album_tracks(self, prov_album_id) -> List[Track]: - ''' get all album tracks for given album id ''' - params = {'album_id': prov_album_id} - async for item in self.__get_all_items('album/get', params, key='tracks'): + """ get all album tracks for given album id """ + params = {"album_id": prov_album_id} + async for item in self.__get_all_items("album/get", params, key="tracks"): track = await self.__parse_track(item) if track: yield track else: - LOGGER.warning("Unavailable track found in album %s: %s", - prov_album_id, item['title']) + LOGGER.warning( + "Unavailable track found in album %s: %s", + prov_album_id, + item["title"], + ) async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]: - ''' get all playlist tracks for given playlist id ''' - params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'} - endpoint = 'playlist/get' - async for item in self.__get_all_items(endpoint, params, key='tracks'): + """ get all playlist tracks for given playlist id """ + params = {"playlist_id": prov_playlist_id, "extra": "tracks"} + endpoint = "playlist/get" + async for item in self.__get_all_items(endpoint, params, key="tracks"): playlist_track = await self.__parse_track(item) if playlist_track: yield playlist_track else: - LOGGER.warning("Unavailable track found in playlist %s: %s", - prov_playlist_id, item['title']) + LOGGER.warning( + "Unavailable track found in playlist %s: %s", + prov_playlist_id, + item["title"], + ) # TODO: should we look for an alternative track version if the original is marked unavailable ? async def get_artist_albums(self, prov_artist_id) -> List[Album]: - ''' get a list of albums for the given artist ''' - params = { 'artist_id': prov_artist_id, 'extra': 'albums' } - endpoint = 'artist/get' - async for item in self.__get_all_items(endpoint, params, key='albums'): - if str(item['artist']['id']) == str(prov_artist_id): + """ get a list of albums for the given artist """ + params = {"artist_id": prov_artist_id, "extra": "albums"} + endpoint = "artist/get" + async for item in self.__get_all_items(endpoint, params, key="albums"): + if str(item["artist"]["id"]) == str(prov_artist_id): album = await self.__parse_album(item) if album: yield album async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - ''' get a list of most popular tracks for the given artist ''' + """ get a list of most popular tracks for the given artist """ # artist toptracks not supported on Qobuz, so use search instead # assuming qobuz returns results sorted by popularity artist = await self.get_artist(prov_artist_id) params = {"query": artist.name, "limit": 25, "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): + if "performer" in item and str(item["performer"]["id"]) == str( + prov_artist_id + ): track = await self.__parse_track(item) if track: yield track async def add_library(self, prov_item_id, media_type: MediaType): - ''' add item to library ''' + """ add item to library """ result = None if media_type == MediaType.Artist: - result = await self.__get_data('favorite/create', - {'artist_ids': prov_item_id}) + result = await self.__get_data( + "favorite/create", {"artist_ids": prov_item_id} + ) elif media_type == MediaType.Album: - result = await self.__get_data('favorite/create', - {'album_ids': prov_item_id}) + result = await self.__get_data( + "favorite/create", {"album_ids": prov_item_id} + ) elif media_type == MediaType.Track: - result = await self.__get_data('favorite/create', - {'track_ids': prov_item_id}) + result = await self.__get_data( + "favorite/create", {"track_ids": prov_item_id} + ) elif media_type == MediaType.Playlist: - result = await self.__get_data('playlist/subscribe', - {'playlist_id': prov_item_id}) + result = await self.__get_data( + "playlist/subscribe", {"playlist_id": prov_item_id} + ) return result async def remove_library(self, prov_item_id, media_type: MediaType): - ''' remove item from library ''' + """ remove item from library """ result = None if media_type == MediaType.Artist: - result = await self.__get_data('favorite/delete', - {'artist_ids': prov_item_id}) + result = await self.__get_data( + "favorite/delete", {"artist_ids": prov_item_id} + ) elif media_type == MediaType.Album: - result = await self.__get_data('favorite/delete', - {'album_ids': prov_item_id}) + result = await self.__get_data( + "favorite/delete", {"album_ids": prov_item_id} + ) elif media_type == MediaType.Track: - result = await self.__get_data('favorite/delete', - {'track_ids': prov_item_id}) + result = await self.__get_data( + "favorite/delete", {"track_ids": prov_item_id} + ) elif media_type == MediaType.Playlist: playlist = await self.playlist(prov_item_id) if playlist.is_editable: - result = await self.__get_data('playlist/delete', - {'playlist_id': prov_item_id}) + result = await self.__get_data( + "playlist/delete", {"playlist_id": prov_item_id} + ) else: - result = await self.__get_data('playlist/unsubscribe', - {'playlist_id': prov_item_id}) + result = await self.__get_data( + "playlist/unsubscribe", {"playlist_id": prov_item_id} + ) return result async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids): - ''' add track(s) to playlist ''' + """ add track(s) to playlist """ params = { - 'playlist_id': prov_playlist_id, - 'track_ids': ",".join(prov_track_ids) + "playlist_id": prov_playlist_id, + "track_ids": ",".join(prov_track_ids), } - return await self.__get_data('playlist/addTracks', params) + return await self.__get_data("playlist/addTracks", params) async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids): - ''' remove track(s) from playlist ''' + """ remove track(s) from playlist """ playlist_track_ids = [] - params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'} - for track in await self.__get_all_items("playlist/get", - params, - key='tracks'): - if track['id'] in prov_track_ids: - playlist_track_ids.append(track['playlist_track_id']) + params = {"playlist_id": prov_playlist_id, "extra": "tracks"} + for track in await self.__get_all_items("playlist/get", params, key="tracks"): + if track["id"] in prov_track_ids: + playlist_track_ids.append(track["playlist_track_id"]) params = { - 'playlist_id': prov_playlist_id, - 'track_ids': ",".join(playlist_track_ids) + "playlist_id": prov_playlist_id, + "track_ids": ",".join(playlist_track_ids), } - return await self.__get_data('playlist/deleteTracks', params) + return await self.__get_data("playlist/deleteTracks", params) async def get_stream_details(self, track_id): - ''' return the content details for the given track when it will be streamed''' + """ return the content details for the given track when it will be streamed""" streamdetails = None for format_id in [27, 7, 6, 5]: # it seems that simply requesting for highest available quality does not work # from time to time the api response is empty for this request ?! - params = { - 'format_id': format_id, - 'track_id': track_id, - 'intent': 'stream' - } - streamdetails = await self.__get_data('track/getFileUrl', - params, - sign_request=True) - if streamdetails and streamdetails.get('url'): + params = {"format_id": format_id, "track_id": track_id, "intent": "stream"} + streamdetails = await self.__get_data( + "track/getFileUrl", params, sign_request=True + ) + if streamdetails and streamdetails.get("url"): break - if not streamdetails or not streamdetails.get('url'): - LOGGER.error("Unable to retrieve stream url for track %s", - track_id) + if not streamdetails or not streamdetails.get("url"): + LOGGER.error("Unable to retrieve stream url for track %s", track_id) return None return { "type": "url", - "path": streamdetails['url'], - "content_type": streamdetails['mime_type'].split('/')[1], - "sample_rate": int(streamdetails['sampling_rate'] * 1000), - "bit_depth": streamdetails['bit_depth'], - "details": - streamdetails # we need these details for reporting playback + "path": streamdetails["url"], + "content_type": streamdetails["mime_type"].split("/")[1], + "sample_rate": int(streamdetails["sampling_rate"] * 1000), + "bit_depth": streamdetails["bit_depth"], + "details": streamdetails, # we need these details for reporting playback } async def mass_event(self, msg, msg_details): - ''' + """ received event from mass we use this to report playback start/stop to qobuz - ''' + """ if not self.__user_auth_info: return # TODO: need to figure out if the streamed track is purchased by user - if msg == EVENT_STREAM_STARTED and msg_details[ - "provider"] == self.prov_id: + if msg == EVENT_STREAM_STARTED and msg_details["provider"] == self.prov_id: # report streaming started to qobuz device_id = self.__user_auth_info["user"]["device"]["id"] credential_id = self.__user_auth_info["user"]["credential"]["id"] user_id = self.__user_auth_info["user"]["id"] format_id = msg_details["details"]["format_id"] timestamp = int(time.time()) - events = [{ - "online": True, - "sample": False, - "intent": "stream", - "device_id": device_id, - "track_id": msg_details["item_id"], - "purchase": False, - "date": timestamp, - "credential_id": credential_id, - "user_id": user_id, - "local": False, - "format_id": format_id - }] + events = [ + { + "online": True, + "sample": False, + "intent": "stream", + "device_id": device_id, + "track_id": msg_details["item_id"], + "purchase": False, + "date": timestamp, + "credential_id": credential_id, + "user_id": user_id, + "local": False, + "format_id": format_id, + } + ] await self.__post_data("track/reportStreamingStart", data=events) - elif msg == EVENT_PLAYBACK_STOPPED and msg_details[ - "provider"] == self.prov_id: + elif msg == EVENT_PLAYBACK_STOPPED and msg_details["provider"] == self.prov_id: # report streaming ended to qobuz - if msg_details.get('msg_details',0) < 6: + if msg_details.get("msg_details", 0) < 6: return user_id = self.__user_auth_info["user"]["id"] params = { - 'user_id': user_id, - 'track_id': msg_details["item_id"], - 'duration': int(msg_details["seconds_played"]) + "user_id": user_id, + "track_id": msg_details["item_id"], + "duration": int(msg_details["seconds_played"]), } - await self.__get_data('/track/reportStreamingEnd', params) + await self.__get_data("/track/reportStreamingEnd", params) async def __parse_artist(self, artist_obj): - ''' parse qobuz artist object to generic layout ''' + """ parse qobuz artist object to generic layout """ artist = Artist() - if not artist_obj or not artist_obj.get('id'): + if not artist_obj or not artist_obj.get("id"): return None - artist.item_id = artist_obj['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'] - }) - artist.name = artist_obj['name'] - if artist_obj.get('image'): - for key in ['extralarge', 'large', 'medium', 'small']: - if artist_obj['image'].get(key): - if not '2a96cbd8b46e442fc41c2b86b821562f' in artist_obj[ - 'image'][key]: - artist.metadata["image"] = artist_obj['image'][key] + artist.provider_ids.append( + {"provider": self.prov_id, "item_id": artist_obj["id"]} + ) + artist.name = artist_obj["name"] + if artist_obj.get("image"): + for key in ["extralarge", "large", "medium", "small"]: + if artist_obj["image"].get(key): + if ( + not "2a96cbd8b46e442fc41c2b86b821562f" + in artist_obj["image"][key] + ): + artist.metadata["image"] = artist_obj["image"][key] break - if artist_obj.get('biography'): - artist.metadata["biography"] = artist_obj['biography'].get( - 'content', '') - if artist_obj.get('url'): - artist.metadata["qobuz_url"] = artist_obj['url'] + if artist_obj.get("biography"): + artist.metadata["biography"] = artist_obj["biography"].get("content", "") + if artist_obj.get("url"): + artist.metadata["qobuz_url"] = artist_obj["url"] return artist async def __parse_album(self, album_obj): - ''' parse qobuz album object to generic layout ''' + """ parse qobuz album object to generic layout """ album = Album() - if not album_obj or not album_obj.get('id') or not album_obj[ - "streamable"] or not album_obj["displayable"]: + if ( + not album_obj + or not album_obj.get("id") + or not album_obj["streamable"] + or not album_obj["displayable"] + ): # do not return unavailable items return None - album.item_id = album_obj['id'] + album.item_id = album_obj["id"] album.provider = self.prov_id - if album_obj['maximum_sampling_rate'] > 192: + if album_obj["maximum_sampling_rate"] > 192: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 - elif album_obj['maximum_sampling_rate'] > 96: + elif album_obj["maximum_sampling_rate"] > 96: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 - elif album_obj['maximum_sampling_rate'] > 48: + elif album_obj["maximum_sampling_rate"] > 48: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - elif album_obj['maximum_bit_depth'] > 16: + elif album_obj["maximum_bit_depth"] > 16: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1 - elif album_obj.get('format_id', 0) == 5: + elif album_obj.get("format_id", 0) == 5: quality = TrackQuality.LOSSY_AAC else: quality = TrackQuality.FLAC_LOSSLESS - album.provider_ids.append({ - "provider": - self.prov_id, - "item_id": - album_obj['id'], - "quality": - quality, - "details": - "%skHz %sbit" % (album_obj['maximum_sampling_rate'], - album_obj['maximum_bit_depth']) - }) + album.provider_ids.append( + { + "provider": self.prov_id, + "item_id": album_obj["id"], + "quality": quality, + "details": "%skHz %sbit" + % (album_obj["maximum_sampling_rate"], album_obj["maximum_bit_depth"]), + } + ) album.name, album.version = parse_title_and_version( - album_obj['title'], album_obj.get('version')) - album.artist = await self.__parse_artist(album_obj['artist']) - if album_obj.get('product_type', '') == 'single': + album_obj["title"], album_obj.get("version") + ) + album.artist = await self.__parse_artist(album_obj["artist"]) + if album_obj.get("product_type", "") == "single": album.albumtype = AlbumType.Single - elif album_obj.get( - 'product_type', '' - ) == 'compilation' or 'Various' in album_obj['artist']['name']: + elif ( + album_obj.get("product_type", "") == "compilation" + or "Various" in album_obj["artist"]["name"] + ): album.albumtype = AlbumType.Compilation else: album.albumtype = AlbumType.Album - if 'genre' in album_obj: - album.tags = [album_obj['genre']['name']] - if album_obj.get('image'): - for key in ['extralarge', 'large', 'medium', 'small']: - if album_obj['image'].get(key): - album.metadata["image"] = album_obj['image'][key] + if "genre" in album_obj: + album.tags = [album_obj["genre"]["name"]] + if album_obj.get("image"): + for key in ["extralarge", "large", "medium", "small"]: + if album_obj["image"].get(key): + album.metadata["image"] = album_obj["image"][key] break - if len(album_obj['upc']) == 13: + if len(album_obj["upc"]) == 13: # qobuz writes ean as upc ?! - album.external_ids.append({"ean": album_obj['upc']}) - album.external_ids.append({"upc": album_obj['upc'][1:]}) + album.external_ids.append({"ean": album_obj["upc"]}) + album.external_ids.append({"upc": album_obj["upc"][1:]}) else: - album.external_ids.append({"upc": album_obj['upc']}) - if 'label' in album_obj: - album.labels = album_obj['label']['name'].split('/') - if album_obj.get('released_at'): - album.year = datetime.datetime.fromtimestamp( - album_obj['released_at']).year - if album_obj.get('copyright'): - album.metadata["copyright"] = album_obj['copyright'] - if album_obj.get('hires'): + album.external_ids.append({"upc": album_obj["upc"]}) + if "label" in album_obj: + album.labels = album_obj["label"]["name"].split("/") + if album_obj.get("released_at"): + album.year = datetime.datetime.fromtimestamp(album_obj["released_at"]).year + if album_obj.get("copyright"): + album.metadata["copyright"] = album_obj["copyright"] + if album_obj.get("hires"): album.metadata["hires"] = "true" - if album_obj.get('url'): - album.metadata["qobuz_url"] = album_obj['url'] - if album_obj.get('description'): - album.metadata["description"] = album_obj['description'] + if album_obj.get("url"): + album.metadata["qobuz_url"] = album_obj["url"] + if album_obj.get("description"): + album.metadata["description"] = album_obj["description"] return album async def __parse_track(self, track_obj): - ''' parse qobuz track object to generic layout ''' + """ parse qobuz track object to generic layout """ track = Track() - if not track_obj or not track_obj.get('id') or not track_obj[ - "streamable"] or not track_obj["displayable"]: + if ( + not track_obj + or not track_obj.get("id") + or not track_obj["streamable"] + or not track_obj["displayable"] + ): # do not return unavailable items return None - track.item_id = track_obj['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']) + if track_obj.get("performer") and not "Various " in track_obj["performer"]: + artist = await self.__parse_artist(track_obj["performer"]) if artist: track.artists.append(artist) if not track.artists: # try to grab artist from album - if track_obj.get('album') and track_obj['album'].get( - 'artist' - ) and not 'Various ' in track_obj['album']['artist']: - artist = await self.__parse_artist(track_obj['album']['artist'] - ) + if ( + track_obj.get("album") + and track_obj["album"].get("artist") + and not "Various " in track_obj["album"]["artist"] + ): + artist = await self.__parse_artist(track_obj["album"]["artist"]) if artist: track.artists.append(artist) if not track.artists: # last resort: parse from performers string - for performer_str in track_obj['performers'].split(' - '): - role = performer_str.split(', ')[1] - name = performer_str.split(', ')[0] - if 'artist' in role.lower(): + for performer_str in track_obj["performers"].split(" - "): + role = performer_str.split(", ")[1] + name = performer_str.split(", ")[0] + if "artist" in role.lower(): artist = Artist() artist.name = name artist.item_id = name track.artists.append(artist) # TODO: fix grabbing composer from details track.name, track.version = parse_title_and_version( - track_obj['title'], track_obj.get('version')) - track.duration = track_obj['duration'] - if 'album' in track_obj: - album = await self.__parse_album(track_obj['album']) + track_obj["title"], track_obj.get("version") + ) + track.duration = track_obj["duration"] + if "album" in track_obj: + album = await self.__parse_album(track_obj["album"]) if album: track.album = album - track.disc_number = track_obj['media_number'] - track.track_number = track_obj['track_number'] - if track_obj.get('hires'): + track.disc_number = track_obj["media_number"] + track.track_number = track_obj["track_number"] + if track_obj.get("hires"): track.metadata["hires"] = "true" - if track_obj.get('url'): - track.metadata["qobuz_url"] = track_obj['url'] - if track_obj.get('isrc'): - track.external_ids.append({"isrc": track_obj['isrc']}) - if track_obj.get('performers'): - track.metadata["performers"] = track_obj['performers'] - if track_obj.get('copyright'): - track.metadata["copyright"] = track_obj['copyright'] + if track_obj.get("url"): + track.metadata["qobuz_url"] = track_obj["url"] + if track_obj.get("isrc"): + track.external_ids.append({"isrc": track_obj["isrc"]}) + if track_obj.get("performers"): + track.metadata["performers"] = track_obj["performers"] + if track_obj.get("copyright"): + track.metadata["copyright"] = track_obj["copyright"] # get track quality - if track_obj['maximum_sampling_rate'] > 192: + if track_obj["maximum_sampling_rate"] > 192: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 - elif track_obj['maximum_sampling_rate'] > 96: + elif track_obj["maximum_sampling_rate"] > 96: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 - elif track_obj['maximum_sampling_rate'] > 48: + elif track_obj["maximum_sampling_rate"] > 48: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 - elif track_obj['maximum_bit_depth'] > 16: + elif track_obj["maximum_bit_depth"] > 16: quality = TrackQuality.FLAC_LOSSLESS_HI_RES_1 - elif track_obj.get('format_id', 0) == 5: + elif track_obj.get("format_id", 0) == 5: quality = TrackQuality.LOSSY_AAC else: quality = TrackQuality.FLAC_LOSSLESS - track.provider_ids.append({ - "provider": - self.prov_id, - "item_id": - track_obj['id'], - "quality": - quality, - "details": - "%skHz %sbit" % (track_obj['maximum_sampling_rate'], - track_obj['maximum_bit_depth']) - }) + track.provider_ids.append( + { + "provider": self.prov_id, + "item_id": track_obj["id"], + "quality": quality, + "details": "%skHz %sbit" + % (track_obj["maximum_sampling_rate"], track_obj["maximum_bit_depth"]), + } + ) return track async def __parse_playlist(self, playlist_obj): - ''' parse qobuz playlist object to generic layout ''' + """ parse qobuz playlist object to generic layout """ playlist = Playlist() - if not playlist_obj or not playlist_obj.get('id'): + if not playlist_obj or not playlist_obj.get("id"): return None - playlist.item_id = playlist_obj['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.__user_auth_info["user"]["id"] or playlist_obj[ - 'is_collaborative'] - if playlist_obj.get('images300'): - playlist.metadata["image"] = playlist_obj['images300'][0] - if playlist_obj.get('url'): - playlist.metadata["qobuz_url"] = playlist_obj['url'] - playlist.checksum = playlist_obj['updated_at'] + playlist.provider_ids.append( + {"provider": self.prov_id, "item_id": playlist_obj["id"]} + ) + playlist.name = playlist_obj["name"] + playlist.owner = playlist_obj["owner"]["name"] + playlist.is_editable = ( + playlist_obj["owner"]["id"] == self.__user_auth_info["user"]["id"] + or playlist_obj["is_collaborative"] + ) + if playlist_obj.get("images300"): + playlist.metadata["image"] = playlist_obj["images300"][0] + if playlist_obj.get("url"): + playlist.metadata["qobuz_url"] = playlist_obj["url"] + playlist.checksum = playlist_obj["updated_at"] return playlist async def __auth_token(self): - ''' login to qobuz and store the token''' + """ login to qobuz and store the token""" if self.__user_auth_info: return self.__user_auth_info["user_auth_token"] params = { "username": self.__username, "password": self.__password, - "device_manufacturer_id": "music_assistant" + "device_manufacturer_id": "music_assistant", } details = await self.__get_data("user/login", params) if details and "user" in details: self.__user_auth_info = details - LOGGER.info("Succesfully logged in to Qobuz as %s", - details["user"]["display_name"]) + LOGGER.info( + "Succesfully logged in to Qobuz as %s", details["user"]["display_name"] + ) return details["user_auth_token"] - async def __get_all_items(self, endpoint, params=None, key='tracks'): - ''' get all items from a paged list ''' + async def __get_all_items(self, endpoint, params=None, key="tracks"): + """ get all items from a paged list """ if not params: params = {} limit = 50 @@ -555,25 +584,25 @@ class QobuzProvider(MusicProvider): offset += limit if not result or not key in result or not "items" in result[key]: break - for item in result[key]['items']: + for item in result[key]["items"]: yield item - if len(result[key]['items']) < limit: + if len(result[key]["items"]) < limit: break async def __get_data(self, endpoint, params=None, sign_request=False): - ''' get data from api''' + """ get data from api""" if not params: params = {} url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint headers = {"X-App-Id": get_app_var(0)} - if endpoint != 'user/login': + if endpoint != "user/login": auth_token = await self.__auth_token() if not auth_token: LOGGER.debug("Not logged in") return None headers["X-User-Auth-Token"] = auth_token if sign_request: - signing_data = "".join(endpoint.split('/')) + signing_data = "".join(endpoint.split("/")) keys = list(params.keys()) keys.sort() for key in keys: @@ -586,18 +615,19 @@ class QobuzProvider(MusicProvider): params["app_id"] = get_app_var(0) params["user_auth_token"] = await self.__auth_token() async with self.throttler: - async with self.http_session.get(url, - headers=headers, - params=params, - verify_ssl=False) as response: + async with self.http_session.get( + url, headers=headers, params=params, verify_ssl=False + ) as response: result = await response.json() - if 'error' in result or ('status' in result and 'error' in result['status']): - LOGGER.error('%s - %s', endpoint, result) + if "error" in result or ( + "status" in result and "error" in result["status"] + ): + LOGGER.error("%s - %s", endpoint, result) return None return result async def __post_data(self, endpoint, params=None, data=None): - ''' post data to api''' + """ post data to api""" if not params: params = {} if not data: @@ -605,12 +635,13 @@ class QobuzProvider(MusicProvider): url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint params["app_id"] = get_app_var(0) params["user_auth_token"] = await self.__auth_token() - async with self.http_session.post(url, - params=params, - json=data, - verify_ssl=False) as response: + async with self.http_session.post( + url, params=params, json=data, verify_ssl=False + ) as response: result = await response.json() - if 'error' in result or ('status' in result and 'error' in result['status']): - LOGGER.error('%s - %s', endpoint, result) + if "error" in result or ( + "status" in result and "error" in result["status"] + ): + LOGGER.error("%s - %s", endpoint, result) return None return result diff --git a/music_assistant/musicproviders/spotify.py b/music_assistant/musicproviders/spotify.py index b508f1f3..e56b3f01 100644 --- a/music_assistant/musicproviders/spotify.py +++ b/music_assistant/musicproviders/spotify.py @@ -1,27 +1,42 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- +import asyncio import os -from typing import List -import time -import subprocess import platform -import asyncio -from asyncio_throttle import Throttler -import aiohttp +import subprocess +import time +from typing import List -from music_assistant.utils import LOGGER, parse_title_and_version, json +import aiohttp +from asyncio_throttle import Throttler from music_assistant.app_vars import get_app_var -from music_assistant.models.media_types import MediaType, AlbumType, Artist, Album, Track, Playlist, TrackQuality +from music_assistant.constants import ( + CONF_ENABLED, + CONF_PASSWORD, + CONF_TYPE_PASSWORD, + CONF_USERNAME, +) +from music_assistant.models.media_types import ( + Album, + AlbumType, + Artist, + MediaType, + Playlist, + Track, + TrackQuality, +) from music_assistant.models.musicprovider import MusicProvider -from music_assistant.constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, CONF_TYPE_PASSWORD +from music_assistant.utils import LOGGER, json, parse_title_and_version -PROV_NAME = 'Spotify' -PROV_CLASS = 'SpotifyProvider' +PROV_NAME = "Spotify" +PROV_CLASS = "SpotifyProvider" -CONFIG_ENTRIES = [(CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD)] +CONFIG_ENTRIES = [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD), +] class SpotifyProvider(MusicProvider): @@ -31,7 +46,7 @@ class SpotifyProvider(MusicProvider): sp_user = None async def setup(self, conf): - ''' perform async setup ''' + """ perform async setup """ self._cur_user = None if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: raise Exception("Username and password must not be empty") @@ -40,10 +55,11 @@ class SpotifyProvider(MusicProvider): self.__auth_token = {} self.throttler = Throttler(rate_limit=4, period=1) self.http_session = aiohttp.ClientSession( - loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) + loop=self.mass.event_loop, connector=aiohttp.TCPConnector() + ) async def search(self, searchstring, media_types=List[MediaType], limit=5): - ''' perform search on the provider ''' + """ perform search on the provider """ result = {"artists": [], "albums": [], "tracks": [], "playlists": []} searchtypes = [] if MediaType.Artist in media_types: @@ -56,8 +72,7 @@ class SpotifyProvider(MusicProvider): searchtypes.append("playlist") searchtype = ",".join(searchtypes) params = {"q": searchstring, "type": searchtype, "limit": limit} - searchresult = await self.__get_data("search", - params=params) + searchresult = await self.__get_data("search", params=params) if searchresult: if "artists" in searchresult: for item in searchresult["artists"]["items"]: @@ -82,58 +97,57 @@ class SpotifyProvider(MusicProvider): return result async def get_library_artists(self) -> List[Artist]: - ''' retrieve library artists from spotify ''' - spotify_artists = await self.__get_data( - "me/following?type=artist&limit=50") + """ retrieve library artists from spotify """ + spotify_artists = await self.__get_data("me/following?type=artist&limit=50") if spotify_artists: # TODO: use cursor method to retrieve more than 50 artists - for artist_obj in spotify_artists['artists']['items']: + for artist_obj in spotify_artists["artists"]["items"]: prov_artist = await self.__parse_artist(artist_obj) yield prov_artist async def get_library_albums(self) -> List[Album]: - ''' retrieve library albums from the provider ''' + """ retrieve library albums from the provider """ async for item in self.__get_all_items("me/albums"): album = await self.__parse_album(item) if album: yield album async def get_library_tracks(self) -> List[Track]: - ''' retrieve library tracks from the provider ''' + """ retrieve library tracks from the provider """ async for item in self.__get_all_items("me/tracks"): track = await self.__parse_track(item) if track: yield track async def get_library_playlists(self) -> List[Playlist]: - ''' retrieve playlists from the provider ''' + """ retrieve playlists from the provider """ async for item in self.__get_all_items("me/playlists"): playlist = await self.__parse_playlist(item) if playlist: yield playlist async def get_artist(self, prov_artist_id) -> Artist: - ''' get full artist details by id ''' + """ get full artist details by id """ artist_obj = await self.__get_data("artists/%s" % prov_artist_id) return await self.__parse_artist(artist_obj) async def get_album(self, prov_album_id) -> Album: - ''' get full album details by id ''' + """ get full album details by id """ album_obj = await self.__get_data("albums/%s" % prov_album_id) return await self.__parse_album(album_obj) async def get_track(self, prov_track_id) -> Track: - ''' get full track details by id ''' + """ get full track details by id """ track_obj = await self.__get_data("tracks/%s" % prov_track_id) return await self.__parse_track(track_obj) async def get_playlist(self, prov_playlist_id) -> Playlist: - ''' get full playlist details by id ''' - playlist_obj = await self.__get_data(f'playlists/{prov_playlist_id}') + """ get full playlist details by id """ + playlist_obj = await self.__get_data(f"playlists/{prov_playlist_id}") return await self.__parse_playlist(playlist_obj) async def get_album_tracks(self, prov_album_id) -> List[Track]: - ''' get all album tracks for given album id ''' + """ get all album tracks for given album id """ endpoint = f"albums/{prov_album_id}/tracks" async for track_obj in self.__get_all_items(endpoint): track = await self.__parse_track(track_obj) @@ -141,92 +155,91 @@ class SpotifyProvider(MusicProvider): yield track async def get_playlist_tracks(self, prov_playlist_id) -> List[Track]: - ''' get all playlist tracks for given playlist id ''' + """ get all playlist tracks for given playlist id """ endpoint = f"playlists/{prov_playlist_id}/tracks" async for track_obj in self.__get_all_items(endpoint): playlist_track = await self.__parse_track(track_obj) if playlist_track: yield playlist_track else: - LOGGER.warning("Unavailable track found in playlist %s: %s", - prov_playlist_id, - track_obj['track']['name']) + LOGGER.warning( + "Unavailable track found in playlist %s: %s", + prov_playlist_id, + track_obj["track"]["name"], + ) async def get_artist_albums(self, prov_artist_id) -> List[Album]: - ''' get a list of all albums for the given artist ''' - params = {'include_groups': 'album,single,compilation'} - endpoint = f'artists/{prov_artist_id}/albums' + """ get a list of all albums for the given artist """ + params = {"include_groups": "album,single,compilation"} + endpoint = f"artists/{prov_artist_id}/albums" async for item in self.__get_all_items(endpoint, params): album = await self.__parse_album(item) if album: yield album async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: - ''' get a list of 10 most popular tracks for the given artist ''' + """ get a list of 10 most popular tracks for the given artist """ artist = await self.get_artist(prov_artist_id) - endpoint = f'artists/{prov_artist_id}/top-tracks' + endpoint = f"artists/{prov_artist_id}/top-tracks" items = await self.__get_data(endpoint) - for item in items['tracks']: + for item in items["tracks"]: track = await self.__parse_track(item) if track: track.artists = [artist] yield track async def add_library(self, prov_item_id, media_type: MediaType): - ''' add item to library ''' + """ add item to library """ result = False if media_type == MediaType.Artist: - result = await self.__put_data('me/following', { - 'ids': prov_item_id, - 'type': 'artist' - }) + result = await self.__put_data( + "me/following", {"ids": prov_item_id, "type": "artist"} + ) elif media_type == MediaType.Album: - result = await self.__put_data('me/albums', {'ids': prov_item_id}) + result = await self.__put_data("me/albums", {"ids": prov_item_id}) elif media_type == MediaType.Track: - result = await self.__put_data('me/tracks', {'ids': prov_item_id}) + result = await self.__put_data("me/tracks", {"ids": prov_item_id}) elif media_type == MediaType.Playlist: - result = await self.__put_data(f'playlists/{prov_item_id}/followers', - data={'public': False}) + result = await self.__put_data( + f"playlists/{prov_item_id}/followers", data={"public": False} + ) return result async def remove_library(self, prov_item_id, media_type: MediaType): - ''' remove item from library ''' + """ remove item from library """ result = False if media_type == MediaType.Artist: - result = await self.__delete_data('me/following', { - 'ids': prov_item_id, - 'type': 'artist' - }) + result = await self.__delete_data( + "me/following", {"ids": prov_item_id, "type": "artist"} + ) elif media_type == MediaType.Album: - result = await self.__delete_data('me/albums', - {'ids': prov_item_id}) + result = await self.__delete_data("me/albums", {"ids": prov_item_id}) elif media_type == MediaType.Track: - result = await self.__delete_data('me/tracks', - {'ids': prov_item_id}) + result = await self.__delete_data("me/tracks", {"ids": prov_item_id}) elif media_type == MediaType.Playlist: - result = await self.__delete_data(f'playlists/{prov_item_id}/followers') + result = await self.__delete_data(f"playlists/{prov_item_id}/followers") return result async def add_playlist_tracks(self, prov_playlist_id, prov_track_ids): - ''' add track(s) to playlist ''' + """ add track(s) to playlist """ track_uris = [] for track_id in prov_track_ids: track_uris.append("spotify:track:%s" % track_id) data = {"uris": track_uris} - return await self.__post_data(f'playlists/{prov_playlist_id}/tracks', - data=data) + return await self.__post_data(f"playlists/{prov_playlist_id}/tracks", data=data) async def remove_playlist_tracks(self, prov_playlist_id, prov_track_ids): - ''' remove track(s) from playlist ''' + """ remove track(s) from playlist """ track_uris = [] for track_id in prov_track_ids: track_uris.append({"uri": "spotify:track:%s" % track_id}) - data = { "tracks": track_uris } - return await self.__delete_data(f'playlists/{prov_playlist_id}/tracks', - data=data) + data = {"tracks": track_uris} + return await self.__delete_data( + f"playlists/{prov_playlist_id}/tracks", data=data + ) async def get_stream_details(self, track_id): - ''' return the content details for the given track when it will be streamed''' + """ return the content details for the given track when it will be streamed""" # make sure a valid track is requested track = await self.get_track(track_id) if not track: @@ -235,7 +248,10 @@ class SpotifyProvider(MusicProvider): await self.get_token() spotty = self.get_spotty_binary() spotty_exec = '%s -n temp -c "%s" --pass-through --single-track %s' % ( - spotty, self.mass.datapath, track.item_id) + spotty, + self.mass.datapath, + track.item_id, + ) return { "type": "executable", "path": spotty_exec, @@ -243,200 +259,216 @@ class SpotifyProvider(MusicProvider): "sample_rate": 44100, "bit_depth": 16, "provider": self.prov_id, - "item_id": track.item_id + "item_id": track.item_id, } async def __parse_artist(self, artist_obj): - ''' parse spotify artist object to generic layout ''' + """ parse spotify artist object to generic layout """ if not artist_obj: return None artist = Artist() - artist.item_id = artist_obj['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'] - }) - artist.name = artist_obj['name'] - if 'genres' in artist_obj: - artist.tags = artist_obj['genres'] - if artist_obj.get('images'): - for img in artist_obj['images']: - img_url = img['url'] - if not '2a96cbd8b46e442fc41c2b86b821562f' in img_url: + artist.provider_ids.append( + {"provider": self.prov_id, "item_id": artist_obj["id"]} + ) + artist.name = artist_obj["name"] + if "genres" in artist_obj: + artist.tags = artist_obj["genres"] + if artist_obj.get("images"): + for img in artist_obj["images"]: + img_url = img["url"] + if not "2a96cbd8b46e442fc41c2b86b821562f" in img_url: artist.metadata["image"] = img_url break - if artist_obj.get('external_urls'): - artist.metadata["spotify_url"] = artist_obj['external_urls'][ - 'spotify'] + if artist_obj.get("external_urls"): + artist.metadata["spotify_url"] = artist_obj["external_urls"]["spotify"] return artist async def __parse_album(self, album_obj): - ''' parse spotify album object to generic layout ''' + """ parse spotify album object to generic layout """ if not album_obj: return None - if 'album' in album_obj: - album_obj = album_obj['album'] - if not album_obj['id'] or not album_obj.get('is_playable', True): + if "album" in album_obj: + album_obj = album_obj["album"] + if not album_obj["id"] or not album_obj.get("is_playable", True): return None album = Album() - album.item_id = album_obj['id'] + album.item_id = album_obj["id"] album.provider = self.prov_id - album.name, album.version = parse_title_and_version(album_obj['name']) - for artist in album_obj['artists']: + album.name, album.version = parse_title_and_version(album_obj["name"]) + for artist in album_obj["artists"]: album.artist = await self.__parse_artist(artist) if album.artist: break - if album_obj['album_type'] == 'single': + if album_obj["album_type"] == "single": album.albumtype = AlbumType.Single - elif album_obj['album_type'] == 'compilation': + elif album_obj["album_type"] == "compilation": album.albumtype = AlbumType.Compilation else: album.albumtype = AlbumType.Album - if 'genres' in album_obj: - album.tags = album_obj['genres'] - if album_obj.get('images'): - album.metadata["image"] = album_obj['images'][0]['url'] - if 'external_ids' in album_obj: - for key, value in album_obj['external_ids'].items(): + if "genres" in album_obj: + album.tags = album_obj["genres"] + if album_obj.get("images"): + album.metadata["image"] = album_obj["images"][0]["url"] + if "external_ids" in album_obj: + for key, value in album_obj["external_ids"].items(): album.external_ids.append({key: value}) - if 'label' in album_obj: - album.labels = album_obj['label'].split('/') - if album_obj.get('release_date'): - album.year = int(album_obj['release_date'].split('-')[0]) - if album_obj.get('copyrights'): - album.metadata["copyright"] = album_obj['copyrights'][0]['text'] - if album_obj.get('external_urls'): - album.metadata["spotify_url"] = album_obj['external_urls'][ - 'spotify'] - if album_obj.get('explicit'): - album.metadata['explicit'] = str(album_obj['explicit']).lower() - album.provider_ids.append({ - "provider": self.prov_id, - "item_id": album_obj['id'], - "quality": TrackQuality.LOSSY_OGG - }) + if "label" in album_obj: + album.labels = album_obj["label"].split("/") + if album_obj.get("release_date"): + album.year = int(album_obj["release_date"].split("-")[0]) + if album_obj.get("copyrights"): + album.metadata["copyright"] = album_obj["copyrights"][0]["text"] + if album_obj.get("external_urls"): + album.metadata["spotify_url"] = album_obj["external_urls"]["spotify"] + if album_obj.get("explicit"): + album.metadata["explicit"] = str(album_obj["explicit"]).lower() + album.provider_ids.append( + { + "provider": self.prov_id, + "item_id": album_obj["id"], + "quality": TrackQuality.LOSSY_OGG, + } + ) return album async def __parse_track(self, track_obj): - ''' parse spotify track object to generic layout ''' + """ parse spotify track object to generic layout """ if not track_obj: return None - if 'track' in track_obj: - track_obj = track_obj['track'] - if track_obj['is_local'] or not track_obj['id'] or not track_obj[ - 'is_playable']: + if "track" in track_obj: + track_obj = track_obj["track"] + if track_obj["is_local"] or not track_obj["id"] or not track_obj["is_playable"]: # do not return unavailable items return None track = Track() - track.item_id = track_obj['id'] + track.item_id = track_obj["id"] track.provider = self.prov_id - for track_artist in track_obj['artists']: + for track_artist in track_obj["artists"]: artist = await self.__parse_artist(track_artist) if artist: track.artists.append(artist) - track.name, track.version = parse_title_and_version(track_obj['name']) - track.duration = track_obj['duration_ms'] / 1000 - track.metadata['explicit'] = str(track_obj['explicit']).lower() - if 'external_ids' in track_obj: - for key, value in track_obj['external_ids'].items(): + track.name, track.version = parse_title_and_version(track_obj["name"]) + track.duration = track_obj["duration_ms"] / 1000 + track.metadata["explicit"] = str(track_obj["explicit"]).lower() + if "external_ids" in track_obj: + for key, value in track_obj["external_ids"].items(): track.external_ids.append({key: value}) - if 'album' in track_obj: - track.album = await self.__parse_album(track_obj['album']) - if track_obj.get('copyright'): - track.metadata["copyright"] = track_obj['copyright'] - if track_obj.get('explicit'): + if "album" in track_obj: + track.album = await self.__parse_album(track_obj["album"]) + if track_obj.get("copyright"): + track.metadata["copyright"] = track_obj["copyright"] + if track_obj.get("explicit"): track.metadata["explicit"] = True - track.disc_number = track_obj['disc_number'] - track.track_number = track_obj['track_number'] - if track_obj.get('external_urls'): - track.metadata["spotify_url"] = track_obj['external_urls'][ - 'spotify'] - track.provider_ids.append({ - "provider": self.prov_id, - "item_id": track_obj['id'], - "quality": TrackQuality.LOSSY_OGG - }) + track.disc_number = track_obj["disc_number"] + track.track_number = track_obj["track_number"] + if track_obj.get("external_urls"): + track.metadata["spotify_url"] = track_obj["external_urls"]["spotify"] + track.provider_ids.append( + { + "provider": self.prov_id, + "item_id": track_obj["id"], + "quality": TrackQuality.LOSSY_OGG, + } + ) return track async def __parse_playlist(self, playlist_obj): - ''' parse spotify playlist object to generic layout ''' + """ parse spotify playlist object to generic layout """ playlist = Playlist() - if not playlist_obj.get('id'): + if not playlist_obj.get("id"): return None - playlist.item_id = playlist_obj['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'): - playlist.metadata["spotify_url"] = playlist_obj['external_urls'][ - 'spotify'] - playlist.checksum = playlist_obj['snapshot_id'] + playlist.provider_ids.append( + {"provider": self.prov_id, "item_id": playlist_obj["id"]} + ) + playlist.name = playlist_obj["name"] + playlist.owner = playlist_obj["owner"]["display_name"] + playlist.is_editable = ( + playlist_obj["owner"]["id"] == self.sp_user["id"] + or playlist_obj["collaborative"] + ) + if playlist_obj.get("images"): + playlist.metadata["image"] = playlist_obj["images"][0]["url"] + if playlist_obj.get("external_urls"): + playlist.metadata["spotify_url"] = playlist_obj["external_urls"]["spotify"] + playlist.checksum = playlist_obj["snapshot_id"] return playlist async def get_token(self): - ''' get auth token on spotify ''' + """ get auth token on spotify """ # return existing token if we have one in memory - if self.__auth_token and (self.__auth_token['expiresAt'] > - int(time.time()) + 20): + if self.__auth_token and ( + self.__auth_token["expiresAt"] > int(time.time()) + 20 + ): return self.__auth_token tokeninfo = {} if not self._username or not self._password: return tokeninfo # retrieve token with spotty - tokeninfo = await self.mass.event_loop.run_in_executor( - None, self.__get_token) + tokeninfo = await self.mass.event_loop.run_in_executor(None, self.__get_token) if tokeninfo: self.__auth_token = tokeninfo self.sp_user = await self.__get_data("me") - LOGGER.info("Succesfully logged in to Spotify as %s", - self.sp_user["id"]) + LOGGER.info("Succesfully logged in to Spotify as %s", self.sp_user["id"]) self.__auth_token = tokeninfo else: - raise Exception("Can't get Spotify token for user %s" % - self._username) + raise Exception("Can't get Spotify token for user %s" % self._username) return tokeninfo def __get_token(self): - ''' get spotify auth token with spotty bin ''' + """ get spotify auth token with spotty bin """ # get token with spotty scopes = [ - "user-read-playback-state", "user-read-currently-playing", - "user-modify-playback-state", "playlist-read-private", - "playlist-read-collaborative", "playlist-modify-public", - "playlist-modify-private", "user-follow-modify", - "user-follow-read", "user-library-read", "user-library-modify", - "user-read-private", "user-read-email", "user-read-birthdate", - "user-top-read" + "user-read-playback-state", + "user-read-currently-playing", + "user-modify-playback-state", + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-public", + "playlist-modify-private", + "user-follow-modify", + "user-follow-read", + "user-library-read", + "user-library-modify", + "user-read-private", + "user-read-email", + "user-read-birthdate", + "user-top-read", ] scope = ",".join(scopes) args = [ - self.get_spotty_binary(), "-t", "--client-id", - get_app_var(2), "--scope", scope, "-n", "temp-spotty", "-u", - self._username, "-p", self._password, "-c", self.mass.datapath, - "--disable-discovery" + self.get_spotty_binary(), + "-t", + "--client-id", + get_app_var(2), + "--scope", + scope, + "-n", + "temp-spotty", + "-u", + self._username, + "-p", + self._password, + "-c", + self.mass.datapath, + "--disable-discovery", ] - spotty = subprocess.Popen(args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT) + spotty = subprocess.Popen( + args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + ) stdout, stderr = spotty.communicate() result = json.loads(stdout) # transform token info to spotipy compatible format if result and "accessToken" in result: tokeninfo = result - tokeninfo['expiresAt'] = tokeninfo['expiresIn'] + int(time.time()) + tokeninfo["expiresAt"] = tokeninfo["expiresIn"] + int(time.time()) return tokeninfo - async def __get_all_items(self, endpoint, params=None, key='items'): - ''' get all items from a paged list ''' + async def __get_all_items(self, endpoint, params=None, key="items"): + """ get all items from a paged list """ if not params: params = {} limit = 50 @@ -454,87 +486,83 @@ class SpotifyProvider(MusicProvider): break async def __get_data(self, endpoint, params=None): - ''' get data from api''' + """ get data from api""" if not params: params = {} - url = 'https://api.spotify.com/v1/%s' % endpoint - params['market'] = 'from_token' - params['country'] = 'from_token' + url = "https://api.spotify.com/v1/%s" % endpoint + params["market"] = "from_token" + params["country"] = "from_token" token = await self.get_token() - headers = {'Authorization': 'Bearer %s' % token["accessToken"]} + headers = {"Authorization": "Bearer %s" % token["accessToken"]} async with self.throttler: - async with self.http_session.get(url, - headers=headers, - params=params, - verify_ssl=False) as response: + async with self.http_session.get( + url, headers=headers, params=params, verify_ssl=False + ) as response: result = await response.json() - if not result or 'error' in result: - LOGGER.error('%s - %s', endpoint, result) + if not result or "error" in result: + LOGGER.error("%s - %s", endpoint, result) result = None return result async def __delete_data(self, endpoint, params=None, data=None): - ''' delete data from api''' + """ delete data from api""" if not params: params = {} - url = 'https://api.spotify.com/v1/%s' % endpoint + url = "https://api.spotify.com/v1/%s" % endpoint token = await self.get_token() - headers = {'Authorization': 'Bearer %s' % token["accessToken"]} - async with self.http_session.delete(url, - headers=headers, - params=params, - json=data, - verify_ssl=False) as response: + headers = {"Authorization": "Bearer %s" % token["accessToken"]} + async with self.http_session.delete( + url, headers=headers, params=params, json=data, verify_ssl=False + ) as response: return await response.text() async def __put_data(self, endpoint, params=None, data=None): - ''' put data on api''' + """ put data on api""" if not params: params = {} - url = 'https://api.spotify.com/v1/%s' % endpoint + url = "https://api.spotify.com/v1/%s" % endpoint token = await self.get_token() - headers = {'Authorization': 'Bearer %s' % token["accessToken"]} - async with self.http_session.put(url, - headers=headers, - params=params, - json=data, - verify_ssl=False) as response: + headers = {"Authorization": "Bearer %s" % token["accessToken"]} + async with self.http_session.put( + url, headers=headers, params=params, json=data, verify_ssl=False + ) as response: return await response.text() async def __post_data(self, endpoint, params=None, data=None): - ''' post data on api''' + """ post data on api""" if not params: params = {} - url = 'https://api.spotify.com/v1/%s' % endpoint + url = "https://api.spotify.com/v1/%s" % endpoint token = await self.get_token() - headers = {'Authorization': 'Bearer %s' % token["accessToken"]} - async with self.http_session.post(url, - headers=headers, - params=params, - json=data, - verify_ssl=False) as response: + headers = {"Authorization": "Bearer %s" % token["accessToken"]} + async with self.http_session.post( + url, headers=headers, params=params, json=data, verify_ssl=False + ) as response: return await response.text() @staticmethod def get_spotty_binary(): - '''find the correct spotty binary belonging to the platform''' + """find the correct spotty binary belonging to the platform""" sp_binary = None if platform.system() == "Windows": - sp_binary = os.path.join(os.path.dirname(__file__), "spotty", - "windows", "spotty.exe") + sp_binary = os.path.join( + os.path.dirname(__file__), "spotty", "windows", "spotty.exe" + ) elif platform.system() == "Darwin": # macos binary is x86_64 intel - sp_binary = os.path.join(os.path.dirname(__file__), "spotty", - "darwin", "spotty") + sp_binary = os.path.join( + os.path.dirname(__file__), "spotty", "darwin", "spotty" + ) elif platform.system() == "Linux": # try to find out the correct architecture by trial and error architecture = platform.machine() - if architecture.startswith('AMD64') or architecture.startswith( - 'x86_64'): + if architecture.startswith("AMD64") or architecture.startswith("x86_64"): # generic linux x86_64 binary - sp_binary = os.path.join(os.path.dirname(__file__), "spotty", - "x86-linux", "spotty-x86_64") + sp_binary = os.path.join( + os.path.dirname(__file__), "spotty", "x86-linux", "spotty-x86_64" + ) else: - sp_binary = os.path.join(os.path.dirname(__file__), "spotty", - "arm-linux", "spotty-muslhf") + sp_binary = os.path.join( + os.path.dirname(__file__), "spotty", "arm-linux", "spotty-muslhf" + ) return sp_binary diff --git a/music_assistant/musicproviders/tunein.py b/music_assistant/musicproviders/tunein.py index 65c3ff38..ef07ba5a 100644 --- a/music_assistant/musicproviders/tunein.py +++ b/music_assistant/musicproviders/tunein.py @@ -2,23 +2,28 @@ # -*- coding:utf-8 -*- from typing import List -from asyncio_throttle import Throttler -import aiohttp -from music_assistant.utils import LOGGER -from music_assistant.models.media_types import MediaType, TrackQuality, Radio +import aiohttp +from asyncio_throttle import Throttler +from music_assistant.constants import ( + CONF_ENABLED, + CONF_PASSWORD, + CONF_TYPE_PASSWORD, + CONF_USERNAME, +) +from music_assistant.models.media_types import MediaType, Radio, TrackQuality from music_assistant.models.musicprovider import MusicProvider -from music_assistant.constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED, CONF_TYPE_PASSWORD - +from music_assistant.utils import LOGGER -PROV_NAME = 'TuneIn Radio' -PROV_CLASS = 'TuneInProvider' +PROV_NAME = "TuneIn Radio" +PROV_CLASS = "TuneInProvider" CONFIG_ENTRIES = [ (CONF_ENABLED, False, CONF_ENABLED), - (CONF_USERNAME, "", CONF_USERNAME), - (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD) - ] + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, CONF_TYPE_PASSWORD, CONF_PASSWORD), +] + class TuneInProvider(MusicProvider): @@ -28,28 +33,29 @@ class TuneInProvider(MusicProvider): throttler = None async def setup(self, conf): - ''' perform async setup ''' + """ perform async setup """ if not conf[CONF_USERNAME] or not conf[CONF_PASSWORD]: raise Exception("Username and password must not be empty") self._username = conf[CONF_USERNAME] self._password = conf[CONF_PASSWORD] self.http_session = aiohttp.ClientSession( - loop=self.mass.event_loop, connector=aiohttp.TCPConnector()) + loop=self.mass.event_loop, connector=aiohttp.TCPConnector() + ) self.throttler = Throttler(rate_limit=1, period=1) async def search(self, searchstring, media_types=List[MediaType], limit=5): - ''' perform search on the provider ''' + """ perform search on the provider """ result = { "artists": [], "albums": [], "tracks": [], "playlists": [], - "radios": [] + "radios": [], } return result async def get_radios(self): - ''' get favorited/library radio stations ''' + """ get favorited/library radio stations """ params = {"c": "presets"} result = await self.__get_data("Browse.ashx", params) if result and "body" in result: @@ -60,7 +66,7 @@ class TuneInProvider(MusicProvider): yield radio async def get_radio(self, radio_id): - ''' get radio station details ''' + """ get radio station details """ radio = None params = {"c": "composite", "detail": "listing", "id": radio_id} result = await self.__get_data("Describe.ashx", params) @@ -70,9 +76,9 @@ class TuneInProvider(MusicProvider): return radio async def __parse_radio(self, details): - ''' parse Radio object from json obj returned from api ''' + """ parse Radio object from json obj returned from api """ radio = Radio() - radio.item_id = details['preset_id'] + radio.item_id = details["preset_id"] radio.provider = self.prov_id if "name" in details: radio.name = details["name"] @@ -86,18 +92,20 @@ class TuneInProvider(MusicProvider): # parse stream urls and format stream_info = await self.__get_stream_urls(radio.item_id) for stream in stream_info["body"]: - if stream["media_type"] == 'aac': + if stream["media_type"] == "aac": quality = TrackQuality.LOSSY_AAC - elif stream["media_type"] == 'ogg': + elif stream["media_type"] == "ogg": quality = TrackQuality.LOSSY_OGG else: quality = TrackQuality.LOSSY_MP3 - radio.provider_ids.append({ - "provider": self.prov_id, - "item_id": "%s--%s" % (details['preset_id'], stream["media_type"]), - "quality": quality, - "details": stream['url'] - }) + radio.provider_ids.append( + { + "provider": self.prov_id, + "item_id": "%s--%s" % (details["preset_id"], stream["media_type"]), + "quality": quality, + "details": stream["url"], + } + ) # image if "image" in details: radio.metadata["image"] = details["image"] @@ -106,44 +114,44 @@ class TuneInProvider(MusicProvider): return radio async def __get_stream_urls(self, radio_id): - ''' get the stream urls for the given radio id ''' + """ get the stream urls for the given radio id """ params = {"id": radio_id} res = await self.__get_data("Tune.ashx", params) return res async def get_stream_details(self, stream_id): - ''' return the content details for the given track when it will be streamed''' - radio_id = stream_id.split('--')[0] - if len(stream_id.split('--')) > 1: - media_type = stream_id.split('--')[1] + """ return the content details for the given track when it will be streamed""" + radio_id = stream_id.split("--")[0] + if len(stream_id.split("--")) > 1: + media_type = stream_id.split("--")[1] else: - media_type = '' + media_type = "" stream_info = await self.__get_stream_urls(radio_id) for stream in stream_info["body"]: - if stream['media_type'] == media_type or not media_type: + if stream["media_type"] == media_type or not media_type: return { "type": "url", - "path": stream['url'], - "content_type": stream['media_type'], + "path": stream["url"], + "content_type": stream["media_type"], "sample_rate": 44100, - "bit_depth": 16 + "bit_depth": 16, } return {} - + async def __get_data(self, endpoint, params={}): - ''' get data from api''' - url = 'https://opml.radiotime.com/%s' % endpoint - params['render'] = 'json' - params['formats'] = 'ogg,aac,wma,mp3' - params['username'] = self._username - params['partnerId'] = '1' + """ get data from api""" + url = "https://opml.radiotime.com/%s" % endpoint + params["render"] = "json" + params["formats"] = "ogg,aac,wma,mp3" + params["username"] = self._username + params["partnerId"] = "1" async with self.throttler: - async with self.http_session.get(url, params=params, verify_ssl=False) as response: + async with self.http_session.get( + url, params=params, verify_ssl=False + ) as response: result = await response.json() - if not result or 'error' in result: + if not result or "error" in result: LOGGER.error(url) LOGGER.error(params) result = None return result - - \ No newline at end of file diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py index ab36cfff..8854ee30 100755 --- a/music_assistant/player_manager.py +++ b/music_assistant/player_manager.py @@ -4,19 +4,24 @@ import os from typing import List -from music_assistant.constants import CONF_KEY_PLAYERPROVIDERS, EVENT_PLAYER_ADDED, \ - EVENT_PLAYER_REMOVED, EVENT_HASS_ENTITY_CHANGED -from music_assistant.utils import LOGGER, load_provider_modules, iter_items +from music_assistant.constants import ( + CONF_KEY_PLAYERPROVIDERS, + EVENT_HASS_ENTITY_CHANGED, + EVENT_PLAYER_ADDED, + EVENT_PLAYER_REMOVED, +) from music_assistant.models.media_types import MediaItem, MediaType -from music_assistant.models.player_queue import QueueItem, QueueOption from music_assistant.models.player import Player +from music_assistant.models.player_queue import QueueItem, QueueOption +from music_assistant.utils import LOGGER, iter_items, load_provider_modules BASE_DIR = os.path.dirname(os.path.abspath(__file__)) MODULES_PATH = os.path.join(BASE_DIR, "playerproviders") -class PlayerManager(): +class PlayerManager: """ several helpers to handle playback through player providers """ + def __init__(self, mass): self.mass = mass self._players = {} @@ -27,20 +32,20 @@ class PlayerManager(): # load providers await self.load_modules() # register state listener - await self.mass.add_event_listener(self.handle_mass_events, - EVENT_HASS_ENTITY_CHANGED) + await self.mass.add_event_listener( + self.handle_mass_events, EVENT_HASS_ENTITY_CHANGED + ) async def load_modules(self, reload_module=None): """Dynamically (un)load musicprovider modules.""" if reload_module and reload_module in self.providers: # unload existing module - if hasattr(self.providers[reload_module], 'http_session'): + if hasattr(self.providers[reload_module], "http_session"): await self.providers[reload_module].http_session.close() self.providers.pop(reload_module, None) - LOGGER.info('Unloaded %s module', reload_module) + LOGGER.info("Unloaded %s module", reload_module) # load all modules (that are not already loaded) - await load_provider_modules(self.mass, self.providers, - CONF_KEY_PLAYERPROVIDERS) + await load_provider_modules(self.mass, self.providers, CONF_KEY_PLAYERPROVIDERS) @property def players(self): @@ -61,15 +66,13 @@ class PlayerManager(): self._players[player.player_id] = player await self.mass.signal_event(EVENT_PLAYER_ADDED, player.to_dict()) # TODO: turn on player if it was previously turned on ? - LOGGER.info("New player added: %s/%s", player.player_provider, - player.player_id) + LOGGER.info("New player added: %s/%s", player.player_provider, player.player_id) return player async def remove_player(self, player_id: str): """ handle a player remove """ self._players.pop(player_id, None) - await self.mass.signal_event(EVENT_PLAYER_REMOVED, - {"player_id": player_id}) + await self.mass.signal_event(EVENT_PLAYER_REMOVED, {"player_id": player_id}) LOGGER.info("Player removed: %s", player_id) async def trigger_update(self, player_id: str): @@ -77,10 +80,9 @@ class PlayerManager(): if player_id in self._players: await self._players[player_id].update(force=True) - async def play_media(self, - player_id: str, - media_items: List[MediaItem], - queue_opt=QueueOption.Play): + async def play_media( + self, player_id: str, media_items: List[MediaItem], queue_opt=QueueOption.Play + ): """ play media item(s) on the given player :param media_item: media item(s) that should be played (single item or list of items) @@ -99,27 +101,33 @@ class PlayerManager(): # collect tracks to play if media_item.media_type == MediaType.Artist: tracks = self.mass.music.artist_toptracks( - media_item.item_id, provider=media_item.provider) + media_item.item_id, provider=media_item.provider + ) elif media_item.media_type == MediaType.Album: tracks = self.mass.music.album_tracks( - media_item.item_id, provider=media_item.provider) + media_item.item_id, provider=media_item.provider + ) elif media_item.media_type == MediaType.Playlist: tracks = self.mass.music.playlist_tracks( - media_item.item_id, provider=media_item.provider) + media_item.item_id, provider=media_item.provider + ) else: tracks = iter_items(media_item) # single track async for track in tracks: queue_item = QueueItem(track) # generate uri for this queue item - queue_item.uri = 'http://%s:%s/stream/%s/%s' % ( - self.mass.web.local_ip, self.mass.web.http_port, player_id, - queue_item.queue_item_id) + queue_item.uri = "http://%s:%s/stream/%s/%s" % ( + self.mass.web.local_ip, + self.mass.web.http_port, + player_id, + queue_item.queue_item_id, + ) queue_items.append(queue_item) # load items into the queue - if (queue_opt == QueueOption.Replace - or (len(queue_items) > 10 - and queue_opt in [QueueOption.Play, QueueOption.Next])): + if queue_opt == QueueOption.Replace or ( + len(queue_items) > 10 and queue_opt in [QueueOption.Play, QueueOption.Next] + ): return await player.queue.load(queue_items) elif queue_opt == QueueOption.Next: return await player.queue.insert(queue_items, 1) @@ -135,20 +143,21 @@ class PlayerManager(): player_ids = list(self._players.keys()) for player_id in player_ids: player = self._players[player_id] - if (msg_details['entity_id'] == player.settings.get( - 'hass_power_entity') or msg_details['entity_id'] == - player.settings.get('hass_volume_entity')): + if msg_details["entity_id"] == player.settings.get( + "hass_power_entity" + ) or msg_details["entity_id"] == player.settings.get( + "hass_volume_entity" + ): await player.update() async def get_gain_correct(self, player_id, item_id, provider_id): """ get gain correction for given player / track combination """ player = self._players[player_id] - if not player.settings['volume_normalisation']: + if not player.settings["volume_normalisation"]: return 0 - target_gain = int(player.settings['target_volume']) - fallback_gain = int(player.settings['fallback_gain_correct']) - track_loudness = await self.mass.db.get_track_loudness( - item_id, provider_id) + target_gain = int(player.settings["target_volume"]) + fallback_gain = int(player.settings["fallback_gain_correct"]) + track_loudness = await self.mass.db.get_track_loudness(item_id, provider_id) if track_loudness is None: gain_correct = fallback_gain else: @@ -156,5 +165,9 @@ class PlayerManager(): gain_correct = round(gain_correct, 2) LOGGER.debug( "Loudness level for track %s/%s is %s - calculated replayGain is %s", - provider_id, item_id, track_loudness, gain_correct) + provider_id, + item_id, + track_loudness, + gain_correct, + ) return gain_correct diff --git a/music_assistant/playerproviders/chromecast.py b/music_assistant/playerproviders/chromecast.py index 92dc11e1..66867e02 100644 --- a/music_assistant/playerproviders/chromecast.py +++ b/music_assistant/playerproviders/chromecast.py @@ -2,38 +2,37 @@ # -*- coding:utf-8 -*- import asyncio -import aiohttp -from typing import List import logging -import pychromecast -from pychromecast.controllers.multizone import MultizoneController -from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED -import types import time +import types +from typing import List import uuid -from music_assistant.utils import run_periodic, LOGGER, try_parse_int -from music_assistant.models.playerprovider import PlayerProvider -from music_assistant.models.player import Player, PlayerState -from music_assistant.models.playerstate import PlayerState -from music_assistant.models.player_queue import QueueItem, PlayerQueue +import aiohttp from music_assistant.constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT +from music_assistant.models.player import Player, PlayerState +from music_assistant.models.player_queue import PlayerQueue, QueueItem +from music_assistant.models.playerprovider import PlayerProvider +from music_assistant.utils import LOGGER, run_periodic, try_parse_int +import pychromecast +from pychromecast.controllers.multizone import MultizoneController +from pychromecast.socket_client import ( + CONNECTION_STATUS_CONNECTED, + CONNECTION_STATUS_DISCONNECTED, +) + +PROV_ID = "chromecast" +PROV_NAME = "Chromecast" +PROV_CLASS = "ChromecastProvider" -PROV_ID = 'chromecast' -PROV_NAME = 'Chromecast' -PROV_CLASS = 'ChromecastProvider' +CONFIG_ENTRIES = [(CONF_ENABLED, True, CONF_ENABLED)] -CONFIG_ENTRIES = [ - (CONF_ENABLED, True, CONF_ENABLED), - ] +PLAYER_CONFIG_ENTRIES = [("gapless_enabled", False, "gapless_enabled")] -PLAYER_CONFIG_ENTRIES = [ - ("gapless_enabled", False, "gapless_enabled"), - ] class ChromecastPlayer(Player): - ''' Chromecast player object ''' - + """ Chromecast player object """ + def __init__(self, *args, **kwargs): self.__cc_report_progress_task = None super().__init__(*args, **kwargs) @@ -42,57 +41,60 @@ class ChromecastPlayer(Player): if self.__cc_report_progress_task: self.__cc_report_progress_task.cancel() - async def try_chromecast_command(self, cmd:types.MethodType, *args, **kwargs): - ''' guard for disconnected socket client ''' - def _try_chromecast_command(_cmd:types.MethodType, *_args, **_kwargs): + async def try_chromecast_command(self, cmd: types.MethodType, *args, **kwargs): + """ guard for disconnected socket client """ + + def _try_chromecast_command(_cmd: types.MethodType, *_args, **_kwargs): try: _cmd(*_args, **_kwargs) except (pychromecast.error.NotConnected, AttributeError): LOGGER.warning("Chromecast %s is not connected!" % self.name) except Exception as exc: LOGGER.warning(exc) + return self.mass.event_loop.call_soon_threadsafe( - _try_chromecast_command, cmd, *args, **kwargs) - + _try_chromecast_command, cmd, *args, **kwargs + ) + async def cmd_stop(self): - ''' send stop command to player ''' + """ send stop command to player """ await self.try_chromecast_command(self.cc.media_controller.stop) async def cmd_play(self): - ''' send play command to player ''' + """ send play command to player """ await self.try_chromecast_command(self.cc.media_controller.play) async def cmd_pause(self): - ''' send pause command to player ''' + """ send pause command to player """ await self.try_chromecast_command(self.cc.media_controller.pause) async def cmd_next(self): - ''' send next track command to player ''' + """ send next track command to player """ await self.try_chromecast_command(self.cc.media_controller.queue_next) async def cmd_previous(self): - ''' [CAN OVERRIDE] send previous track command to player ''' + """ [CAN OVERRIDE] send previous track command to player """ await self.try_chromecast_command(self.cc.media_controller.queue_prev) - + async def cmd_power_on(self): - ''' send power ON command to player ''' + """ send power ON command to player """ self.powered = True async def cmd_power_off(self): - ''' send power OFF command to player ''' + """ send power OFF command to player """ self.powered = False async def cmd_volume_set(self, volume_level): - ''' send new volume level command to player ''' - await self.try_chromecast_command(self.cc.set_volume, volume_level/100) + """ send new volume level command to player """ + await self.try_chromecast_command(self.cc.set_volume, volume_level / 100) self.volume_level = volume_level async def cmd_volume_mute(self, is_muted=False): - ''' send mute command to player ''' + """ send mute command to player """ await self.try_chromecast_command(self.cc.set_volume_muted, is_muted) - async def cmd_play_uri(self, uri:str): - ''' play single uri on player ''' + async def cmd_play_uri(self, uri: str): + """ play single uri on player """ if self.queue.use_queue_stream: # create CC queue so that skip and previous will work queue_item = QueueItem() @@ -100,18 +102,18 @@ class ChromecastPlayer(Player): queue_item.uri = uri return await self.cmd_queue_load([queue_item, queue_item]) else: - await self.try_chromecast_command(self.cc.play_media, uri, 'audio/flac') + await self.try_chromecast_command(self.cc.play_media, uri, "audio/flac") - async def cmd_queue_load(self, queue_items:List[QueueItem]): - ''' load (overwrite) queue with new items ''' + async def cmd_queue_load(self, queue_items: List[QueueItem]): + """ load (overwrite) queue with new items """ cc_queue_items = await self.__create_queue_items(queue_items[:50]) - queuedata = { - "type": 'QUEUE_LOAD', - "repeatMode": "REPEAT_ALL" if self.queue.repeat_enabled else "REPEAT_OFF", - "shuffle": False, # handled by our queue controller - "queueType": "PLAYLIST", - "startIndex": 0, # Item index to play after this request or keep same item if undefined - "items": cc_queue_items # only load 50 tracks at once or the socket will crash + queuedata = { + "type": "QUEUE_LOAD", + "repeatMode": "REPEAT_ALL" if self.queue.repeat_enabled else "REPEAT_OFF", + "shuffle": False, # handled by our queue controller + "queueType": "PLAYLIST", + "startIndex": 0, # Item index to play after this request or keep same item if undefined + "items": cc_queue_items, # only load 50 tracks at once or the socket will crash } await self.try_chromecast_command(self.__send_player_queue, queuedata) await asyncio.sleep(0.2) @@ -119,27 +121,23 @@ class ChromecastPlayer(Player): await self.cmd_queue_append(queue_items[51:]) await asyncio.sleep(0.2) - async def cmd_queue_insert(self, queue_items:List[QueueItem], insert_at_index): + async def cmd_queue_insert(self, queue_items: List[QueueItem], insert_at_index): # for now we don't support this as google requires a special internal id # as item id to determine the insert position # https://developers.google.com/cast/docs/reference/caf_receiver/cast.framework.QueueManager#insertItems raise NotImplementedError - async def cmd_queue_append(self, queue_items:List[QueueItem]): - ''' + async def cmd_queue_append(self, queue_items: List[QueueItem]): + """ append new items at the end of the queue - ''' + """ cc_queue_items = await self.__create_queue_items(queue_items) for chunk in chunks(cc_queue_items, 50): - queuedata = { - "type": 'QUEUE_INSERT', - "insertBefore": None, - "items": chunk - } + queuedata = {"type": "QUEUE_INSERT", "insertBefore": None, "items": chunk} await self.try_chromecast_command(self.__send_player_queue, queuedata) async def __create_queue_items(self, tracks): - ''' create list of CC queue items from tracks ''' + """ create list of CC queue items from tracks """ queue_items = [] for track in tracks: queue_item = await self.__create_queue_item(track) @@ -147,56 +145,59 @@ class ChromecastPlayer(Player): return queue_items async def __create_queue_item(self, track): - '''create CC queue item from track info ''' + """create CC queue item from track info """ return { - 'opt_itemId': track.queue_item_id, - 'autoplay' : True, - 'preloadTime' : 10, - 'playbackDuration': int(track.duration), - 'startTime' : 0, - 'activeTrackIds' : [], - 'media': { - 'contentId': track.uri, - 'customData': { - 'provider': track.provider, - 'uri': track.uri, - 'item_id': track.queue_item_id + "opt_itemId": track.queue_item_id, + "autoplay": True, + "preloadTime": 10, + "playbackDuration": int(track.duration), + "startTime": 0, + "activeTrackIds": [], + "media": { + "contentId": track.uri, + "customData": { + "provider": track.provider, + "uri": track.uri, + "item_id": track.queue_item_id, }, - 'contentType': "audio/flac", - 'streamType': 'LIVE' if self.queue.use_queue_stream else 'BUFFERED', - 'metadata': { - 'title': track.name, - 'artist': track.artists[0].name if track.artists else "", + "contentType": "audio/flac", + "streamType": "LIVE" if self.queue.use_queue_stream else "BUFFERED", + "metadata": { + "title": track.name, + "artist": track.artists[0].name if track.artists else "", }, - 'duration': int(track.duration) - } + "duration": int(track.duration), + }, } - + def __send_player_queue(self, queuedata): - '''send new data to the CC queue''' + """send new data to the CC queue""" media_controller = self.cc.media_controller receiver_ctrl = media_controller._socket_client.receiver_controller + def send_queue(): """Plays media after chromecast has switched to requested app.""" - queuedata['mediaSessionId'] = media_controller.status.media_session_id + queuedata["mediaSessionId"] = media_controller.status.media_session_id media_controller.send_message(queuedata, inc_session_id=False) + if not media_controller.status.media_session_id: - receiver_ctrl.launch_app(media_controller.app_id, callback_function=send_queue) + receiver_ctrl.launch_app( + media_controller.app_id, callback_function=send_queue + ) else: send_queue() async def __report_progress(self): - ''' report current progress while playing ''' + """ report current progress while playing """ # chromecast does not send updates of the player's progress (cur_time) # so we need to send it in periodically while self._state == PlayerState.Playing: self.cur_time = self.cc.media_controller.status.adjusted_current_time await asyncio.sleep(1) self.__cc_report_progress_task = None - - async def handle_player_state(self, caststatus=None, - mediastatus=None): - ''' handle a player state message from the socket ''' + + async def handle_player_state(self, caststatus=None, mediastatus=None): + """ handle a player state message from the socket """ # handle generic cast status if caststatus: self.muted = caststatus.volume_muted @@ -204,32 +205,40 @@ class ChromecastPlayer(Player): self.name = self.cc.name # handle media status if mediastatus: - if mediastatus.player_state in ['PLAYING', 'BUFFERING']: + if mediastatus.player_state in ["PLAYING", "BUFFERING"]: self.state = PlayerState.Playing self.powered = True - elif mediastatus.player_state == 'PAUSED': + elif mediastatus.player_state == "PAUSED": self.state = PlayerState.Paused else: self.state = PlayerState.Stopped self.cur_uri = mediastatus.content_id self.cur_time = mediastatus.adjusted_current_time - if self._state == PlayerState.Playing and self.__cc_report_progress_task == None: - self.__cc_report_progress_task = self.mass.event_loop.create_task(self.__report_progress()) + if ( + self._state == PlayerState.Playing + and self.__cc_report_progress_task == None + ): + self.__cc_report_progress_task = self.mass.event_loop.create_task( + self.__report_progress() + ) + class ChromecastProvider(PlayerProvider): - ''' support for ChromeCast Audio ''' + """ support for ChromeCast Audio """ + _discovery_running = False - + async def setup(self, conf): - ''' perform async setup ''' + """ perform async setup """ self._discovery_running = False - logging.getLogger('pychromecast').setLevel(logging.WARNING) + logging.getLogger("pychromecast").setLevel(logging.WARNING) self.player_config_entries = PLAYER_CONFIG_ENTRIES - self.mass.event_loop.create_task( - self.__periodic_chromecast_discovery()) + self.mass.event_loop.create_task(self.__periodic_chromecast_discovery()) - async def __handle_group_members_update(self, mz, added_player=None, removed_player=None): - ''' handle callback from multizone manager ''' + async def __handle_group_members_update( + self, mz, added_player=None, removed_player=None + ): + """ handle callback from multizone manager """ group_player_id = str(uuid.UUID(mz._uuid)) group_player = await self.get_player(group_player_id) if added_player: @@ -251,14 +260,14 @@ class ChromecastProvider(PlayerProvider): if not player_id in group_player.group_childs: group_player.add_group_child(player_id) LOGGER.debug("%s added to %s", child_player.name, group_player.name) - + @run_periodic(1800) async def __periodic_chromecast_discovery(self): - ''' run chromecast discovery on interval ''' + """ run chromecast discovery on interval """ self.mass.event_loop.run_in_executor(None, self.run_chromecast_discovery) def run_chromecast_discovery(self): - ''' background non-blocking chromecast discovery and handler ''' + """ background non-blocking chromecast discovery and handler """ if self._discovery_running: return self._discovery_running = True @@ -271,6 +280,7 @@ class ChromecastProvider(PlayerProvider): self.mass.run_task(self.remove_player(player.player_id)) # search for available chromecasts from pychromecast.discovery import start_discovery, stop_discovery + def discovered_callback(name): """Called when zeroconf has discovered a (new) chromecast.""" discovery_info = listener.services[name] @@ -279,17 +289,21 @@ class ChromecastProvider(PlayerProvider): if not player_id in self.mass.players._players: self.__chromecast_discovered(player_id, discovery_info) self.__update_group_players() + listener, browser = start_discovery(discovered_callback) - time.sleep(30) # run discovery for 30 seconds + time.sleep(30) # run discovery for 30 seconds stop_discovery(browser) LOGGER.debug("Chromecast discovery completed...") self._discovery_running = False - + def __chromecast_discovered(self, player_id, discovery_info): - ''' callback when a (new) chromecast device is discovered ''' + """ callback when a (new) chromecast device is discovered """ from pychromecast import _get_chromecast_from_host, ChromecastConnectionError + try: - chromecast = _get_chromecast_from_host(discovery_info, tries=2, timeout=5, retry_wait=5) + chromecast = _get_chromecast_from_host( + discovery_info, tries=2, timeout=5, retry_wait=5 + ) except ChromecastConnectionError: LOGGER.warning("Could not connect to device %s" % player_id) return @@ -300,12 +314,14 @@ class ChromecastProvider(PlayerProvider): self.supports_gapless = False self.supports_crossfade = False # register status listeners - status_listener = StatusListener(player_id, - player.handle_player_state, self.mass) - if chromecast.cast_type == 'group': + status_listener = StatusListener( + player_id, player.handle_player_state, self.mass + ) + if chromecast.cast_type == "group": mz = MultizoneController(chromecast.uuid) - mz.register_listener(MZListener(mz, - self.__handle_group_members_update, self.mass.event_loop)) + mz.register_listener( + MZListener(mz, self.__handle_group_members_update, self.mass.event_loop) + ) chromecast.register_handler(mz) player.mz = mz chromecast.register_connection_listener(status_listener) @@ -315,15 +331,16 @@ class ChromecastProvider(PlayerProvider): self.mass.run_task(self.add_player(player)) def __update_group_players(self): - '''update childs of all group players''' + """update childs of all group players""" for player in self.players: - if player.cc.cast_type == 'group': + if player.cc.cast_type == "group": player.mz.update_members() + def chunks(l, n): """Yield successive n-sized chunks from l.""" for i in range(0, len(l), n): - yield l[i:i + n] + yield l[i : i + n] class StatusListener: @@ -331,20 +348,23 @@ class StatusListener: self.__handle_callback = status_callback self.mass = mass self.player_id = player_id + def new_cast_status(self, status): - ''' chromecast status changed (like volume etc.)''' - self.mass.run_task( - self.__handle_callback(caststatus=status)) + """ chromecast status changed (like volume etc.)""" + self.mass.run_task(self.__handle_callback(caststatus=status)) + def new_media_status(self, status): - ''' mediacontroller has new state ''' - self.mass.run_task( - self.__handle_callback(mediastatus=status)) + """ mediacontroller has new state """ + self.mass.run_task(self.__handle_callback(mediastatus=status)) + def new_connection_status(self, status): - ''' will be called when the connection changes ''' + """ will be called when the connection changes """ if status.status == CONNECTION_STATUS_DISCONNECTED: # schedule a new scan which will handle reconnects and group parent changes - self.mass.event_loop.run_in_executor(None, - self.mass.players.providers[PROV_ID].run_chromecast_discovery) + self.mass.event_loop.run_in_executor( + None, self.mass.players.providers[PROV_ID].run_chromecast_discovery + ) + class MZListener: def __init__(self, mz, callback, loop): @@ -354,14 +374,17 @@ class MZListener: def multizone_member_added(self, uuid): asyncio.run_coroutine_threadsafe( - self.__handle_group_members_update( - self._mz, added_player=str(uuid)), self._loop) + self.__handle_group_members_update(self._mz, added_player=str(uuid)), + self._loop, + ) def multizone_member_removed(self, uuid): asyncio.run_coroutine_threadsafe( - self.__handle_group_members_update( - self._mz, removed_player=str(uuid)), self._loop) + self.__handle_group_members_update(self._mz, removed_player=str(uuid)), + self._loop, + ) def multizone_status_received(self): asyncio.run_coroutine_threadsafe( - self.__handle_group_members_update(self._mz), self._loop) + self.__handle_group_members_update(self._mz), self._loop + ) diff --git a/music_assistant/playerproviders/sonos.py b/music_assistant/playerproviders/sonos.py index 513f99b4..c4647e5d 100644 --- a/music_assistant/playerproviders/sonos.py +++ b/music_assistant/playerproviders/sonos.py @@ -2,29 +2,27 @@ # -*- coding:utf-8 -*- import asyncio -import aiohttp -from typing import List import logging -import types import time +import types +from typing import List -from music_assistant.utils import run_periodic, LOGGER, try_parse_int -from music_assistant.models.playerprovider import PlayerProvider -from music_assistant.models.player import Player, PlayerState -from music_assistant.models.playerstate import PlayerState -from music_assistant.models.player_queue import QueueItem, PlayerQueue +import aiohttp from music_assistant.constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT +from music_assistant.models.player import Player, PlayerState +from music_assistant.models.player_queue import PlayerQueue, QueueItem +from music_assistant.models.playerprovider import PlayerProvider +from music_assistant.utils import LOGGER, run_periodic, try_parse_int -PROV_ID = 'sonos' -PROV_NAME = 'Sonos' -PROV_CLASS = 'SonosProvider' +PROV_ID = "sonos" +PROV_NAME = "Sonos" +PROV_CLASS = "SonosProvider" + +CONFIG_ENTRIES = [(CONF_ENABLED, True, CONF_ENABLED)] -CONFIG_ENTRIES = [ - (CONF_ENABLED, True, CONF_ENABLED), - ] class SonosPlayer(Player): - ''' Sonos player object ''' + """ Sonos player object """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -35,74 +33,74 @@ class SonosPlayer(Player): self.__sonos_report_progress_task.cancel() async def cmd_stop(self): - ''' send stop command to player ''' + """ send stop command to player """ self.soco.stop() async def cmd_play(self): - ''' send play command to player ''' + """ send play command to player """ self.soco.play() async def cmd_pause(self): - ''' send pause command to player ''' + """ send pause command to player """ self.soco.pause() async def cmd_next(self): - ''' send next track command to player ''' + """ send next track command to player """ self.soco.next() async def cmd_previous(self): - ''' send previous track command to player ''' + """ send previous track command to player """ self.soco.previous() - + async def cmd_power_on(self): - ''' send power ON command to player ''' + """ send power ON command to player """ self.powered = True async def cmd_power_off(self): - ''' send power OFF command to player ''' + """ send power OFF command to player """ self.powered = False # power is not supported so send stop instead self.soco.stop() async def cmd_volume_set(self, volume_level): - ''' send new volume level command to player ''' + """ send new volume level command to player """ self.soco.volume = volume_level async def cmd_volume_mute(self, is_muted=False): - ''' send mute command to player ''' + """ send mute command to player """ self.soco.mute = is_muted - async def cmd_play_uri(self, uri:str): - ''' play single uri on player ''' + async def cmd_play_uri(self, uri: str): + """ play single uri on player """ self.soco.play_uri(uri) - async def cmd_queue_play_index(self, index:int): - ''' + async def cmd_queue_play_index(self, index: int): + """ play item at index X on player's queue :attrib index: (int) index of the queue item that should start playing - ''' + """ self.soco.play_from_queue(index) - async def cmd_queue_load(self, queue_items:List[QueueItem]): - ''' load (overwrite) queue with new items ''' + async def cmd_queue_load(self, queue_items: List[QueueItem]): + """ load (overwrite) queue with new items """ self.soco.clear_queue() for pos, item in enumerate(queue_items): self.soco.add_uri_to_queue(item.uri, pos) - async def cmd_queue_insert(self, queue_items:List[QueueItem], insert_at_index): + async def cmd_queue_insert(self, queue_items: List[QueueItem], insert_at_index): for pos, item in enumerate(queue_items): - self.soco.add_uri_to_queue(item.uri, insert_at_index+pos) + self.soco.add_uri_to_queue(item.uri, insert_at_index + pos) - async def cmd_queue_append(self, queue_items:List[QueueItem]): - ''' + async def cmd_queue_append(self, queue_items: List[QueueItem]): + """ append new items at the end of the queue - ''' + """ last_index = len(self.queue.items) for pos, item in enumerate(queue_items): - self.soco.add_uri_to_queue(item.uri, last_index+pos) + self.soco.add_uri_to_queue(item.uri, last_index + pos) async def __report_progress(self): - ''' report current progress while playing ''' + """ report current progress while playing """ # sonos does not send instant updates of the player's progress (cur_time) # so we need to send it in periodically while self._state == PlayerState.Playing: @@ -111,9 +109,9 @@ class SonosPlayer(Player): self.cur_time = adjusted_current_time await asyncio.sleep(1) self.__sonos_report_progress_task = None - + async def update_state(self, event=None): - ''' update state, triggerer by event ''' + """ update state, triggerer by event """ if event: variables = event.variables if "volume" in variables: @@ -135,18 +133,24 @@ class SonosPlayer(Player): track_info = self.soco.get_current_track_info() self.cur_uri = track_info["uri"] position_info = self.soco.avTransport.GetPositionInfo( - [("InstanceID", 0), ("Channel", "Master")]) + [("InstanceID", 0), ("Channel", "Master")] + ) rel_time = self.__timespan_secs(position_info.get("RelTime")) self.cur_time = rel_time - if self._state == PlayerState.Playing and self.__sonos_report_progress_task == None: - self.__sonos_report_progress_task = self.mass.event_loop.create_task(self.__report_progress()) + if ( + self._state == PlayerState.Playing + and self.__sonos_report_progress_task == None + ): + self.__sonos_report_progress_task = self.mass.event_loop.create_task( + self.__report_progress() + ) @staticmethod def __convert_state(sonos_state): - ''' convert sonos state to internal state ''' - if sonos_state == 'PLAYING': + """ convert sonos state to internal state """ + if sonos_state == "PLAYING": return PlayerState.Playing - elif sonos_state == 'PAUSED_PLAYBACK': + elif sonos_state == "PAUSED_PLAYBACK": return PlayerState.Paused else: return PlayerState.Stopped @@ -156,30 +160,33 @@ class SonosPlayer(Player): """Parse a time-span into number of seconds.""" if timespan in ("", "NOT_IMPLEMENTED", None): return None - return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":")))) - + return sum( + 60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":"))) + ) + class SonosProvider(PlayerProvider): - ''' support for Sonos speakers ''' + """ support for Sonos speakers """ + _discovery_running = False async def setup(self, conf): - ''' perform async setup ''' - self.mass.event_loop.create_task( - self.__periodic_discovery()) + """ perform async setup """ + self.mass.event_loop.create_task(self.__periodic_discovery()) @run_periodic(1800) async def __periodic_discovery(self): - ''' run sonos discovery on interval ''' + """ run sonos discovery on interval """ self.mass.event_loop.run_in_executor(None, self.run_discovery) def run_discovery(self): - ''' background sonos discovery and handler ''' + """ background sonos discovery and handler """ if self._discovery_running: return self._discovery_running = True LOGGER.debug("Sonos discovery started...") import soco + discovered_devices = soco.discover() if discovered_devices == None: discovered_devices = [] @@ -200,7 +207,7 @@ class SonosProvider(PlayerProvider): self.__process_groups([]) def __device_discovered(self, soco_device): - '''handle new sonos player ''' + """handle new sonos player """ player = SonosPlayer(self.mass, soco_device.uid, self.prov_id) player.soco = soco_device player.name = soco_device.player_name @@ -214,6 +221,7 @@ class SonosProvider(PlayerProvider): queue = _ProcessSonosEventQueue(self.mass, action) sub = service.subscribe(auto_renew=True, event_queue=queue) player._subscriptions.append(sub) + subscribe(soco_device.avTransport, player.update_state) subscribe(soco_device.renderingControl, player.update_state) subscribe(soco_device.zoneGroupTopology, self.__topology_changed) @@ -221,7 +229,7 @@ class SonosProvider(PlayerProvider): return player def __process_groups(self, sonos_groups): - ''' process all sonos groups ''' + """ process all sonos groups """ all_group_ids = [] for group in sonos_groups: all_group_ids.append(group.uid) @@ -233,15 +241,16 @@ class SonosProvider(PlayerProvider): # check members group_player.name = group.label group_player.group_childs = [item.uid for item in group.members] - + async def __topology_changed(self, event=None): - ''' + """ received topology changed event from one of the sonos players schedule discovery to work out the changes - ''' + """ self.mass.event_loop.run_in_executor(None, self.run_discovery) + class _ProcessSonosEventQueue: """Queue like object for dispatching sonos events.""" @@ -255,4 +264,4 @@ class _ProcessSonosEventQueue: try: self.mass.run_task(self._handler(item), wait_for_result=True) except Exception as ex: - LOGGER.warning("Error calling %s: %s", self._handler, ex) \ No newline at end of file + LOGGER.warning("Error calling %s: %s", self._handler, ex) diff --git a/music_assistant/playerproviders/squeezebox.py b/music_assistant/playerproviders/squeezebox.py index d4d9cbda..8d7938fb 100644 --- a/music_assistant/playerproviders/squeezebox.py +++ b/music_assistant/playerproviders/squeezebox.py @@ -2,46 +2,54 @@ # -*- coding:utf-8 -*- import asyncio -import os -import struct from collections import OrderedDict -import time import decimal -from typing import List +import os import random -import sys import socket -from music_assistant.utils import run_periodic, LOGGER, try_parse_int, get_ip, get_hostname -from music_assistant.models import PlayerProvider, Player, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from music_assistant.constants import CONF_ENABLED +import struct +import sys +import time +from typing import List +from music_assistant.constants import CONF_ENABLED +from music_assistant.models.player import Player, PlayerState +from music_assistant.models.player_queue import PlayerQueue, QueueItem +from music_assistant.models.playerprovider import PlayerProvider +from music_assistant.utils import ( + LOGGER, + get_hostname, + get_ip, + run_periodic, + try_parse_int, +) -PROV_ID = 'squeezebox' -PROV_NAME = 'Squeezebox' -PROV_CLASS = 'PySqueezeProvider' +PROV_ID = "squeezebox" +PROV_NAME = "Squeezebox" +PROV_CLASS = "PySqueezeProvider" -CONFIG_ENTRIES = [ - (CONF_ENABLED, True, CONF_ENABLED), - ] +CONFIG_ENTRIES = [(CONF_ENABLED, True, CONF_ENABLED)] class PySqueezeProvider(PlayerProvider): - ''' Python implementation of SlimProto server ''' + """ Python implementation of SlimProto server """ - ### Provider specific implementation ##### + ### Provider specific implementation ##### async def setup(self, conf): - ''' async initialize of module ''' + """ async initialize of module """ # start slimproto server self.mass.event_loop.create_task( - asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483)) + asyncio.start_server(self.__handle_socket_client, "0.0.0.0", 3483) + ) # setup discovery self.mass.event_loop.create_task(self.start_discovery()) async def start_discovery(self): transport, protocol = await self.mass.event_loop.create_datagram_endpoint( lambda: DiscoveryProtocol(self.mass.web.http_port), - local_addr=('0.0.0.0', 3483)) + local_addr=("0.0.0.0", 3483), + ) try: while True: await asyncio.sleep(60) # serve forever @@ -49,10 +57,10 @@ class PySqueezeProvider(PlayerProvider): transport.close() async def __handle_socket_client(self, reader, writer): - ''' handle a client connection on the socket''' - buffer = b'' + """ handle a client connection on the socket""" + buffer = b"" player = None - try: + try: # keep reading bytes from the socket while True: data = await reader.read(64) @@ -64,17 +72,19 @@ class PySqueezeProvider(PlayerProvider): del data if len(buffer) > 8: operation, length = buffer[:4], buffer[4:8] - plen = struct.unpack('!I', length)[0] + 8 + plen = struct.unpack("!I", length)[0] + 8 if len(buffer) >= plen: packet, buffer = buffer[8:plen], buffer[plen:] operation = operation.strip(b"!").strip().decode() - if operation == 'HELO': + if operation == "HELO": # player connected - (dev_id, rev, mac) = struct.unpack('BB6s', packet[:8]) - device_mac = ':'.join("%02x" % x for x in mac) + (dev_id, rev, mac) = struct.unpack("BB6s", packet[:8]) + device_mac = ":".join("%02x" % x for x in mac) player_id = str(device_mac).lower() - device_type = devices.get(dev_id, 'unknown device') - player = PySqueezePlayer(self.mass, player_id, self.prov_id, device_type, writer) + device_type = devices.get(dev_id, "unknown device") + player = PySqueezePlayer( + self.mass, player_id, self.prov_id, device_type, writer + ) await self.mass.players.add_player(player) elif player != None: await player.process_msg(operation, packet) @@ -89,8 +99,9 @@ class PySqueezeProvider(PlayerProvider): await self.mass.players.remove_player(player.player_id) self.mass.config.save() + class PySqueezePlayer(Player): - ''' Squeezebox socket client ''' + """ Squeezebox socket client """ def __init__(self, mass, player_id, prov_id, dev_type, writer): super().__init__(mass, player_id, prov_id) @@ -98,8 +109,8 @@ class PySqueezePlayer(Player): self.supports_gapless = True self.supports_crossfade = True self._writer = writer - self.buffer = b'' - self.name = "%s - %s" %(dev_type, player_id) + self.buffer = b"" + self.name = "%s - %s" % (dev_type, player_id) self._volume = PySqueezeVolume() self._last_volume = 0 self._last_heartbeat = 0 @@ -109,13 +120,13 @@ class PySqueezePlayer(Player): self._heartbeat_task = self.mass.event_loop.create_task(self.__send_heartbeat()) async def initialize_player(self): - ''' set some startup settings for the player ''' + """ set some startup settings for the player """ # send version - await self.__send_frame(b'vers', b'7.8') + await self.__send_frame(b"vers", b"7.8") await self.__send_frame(b"setd", struct.pack("B", 0)) await self.__send_frame(b"setd", struct.pack("B", 4)) # TODO: handle display stuff - #await self.setBrightness() + # await self.setBrightness() # restore last volume and power state if self.settings.get("last_volume"): await self.volume_set(self.settings["last_volume"]) @@ -127,65 +138,65 @@ class PySqueezePlayer(Player): await self.power_off() async def cmd_stop(self): - ''' send stop command to player ''' + """ send stop command to player """ data = await self.__pack_stream(b"q", autostart=b"0", flags=0) await self.__send_frame(b"strm", data) async def cmd_play(self): - ''' send play (unpause) command to player ''' + """ send play (unpause) command to player """ data = await self.__pack_stream(b"u", autostart=b"0", flags=0) await self.__send_frame(b"strm", data) async def cmd_pause(self): - ''' send pause command to player ''' + """ send pause command to player """ data = await self.__pack_stream(b"p", autostart=b"0", flags=0) await self.__send_frame(b"strm", data) - + async def cmd_power_on(self): - ''' send power ON command to player ''' + """ send power ON command to player """ await self.__send_frame(b"aude", struct.pack("2B", 1, 1)) self.settings["last_power"] = True self.powered = True async def cmd_power_off(self): - ''' send power TOGGLE command to player ''' + """ send power TOGGLE command to player """ await self.cmd_stop() await self.__send_frame(b"aude", struct.pack("2B", 0, 0)) self.settings["last_power"] = False self.powered = False async def cmd_volume_set(self, volume_level): - ''' send new volume level command to player ''' + """ send new volume level command to player """ self._volume.volume = volume_level og = self._volume.old_gain() ng = self._volume.new_gain() await self.__send_frame(b"audg", struct.pack("!LLBBLL", og, og, 1, 255, ng, ng)) self.settings["last_volume"] = volume_level self.volume_level = volume_level - + async def cmd_volume_mute(self, is_muted=False): - ''' send mute command to player ''' + """ send mute command to player """ if is_muted: await self.__send_frame(b"aude", struct.pack("2B", 0, 0)) else: await self.__send_frame(b"aude", struct.pack("2B", 1, 1)) self.muted = is_muted - async def cmd_queue_play_index(self, index:int): - ''' + async def cmd_queue_play_index(self, index: int): + """ play item at index X on player's queue :param index: (int) index of the queue item that should start playing - ''' + """ new_track = await self.queue.get_item(index) if new_track: await self.__send_flush() await self.__send_play(new_track.uri) async def cmd_queue_load(self, queue_items): - ''' + """ load/overwrite given items in the player's own queue implementation :param queue_items: a list of QueueItems - ''' + """ await self.__send_flush() if queue_items: await self.__send_play(queue_items[0].uri) @@ -197,70 +208,104 @@ class PySqueezePlayer(Player): return await self.cmd_queue_play_index(insert_at_index) async def cmd_queue_append(self, queue_items): - pass # automagically handled by built-in queue controller + pass # automagically handled by built-in queue controller - async def cmd_play_uri(self, uri:str): - ''' + async def cmd_play_uri(self, uri: str): + """ [MUST OVERRIDE] tell player to start playing a single uri - ''' + """ await self.__send_flush() await self.__send_play(uri) async def __send_flush(self): data = await self.__pack_stream(b"f", autostart=b"0", flags=0) await self.__send_frame(b"strm", data) - + async def __send_play(self, uri): - ''' play uri ''' + """ play uri """ self.cur_uri = uri self.powered = True enable_crossfade = self.settings["crossfade_duration"] > 0 - command = b's' - autostart = b'3' # we use direct stream for now so let the player do the messy work with buffers - transType= b'1' if enable_crossfade else b'0' + command = b"s" + autostart = ( + b"3" + ) # we use direct stream for now so let the player do the messy work with buffers + transType = b"1" if enable_crossfade else b"0" transDuration = self.settings["crossfade_duration"] - formatbyte = b'f' # fixed to flac - uri = '/stream' + uri.split('/stream')[1] - data = await self.__pack_stream(command, autostart=autostart, flags=0x00, - formatbyte=formatbyte, transType=transType, - transDuration=transDuration) - headers = "Connection: close\r\nAccept: */*\r\nHost: %s:%s\r\n" %(self.mass.web.local_ip, self.mass.web.http_port) + formatbyte = b"f" # fixed to flac + uri = "/stream" + uri.split("/stream")[1] + data = await self.__pack_stream( + command, + autostart=autostart, + flags=0x00, + formatbyte=formatbyte, + transType=transType, + transDuration=transDuration, + ) + headers = "Connection: close\r\nAccept: */*\r\nHost: %s:%s\r\n" % ( + self.mass.web.local_ip, + self.mass.web.http_port, + ) request = "GET %s HTTP/1.0\r\n%s\r\n" % (uri, headers) data = data + request.encode("utf-8") - await self.__send_frame(b'strm', data) + await self.__send_frame(b"strm", data) def __delete__(self, instance): - ''' make sure the heartbeat task is deleted ''' + """ make sure the heartbeat task is deleted """ if self._heartbeat_task: self._heartbeat_task.cancel() @run_periodic(5) async def __send_heartbeat(self): - ''' send periodic heartbeat message to player ''' + """ send periodic heartbeat message to player """ timestamp = int(time.time()) data = await self.__pack_stream(b"t", replayGain=timestamp, flags=0) await self.__send_frame(b"strm", data) async def __send_frame(self, command, data): - ''' send command to Squeeze player''' - packet = struct.pack('!H', len(data) + 4) + command + data + """ send command to Squeeze player""" + packet = struct.pack("!H", len(data) + 4) + command + data self._writer.write(packet) await self._writer.drain() - async def __pack_stream(self, command, autostart=b"1", formatbyte = b'o', - pcmargs = (b'?',b'?',b'?',b'?'), threshold = 200, - spdif = b'0', transDuration = 0, transType = b'0', - flags = 0x40, outputThreshold = 0, - replayGain=0, serverPort = 8095, serverIp = 0): - return struct.pack("!cccccccBcBcBBBLHL", - command, autostart, formatbyte, *pcmargs, - threshold, spdif, transDuration, transType, - flags, outputThreshold, 0, replayGain, serverPort, serverIp) - + async def __pack_stream( + self, + command, + autostart=b"1", + formatbyte=b"o", + pcmargs=(b"?", b"?", b"?", b"?"), + threshold=200, + spdif=b"0", + transDuration=0, + transType=b"0", + flags=0x40, + outputThreshold=0, + replayGain=0, + serverPort=8095, + serverIp=0, + ): + return struct.pack( + "!cccccccBcBcBBBLHL", + command, + autostart, + formatbyte, + *pcmargs, + threshold, + spdif, + transDuration, + transType, + flags, + outputThreshold, + 0, + replayGain, + serverPort, + serverIp + ) + async def displayTrack(self, track): await self.render("%s by %s" % (track.title, track.artist)) - + async def setBrightness(self, level=4): assert 0 <= level <= 4 await self.__send_frame(b"grfb", struct.pack("!H", level)) @@ -269,12 +314,12 @@ class PySqueezePlayer(Player): await self.__send_frame(b"visu", visualisation.pack()) async def render(self, text): - #self.display.clear() - #self.display.renderText(text, "DejaVu-Sans", 16, (0,0)) - #self.updateDisplay(self.display.frame()) + # self.display.clear() + # self.display.renderText(text, "DejaVu-Sans", 16, (0,0)) + # self.updateDisplay(self.display.frame()) pass - async def updateDisplay(self, bitmap, transition = 'c', offset=0, param=0): + async def updateDisplay(self, bitmap, transition="c", offset=0, param=0): frame = struct.pack("!Hcb", offset, transition, param) + bitmap await self.__send_frame(b"grfe", frame) @@ -286,15 +331,15 @@ class PySqueezePlayer(Player): await handler(packet) async def process_STAT(self, data): - '''process incoming event from player''' + """process incoming event from player""" event = data[:4].decode() event_data = data[4:] - if event == b'\x00\x00\x00\x00': + if event == b"\x00\x00\x00\x00": # Presumed informational stat message return - event_handler = getattr(self, 'stat_%s' %event, None) + event_handler = getattr(self, "stat_%s" % event, None) if event_handler is None: - LOGGER.debug("Got event %s - event_data: %s" %(event, event_data)) + LOGGER.debug("Got event %s - event_data: %s" % (event, event_data)) else: await event_handler(data[4:]) @@ -321,40 +366,53 @@ class PySqueezePlayer(Player): self.state = PlayerState.Stopped async def stat_STMo(self, data): - ''' No more decoded (uncompressed) data to play; triggers rebuffering. ''' + """ No more decoded (uncompressed) data to play; triggers rebuffering. """ LOGGER.debug("Output Underrun") - + async def stat_STMp(self, data): - '''Pause confirmed''' + """Pause confirmed""" self.state = PlayerState.Paused async def stat_STMr(self, data): - '''Resume confirmed''' + """Resume confirmed""" self.state = PlayerState.Playing async def stat_STMs(self, data): - '''Playback of new track has started''' + """Playback of new track has started""" self.state = PlayerState.Playing async def stat_STMt(self, data): """ heartbeat from client """ timestamp = time.time() self._last_heartbeat = timestamp - (num_crlf, mas_initialized, mas_mode, rptr, wptr, - bytes_received_h, bytes_received_l, signal_strength, - jiffies, output_buffer_size, output_buffer_fullness, - elapsed_seconds, voltage, cur_time_milliseconds, - server_timestamp, error_code) = struct.unpack("!BBBLLLLHLLLLHLLH", data) + ( + num_crlf, + mas_initialized, + mas_mode, + rptr, + wptr, + bytes_received_h, + bytes_received_l, + signal_strength, + jiffies, + output_buffer_size, + output_buffer_fullness, + elapsed_seconds, + voltage, + cur_time_milliseconds, + server_timestamp, + error_code, + ) = struct.unpack("!BBBLLLLHLLLLHLLH", data) if self.state == PlayerState.Playing and elapsed_seconds != self.cur_time: self.cur_time = elapsed_seconds self._cur_time_milliseconds = cur_time_milliseconds async def stat_STMu(self, data): - ''' Buffer underrun: Normal end of playback''' + """ Buffer underrun: Normal end of playback""" self.state = PlayerState.Stopped async def process_RESP(self, data): - ''' response received at player, send continue ''' + """ response received at player, send continue """ LOGGER.debug("RESP received") await self.__send_frame(b"cont", b"0") @@ -373,7 +431,7 @@ class PySqueezePlayer(Player): # LOGGER.info("Unknown IR received: %r, %r" % (time, code)) async def process_SETD(self, data): - ''' Get/set player firmware settings ''' + """ Get/set player firmware settings """ LOGGER.debug("SETD received %s" % data) cmd_id = data[0] if cmd_id == 0: @@ -384,18 +442,18 @@ class PySqueezePlayer(Player): # from http://wiki.slimdevices.com/index.php/SlimProtoTCPProtocol#HELO devices = { - 2: 'squeezebox', - 3: 'softsqueeze', - 4: 'squeezebox2', - 5: 'transporter', - 6: 'softsqueeze3', - 7: 'receiver', - 8: 'squeezeslave', - 9: 'controller', - 10: 'boom', - 11: 'softboom', - 12: 'squeezeplay', - } + 2: "squeezebox", + 3: "softsqueeze", + 4: "squeezebox2", + 5: "transporter", + 6: "softsqueeze3", + 7: "receiver", + 8: "squeezeslave", + 9: "controller", + 10: "boom", + 11: "softboom", + 12: "squeezeplay", +} class PySqueezeVolume(object): @@ -410,22 +468,117 @@ class PySqueezeVolume(object): # this map is taken from Slim::Player::Squeezebox2 in the squeezecenter source # i don't know how much magic it contains, or any way I can test it old_map = [ - 0, 1, 1, 1, 2, 2, 2, 3, 3, 4, - 5, 5, 6, 6, 7, 8, 9, 9, 10, 11, - 12, 13, 14, 15, 16, 16, 17, 18, 19, 20, - 22, 23, 24, 25, 26, 27, 28, 29, 30, 32, - 33, 34, 35, 37, 38, 39, 40, 42, 43, 44, - 46, 47, 48, 50, 51, 53, 54, 56, 57, 59, - 60, 61, 63, 65, 66, 68, 69, 71, 72, 74, - 75, 77, 79, 80, 82, 84, 85, 87, 89, 90, - 92, 94, 96, 97, 99, 101, 103, 104, 106, 108, 110, - 112, 113, 115, 117, 119, 121, 123, 125, 127, 128 - ]; + 0, + 1, + 1, + 1, + 2, + 2, + 2, + 3, + 3, + 4, + 5, + 5, + 6, + 6, + 7, + 8, + 9, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 16, + 17, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 32, + 33, + 34, + 35, + 37, + 38, + 39, + 40, + 42, + 43, + 44, + 46, + 47, + 48, + 50, + 51, + 53, + 54, + 56, + 57, + 59, + 60, + 61, + 63, + 65, + 66, + 68, + 69, + 71, + 72, + 74, + 75, + 77, + 79, + 80, + 82, + 84, + 85, + 87, + 89, + 90, + 92, + 94, + 96, + 97, + 99, + 101, + 103, + 104, + 106, + 108, + 110, + 112, + 113, + 115, + 117, + 119, + 121, + 123, + 125, + 127, + 128, + ] # new gain parameters, from the same place - total_volume_range = -50 # dB - step_point = -1 # Number of steps, up from the bottom, where a 2nd volume ramp kicks in. - step_fraction = 1 # fraction of totalVolumeRange where alternate volume ramp kicks in. + total_volume_range = -50 # dB + step_point = ( + -1 + ) # Number of steps, up from the bottom, where a 2nd volume ramp kicks in. + step_fraction = ( + 1 + ) # fraction of totalVolumeRange where alternate volume ramp kicks in. def __init__(self): self.volume = 50 @@ -450,7 +603,7 @@ class PySqueezeVolume(object): """ Return the "new" gain value. """ step_db = self.total_volume_range * self.step_fraction - max_volume_db = 0 # different on the boom? + max_volume_db = 0 # different on the boom? # Equation for a line: # y = mx+b @@ -460,7 +613,7 @@ class PySqueezeVolume(object): slope_high = max_volume_db - step_db / (100.0 - self.step_point) slope_low = step_db - self.total_volume_range / (self.step_point - 0.0) x2 = self.volume - if (x2 > self.step_point): + if x2 > self.step_point: m = slope_high x1 = 100 y1 = max_volume_db @@ -472,34 +625,35 @@ class PySqueezeVolume(object): def new_gain(self): db = self.decibels() - floatmult = 10 ** (db/20.0) + floatmult = 10 ** (db / 20.0) # avoid rounding errors somehow if -30 <= db <= 0: - return int(floatmult * (1 << 8) + 0.5) * (1<<8) + return int(floatmult * (1 << 8) + 0.5) * (1 << 8) else: - return int((floatmult * (1<<16)) + 0.5) + return int((floatmult * (1 << 16)) + 0.5) ##### UDP DISCOVERY STUFF ############# -class Datagram(object): +class Datagram(object): @classmethod def decode(self, data): - if data[0] == 'e': + if data[0] == "e": return TLVDiscoveryRequestDatagram(data) - elif data[0] == 'E': + elif data[0] == "E": return TLVDiscoveryResponseDatagram(data) - elif data[0] == 'd': + elif data[0] == "d": return ClientDiscoveryDatagram(data) - elif data[0] == 'h': - pass # Hello! - elif data[0] == 'i': - pass # IR - elif data[0] == '2': - pass # i2c? - elif data[0] == 'a': - pass # ack! + elif data[0] == "h": + pass # Hello! + elif data[0] == "i": + pass # IR + elif data[0] == "2": + pass # i2c? + elif data[0] == "a": + pass # ack! + class ClientDiscoveryDatagram(Datagram): @@ -508,68 +662,73 @@ class ClientDiscoveryDatagram(Datagram): client = None def __init__(self, data): - s = struct.unpack('!cxBB8x6B', data.encode()) - assert s[0] == 'd' + s = struct.unpack("!cxBB8x6B", data.encode()) + assert s[0] == "d" self.device = s[1] self.firmware = hex(s[2]) self.client = ":".join(["%02x" % (x,) for x in s[3:]]) def __repr__(self): - return "<%s device=%r firmware=%r client=%r>" % (self.__class__.__name__, self.device, self.firmware, self.client) + return "<%s device=%r firmware=%r client=%r>" % ( + self.__class__.__name__, + self.device, + self.firmware, + self.client, + ) -class DiscoveryResponseDatagram(Datagram): +class DiscoveryResponseDatagram(Datagram): def __init__(self, hostname, port): hostname = hostname[:16].encode("UTF-8") - hostname += (16 - len(hostname)) * '\x00' - self.packet = struct.pack('!c16s', 'D', hostname).decode() + hostname += (16 - len(hostname)) * "\x00" + self.packet = struct.pack("!c16s", "D", hostname).decode() + class TLVDiscoveryRequestDatagram(Datagram): - def __init__(self, data): requestdata = OrderedDict() - assert data[0] == 'e' + assert data[0] == "e" idx = 1 - length = len(data)-5 + length = len(data) - 5 while idx <= length: typ, l = struct.unpack_from("4sB", data.encode(), idx) if l: - val = data[idx+5:idx+5+l] - idx += 5+l + val = data[idx + 5 : idx + 5 + l] + idx += 5 + l else: val = None idx += 5 typ = typ.decode() requestdata[typ] = val self.data = requestdata - + def __repr__(self): return "<%s data=%r>" % (self.__class__.__name__, self.data.items()) -class TLVDiscoveryResponseDatagram(Datagram): +class TLVDiscoveryResponseDatagram(Datagram): def __init__(self, responsedata): - parts = ['E'] # new discovery format + parts = ["E"] # new discovery format for typ, value in responsedata.items(): if value is None: - value = '' + value = "" elif len(value) > 255: # Response too long, truncating to 255 bytes value = value[:255] parts.extend((typ, chr(len(value)), value)) - self.packet = ''.join(parts) + self.packet = "".join(parts) -class DiscoveryProtocol(): +class DiscoveryProtocol: def __init__(self, web_port): self.web_port = web_port - + def connection_made(self, transport): self.transport = transport # Allow receiving multicast broadcasts - sock = self.transport.get_extra_info('socket') - group = socket.inet_aton('239.255.255.250') - mreq = struct.pack('4sL', group, socket.INADDR_ANY) + sock = self.transport.get_extra_info("socket") + group = socket.inet_aton("239.255.255.250") + mreq = struct.pack("4sL", group, socket.INADDR_ANY) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) def error_received(self, exc): @@ -577,32 +736,32 @@ class DiscoveryProtocol(): def connection_lost(self, *args, **kwargs): LOGGER.debug("Connection lost to discovery") - + def build_TLV_response(self, requestdata): responsedata = OrderedDict() for typ, value in requestdata.items(): - if typ == 'NAME': + if typ == "NAME": # send full host name - no truncation value = get_hostname() - elif typ == 'IPAD': + elif typ == "IPAD": # send ipaddress as a string only if it is set value = get_ip() # :todo: IPv6 - if value == '0.0.0.0': + if value == "0.0.0.0": # do not send back an ip address typ = None - elif typ == 'JSON': + elif typ == "JSON": # send port as a string json_port = self.web_port value = str(json_port) - elif typ == 'VERS': + elif typ == "VERS": # send server version - value = '7.9' - elif typ == 'UUID': + value = "7.9" + elif typ == "UUID": # send server uuid - value = 'musicassistant' + value = "musicassistant" else: - LOGGER.debug('Unexpected information request: %r', typ) + LOGGER.debug("Unexpected information request: %r", typ) typ = None if typ: responsedata[typ] = value @@ -627,4 +786,3 @@ class DiscoveryProtocol(): def sendTLVDiscoveryResponse(self, resonsedata, addr): dgram = TLVDiscoveryResponseDatagram(resonsedata) self.transport.sendto(dgram.packet.encode(), addr) - diff --git a/music_assistant/playerproviders/webplayer.py b/music_assistant/playerproviders/webplayer.py index f8e5a97a..fe90ff6b 100644 --- a/music_assistant/playerproviders/webplayer.py +++ b/music_assistant/playerproviders/webplayer.py @@ -2,68 +2,79 @@ # -*- coding:utf-8 -*- import asyncio -import os -import struct from collections import OrderedDict -import time import decimal -from typing import List +import os import random -import sys import socket -from music_assistant.utils import run_periodic, LOGGER, try_parse_int, get_ip, get_hostname -from music_assistant.models import PlayerProvider, Player, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +import struct +import sys +import time +from typing import List + from music_assistant.constants import CONF_ENABLED +from music_assistant.models.player import Player, PlayerState +from music_assistant.models.player_queue import QueueItem +from music_assistant.models.playerprovider import PlayerProvider +from music_assistant.utils import ( + LOGGER, + get_hostname, + get_ip, + run_periodic, + try_parse_int, +) +PROV_ID = "webplayer" +PROV_NAME = "WebPlayer" +PROV_CLASS = "WebPlayerProvider" -PROV_ID = 'webplayer' -PROV_NAME = 'WebPlayer' -PROV_CLASS = 'WebPlayerProvider' +CONFIG_ENTRIES = [(CONF_ENABLED, True, CONF_ENABLED)] -CONFIG_ENTRIES = [ - (CONF_ENABLED, True, CONF_ENABLED), - ] +EVENT_WEBPLAYER_CMD = "webplayer command" +EVENT_WEBPLAYER_STATE = "webplayer state" +EVENT_WEBPLAYER_REGISTER = "webplayer register" -EVENT_WEBPLAYER_CMD = 'webplayer command' -EVENT_WEBPLAYER_STATE = 'webplayer state' -EVENT_WEBPLAYER_REGISTER = 'webplayer register' class WebPlayerProvider(PlayerProvider): - ''' + """ Implementation of a player using pure HTML/javascript used in the front-end. Communication is handled through the websocket connection and our internal event bus - ''' + """ - ### Provider specific implementation ##### + ### Provider specific implementation ##### async def setup(self, conf): - ''' async initialize of module ''' - await self.mass.add_event_listener(self.handle_mass_event, EVENT_WEBPLAYER_STATE) - await self.mass.add_event_listener(self.handle_mass_event, EVENT_WEBPLAYER_REGISTER) + """ async initialize of module """ + await self.mass.add_event_listener( + self.handle_mass_event, EVENT_WEBPLAYER_STATE + ) + await self.mass.add_event_listener( + self.handle_mass_event, EVENT_WEBPLAYER_REGISTER + ) self.mass.event_loop.create_task(self.check_players()) async def handle_mass_event(self, msg, msg_details): - ''' received event for the webplayer component ''' + """ received event for the webplayer component """ if msg == EVENT_WEBPLAYER_REGISTER: # register new player - player_id = msg_details['player_id'] + player_id = msg_details["player_id"] player = WebPlayer(self.mass, player_id, self.prov_id) player.supports_crossfade = False player.supports_gapless = False player.supports_queue = False - player.name = msg_details['name'] + player.name = msg_details["name"] await self.add_player(player) elif msg == EVENT_WEBPLAYER_STATE: - player_id = msg_details['player_id'] + player_id = msg_details["player_id"] player = await self.get_player(player_id) if player: await player.handle_state(msg_details) @run_periodic(30) async def check_players(self): - ''' invalidate players that did not send a heartbeat message in a while ''' + """ invalidate players that did not send a heartbeat message in a while """ cur_time = time.time() offline_players = [] for player in self.players: @@ -74,68 +85,70 @@ class WebPlayerProvider(PlayerProvider): class WebPlayer(Player): - ''' Web player object ''' + """ Web player object """ def __init__(self, mass, player_id, prov_id): self._last_message = time.time() super().__init__(mass, player_id, prov_id) async def cmd_stop(self): - ''' send stop command to player ''' - data = { 'player_id': self.player_id, 'cmd': 'stop'} + """ send stop command to player """ + data = {"player_id": self.player_id, "cmd": "stop"} await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) async def cmd_play(self): - ''' send play command to player ''' - data = { 'player_id': self.player_id, 'cmd': 'play'} + """ send play command to player """ + data = {"player_id": self.player_id, "cmd": "play"} await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) async def cmd_pause(self): - ''' send pause command to player ''' - data = { 'player_id': self.player_id, 'cmd': 'pause'} + """ send pause command to player """ + data = {"player_id": self.player_id, "cmd": "pause"} await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - + async def cmd_power_on(self): - ''' send power ON command to player ''' - self.powered = True # not supported on webplayer - data = { 'player_id': self.player_id, 'cmd': 'stop'} + """ send power ON command to player """ + self.powered = True # not supported on webplayer + data = {"player_id": self.player_id, "cmd": "stop"} await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) async def cmd_power_off(self): - ''' send power OFF command to player ''' + """ send power OFF command to player """ self.powered = False async def cmd_volume_set(self, volume_level): - ''' send new volume level command to player ''' - data = { 'player_id': self.player_id, 'cmd': 'volume_set', 'volume_level': volume_level} + """ send new volume level command to player """ + data = { + "player_id": self.player_id, + "cmd": "volume_set", + "volume_level": volume_level, + } await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) async def cmd_volume_mute(self, is_muted=False): - ''' send mute command to player ''' - data = { 'player_id': self.player_id, 'cmd': 'volume_mute', 'is_muted': is_muted} + """ send mute command to player """ + data = {"player_id": self.player_id, "cmd": "volume_mute", "is_muted": is_muted} await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) - async def cmd_play_uri(self, uri:str): - ''' play single uri on player ''' - data = { 'player_id': self.player_id, 'cmd': 'play_uri', 'uri': uri} + async def cmd_play_uri(self, uri: str): + """ play single uri on player """ + data = {"player_id": self.player_id, "cmd": "play_uri", "uri": uri} await self.mass.signal_event(EVENT_WEBPLAYER_CMD, data) async def handle_state(self, data): - ''' handle state event from player ''' - if 'volume_level' in data: - self.volume_level = data['volume_level'] - if 'muted' in data: - self.muted = data['muted'] - if 'state' in data: - self.state = PlayerState(data['state']) - if 'cur_time' in data: - self.cur_time = data['cur_time'] - if 'cur_uri' in data: - self.cur_uri = data['cur_uri'] - if 'powered' in data: - self.powered = data['powered'] - if 'name' in data: - self.name = data['name'] + """ handle state event from player """ + if "volume_level" in data: + self.volume_level = data["volume_level"] + if "muted" in data: + self.muted = data["muted"] + if "state" in data: + self.state = PlayerState(data["state"]) + if "cur_time" in data: + self.cur_time = data["cur_time"] + if "cur_uri" in data: + self.cur_uri = data["cur_uri"] + if "powered" in data: + self.powered = data["powered"] + if "name" in data: + self.name = data["name"] self._last_message = time.time() - - diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 796fc301..bb5ab163 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -2,21 +2,23 @@ # -*- coding:utf-8 -*- import asyncio -import logging -import socket import importlib +import logging import os import re +import socket + +from music_assistant.constants import CONF_ENABLED, CONF_KEY_MUSICPROVIDERS import unidecode + try: import simplejson as json except ImportError: import json -LOGGER = logging.getLogger('music_assistant') +LOGGER = logging.getLogger("music_assistant") -from music_assistant.constants import CONF_KEY_MUSICPROVIDERS, CONF_ENABLED -IS_HASSIO = os.path.isfile('/data/options.json') +IS_HASSIO = os.path.isfile("/data/options.json") def run_periodic(period): @@ -33,9 +35,8 @@ def run_periodic(period): def filename_from_string(string): """ create filename from unsafe string """ - keepcharacters = (' ', '.', '_') - return "".join(c for c in string - if c.isalnum() or c in keepcharacters).rstrip() + keepcharacters = (" ", ".", "_") + return "".join(c for c in string if c.isalnum() or c in keepcharacters).rstrip() def run_background_task(corofn, *args, executor=None): @@ -45,18 +46,18 @@ def run_background_task(corofn, *args, executor=None): def run_async_background_task(executor, corofn, *args): """ run async task in background """ + def run_task(corofn, *args): - LOGGER.debug('running %s in background task', corofn.__name__) + LOGGER.debug("running %s in background task", corofn.__name__) new_loop = asyncio.new_event_loop() asyncio.set_event_loop(new_loop) coro = corofn(*args) res = new_loop.run_until_complete(coro) new_loop.close() - LOGGER.debug('completed %s in background task', corofn.__name__) + LOGGER.debug("completed %s in background task", corofn.__name__) return res - return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, - *args) + return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args) def get_sort_name(name): @@ -95,13 +96,13 @@ def try_parse_bool(possible_bool): if isinstance(possible_bool, bool): return possible_bool else: - return possible_bool in ['true', 'True', '1', 'on', 'ON', 1] + return possible_bool in ["true", "True", "1", "on", "ON", 1] def parse_title_and_version(track_title, track_version=None): """ try to parse clean track title and version from the title """ title = track_title.lower() - version = '' + version = "" for splitter in [" (", " [", " - ", " (", " [", "-"]: if splitter in title: title_parts = title.split(splitter) @@ -111,14 +112,29 @@ def parse_title_and_version(track_title, track_version=None): if end_splitter in title_part: title_part = title_part.split(end_splitter)[0] for ignore_str in [ - "feat.", "featuring", "ft.", "with ", " & ", "explicit" + "feat.", + "featuring", + "ft.", + "with ", + " & ", + "explicit", ]: if ignore_str in title_part: title = title.split(splitter + title_part)[0] for version_str in [ - "version", "live", "edit", "remix", "mix", "acoustic", - " instrumental", "karaoke", "remaster", "versie", - "radio", "unplugged", "disco" + "version", + "live", + "edit", + "remix", + "mix", + "acoustic", + " instrumental", + "karaoke", + "remaster", + "versie", + "radio", + "unplugged", + "disco", ]: if version_str in title_part: version = title_part @@ -134,19 +150,19 @@ def get_version_substitute(version_str): """ transform provider version str to universal version type """ version_str = version_str.lower() # substitute edit and edition with version - if 'edition' in version_str or 'edit' in version_str: - version_str = version_str.replace(' edition', ' version') - version_str = version_str.replace(' edit ', ' version') - if version_str.startswith('the '): - version_str = version_str.split('the ')[1] + if "edition" in version_str or "edit" in version_str: + version_str = version_str.replace(" edition", " version") + version_str = version_str.replace(" edit ", " version") + if version_str.startswith("the "): + version_str = version_str.split("the ")[1] if "radio mix" in version_str: version_str = "radio version" elif "video mix" in version_str: version_str = "video version" elif "spanglish" in version_str or "spanish" in version_str: version_str = "spanish version" - elif version_str.endswith('remaster'): - version_str = 'remaster' + elif version_str.endswith("remaster"): + version_str = "remaster" return version_str.strip() @@ -155,10 +171,10 @@ def get_ip(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: # doesn't even have to be reachable - s.connect(('10.255.255.255', 1)) + s.connect(("10.255.255.255", 1)) IP = s.getsockname()[0] except Exception: - IP = '127.0.0.1' + IP = "127.0.0.1" finally: s.close() return IP @@ -187,6 +203,7 @@ def get_folder_size(folderpath): def serialize_values(obj): """Recursively create serializable values for (custom) data types.""" + def get_val(val): if isinstance(val, (int, str, bool, float, tuple)): return val @@ -195,14 +212,14 @@ def serialize_values(obj): for item in val: new_list.append(get_val(item)) return new_list - elif hasattr(val, 'to_dict'): + elif hasattr(val, "to_dict"): return get_val(val.to_dict()) elif isinstance(val, dict): new_dict = {} for key, value in val.items(): new_dict[key] = get_val(value) return new_dict - elif hasattr(val, '__dict__'): + elif hasattr(val, "__dict__"): new_dict = {} for key, value in val.__dict__.items(): new_dict[key] = get_val(value) @@ -237,28 +254,28 @@ def try_load_json_file(jsonfile): with open(jsonfile) as f: return json.loads(f.read()) except Exception as exc: - LOGGER.debug("Could not load json from file %s", - jsonfile, - exc_info=exc) + LOGGER.debug("Could not load json from file %s", jsonfile, exc_info=exc) return None # pylint: enable=broad-except -async def load_provider_modules(mass, - provider_modules, - prov_type=CONF_KEY_MUSICPROVIDERS): +async def load_provider_modules( + mass, provider_modules, prov_type=CONF_KEY_MUSICPROVIDERS +): """ dynamically load music/player providers """ base_dir = os.path.dirname(os.path.abspath(__file__)) modules_path = os.path.join(base_dir, prov_type) # load modules for item in os.listdir(modules_path): - if (os.path.isfile(os.path.join(modules_path, item)) - and not item.startswith("_") and item.endswith('.py') - and not item.startswith('.')): + if ( + os.path.isfile(os.path.join(modules_path, item)) + and not item.startswith("_") + and item.endswith(".py") + and not item.startswith(".") + ): module_name = item.replace(".py", "") if module_name not in provider_modules: - prov_mod = await load_provider_module(mass, module_name, - prov_type) + prov_mod = await load_provider_module(mass, module_name, prov_type) if prov_mod: provider_modules[module_name] = prov_mod @@ -267,16 +284,17 @@ async def load_provider_module(mass, module_name, prov_type): """ dynamically load music/player provider """ # pylint: disable=broad-except try: - prov_mod = importlib.import_module(f".{module_name}", - f"music_assistant.{prov_type}") + prov_mod = importlib.import_module( + f".{module_name}", f"music_assistant.{prov_type}" + ) prov_conf_entries = prov_mod.CONFIG_ENTRIES prov_id = module_name prov_name = prov_mod.PROV_NAME prov_class = prov_mod.PROV_CLASS # get/create config for the module - prov_config = mass.config.create_module_config(prov_id, - prov_conf_entries, - prov_type) + prov_config = mass.config.create_module_config( + prov_id, prov_conf_entries, prov_type + ) if prov_config[CONF_ENABLED]: prov_mod_cls = getattr(prov_mod, prov_class) provider = prov_mod_cls(mass) diff --git a/music_assistant/web.py b/music_assistant/web.py index f6d7d246..a3ba90b3 100755 --- a/music_assistant/web.py +++ b/music_assistant/web.py @@ -2,33 +2,53 @@ # -*- coding:utf-8 -*- import asyncio -import os -import aiohttp -import inspect -import aiohttp_cors -from aiohttp import web +import concurrent from functools import partial +import inspect +import os import ssl -import concurrent import threading -from music_assistant.models.media_types import MediaItem, MediaType, media_type_from_string -from music_assistant.utils import run_periodic, LOGGER, IS_HASSIO, run_async_background_task, get_ip, json_serializer -from music_assistant.constants import CONF_KEY_PLAYERSETTINGS, CONF_KEY_MUSICPROVIDERS, CONF_KEY_PLAYERPROVIDERS -CONF_KEY = 'web' +import aiohttp +from aiohttp import web +import aiohttp_cors +from music_assistant.constants import ( + CONF_KEY_MUSICPROVIDERS, + CONF_KEY_PLAYERPROVIDERS, + CONF_KEY_PLAYERSETTINGS, +) +from music_assistant.models.media_types import ( + MediaItem, + MediaType, + media_type_from_string, +) +from music_assistant.utils import ( + IS_HASSIO, + LOGGER, + get_ip, + json_serializer, + run_async_background_task, + run_periodic, +) + +CONF_KEY = "web" if IS_HASSIO: # on hassio we use ingress - CONFIG_ENTRIES = [('https_port', 8096, 'web_https_port'), - ('ssl_certificate', '', 'web_ssl_cert'), - ('ssl_key', '', 'web_ssl_key'), - ('cert_fqdn_host', '', 'cert_fqdn_host')] + CONFIG_ENTRIES = [ + ("https_port", 8096, "web_https_port"), + ("ssl_certificate", "", "web_ssl_cert"), + ("ssl_key", "", "web_ssl_key"), + ("cert_fqdn_host", "", "cert_fqdn_host"), + ] else: - CONFIG_ENTRIES = [('http_port', 8095, 'web_http_port'), - ('https_port', 8096, 'web_https_port'), - ('ssl_certificate', '', 'web_ssl_cert'), - ('ssl_key', '', 'web_ssl_key'), - ('cert_fqdn_host', '', 'cert_fqdn_host')] + CONFIG_ENTRIES = [ + ("http_port", 8095, "web_http_port"), + ("https_port", 8096, "web_https_port"), + ("ssl_certificate", "", "web_ssl_cert"), + ("ssl_key", "", "web_ssl_key"), + ("cert_fqdn_host", "", "cert_fqdn_host"), + ] class ClassRouteTableDef(web.RouteTableDef): @@ -44,8 +64,9 @@ class ClassRouteTableDef(web.RouteTableDef): def add_class_routes(self, instance) -> None: def predicate(member) -> bool: - return all((inspect.iscoroutinefunction(member), - hasattr(member, "route_info"))) + return all( + (inspect.iscoroutinefunction(member), hasattr(member, "route_info")) + ) for _, handler in inspect.getmembers(instance, predicate): method, path, kwargs = handler.route_info @@ -55,38 +76,38 @@ class ClassRouteTableDef(web.RouteTableDef): routes = ClassRouteTableDef() -class Web(): +class Web: """ webserver and json/websocket api """ + runner = None def __init__(self, mass): self.mass = mass # load/create/update config - config = self.mass.config.create_module_config(CONF_KEY, - CONFIG_ENTRIES) + config = self.mass.config.create_module_config(CONF_KEY, CONFIG_ENTRIES) self.local_ip = get_ip() self.config = config if IS_HASSIO: # retrieve ingress http port import requests - url = 'http://hassio/addons/self/info' - headers = { "X-HASSIO-KEY":os.environ["HASSIO_TOKEN"] } + + url = "http://hassio/addons/self/info" + headers = {"X-HASSIO-KEY": os.environ["HASSIO_TOKEN"]} response = requests.get(url, headers=headers).json() self.http_port = response["data"]["ingress_port"] else: # use settings from config - self.http_port = config['http_port'] - enable_ssl = config['ssl_certificate'] and config['ssl_key'] - if config['ssl_certificate'] and not os.path.isfile( - config['ssl_certificate']): + self.http_port = config["http_port"] + enable_ssl = config["ssl_certificate"] and config["ssl_key"] + if config["ssl_certificate"] and not os.path.isfile(config["ssl_certificate"]): enable_ssl = False - LOGGER.warning("SSL certificate file not found: %s", - config['ssl_certificate']) - if config['ssl_key'] and not os.path.isfile(config['ssl_key']): + LOGGER.warning( + "SSL certificate file not found: %s", config["ssl_certificate"] + ) + if config["ssl_key"] and not os.path.isfile(config["ssl_key"]): enable_ssl = False - LOGGER.warning("SSL certificate key file not found: %s", - config['ssl_key']) - self.https_port = config['https_port'] + LOGGER.warning("SSL certificate key file not found: %s", config["ssl_key"]) + self.https_port = config["https_port"] self._enable_ssl = enable_ssl async def setup(self): @@ -94,103 +115,117 @@ class Web(): routes.add_class_routes(self) app = web.Application() app.add_routes(routes) - app.add_routes([ - web.get('/stream/{player_id}', + app.add_routes( + [ + web.get( + "/stream/{player_id}", self.mass.http_streamer.stream, - allow_head=False), - web.get('/stream/{player_id}/{queue_item_id}', + allow_head=False, + ), + web.get( + "/stream/{player_id}/{queue_item_id}", self.mass.http_streamer.stream, - allow_head=False), - web.get('/', self.index), - web.get('/jsonrpc.js', self.json_rpc), - web.post('/jsonrpc.js', self.json_rpc), - web.get('/ws', self.websocket_handler) - ]) - webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'web/') + allow_head=False, + ), + web.get("/", self.index), + web.get("/jsonrpc.js", self.json_rpc), + web.post("/jsonrpc.js", self.json_rpc), + web.get("/ws", self.websocket_handler), + ] + ) + webdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web/") app.router.add_static("/", webdir) # Add CORS support to all routes cors = aiohttp_cors.setup( app, defaults={ - "*": - aiohttp_cors.ResourceOptions( + "*": aiohttp_cors.ResourceOptions( allow_credentials=True, expose_headers="*", allow_headers="*", - allow_methods=["POST", "PUT", "DELETE"]) - }) + allow_methods=["POST", "PUT", "DELETE"], + ) + }, + ) for route in list(app.router.routes()): cors.add(route) self.runner = web.AppRunner(app, access_log=None) await self.runner.setup() - http_site = web.TCPSite(self.runner, '0.0.0.0', self.http_port) + http_site = web.TCPSite(self.runner, "0.0.0.0", self.http_port) await http_site.start() LOGGER.info("Started HTTP webserver on port %s", self.http_port) if self._enable_ssl: ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_context.load_cert_chain(self.config['ssl_certificate'], - self.config['ssl_key']) - https_site = web.TCPSite(self.runner, - '0.0.0.0', - self.config['https_port'], - ssl_context=ssl_context) + ssl_context.load_cert_chain( + self.config["ssl_certificate"], self.config["ssl_key"] + ) + https_site = web.TCPSite( + self.runner, + "0.0.0.0", + self.config["https_port"], + ssl_context=ssl_context, + ) await https_site.start() - LOGGER.info("Started HTTPS webserver on port %s", - self.config['https_port']) + LOGGER.info("Started HTTPS webserver on port %s", self.config["https_port"]) async def index(self, request): - index_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'web/index.html') + index_file = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "web/index.html" + ) return web.FileResponse(index_file) - @routes.get('/api/library/artists') + @routes.get("/api/library/artists") async def library_artists(self, request): """Get all library artists.""" - orderby = request.query.get('orderby', 'name') - provider_filter = request.rel_url.query.get('provider') + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") iterator = self.mass.music.library_artists( - orderby=orderby, provider_filter=provider_filter) + orderby=orderby, provider_filter=provider_filter + ) return await self.__stream_json(request, iterator) - @routes.get('/api/library/albums') + @routes.get("/api/library/albums") async def library_albums(self, request): """Get all library albums.""" - orderby = request.query.get('orderby', 'name') - provider_filter = request.rel_url.query.get('provider') + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") iterator = self.mass.music.library_albums( - orderby=orderby, provider_filter=provider_filter) + orderby=orderby, provider_filter=provider_filter + ) return await self.__stream_json(request, iterator) - @routes.get('/api/library/tracks') + @routes.get("/api/library/tracks") async def library_tracks(self, request): """Get all library tracks.""" - orderby = request.query.get('orderby', 'name') - provider_filter = request.rel_url.query.get('provider') + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") iterator = self.mass.music.library_tracks( - orderby=orderby, provider_filter=provider_filter) + orderby=orderby, provider_filter=provider_filter + ) return await self.__stream_json(request, iterator) - @routes.get('/api/library/radios') + @routes.get("/api/library/radios") async def library_radios(self, request): """Get all library radios.""" - orderby = request.query.get('orderby', 'name') - provider_filter = request.rel_url.query.get('provider') + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") iterator = self.mass.music.library_radios( - orderby=orderby, provider_filter=provider_filter) + orderby=orderby, provider_filter=provider_filter + ) return await self.__stream_json(request, iterator) - @routes.get('/api/library/playlists') + @routes.get("/api/library/playlists") async def library_playlists(self, request): """Get all library playlists.""" - orderby = request.query.get('orderby', 'name') - provider_filter = request.rel_url.query.get('provider') + orderby = request.query.get("orderby", "name") + provider_filter = request.rel_url.query.get("provider") iterator = self.mass.music.library_playlists( - orderby=orderby, provider_filter=provider_filter) + orderby=orderby, provider_filter=provider_filter + ) return await self.__stream_json(request, iterator) - @routes.put('/api/library') + @routes.put("/api/library") async def library_add(self, request): """Add item(s) to the library""" body = await request.json() @@ -198,7 +233,7 @@ class Web(): result = await self.mass.music.library_add(media_items) return web.json_response(result, dumps=json_serializer) - @routes.delete('/api/library') + @routes.delete("/api/library") async def library_remove(self, request): """R remove item(s) from the library""" body = await request.json() @@ -206,145 +241,142 @@ class Web(): result = await self.mass.music.library_remove(media_items) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/artists/{item_id}') + @routes.get("/api/artists/{item_id}") async def artist(self, request): """ get full artist details""" - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - lazy = request.rel_url.query.get('lazy', 'true') != 'false' - if (item_id is None or provider is None): - return web.Response(text='invalid item or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + lazy = request.rel_url.query.get("lazy", "true") != "false" + if item_id is None or provider is None: + return web.Response(text="invalid item or provider", status=501) result = await self.mass.music.artist(item_id, provider, lazy=lazy) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/albums/{item_id}') + @routes.get("/api/albums/{item_id}") async def album(self, request): """ get full album details""" - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - lazy = request.rel_url.query.get('lazy', 'true') != 'false' - if (item_id is None or provider is None): - return web.Response(text='invalid item or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + lazy = request.rel_url.query.get("lazy", "true") != "false" + if item_id is None or provider is None: + return web.Response(text="invalid item or provider", status=501) result = await self.mass.music.album(item_id, provider, lazy=lazy) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/tracks/{item_id}') + @routes.get("/api/tracks/{item_id}") async def track(self, request): """ get full track details""" - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - lazy = request.rel_url.query.get('lazy', 'true') != 'false' - if (item_id is None or provider is None): - return web.Response(text='invalid item or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + lazy = request.rel_url.query.get("lazy", "true") != "false" + if item_id is None or provider is None: + return web.Response(text="invalid item or provider", status=501) result = await self.mass.music.track(item_id, provider, lazy=lazy) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/playlists/{item_id}') + @routes.get("/api/playlists/{item_id}") async def playlist(self, request): """ get full playlist details""" - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - if (item_id is None or provider is None): - return web.Response(text='invalid item or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item or provider", status=501) result = await self.mass.music.playlist(item_id, provider) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/radios/{item_id}') + @routes.get("/api/radios/{item_id}") async def radio(self, request): """ get full radio details""" - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - if (item_id is None or provider is None): - return web.Response(text='invalid item_id or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) result = await self.mass.music.radio(item_id, provider) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/{media_type}/{media_id}/thumb') + @routes.get("/api/{media_type}/{media_id}/thumb") async def get_image(self, request): """ get (resized) thumb image """ - media_type_str = request.match_info.get('media_type') + media_type_str = request.match_info.get("media_type") media_type = media_type_from_string(media_type_str) - media_id = request.match_info.get('media_id') - provider = request.rel_url.query.get('provider') - if (media_id is None or provider is None): - return web.Response(text='invalid media_id or provider', - status=501) - size = int(request.rel_url.query.get('size', 0)) + media_id = request.match_info.get("media_id") + provider = request.rel_url.query.get("provider") + if media_id is None or provider is None: + return web.Response(text="invalid media_id or provider", status=501) + size = int(request.rel_url.query.get("size", 0)) img_file = await self.mass.music.get_image_thumb( - media_id, media_type, provider, size) + media_id, media_type, provider, size + ) if not img_file or not os.path.isfile(img_file): return web.Response(status=404) - headers = { - 'Cache-Control': 'max-age=86400, public', - 'Pragma': 'public' - } + headers = {"Cache-Control": "max-age=86400, public", "Pragma": "public"} return web.FileResponse(img_file, headers=headers) - @routes.get('/api/artists/{item_id}/toptracks') + @routes.get("/api/artists/{item_id}/toptracks") async def artist_toptracks(self, request): """ get top tracks for given artist """ - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - if (item_id is None or provider is None): - return web.Response(text='invalid item_id or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) iterator = self.mass.music.artist_toptracks(item_id, provider) return await self.__stream_json(request, iterator) - @routes.get('/api/artists/{item_id}/albums') + @routes.get("/api/artists/{item_id}/albums") async def artist_albums(self, request): """ get (all) albums for given artist """ - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - if (item_id is None or provider is None): - return web.Response(text='invalid item_id or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) iterator = self.mass.music.artist_albums(item_id, provider) return await self.__stream_json(request, iterator) - @routes.get('/api/playlists/{item_id}/tracks') + @routes.get("/api/playlists/{item_id}/tracks") async def playlist_tracks(self, request): """ get playlist tracks from provider""" - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - if (item_id is None or provider is None): - return web.Response(text='invalid item_id or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) iterator = self.mass.music.playlist_tracks(item_id, provider) return await self.__stream_json(request, iterator) - @routes.put('/api/playlists/{item_id}/tracks') + @routes.put("/api/playlists/{item_id}/tracks") async def add_playlist_tracks(self, request): """Add tracks to (editable) playlist.""" - item_id = request.match_info.get('item_id') + item_id = request.match_info.get("item_id") body = await request.json() tracks = await self.__media_items_from_body(body) result = await self.mass.music.add_playlist_tracks(item_id, tracks) return web.json_response(result, dumps=json_serializer) - @routes.delete('/api/playlists/{item_id}/tracks') + @routes.delete("/api/playlists/{item_id}/tracks") async def remove_playlist_tracks(self, request): """Remove tracks from (editable) playlist.""" - item_id = request.match_info.get('item_id') + item_id = request.match_info.get("item_id") body = await request.json() tracks = await self.__media_items_from_body(body) result = await self.mass.music.remove_playlist_tracks(item_id, tracks) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/albums/{item_id}/tracks') + @routes.get("/api/albums/{item_id}/tracks") async def album_tracks(self, request): """ get album tracks from provider""" - item_id = request.match_info.get('item_id') - provider = request.rel_url.query.get('provider') - if (item_id is None or provider is None): - return web.Response(text='invalid item_id or provider', status=501) + item_id = request.match_info.get("item_id") + provider = request.rel_url.query.get("provider") + if item_id is None or provider is None: + return web.Response(text="invalid item_id or provider", status=501) iterator = self.mass.music.album_tracks(item_id, provider) return await self.__stream_json(request, iterator) - @routes.get('/api/search') + @routes.get("/api/search") async def search(self, request): """ search database or providers """ - searchquery = request.rel_url.query.get('query') - media_types_query = request.rel_url.query.get('media_types') - limit = request.rel_url.query.get('limit', 5) - online = request.rel_url.query.get('online', False) + searchquery = request.rel_url.query.get("query") + media_types_query = request.rel_url.query.get("media_types") + limit = request.rel_url.query.get("limit", 5) + online = request.rel_url.query.get("online", False) media_types = [] if not media_types_query or "artists" in media_types_query: media_types.append(MediaType.Artist) @@ -357,28 +389,27 @@ class Web(): if not media_types_query or "radios" in media_types_query: media_types.append(MediaType.Radio) # get results from database - result = await self.mass.music.search(searchquery, - media_types, - limit=limit, - online=online) + result = await self.mass.music.search( + searchquery, media_types, limit=limit, online=online + ) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/players') + @routes.get("/api/players") async def players(self, request): """ get all players """ players = list(self.mass.players.players) players.sort(key=lambda x: x.name, reverse=False) return web.json_response(players, dumps=json_serializer) - @routes.post('/api/players/{player_id}/cmd/{cmd}') + @routes.post("/api/players/{player_id}/cmd/{cmd}") async def player_command(self, request): """ issue player command""" result = False - player_id = request.match_info.get('player_id') + player_id = request.match_info.get("player_id") player = await self.mass.players.get_player(player_id) if not player: - return web.Response(text='invalid player', status=404) - cmd = request.match_info.get('cmd') + return web.Response(text="invalid player", status=404) + cmd = request.match_info.get("cmd") cmd_args = await request.json() player_cmd = getattr(player, cmd, None) if player_cmd and cmd_args is not None: @@ -386,28 +417,27 @@ class Web(): elif player_cmd: result = await player_cmd() else: - return web.Response(text='invalid command', status=501) + return web.Response(text="invalid command", status=501) return web.json_response(result, dumps=json_serializer) - @routes.post('/api/players/{player_id}/play_media/{queue_opt}') + @routes.post("/api/players/{player_id}/play_media/{queue_opt}") async def player_play_media(self, request): """ issue player play_media command""" - player_id = request.match_info.get('player_id') + player_id = request.match_info.get("player_id") player = await self.mass.players.get_player(player_id) if not player: return web.Response(status=404) - queue_opt = request.match_info.get('queue_opt', 'play') + queue_opt = request.match_info.get("queue_opt", "play") body = await request.json() media_items = await self.__media_items_from_body(body) - result = await self.mass.players.play_media(player_id, media_items, - queue_opt) + result = await self.mass.players.play_media(player_id, media_items, queue_opt) return web.json_response(result, dumps=json_serializer) - @routes.get('/api/players/{player_id}/queue/items/{queue_item}') + @routes.get("/api/players/{player_id}/queue/items/{queue_item}") async def player_queue_item(self, request): """ return item (by index or queue item id) from the player's queue """ - player_id = request.match_info.get('player_id') - item_id = request.match_info.get('queue_item') + player_id = request.match_info.get("player_id") + item_id = request.match_info.get("queue_item") player = await self.mass.players.get_player(player_id) try: item_id = int(item_id) @@ -416,10 +446,10 @@ class Web(): queue_item = await player.queue.by_item_id(item_id) return web.json_response(queue_item, dumps=json_serializer) - @routes.get('/api/players/{player_id}/queue/items') + @routes.get("/api/players/{player_id}/queue/items") async def player_queue_items(self, request): """ return the items in the player's queue """ - player_id = request.match_info.get('player_id') + player_id = request.match_info.get("player_id") player = await self.mass.players.get_player(player_id) async def queue_tracks_iter(): @@ -428,65 +458,61 @@ class Web(): return await self.__stream_json(request, queue_tracks_iter()) - @routes.get('/api/players/{player_id}/queue') + @routes.get("/api/players/{player_id}/queue") async def player_queue(self, request): """ return the player queue details """ - player_id = request.match_info.get('player_id') + player_id = request.match_info.get("player_id") player = await self.mass.players.get_player(player_id) return web.json_response(player.queue, dumps=json_serializer) - @routes.put('/api/players/{player_id}/queue/{cmd}') + @routes.put("/api/players/{player_id}/queue/{cmd}") async def player_queue_cmd(self, request): """ change the player queue details """ - player_id = request.match_info.get('player_id') + player_id = request.match_info.get("player_id") player = await self.mass.players.get_player(player_id) - cmd = request.match_info.get('cmd') + cmd = request.match_info.get("cmd") cmd_args = await request.json() - if cmd == 'repeat_enabled': + if cmd == "repeat_enabled": player.queue.repeat_enabled = cmd_args - elif cmd == 'shuffle_enabled': + elif cmd == "shuffle_enabled": player.queue.shuffle_enabled = cmd_args - elif cmd == 'clear': + elif cmd == "clear": await player.queue.clear() - elif cmd == 'index': + elif cmd == "index": await player.queue.play_index(cmd_args) - elif cmd == 'move_up': + elif cmd == "move_up": await player.queue.move_item(cmd_args, -1) - elif cmd == 'move_down': + elif cmd == "move_down": await player.queue.move_item(cmd_args, 1) - elif cmd == 'next': + elif cmd == "next": await player.queue.move_item(cmd_args, 0) return web.json_response(player.queue, dumps=json_serializer) - @routes.get('/api/players/{player_id}') + @routes.get("/api/players/{player_id}") async def player(self, request): """ get single player """ - player_id = request.match_info.get('player_id') + player_id = request.match_info.get("player_id") player = await self.mass.players.get_player(player_id) if not player: - return web.Response(text='invalid player', status=404) + return web.Response(text="invalid player", status=404) return web.json_response(player, dumps=json_serializer) - @routes.get('/api/config') + @routes.get("/api/config") async def get_config(self, request): """ get the config """ return web.json_response(self.mass.config) - @routes.put('/api/config/{key}/{subkey}') + @routes.put("/api/config/{key}/{subkey}") async def put_config(self, request): """ save (partial) config """ - conf_key = request.match_info.get('key') - conf_subkey = request.match_info.get('subkey') + conf_key = request.match_info.get("key") + conf_subkey = request.match_info.get("subkey") new_values = await request.json() LOGGER.debug( - f'save config called for {conf_key}/{conf_subkey} - new value: {new_values}' + f"save config called for {conf_key}/{conf_subkey} - new value: {new_values}" ) cur_values = self.mass.config[conf_key][conf_subkey] - result = { - "success": True, - "restart_required": False, - "settings_changed": False - } + result = {"success": True, "restart_required": False, "settings_changed": False} if cur_values != new_values: # config changed result["settings_changed"] = True @@ -494,15 +520,18 @@ class Web(): if conf_key == CONF_KEY_PLAYERSETTINGS: # player settings: force update of player self.mass.event_loop.create_task( - self.mass.players.trigger_update(conf_subkey)) + self.mass.players.trigger_update(conf_subkey) + ) elif conf_key == CONF_KEY_MUSICPROVIDERS: # (re)load music provider module self.mass.event_loop.create_task( - self.mass.music.load_modules(conf_subkey)) + self.mass.music.load_modules(conf_subkey) + ) elif conf_key == CONF_KEY_PLAYERPROVIDERS: # (re)load player provider module self.mass.event_loop.create_task( - self.mass.players.load_modules(conf_subkey)) + self.mass.players.load_modules(conf_subkey) + ) else: # other settings need restart result["restart_required"] = True @@ -529,21 +558,23 @@ class Web(): # process incoming messages async for msg in ws: if msg.type == aiohttp.WSMsgType.ERROR: - LOGGER.debug('ws connection closed with exception %s' % - ws.exception()) + LOGGER.debug( + "ws connection closed with exception %s" % ws.exception() + ) elif msg.type != aiohttp.WSMsgType.TEXT: LOGGER.warning(msg.data) else: data = msg.json() # echo the websocket message on event bus # can be picked up by other modules, e.g. the webplayer - await self.mass.signal_event(data['message'], - data['message_details']) + await self.mass.signal_event( + data["message"], data["message_details"] + ) except (Exception, AssertionError, asyncio.CancelledError) as exc: LOGGER.warning("Websocket disconnected - %s" % str(exc)) finally: await self.mass.remove_event_listener(cb_id) - LOGGER.debug('websocket connection closed') + LOGGER.debug("websocket connection closed") return ws async def json_rpc(self, request): @@ -554,51 +585,51 @@ class Web(): """ data = await request.json() LOGGER.debug("jsonrpc: %s" % data) - params = data['params'] + params = data["params"] player_id = params[0] cmds = params[1] cmd_str = " ".join(cmds) player = await self.mass.players.get_player(player_id) if not player: return web.Response(status=404) - if cmd_str == 'play': + if cmd_str == "play": await player.play() - elif cmd_str == 'pause': + elif cmd_str == "pause": await player.pause() - elif cmd_str == 'stop': + elif cmd_str == "stop": await player.stop() - elif cmd_str == 'next': + elif cmd_str == "next": await player.next() - elif cmd_str == 'previous': + elif cmd_str == "previous": await player.previous() - elif 'power' in cmd_str: + elif "power" in cmd_str: args = cmds[1] if len(cmds) > 1 else None await player.power(args) - elif cmd_str == 'playlist index +1': + elif cmd_str == "playlist index +1": await player.next() - elif cmd_str == 'playlist index -1': + elif cmd_str == "playlist index -1": await player.previous() - elif 'mixer volume' in cmd_str and '+' in cmds[2]: - volume_level = player.volume_level + int(cmds[2].split('+')[1]) + elif "mixer volume" in cmd_str and "+" in cmds[2]: + volume_level = player.volume_level + int(cmds[2].split("+")[1]) await player.volume_set(volume_level) - elif 'mixer volume' in cmd_str and '-' in cmds[2]: - volume_level = player.volume_level - int(cmds[2].split('-')[1]) + elif "mixer volume" in cmd_str and "-" in cmds[2]: + volume_level = player.volume_level - int(cmds[2].split("-")[1]) await player.volume_set(volume_level) - elif 'mixer volume' in cmd_str: + elif "mixer volume" in cmd_str: await player.volume_set(cmds[2]) - elif cmd_str == 'mixer muting 1': + elif cmd_str == "mixer muting 1": await player.volume_mute(True) - elif cmd_str == 'mixer muting 0': + elif cmd_str == "mixer muting 0": await player.volume_mute(False) - elif cmd_str == 'button volup': + elif cmd_str == "button volup": await player.volume_up() - elif cmd_str == 'button voldown': + elif cmd_str == "button voldown": await player.volume_down() - elif cmd_str == 'button power': + elif cmd_str == "button power": await player.power_toggle() else: - return web.Response(text='command not supported') - return web.Response(text='success') + return web.Response(text="command not supported") + return web.Response(text="success") async def __media_items_from_body(self, data): """Helper to turn posted body data into media items.""" @@ -606,33 +637,32 @@ class Web(): data = [data] media_items = [] for item in data: - media_item = await self.mass.music.item(item['item_id'], - item['media_type'], - item['provider'], - lazy=True) + media_item = await self.mass.music.item( + item["item_id"], item["media_type"], item["provider"], lazy=True + ) media_items.append(media_item) return media_items async def __stream_json(self, request, iterator): """ stream items from async iterator as json object """ - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'application/json'}) + resp = web.StreamResponse( + status=200, reason="OK", headers={"Content-Type": "application/json"} + ) await resp.prepare(request) # write json open tag json_response = '{ "items": [' - await resp.write(json_response.encode('utf-8')) + await resp.write(json_response.encode("utf-8")) count = 0 async for item in iterator: # write each item into the items object of the json if count: - json_response = ',' + json_serializer(item) + json_response = "," + json_serializer(item) else: json_response = json_serializer(item) - await resp.write(json_response.encode('utf-8')) + await resp.write(json_response.encode("utf-8")) count += 1 # write json close tag json_response = '], "count": %s }' % count - await resp.write((json_response).encode('utf-8')) + await resp.write((json_response).encode("utf-8")) await resp.write_eof() return resp diff --git a/pylintrc b/pylintrc index 1f4053fd..f68343bf 100644 --- a/pylintrc +++ b/pylintrc @@ -10,46 +10,17 @@ good-names=id,i,j,k,ex,Run,_,fp [MESSAGES CONTROL] # Reasons disabled: -# format - handled by black # locally-disabled - it spams too much -# duplicate-code - unavoidable -# cyclic-import - doesn't test if both import on load -# abstract-class-little-used - prevents from setting right foundation -# unused-argument - generic callbacks and setup methods create a lot of warnings -# global-statement - used for the on-demand requirement installation -# redefined-variable-type - this is Python, we're duck typing! # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* -# abstract-method - with intro of async there are always methods missing -# inconsistent-return-statements - doesn't handle raise -# unnecessary-pass - readability for functions which only contain pass # import-outside-toplevel - TODO -# too-many-ancestors - it's too strict. disable= - format, - abstract-class-little-used, - abstract-method, - cyclic-import, - duplicate-code, - global-statement, + bad-continuation, + fixme, import-outside-toplevel, - inconsistent-return-statements, locally-disabled, - not-context-manager, - redefined-variable-type, too-few-public-methods, - too-many-ancestors, - too-many-arguments, - too-many-branches, - too-many-instance-attributes, - too-many-lines, - too-many-locals, too-many-public-methods, - too-many-return-statements, - too-many-statements, - too-many-boolean-expressions, - unnecessary-pass, - unused-argument [REPORTS] score=no diff --git a/setup.cfg b/setup.cfg index 05e3f75e..e90e1850 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,21 +15,25 @@ ignore = W504 [isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -# splits long import on multiple lines indented by 4 spaces multi_line_output = 3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -indent = " " -# by default isort don't check module indexes -not_skip = __init__.py -# will group `import x` and `from x import` of the same module. -force_sort_within_sections = true -sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -default_section = THIRDPARTY -known_first_party = homeassistant,tests -forced_separate = tests -combine_as_imports = true \ No newline at end of file +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +line_length = 88 + +[mypy] +follow_imports = skip +ignore_missing_imports = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_calls = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true +warn_unused_ignores = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true + +[pydocstyle] +add-ignore = D202 \ No newline at end of file diff --git a/setup.py b/setup.py index 02e50299..b721052f 100644 --- a/setup.py +++ b/setup.py @@ -2,16 +2,17 @@ # sudo python3 setup.py sdist bdist_wheel # sudo python3 -m twine upload dist/* -import setuptools import os +import setuptools + VERSION = "0.0.20" NAME = "music_assistant" with open("README.md", "r") as fh: LONG_DESC = fh.read() -with open('requirements.txt') as f: +with open("requirements.txt") as f: INSTALL_REQUIRES = f.read().splitlines() if os.name != "nt": INSTALL_REQUIRES.append("uvloop") @@ -19,16 +20,16 @@ if os.name != "nt": setuptools.setup( name=NAME, version=VERSION, - author='Marcel van der Veldt', - author_email='marcelveldt@users.noreply.github.com', - description='Music library manager and player based on sox.', + author="Marcel van der Veldt", + author_email="marcelveldt@users.noreply.github.com", + description="Music library manager and player based on sox.", long_description=LONG_DESC, long_description_content_type="text/markdown", - url = 'https://github.com/marcelveldt/musicassistant.git', - packages=['music_assistant'], + url="https://github.com/marcelveldt/musicassistant.git", + packages=["music_assistant"], classifiers=[ "Programming Language :: Python :: 3", "Operating System :: OS Independent", ], install_requires=INSTALL_REQUIRES, - ) \ No newline at end of file +) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..4e186946 --- /dev/null +++ b/tox.ini @@ -0,0 +1,35 @@ +[tox] +envlist = py36, py37, py38, lint, mypy +skip_missing_interpreters = True + +[gh-actions] +python = + 3.6: py36, lint, mypy + 3.7: py37 + 3.8: py38 + +[testenv] +commands = + pytest --timeout=30 --cov=music_assistant --cov-report= {posargs} +deps = + -rrequirements.txt + +[testenv:lint] +basepython = python3 +ignore_errors = True +commands = + black --check ./ + flake8 music_assistant test + pylint music_assistant test + pydocstyle music_assistant test +deps = + -rrequirements_lint.txt + -rrequirements_test.txt + +[testenv:mypy] +basepython = python3 +ignore_errors = True +commands = + mypy music_assistant +deps = + -rrequirements_lint.txt -- 2.34.1