From: Marcel van der Veldt Date: Thu, 9 May 2019 06:32:46 +0000 (+0200) Subject: first alpha version X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=ee235b3ccc5d2203f1db8cc05e60d7bf9fb29975;p=music-assistant-server.git first alpha version still needs a lot of work --- diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1eb6c375 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ + +.DS_Store +*.db +*.pyc +music_assistant/config.json diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 00000000..596a04c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.7.3-alpine + +# install deps +RUN pip install --upgrade requirements.txt + +# copy files +RUN mkdir -p /usr/src/app +WORKDIR /usr/src/app +COPY music_assistant /usr/src/app +RUN chmod a+x /usr/src/app/main.py +RUN pip install --upgrade requirements.txt + +VOLUME ["/data"] + +CMD ["python3.7", "/usr/src/app/main.py", "/data"] \ No newline at end of file diff --git a/config.json b/config.json new file mode 100755 index 00000000..ef4eddad --- /dev/null +++ b/config.json @@ -0,0 +1,14 @@ +{ + "name": "Music Assistant", + "version": "0.0.1", + "description": "Media library manager for (streaming) media", + "slug": "music_assistant", + "startup": "application", + "boot": "auto", + "map": [], + "host_network": true, + "options": { + }, + "schema": { + } +} diff --git a/music_assistant/.vscode/launch.json b/music_assistant/.vscode/launch.json new file mode 100644 index 00000000..7a6c16fc --- /dev/null +++ b/music_assistant/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "program": "${workspaceFolder}/web/components/vue-read-more/index.js" + } + ] +} \ No newline at end of file diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/music_assistant/api.py b/music_assistant/api.py new file mode 100755 index 00000000..02e84379 --- /dev/null +++ b/music_assistant/api.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from utils import run_periodic, LOGGER +import json +import aiohttp +from aiohttp import web +from models import MediaType, media_type_from_string +from functools import partial +json_serializer = partial(json.dumps, default=lambda x: x.__dict__) + + +class Api(): + ''' expose our data through json api ''' + + def __init__(self, mass): + self.mass = mass + self.http_session = aiohttp.ClientSession() + + def stop(self): + self.runner.cleanup() + self.http_session.close() + + async def setup_web(self): + app = web.Application() + app.add_routes([web.get('/ws', self.websocket_handler)]) + app.add_routes([web.get('/stream/{provider}/{track_id}', self.stream)]) + app.add_routes([web.get('/api/search', self.search)]) + app.add_routes([web.get('/api/config', self.get_config)]) + app.add_routes([web.post('/api/config', self.save_config)]) + app.add_routes([web.get('/api/players', self.players)]) + app.add_routes([web.get('/api/players/{player_id}/queue', self.player_queue)]) + app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}', self.player_command)]) + app.add_routes([web.get('/api/players/{player_id}/cmd/{cmd}/{cmd_args}', self.player_command)]) + app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}', self.play_media)]) + app.add_routes([web.get('/api/players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}', self.play_media)]) + app.add_routes([web.get('/api/playlists/{playlist_id}/tracks', self.playlist_tracks)]) + app.add_routes([web.get('/api/artists/{artist_id}/toptracks', self.artist_toptracks)]) + app.add_routes([web.get('/api/artists/{artist_id}/albums', self.artist_albums)]) + app.add_routes([web.get('/api/albums/{album_id}/tracks', self.album_tracks)]) + app.add_routes([web.get('/api/{media_type}', self.get_items)]) + app.add_routes([web.get('/api/{media_type}/{media_id}/{action}', self.get_item)]) + app.add_routes([web.get('/api/{media_type}/{media_id}', self.get_item)]) + app.add_routes([web.get('/', self.index)]) + app.router.add_static("/", "./web") + + self.runner = web.AppRunner(app) + await self.runner.setup() + site = web.TCPSite(self.runner, '0.0.0.0', 8095) + await site.start() + + async def get_items(self, request): + ''' get multiple library items''' + media_type_str = request.match_info.get('media_type') + media_type = media_type_from_string(media_type_str) + limit = int(request.query.get('limit', 50)) + offset = int(request.query.get('offset', 0)) + orderby = request.query.get('orderby', 'name') + provider_filter = request.rel_url.query.get('provider') + result = await self.mass.music.library_items(media_type, + limit=limit, offset=offset, + orderby=orderby, provider_filter=provider_filter) + return web.json_response(result, dumps=json_serializer) + + async def get_item(self, request): + ''' get item full details''' + media_type_str = request.match_info.get('media_type') + media_type = media_type_from_string(media_type_str) + media_id = request.match_info.get('media_id') + action = request.match_info.get('action','') + lazy = request.rel_url.query.get('lazy', '') != 'false' + if action: + result = await self.mass.music.item_action(media_id, media_type, action) + else: + result = await self.mass.music.item(media_id, media_type, lazy=lazy) + return web.json_response(result, dumps=json_serializer) + + async def artist_toptracks(self, request): + ''' get top tracks for given artist ''' + artist_id = request.match_info.get('artist_id') + result = await self.mass.music.artist_toptracks(artist_id) + return web.json_response(result, dumps=json_serializer) + + async def artist_albums(self, request): + ''' get (all) albums for given artist ''' + artist_id = request.match_info.get('artist_id') + result = await self.mass.music.artist_albums(artist_id) + return web.json_response(result, dumps=json_serializer) + + async def playlist_tracks(self, request): + ''' get playlist tracks from provider''' + playlist_id = request.match_info.get('playlist_id') + limit = int(request.query.get('limit', 50)) + offset = int(request.query.get('offset', 0)) + result = await self.mass.music.playlist_tracks(playlist_id, offset=offset, limit=limit) + return web.json_response(result, dumps=json_serializer) + + async def album_tracks(self, request): + ''' get album tracks from provider''' + album_id = request.match_info.get('album_id') + result = await self.mass.music.album_tracks(album_id) + return web.json_response(result, dumps=json_serializer) + + async def search(self, request): + ''' search database or providers ''' + searchquery = request.rel_url.query.get('query') + media_types_query = request.rel_url.query.get('media_types') + limit = request.rel_url.query.get('media_id', 5) + online = request.rel_url.query.get('online', False) + media_types = [] + if not media_types_query or "artists" in media_types_query: + media_types.append(MediaType.Artist) + if not media_types_query or "albums" in media_types_query: + media_types.append(MediaType.Album) + if not media_types_query or "tracks" in media_types_query: + media_types.append(MediaType.Track) + if not media_types_query or "playlists" in media_types_query: + media_types.append(MediaType.Playlist) + # get results from database + result = await self.mass.music.search(searchquery, media_types, limit=limit, online=online) + return web.json_response(result, dumps=json_serializer) + + async def players(self, request): + ''' get all players ''' + players = await self.mass.player.players() + return web.json_response(players, dumps=json_serializer) + + async def player_command(self, request): + ''' issue player command''' + player_id = request.match_info.get('player_id') + cmd = request.match_info.get('cmd') + cmd_args = request.match_info.get('cmd_args') + result = await self.mass.player.player_command(player_id, cmd, cmd_args) + return web.json_response(result, dumps=json_serializer) + + async def play_media(self, request): + ''' issue player play_media command''' + player_id = request.match_info.get('player_id') + media_type_str = request.match_info.get('media_type') + media_type = media_type_from_string(media_type_str) + media_id = request.match_info.get('media_id') + queue_opt = request.match_info.get('queue_opt','') + media_item = await self.mass.music.item(media_id, media_type, lazy=True) + result = await self.mass.player.play_media(player_id, media_item, queue_opt) + return web.json_response(result, dumps=json_serializer) + + async def player_queue(self, request): + ''' return the items in the player's queue ''' + player_id = request.match_info.get('player_id') + limit = int(request.query.get('limit', 50)) + offset = int(request.query.get('offset', 0)) + result = await self.mass.player.player_queue(player_id, offset, limit) + return web.json_response(result, dumps=json_serializer) + + async def index(self, request): + return web.FileResponse("./web/index.html") + + async def websocket_handler(self, request): + ''' websockets handler ''' + ws = web.WebSocketResponse() + await ws.prepare(request) + # register callback for internal events + async def send_event(msg, msg_details): + ws_msg = {"message": msg, "message_details": msg_details } + try: + await ws.send_json(ws_msg, dumps=json_serializer) + except Exception as exc: + if 'the handler is closed' in str(exc): + await self.mass.remove_event_listener(cb_id) + else: + LOGGER.exception(exc) + + cb_id = await self.mass.add_event_listener(send_event) + # process incoming messages + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + if msg.data == 'close': + await self.mass.remove_event_listener(cb_id) + await ws.close() + else: + # for now we only use WS for player commands + if msg.data == 'players': + players = await self.mass.player.players() + ws_msg = {'message': 'players', 'message_details': players} + await ws.send_json(ws_msg, dumps=json_serializer) + elif msg.data.startswith('players') and '/play_media/' in msg.data: + #'players/{player_id}/play_media/{media_type}/{media_id}/{queue_opt}' + msg_data_parts = msg.data.split('/') + player_id = msg_data_parts[1] + media_type = msg_data_parts[3] + media_type = media_type_from_string(media_type) + media_id = msg_data_parts[4] + queue_opt = msg_data_parts[5] if len(msg_data_parts) == 6 else 'replace' + media_item = await self.mass.music.item(media_id, media_type, lazy=True) + await self.mass.player.play_media(player_id, media_item, queue_opt) + + elif msg.data.startswith('players') and '/cmd/' in msg.data: + # players/{player_id}/cmd/{cmd} or players/{player_id}/cmd/{cmd}/{cmd_args} + msg_data_parts = msg.data.split('/') + player_id = msg_data_parts[1] + cmd = msg_data_parts[3] + cmd_args = msg_data_parts[4] if len(msg_data_parts) == 5 else None + await self.mass.player.player_command(player_id, cmd, cmd_args) + elif msg.type == aiohttp.WSMsgType.ERROR: + LOGGER.error('ws connection closed with exception %s' % + ws.exception()) + LOGGER.info('websocket connection closed') + return ws + + async def get_config(self, request): + ''' get the config ''' + return web.json_response(self.mass.config) + + async def save_config(self, request): + ''' save the config ''' + LOGGER.debug('save config called from api') + new_config = await request.json() + for key, value in self.mass.config.items(): + if isinstance(value, dict): + for subkey, subvalue in value.items(): + if subkey in new_config[key]: + self.mass.config[key][subkey] = new_config[key][subkey] + elif key in new_config: + self.mass.config[key] = new_config[key] + self.mass.save_config() + return web.Response(text='success') + + async def stream(self, request): + ''' start streaming audio from provider ''' + track_id = request.match_info.get('track_id') + provider = request.match_info.get('provider') + stream_details = await self.mass.music.providers[provider].get_stream_details(track_id) + resp = web.StreamResponse(status=200, + reason='OK', + headers={'Content-Type': stream_details['mime_type']}) + await resp.prepare(request) + async for chunk in self.mass.music.providers[provider].get_stream(track_id): + await resp.write(chunk) + return resp \ No newline at end of file diff --git a/music_assistant/cache.py b/music_assistant/cache.py new file mode 100644 index 00000000..16cdc398 --- /dev/null +++ b/music_assistant/cache.py @@ -0,0 +1,236 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +'''provides a simple stateless caching system''' + +import datetime +import time +import sqlite3 +from functools import reduce +import os +from utils import run_periodic, LOGGER, parse_track_title +import functools +import asyncio + + +class Cache(object): + '''basic stateless caching system ''' + _exit = False + _mem_cache = {} + _busy_tasks = [] + _database = None + + def __init__(self): + '''Initialize our caching class''' + asyncio.ensure_future(self._do_cleanup()) + LOGGER.debug("Initialized") + + async def get(self, endpoint, checksum=""): + ''' + get object from cache and return the results + endpoint: the (unique) name of the cache object as reference + checkum: optional argument to check if the checksum in the cacheobject matches the checkum provided + ''' + checksum = self._get_checksum(checksum) + cur_time = self._get_timestamp(datetime.datetime.now()) + result = None + # 1: try memory cache first + result = await self._get_mem_cache(endpoint, checksum, cur_time) + # 2: fallback to _database cache + if result is None: + result = await self._get_db_cache(endpoint, checksum, cur_time) + return result + + async def set(self, endpoint, data, checksum="", expiration=datetime.timedelta(days=14)): + ''' + set data in cache + ''' + task_name = "set.%s" % endpoint + self._busy_tasks.append(task_name) + checksum = self._get_checksum(checksum) + expires = self._get_timestamp(datetime.datetime.now() + expiration) + + # memory cache + await self._set_mem_cache(endpoint, checksum, expires, data) + + # db cache + if not self._exit: + await self._set_db_cache(endpoint, checksum, expires, data) + + # remove this task from list + self._busy_tasks.remove(task_name) + + async def _get_mem_cache(self, endpoint, checksum, cur_time): + ''' + get cache data from memory cache + ''' + result = None + cachedata = self._mem_cache.get(endpoint) + if cachedata: + cachedata = cachedata + if cachedata[0] > cur_time: + if checksum == None or checksum == cachedata[2]: + result = cachedata[1] + return result + + async def _set_mem_cache(self, endpoint, checksum, expires, data): + ''' + put data in memory cache + ''' + cachedata = (expires, data, checksum) + self._mem_cache[endpoint] = cachedata + + async def _get_db_cache(self, endpoint, checksum, cur_time): + '''get cache data from sqllite database''' + result = None + query = "SELECT expires, data, checksum FROM simplecache WHERE id = ?" + cache_data = self._execute_sql(query, (endpoint,)) + if cache_data: + cache_data = cache_data.fetchone() + if cache_data and cache_data[0] > cur_time: + if checksum == None or cache_data[2] == checksum: + result = eval(cache_data[1]) + # also set result in memory cache for further access + await self._set_mem_cache(endpoint, checksum, cache_data[0], result) + return result + + async def _set_db_cache(self, endpoint, checksum, expires, data): + ''' store cache data in _database ''' + query = "INSERT OR REPLACE INTO simplecache( id, expires, data, checksum) VALUES (?, ?, ?, ?)" + data = repr(data) + self._execute_sql(query, (endpoint, expires, data, checksum)) + + @run_periodic(3600) + async def _do_cleanup(self): + '''perform cleanup task''' + if self._exit: + return + self._busy_tasks.append(__name__) + cur_time = datetime.datetime.now() + cur_timestamp = self._get_timestamp(cur_time) + LOGGER.debug("Running cleanup...") + query = "SELECT id, expires FROM simplecache" + for cache_data in self._execute_sql(query).fetchall(): + cache_id = cache_data[0] + cache_expires = cache_data[1] + if self._exit: + return + # always cleanup all memory objects on each interval + self._mem_cache.pop(cache_id, None) + # clean up db cache object only if expired + if cache_expires < cur_timestamp: + query = 'DELETE FROM simplecache WHERE id = ?' + self._execute_sql(query, (cache_id,)) + LOGGER.debug("delete from db %s" % cache_id) + + # compact db + self._execute_sql("VACUUM") + + # remove task from list + self._busy_tasks.remove(__name__) + LOGGER.debug("Auto cleanup done") + + def _get_database(self): + '''get reference to our sqllite _database - performs basic integrity check''' + dbfile = "/tmp/simplecache.db" + try: + connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None) + connection.execute('SELECT * FROM simplecache LIMIT 1') + return connection + except Exception as error: + # our _database is corrupt or doesn't exist yet, we simply try to recreate it + if os.path.isfile(dbfile): + os.remove(dbfile) + try: + connection = sqlite3.connect(dbfile, timeout=30, isolation_level=None) + connection.execute( + """CREATE TABLE IF NOT EXISTS simplecache( + id TEXT UNIQUE, expires INTEGER, data TEXT, checksum INTEGER)""") + return connection + except Exception as error: + LOGGER.warning("Exception while initializing _database: %s" % str(error)) + return None + + def _execute_sql(self, query, data=None): + '''little wrapper around execute and executemany to just retry a db command if db is locked''' + retries = 0 + result = None + error = None + # always use new db object because we need to be sure that data is available for other simplecache instances + with self._get_database() as _database: + while not retries == 10: + if self._exit: + return None + try: + if isinstance(data, list): + result = _database.executemany(query, data) + elif data: + result = _database.execute(query, data) + else: + result = _database.execute(query) + return result + except sqlite3.OperationalError as error: + if "_database is locked" in error: + LOGGER.debug("retrying DB commit...") + retries += 1 + time.sleep(0.5) + else: + break + except Exception as error: + LOGGER.error("_database ERROR ! -- %s" % str(error)) + break + return None + + @staticmethod + def _get_timestamp(date_time): + '''Converts a datetime object to unix timestamp''' + return int(time.mktime(date_time.timetuple())) + + @staticmethod + def _get_checksum(stringinput): + '''get int checksum from string''' + if not stringinput: + return 0 + else: + stringinput = str(stringinput) + return reduce(lambda x, y: x + y, map(ord, stringinput)) + +def use_cache(cache_days=14, cache_hours=8): + def wrapper(func): + @functools.wraps(func) + async def wrapped(*args, **kwargs): + if kwargs.get("ignore_cache"): + return await func(*args, **kwargs) + cache_checksum = kwargs.get("cache_checksum") + method_class = args[0] + method_class_name = method_class.__class__.__name__ + cache_str = "%s.%s" % (method_class_name, func.__name__) + # append args to cache identifier + for item in args[1:]: + if isinstance(item, dict): + for subkey in sorted(list(item.keys())): + subvalue = item[subkey] + cache_str += ".%s%s" %(subkey,subvalue) + else: + cache_str += ".%s" % item + # append kwargs to cache identifier + for key in sorted(list(kwargs.keys())): + if key in ["ignore_cache", "cache_checksum"]: + continue + value = kwargs[key] + if isinstance(value, dict): + for subkey in sorted(list(value.keys())): + subvalue = value[subkey] + cache_str += ".%s%s" %(subkey,subvalue) + else: + cache_str += ".%s%s" %(key,value) + cache_str = cache_str.lower() + cachedata = await method_class.cache.get(cache_str, checksum=cache_checksum) + if cachedata is not None: + return cachedata + else: + result = await func(*args, **kwargs) + await method_class.cache.set(cache_str, result, checksum=cache_checksum, expiration=datetime.timedelta(days=cache_days, hours=cache_hours)) + return result + return wrapped + return wrapper diff --git a/music_assistant/constants.py b/music_assistant/constants.py new file mode 100755 index 00000000..93f1e06d --- /dev/null +++ b/music_assistant/constants.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_ENABLED = "enabled" +CONF_HOSTNAME = "hostname" +CONF_PORT = "port" \ No newline at end of file diff --git a/music_assistant/database.py b/music_assistant/database.py new file mode 100755 index 00000000..7e9e92b4 --- /dev/null +++ b/music_assistant/database.py @@ -0,0 +1,595 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from utils import run_periodic, LOGGER, get_sort_name, try_parse_int +from models import MediaType, Artist, Album, Track, Playlist +from typing import List +import aiosqlite + +DBFILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),"database.db") + +class Database(): + + def __init__(self, event_loop, dbfile=DBFILE): + self.event_loop = event_loop + self.dbfile = dbfile + self.db_ready = False + event_loop.run_until_complete(self.__init_database()) + + async def __init_database(self): + ''' init database tables''' + async with aiosqlite.connect(self.dbfile) as db: + + await db.execute('CREATE TABLE IF NOT EXISTS library_items(item_id INTEGER NOT NULL, provider TEXT NOT NULL, media_type INTEGER NOT NULL, UNIQUE(item_id, provider, media_type));') + + await db.execute('CREATE TABLE IF NOT EXISTS artists(artist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sort_name TEXT, musicbrainz_id TEXT NOT NULL UNIQUE);') + await db.execute('CREATE TABLE IF NOT EXISTS albums(album_id INTEGER PRIMARY KEY AUTOINCREMENT, artist_id INTEGER NOT NULL, name TEXT NOT NULL, albumtype TEXT, year INTEGER, version TEXT, UNIQUE(artist_id, name, version, albumtype));') + await db.execute('CREATE TABLE IF NOT EXISTS labels(label_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);') + await db.execute('CREATE TABLE IF NOT EXISTS album_labels(album_id INTEGER, label_id INTEGER, UNIQUE(album_id, label_id));') + + await db.execute('CREATE TABLE IF NOT EXISTS tracks(track_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, album_id INTEGER, duration INTEGER, version TEXT, disc_number INT, track_number INT, UNIQUE(name, album_id, version));') + await db.execute('CREATE TABLE IF NOT EXISTS track_artists(track_id INTEGER, artist_id INTEGER, UNIQUE(track_id, artist_id));') + + await db.execute('CREATE TABLE IF NOT EXISTS tags(tag_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE);') + await db.execute('CREATE TABLE IF NOT EXISTS media_tags(item_id INTEGER, media_type INTEGER, tag_id, UNIQUE(item_id, media_type, tag_id));') + + await db.execute('CREATE TABLE IF NOT EXISTS provider_mappings(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, prov_item_id TEXT NOT NULL, provider TEXT NOT NULL, quality INTEGER NOT NULL, details TEXT NULL, UNIQUE(item_id, media_type, prov_item_id, provider, quality));') + + await db.execute('CREATE TABLE IF NOT EXISTS metadata(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(item_id, media_type, key));') + await db.execute('CREATE TABLE IF NOT EXISTS external_ids(item_id INTEGER NOT NULL, media_type INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(item_id, media_type, key, value));') + + await db.execute('CREATE TABLE IF NOT EXISTS playlists(playlist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner TEXT NOT NULL, UNIQUE(name, owner));') + await db.execute('CREATE TABLE IF NOT EXISTS playlist_tracks(playlist_id INTEGER NOT NULL, track_id INTEGER NOT NULL, position INTEGER, UNIQUE(playlist_id, track_id));') + + await db.commit() + await db.execute('VACUUM;') + self.db_ready = True + + async def get_database_id(self, provider:str, prov_item_id:str, media_type:MediaType): + ''' get the database id for the given prov_id ''' + async with aiosqlite.connect(self.dbfile) as db: + sql_query = 'SELECT item_id FROM provider_mappings WHERE prov_item_id = ? AND provider = ? AND media_type = ?;' + cursor = await db.execute(sql_query, (prov_item_id, provider, media_type)) + item_id = await cursor.fetchone() + if item_id: + item_id = item_id[0] + await cursor.close() + return item_id + + async def search(self, searchquery, media_types:List[MediaType], limit=10): + ''' search library for the given searchphrase ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [] + } + searchquery = "%" + searchquery + "%" + sql_query = ' WHERE name LIKE "%s"' % searchquery + if MediaType.Artist in media_types: + result["artists"] = await self.artists(sql_query, limit=limit) + if MediaType.Album in media_types: + result["albums"] = await self.albums(sql_query, limit=limit) + if MediaType.Track in media_types: + result["tracks"] = await self.tracks(sql_query, limit=limit) + if MediaType.Playlist in media_types: + result["playlists"] = await self.playlists(sql_query, limit=limit) + return result + + async def library_artists(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Artist]: + ''' get all library artists, optionally filtered by provider''' + if provider != None: + sql_query = ' WHERE artist_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Artist) + else: + sql_query = ' WHERE artist_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Artist + return await self.artists(sql_query, limit=limit, offset=offset, orderby=orderby) + + async def library_albums(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Album]: + ''' get all library albums, optionally filtered by provider''' + if provider != None: + sql_query = ' WHERE album_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Album) + else: + sql_query = ' WHERE album_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Album + return await self.albums(sql_query, limit=limit, offset=offset, orderby=orderby) + + async def library_tracks(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Track]: + ''' get all library tracks, optionally filtered by provider''' + if provider != None: + sql_query = ' WHERE track_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Track) + else: + sql_query = ' WHERE track_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Track + return await self.tracks(sql_query, limit=limit, offset=offset, orderby=orderby) + + async def library_playlists(self, provider=None, limit=100000, offset=0, orderby='name') -> List[Playlist]: + ''' get all library playlists, optionally filtered by provider''' + if provider != None: + sql_query = ' WHERE playlist_id in (SELECT item_id FROM library_items WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Playlist) + else: + sql_query = ' WHERE playlist_id in (SELECT item_id FROM library_items WHERE media_type = %d)' % MediaType.Playlist + return await self.playlists(sql_query, limit=limit, offset=offset, orderby=orderby) + + async def add_to_library(self, item_id:int, media_type:MediaType, provider:str): + ''' add an item to the library (item must already be present in the db!) ''' + item_id = try_parse_int(item_id) + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + sql_query = 'INSERT or REPLACE INTO library_items (item_id, provider, media_type) VALUES(?,?,?);' + await db.execute(sql_query, (item_id, provider, media_type)) + await db.commit() + + async def remove_from_library(self, item_id:int, media_type:MediaType, provider:str): + ''' remove item from the library ''' + item_id = try_parse_int(item_id) + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + sql_query = 'DELETE FROM library_items WHERE item_id=? AND provider=? AND media_type=?;' + await db.execute(sql_query, (item_id, provider, media_type)) + if media_type == MediaType.Playlist: + sql_query = 'DELETE FROM playlist_tracks WHERE playlist_id=?;' + await db.execute(sql_query, (item_id,)) + await db.commit() + + async def artists(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Artist]: + ''' fetch artist records from table''' + artists = [] + sql_query = 'SELECT * FROM artists' + if filter_query: + sql_query += ' ' + filter_query + sql_query += ' ORDER BY %s' % orderby + if limit: + sql_query += ' LIMIT %d OFFSET %d' %(limit, offset) + async with aiosqlite.connect(self.dbfile) as db: + async with db.execute(sql_query) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + artist = Artist() + artist.item_id = db_row[0] + artist.name = db_row[1] + artist.sort_name = db_row[2] + artist.provider_ids = await self.__get_prov_ids(artist.item_id, MediaType.Artist, db) + artist.in_library = await self.__get_library_providers(artist.item_id, MediaType.Artist, db) + artist.external_ids = await self.__get_external_ids(artist.item_id, MediaType.Artist, db) + if fulldata: + artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db) + artist.tags = await self.__get_tags(artist.item_id, MediaType.Artist, db) + else: + artist.metadata = await self.__get_metadata(artist.item_id, MediaType.Artist, db, filter_key='image') + artists.append(artist) + return artists + + async def artist(self, artist_id:int, fulldata=True) -> Artist: + ''' get artist record by id ''' + artist_id = try_parse_int(artist_id) + artists = await self.artists('WHERE artist_id = %s' % artist_id, fulldata=fulldata) + if not artists: + return None + return artists[0] + + async def add_artist(self, artist:Artist): + ''' add a new artist record into table''' + artist_id = None + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + # always prefer to grab existing artist with external_id (=musicbrainz_id) + artist_id = await self.__get_item_by_external_id(artist, db) + if not artist_id: + # insert artist + musicbrainz_id = None + for item in artist.external_ids: + if item.get('musicbrainz'): + musicbrainz_id = item['musicbrainz'] + break + assert(musicbrainz_id) # musicbrainz id is required + if not artist.sort_name: + artist.sort_name = get_sort_name(artist.name) + sql_query = 'INSERT OR IGNORE INTO artists (name, sort_name, musicbrainz_id) VALUES(?,?,?);' + await db.execute(sql_query, (artist.name, artist.sort_name, musicbrainz_id)) + await db.commit() + # get id from (newly created) item (the safe way) + artist_id = await self.__get_item_by_external_id(artist, db) + if not artist_id: + async with db.execute('SELECT (artist_id) FROM artists WHERE musicbrainz_id=?;', (musicbrainz_id,)) as cursor: + artist_id = await cursor.fetchone() + artist_id = artist_id[0] + # add metadata and tags etc. + await self.__add_prov_ids(artist_id, MediaType.Artist, artist.provider_ids, db) + await self.__add_metadata(artist_id, MediaType.Artist, artist.metadata, db) + await self.__add_tags(artist_id, MediaType.Artist, artist.tags, db) + await self.__add_external_ids(artist_id, MediaType.Artist, artist.external_ids, db) + # save + await db.commit() + LOGGER.debug('added artist %s (%s) to database: %s' %(artist.name, artist.provider_ids, artist_id)) + return artist_id + + async def albums(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Album]: + ''' fetch all album records from table''' + albums = [] + sql_query = 'SELECT * FROM albums' + if filter_query: + sql_query += ' ' + filter_query + sql_query += ' ORDER BY %s' % orderby + if limit: + sql_query += ' LIMIT %d OFFSET %d' %(limit, offset) + async with aiosqlite.connect(self.dbfile) as db: + async with db.execute(sql_query) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + album = Album() + album.item_id = db_row[0] + album.artist = await self.artist(db_row[1], fulldata=fulldata) + album.name = db_row[2] + album.albumtype = db_row[3] + album.year = db_row[4] + album.version = db_row[5] + album.provider_ids = await self.__get_prov_ids(album.item_id, MediaType.Album, db) + album.in_library = await self.__get_library_providers(album.item_id, MediaType.Album, db) + album.external_ids = await self.__get_external_ids(album.item_id, MediaType.Album, db) + if fulldata: + album.metadata = await self.__get_metadata(album.item_id, MediaType.Album, db) + album.tags = await self.__get_tags(album.item_id, MediaType.Album, db) + album.labels = await self.__get_album_labels(album.item_id, db) + else: + album.metadata = await self.__get_metadata(album.item_id, MediaType.Album, db, filter_key='image') + albums.append(album) + return albums + + async def album(self, album_id:int, fulldata=True) -> Album: + ''' get album record by id ''' + album_id = try_parse_int(album_id) + albums = await self.albums('WHERE album_id = %s' % album_id, fulldata=fulldata) + if not albums: + return None + return albums[0] + + async def add_album(self, album:Album): + ''' add a new album record into table''' + album_id = None + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + # always try to grab existing album with external_id + album_id = await self.__get_item_by_external_id(album, db) + # fallback to matching on artist_id, name and version + if not album_id: + async with db.execute('SELECT (album_id) FROM albums WHERE artist_id=? AND name=? AND version=?;', (album.artist.item_id, album.name, album.version)) as cursor: + result = await cursor.fetchone() + if result: + album_id = result[0] + if not album_id and album.year: + async with db.execute('SELECT (album_id) FROM albums WHERE year=? AND name=? AND version=?;', (album.year, album.name, album.version)) as cursor: + result = await cursor.fetchone() + if result: + album_id = result[0] + if not album_id: + # insert album + sql_query = 'INSERT OR IGNORE INTO albums (artist_id, name, albumtype, year, version) VALUES(?,?,?,?,?);' + await db.execute(sql_query, (album.artist.item_id, album.name, album.albumtype, album.year, album.version)) + await db.commit() + # get id from newly created item + async with db.execute('SELECT (album_id) FROM albums WHERE artist_id=? AND name=? AND version=?;', (album.artist.item_id, album.name, album.version)) as cursor: + album_id = await cursor.fetchone() + assert(album_id) + album_id = album_id[0] + # add metadata, artists and tags etc. + await self.__add_prov_ids(album_id, MediaType.Album, album.provider_ids, db) + await self.__add_metadata(album_id, MediaType.Album, album.metadata, db) + await self.__add_tags(album_id, MediaType.Album, album.tags, db) + await self.__add_album_labels(album_id, album.labels, db) + await self.__add_external_ids(album_id, MediaType.Album, album.external_ids, db) + # save + await db.commit() + LOGGER.debug('added album %s (%s) to database: %s' %(album.name, album.provider_ids, album_id)) + return album_id + + async def tracks(self, filter_query=None, limit=100000, offset=0, orderby='name', fulldata=False) -> List[Track]: + ''' fetch all track records from table''' + tracks = [] + sql_query = 'SELECT * FROM tracks' + if filter_query: + sql_query += ' ' + filter_query + sql_query += ' ORDER BY %s' % orderby + if limit: + sql_query += ' LIMIT %d OFFSET %d' %(limit, offset) + async with aiosqlite.connect(self.dbfile) as db: + async with db.execute(sql_query) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + track = Track() + track.item_id = db_row[0] + track.name = db_row[1] + track.album = await self.album(db_row[2], fulldata=fulldata) + track.duration = db_row[3] + track.version = db_row[4] + track.disc_number = db_row[5] + track.track_number = db_row[6] + track.metadata = await self.__get_metadata(track.item_id, MediaType.Track, db) + track.tags = await self.__get_tags(track.item_id, MediaType.Track, db) + track.provider_ids = await self.__get_prov_ids(track.item_id, MediaType.Track, db) + track.in_library = await self.__get_library_providers(track.item_id, MediaType.Track, db) + track.artists = await self.__get_track_artists(track.item_id, db, fulldata=fulldata) + track.external_ids = await self.__get_external_ids(track.item_id, MediaType.Track, db) + tracks.append(track) + return tracks + + async def track(self, track_id:int, fulldata=True) -> Track: + ''' get track record by id ''' + track_id = try_parse_int(track_id) + tracks = await self.tracks('WHERE track_id = %s' % track_id, fulldata=fulldata) + if not tracks: + return None + return tracks[0] + + async def add_track(self, track:Track): + ''' add a new track record into table''' + assert(track.name and track.album) + track_id = None + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + # always try to grab existing track with external_id + track_id = await self.__get_item_by_external_id(track, db) + # fallback to matching on album_id, name and version or track number + if not track_id and track.track_number: + async with db.execute('SELECT (track_id) FROM tracks WHERE album_id=? AND track_number=?;', (track.album.item_id, track.track_number)) as cursor: + result = await cursor.fetchone() + if result: + track_id = result[0] + if not track_id: + async with db.execute('SELECT (track_id) FROM tracks WHERE album_id=? AND name=? AND version=?;', (track.album.item_id, track.name, track.version)) as cursor: + result = await cursor.fetchone() + if result: + track_id = result[0] + if not track_id: + # insert track + assert(track.name and track.album.item_id) + sql_query = 'INSERT OR IGNORE INTO tracks (name, album_id, duration, version, disc_number, track_number) VALUES(?,?,?,?,?,?);' + await db.execute(sql_query, (track.name, track.album.item_id, track.duration, track.version, track.disc_number, track.track_number)) + await db.commit() + # get id from newly created item (the safe way) + async with db.execute('SELECT (track_id) FROM tracks WHERE name=? AND album_id=? AND version=?;', (track.name, track.album.item_id, track.version)) as cursor: + track_id = await cursor.fetchone() + assert(track_id) + track_id = track_id[0] + # add track artists + for artist in track.artists: + sql_query = 'INSERT or IGNORE INTO track_artists (track_id, artist_id) VALUES(?,?);' + await db.execute(sql_query, (track_id, artist.item_id)) + # add metadata, tags and artists etc. + await self.__add_prov_ids(track_id, MediaType.Track, track.provider_ids, db) + await self.__add_metadata(track_id, MediaType.Track, track.metadata, db) + await self.__add_tags(track_id, MediaType.Track, track.tags, db) + await self.__add_external_ids(track_id, MediaType.Track, track.external_ids, db) + # save to db + await db.commit() + LOGGER.debug('added track %s (%s) to database: %s' %(track.name, track.provider_ids, track_id)) + return track_id + + async def playlists(self, filter_query=None, limit=100000, offset=0, orderby='name') -> List[Playlist]: + ''' fetch all playlist records from table''' + playlists = [] + sql_query = 'SELECT * FROM playlists' + if filter_query: + sql_query += ' ' + filter_query + sql_query += ' ORDER BY %s' % orderby + if limit: + sql_query += ' LIMIT %d OFFSET %d' %(limit, offset) + async with aiosqlite.connect(self.dbfile) as db: + async with db.execute(sql_query) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + playlist = Playlist() + playlist.item_id = db_row[0] + playlist.name = db_row[1] + playlist.owner = db_row[2] + playlist.metadata = await self.__get_metadata(playlist.item_id, MediaType.Playlist, db) + playlist.provider_ids = await self.__get_prov_ids(playlist.item_id, MediaType.Playlist, db) + playlist.in_library = await self.__get_library_providers(playlist.item_id, MediaType.Playlist, db) + playlists.append(playlist) + return playlists + + async def playlist(self, playlist_id:int) -> Playlist: + ''' get playlist record by id ''' + playlist_id = try_parse_int(playlist_id) + playlists = await self.playlists('WHERE playlist_id = %s' % playlist_id) + if not playlists: + return None + return playlists[0] + + async def add_playlist(self, playlist:Playlist): + ''' add a new playlist record into table''' + assert(playlist.name) + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + # insert playlist + sql_query = 'INSERT OR IGNORE INTO playlists (name, owner) VALUES(?,?);' + await db.execute(sql_query, (playlist.name, playlist.owner)) + # get id from newly created item (the safe way) + async with db.execute('SELECT (playlist_id) FROM playlists WHERE name=? AND owner=?;', (playlist.name,playlist.owner)) as cursor: + playlist_id = await cursor.fetchone() + playlist_id = playlist_id[0] + # add metadata + await self.__add_prov_ids(playlist_id, MediaType.Playlist, playlist.provider_ids, db) + await self.__add_metadata(playlist_id, MediaType.Playlist, playlist.metadata, db) + # save + await db.commit() + LOGGER.debug('added playlist %s to database: %s' %(playlist.name, playlist_id)) + return playlist_id + + async def artist_tracks(self, artist_id, limit=1000000, offset=0, orderby='name') -> List[Track]: + ''' get all library tracks for the given artist ''' + artist_id = try_parse_int(artist_id) + sql_query = ' WHERE track_id in (SELECT track_id FROM track_artists WHERE artist_id = %d)' % artist_id + return await self.tracks(sql_query, limit=limit, offset=offset, orderby=orderby) + + async def artist_albums(self, artist_id, limit=1000000, offset=0, orderby='name') -> List[Album]: + ''' get all library albums for the given artist ''' + sql_query = ' WHERE artist_id = %d' % artist_id + return await self.albums(sql_query, limit=limit, offset=offset, orderby=orderby) + + async def playlist_tracks(self, playlist_id:int, limit=100000, offset=0, orderby='position', fulldata=False) -> List[Track]: + ''' get playlist tracks for the given playlist_id ''' + playlist_id = try_parse_int(playlist_id) + playlist_tracks = [] + sql_query = 'SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY %s' % orderby + if limit: + sql_query += ' LIMIT %d OFFSET %d' %(limit, offset) + async with aiosqlite.connect(self.dbfile) as db: + async with db.execute(sql_query, (playlist_id,)) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + playlist_track = await self.track(db_row[0], fulldata=fulldata) + playlist_track.position = db_row[1] + playlist_tracks.append(playlist_track) + return playlist_tracks + + async def add_playlist_track(self, playlist_id:int, track_id, position): + ''' add playlist track to playlist ''' + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + sql_query = 'INSERT or IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES(?,?,?);' + await db.execute(sql_query, (playlist_id, track_id, position)) + await db.commit() + + async def remove_playlist_track(self, playlist_id:int, track_id): + ''' remove playlist track from playlist ''' + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + sql_query = 'DELETE FROM playlist_tracks WHERE playlist_id=? AND track_id=?;' + await db.execute(sql_query, (playlist_id, track_id)) + await db.commit() + + async def __add_metadata(self, item_id, media_type, metadata, db): + ''' add or update metadata''' + for key, value in metadata.items(): + if value: + sql_query = 'INSERT or REPLACE INTO metadata (item_id, media_type, key, value) VALUES(?,?,?,?);' + await db.execute(sql_query, (item_id, media_type, key, value)) + + async def __get_metadata(self, item_id, media_type, db, filter_key=None): + ''' get metadata for media item ''' + metadata = {} + sql_query = 'SELECT key, value FROM metadata WHERE item_id = ? AND media_type = ?' + if filter_key: + sql_query += ' AND key = "%s"' % filter_key + async with db.execute(sql_query, (item_id, media_type)) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + key = db_row[0] + value = db_row[1] + metadata[key] = value + return metadata + + async def __add_tags(self, item_id, media_type, tags, db): + ''' add tags to db ''' + for tag in tags: + sql_query = 'INSERT or IGNORE INTO tags (name) VALUES(?);' + async with db.execute(sql_query, (tag,)) as cursor: + tag_id = cursor.lastrowid + sql_query = 'INSERT or IGNORE INTO media_tags (item_id, media_type, tag_id) VALUES(?,?,?);' + await db.execute(sql_query, (item_id, media_type, tag_id)) + + async def __get_tags(self, item_id, media_type, db): + ''' get tags for media item ''' + tags = [] + sql_query = 'SELECT name FROM tags INNER JOIN media_tags on tags.tag_id = media_tags.tag_id WHERE item_id = ? AND media_type = ?' + async with db.execute(sql_query, (item_id, media_type)) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + tags.append(db_row[0]) + return tags + + async def __add_album_labels(self, album_id, labels, db): + ''' add labels to album in db ''' + for label in labels: + sql_query = 'INSERT or IGNORE INTO labels (name) VALUES(?);' + async with db.execute(sql_query, (label,)) as cursor: + label_id = cursor.lastrowid + sql_query = 'INSERT or IGNORE INTO album_labels (album_id, label_id) VALUES(?,?);' + await db.execute(sql_query, (album_id, label_id)) + + async def __get_album_labels(self, album_id, db): + ''' get labels for album item ''' + labels = [] + sql_query = 'SELECT name FROM labels INNER JOIN album_labels on labels.label_id = album_labels.label_id WHERE album_id = ?' + async with db.execute(sql_query, (album_id,)) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + labels.append(db_row[0]) + return labels + + async def __get_track_artists(self, track_id, db, fulldata=False) -> List[Artist]: + ''' get artists for track ''' + artists = [] + sql_query = 'SELECT artist_id FROM track_artists WHERE track_id = ?' + async with db.execute(sql_query, (track_id,)) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + artist = await self.artist(db_row[0], fulldata=fulldata) + artists.append(artist) + return artists + + async def __add_external_ids(self, item_id, media_type, external_ids, db): + ''' add or update external_ids''' + for external_id in external_ids: + for key, value in external_id.items(): + sql_query = 'INSERT or REPLACE INTO external_ids (item_id, media_type, key, value) VALUES(?,?,?,?);' + await db.execute(sql_query, (item_id, media_type, key, value)) + + async def __get_external_ids(self, item_id, media_type, db): + ''' get external_ids for media item ''' + external_ids = [] + sql_query = 'SELECT key, value FROM external_ids WHERE item_id = ? AND media_type = ?' + async with db.execute(sql_query, (item_id, media_type)) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + external_id = { + db_row[0]: db_row[1] + } + external_ids.append(external_id) + return external_ids + + async def __add_prov_ids(self, item_id, media_type, provider_ids, db): + ''' add provider ids for media item to db ''' + for prov_mapping in provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + quality = prov_mapping.get('quality',0) + details = prov_mapping.get('details','') + sql_query = 'INSERT OR REPLACE INTO provider_mappings (item_id, media_type, prov_item_id, provider, quality, details) VALUES(?,?,?,?,?,?);' + await db.execute(sql_query, (item_id, media_type, prov_item_id, prov_id, quality, details)) + + async def __get_prov_ids(self, item_id, media_type:MediaType, db): + ''' get all provider_ids for media item ''' + provider_ids = [] + sql_query = 'SELECT prov_item_id, provider, quality, details \ + FROM provider_mappings \ + WHERE item_id = ? AND media_type = ?' + async with db.execute(sql_query, (item_id, media_type)) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + prov_mapping = { + "provider": db_row[1], + "item_id": db_row[0], + "quality": db_row[2], + "details": db_row[3] + } + provider_ids.append(prov_mapping) + return provider_ids + + async def __get_library_providers(self, item_id, media_type:MediaType, db): + ''' get the providers that have this media_item added to the library ''' + providers = [] + sql_query = 'SELECT provider FROM library_items WHERE item_id = ? AND media_type = ?' + async with db.execute(sql_query, (item_id, media_type)) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + providers.append( db_row[0] ) + return providers + + async def __get_item_by_external_id(self, media_item, db): + ''' try to get existing item in db by matching the new item's external id's ''' + item_id = None + for external_id in media_item.external_ids: + if item_id: + break + for key, value in external_id.items(): + async with db.execute('SELECT (item_id) FROM external_ids WHERE media_type=? AND key=? AND value=?;', (media_item.media_type, key, value)) as cursor: + result = await cursor.fetchone() + if result: + item_id = result[0] + break + if item_id: + break + return item_id \ No newline at end of file diff --git a/music_assistant/main.py b/music_assistant/main.py new file mode 100755 index 00000000..70bcda0b --- /dev/null +++ b/music_assistant/main.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import sys +import asyncio +from concurrent.futures import ThreadPoolExecutor +import re +import uvloop +import logging +import os +import shutil +import slugify as unicode_slug +import uuid +import json +import pkgutil +import time + +from database import Database +from metadata import MetaData +from api import Api +from utils import run_periodic, LOGGER +from cache import Cache +from music import Music +from player import Player + +class Main(): + + def __init__(self, datapath): + uvloop.install() + self._datapath = datapath + self.parse_config() + self.event_loop = asyncio.get_event_loop() + self.bg_executor = ThreadPoolExecutor(max_workers=5) + self.event_listeners = {} + + import signal + signal.signal(signal.SIGINT, self.stop) + signal.signal(signal.SIGTERM, self.stop) + + # init database and metadata modules + self.db = Database(self.event_loop) + # allow some time for the database to initialize + while not self.db.db_ready: + time.sleep(0.5) + self.cache = Cache() + self.metadata = MetaData(self.event_loop, self.db, self.cache) + self.music = Music(self) + self.player = Player(self) + + # init web/api + self.api = Api(self) + asyncio.ensure_future(self.api.setup_web()) + + # start the event loop + self.event_loop.run_forever() + + async def event(self, msg, msg_details=None): + ''' signal event ''' + LOGGER.debug("Event: %s - %s" %(msg, msg_details)) + for listener in self.event_listeners.values(): + await listener(msg, msg_details) + + async def add_event_listener(self, cb): + ''' add callback to our event listeners ''' + cb_id = str(uuid.uuid4()) + self.event_listeners[cb_id] = cb + + async def remove_event_listener(self, cb_id): + ''' add callback to our event listeners ''' + self.event_listeners.pop(cb_id, None) + + def save_config(self): + ''' save config to file ''' + # backup existing file + conf_file = os.path.join(self._datapath, 'config.json') + conf_file_backup = os.path.join(self._datapath, 'config.json') + if os.path.isfile(conf_file): + shutil.move(conf_file, conf_file_backup) + with open(conf_file, 'w') as f: + f.write(json.dumps(self.config, indent=4)) + + def parse_config(self): + '''get config from config file''' + config = { + "musicproviders": {}, + "playerproviders": {}, + "player_settings": + { + "__desc__": + [ + ("name", "", "Custom name for this player"), + ("group_parent", "", "Group this player with another player"), + ("mute_as_power", False, "Use muting as power control"), + ("disable_volume", False, "Disable volume controls"), + ("apply_group_volume", False, "Apply group volume to childs (for group players only)"), + ("enabled", False, "Enable player") + ] + } + } + conf_file = os.path.join(self._datapath, 'config.json') + if os.path.isfile(conf_file): + with open(conf_file) as f: + data = f.read() + stored_config = json.loads(data) + for key in config.keys(): + if stored_config.get(key): + config[key].update(stored_config[key]) + self.config = config + + def stop(self, signum=None, frame=None): + ''' properly close all connections''' + print('stop requested!') + self.save_config() + self.api.stop() + print('stopping event loop...') + self.event_loop.stop() + self.event_loop.close() + +if __name__ == "__main__": + datapath = sys.argv[1:] + if not datapath: + datapath = os.path.dirname(os.path.abspath(__file__)) + Main(datapath) + \ No newline at end of file diff --git a/music_assistant/metadata.py b/music_assistant/metadata.py new file mode 100755 index 00000000..e3e3a35c --- /dev/null +++ b/music_assistant/metadata.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from utils import run_periodic, LOGGER +import json +import aiohttp +from asyncio_throttle import Throttler +from difflib import SequenceMatcher as Matcher +from cache import use_cache +from yarl import URL +import re + +LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' + +class MetaData(): + ''' several helpers to search and store mediadata for mediaitems ''' + + def __init__(self, event_loop, db, cache): + self.event_loop = event_loop + self.db = db + self.cache = cache + self.musicbrainz = MusicBrainz(event_loop, cache) + self.fanarttv = FanartTv(event_loop, cache) + + async def get_artist_metadata(self, mb_artist_id, cur_metadata): + ''' get/update rich metadata for an artist by providing the musicbrainz artist id ''' + metadata = cur_metadata + if not ('fanart' in metadata or 'thumb' in metadata): + res = await self.fanarttv.artist_images(mb_artist_id) + self.merge_metadata(cur_metadata, res) + return metadata + + async def get_mb_artist_id(self, artistname, albumname=None, album_upc=None, trackname=None, track_isrc=None): + ''' retrieve musicbrainz artist id for the given details ''' + LOGGER.debug('searching musicbrainz for %s (albumname: %s - album_upc: %s - trackname: %s - track_isrc: %s)' %(artistname, albumname, album_upc, trackname, track_isrc)) + mb_artist_id = None + if album_upc: + mb_artist_id = await self.musicbrainz.search_artist_by_album(artistname, None, album_upc) + if not mb_artist_id and track_isrc: + mb_artist_id = await self.musicbrainz.search_artist_by_track(artistname, None, track_isrc) + if not mb_artist_id and albumname: + mb_artist_id = await self.musicbrainz.search_artist_by_album(artistname, albumname) + if not mb_artist_id and trackname: + mb_artist_id = await self.musicbrainz.search_artist_by_track(artistname, trackname) + LOGGER.debug('Got musicbrainz artist id for artist %s --> %s' %(artistname, mb_artist_id)) + return mb_artist_id + + @staticmethod + def merge_metadata(cur_metadata, new_values): + ''' merge new info into the metadata dict without overwiteing existing values ''' + for key, value in new_values.items(): + if not cur_metadata.get(key): + cur_metadata[key] = value + return cur_metadata + +class MusicBrainz(): + + def __init__(self, event_loop, cache): + self.event_loop = event_loop + self.cache = cache + self.http_session = aiohttp.ClientSession(loop=event_loop) + self.throttler = Throttler(rate_limit=1, period=1) + + async def search_artist_by_album(self, artistname, albumname=None, album_upc=None): + ''' retrieve musicbrainz artist id by providing the artist name and albumname or upc ''' + if album_upc: + endpoint = 'release' + params = {'query': 'barcode:%s' % album_upc} + else: + searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname) + searchartist = searchartist.replace('/','').replace('\\','') + searchalbum = re.sub(LUCENE_SPECIAL, r'\\\1', albumname) + endpoint = 'release' + params = {'query': 'artist:"%s" AND release:"%s"' % (searchartist, searchalbum)} + result = await self.get_data(endpoint, params) + if result and result.get('releases'): + for strictness in [1, 0.95, 0.9, 0.8]: + for item in result['releases']: + for artist in item['artist-credit']: + artist = artist['artist'] + if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness: + return artist['id'] + for item in artist.get('aliases',[]): + if item['name'].lower() == artistname.lower(): + return artist['id'] + return '' + + async def search_artist_by_track(self, artistname, trackname=None, track_isrc=None): + ''' retrieve artist id by providing the artist name and trackname or track isrc ''' + endpoint = 'recording' + searchartist = re.sub(LUCENE_SPECIAL, r'\\\1', artistname) + searchartist = searchartist.replace('/','').replace('\\','') + if track_isrc: + endpoint = 'isrc/%s' % track_isrc + params = {'inc': 'artist-credits'} + else: + searchtrack = re.sub(LUCENE_SPECIAL, r'\\\1', trackname) + endpoint = 'recording' + params = {'query': '"%s" AND artist:"%s"' % (searchtrack, searchartist)} + result = await self.get_data(endpoint, params) + if result and result.get('recordings'): + for strictness in [1, 0.95, 0.9, 0.8]: + for item in result['recordings']: + for artist in item['artist-credit']: + artist = artist['artist'] + if Matcher(None, artist['name'].lower(), artistname.lower()).ratio() >= strictness: + return artist['id'] + for item in artist.get('aliases',[]): + if item['name'].lower() == artistname.lower(): + return artist['id'] + return '' + + @use_cache(30) + async def get_data(self, endpoint, params={}): + ''' get data from api''' + url = 'https://musicbrainz.org/ws/2/%s' % endpoint + headers = {'User-Agent': 'Music Assistant/1.0.0 https://github.com/marcelveldt'} + params['fmt'] = 'json' + async with self.throttler: + async with self.http_session.get(url, headers=headers, params=params) as response: + try: + result = await response.json() + except Exception as exc: + msg = await response.text() + LOGGER.exception("%s - %s" % (str(exc), msg)) + result = None + return result + + +class FanartTv(): + + def __init__(self, event_loop, cache): + self.event_loop = event_loop + self.cache = cache + self.http_session = aiohttp.ClientSession(loop=event_loop) + self.throttler = Throttler(rate_limit=5, period=1) + + async def artist_images(self, mb_artist_id): + ''' retrieve images by musicbrainz artist id ''' + metadata = {} + data = await self.get_data("music/%s" % mb_artist_id) + if data: + if data.get('hdmusiclogo'): + metadata['logo'] = data['hdmusiclogo'][0]["url"] + elif data.get('musiclogo'): + metadata['logo'] = data['musiclogo'][0]["url"] + if data.get('artistbackground'): + count = 0 + for item in data['artistbackground']: + key = "fanart" if count == 0 else "fanart.%s" % count + metadata[key] = item["url"] + if data.get('artistthumb'): + url = data['artistthumb'][0]["url"] + if not '2a96cbd8b46e442fc41c2b86b821562f' in url: + metadata['image'] = url + if data.get('musicbanner'): + metadata['banner'] = data['musicbanner'][0]["url"] + return metadata + + @use_cache(30) + async def get_data(self, endpoint, params={}): + ''' get data from api''' + url = 'http://webservice.fanart.tv/v3/%s' % endpoint + params['api_key'] = '639191cb0774661597f28a47e7e2bad5' + async with self.throttler: + async with self.http_session.get(url, params=params) as response: + result = await response.json() + if 'error' in result and 'limit' in result['error']: + raise Exception(result['error']) + return result diff --git a/music_assistant/models.py b/music_assistant/models.py new file mode 100755 index 00000000..e40f87e7 --- /dev/null +++ b/music_assistant/models.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +from enum import Enum, IntEnum +from typing import List +import sys +sys.path.append("..") +from utils import run_periodic, LOGGER, parse_track_title +from difflib import SequenceMatcher as Matcher +import asyncio +from cache import use_cache + + +class MediaType(IntEnum): + Artist = 1 + Album = 2 + Track = 3 + Playlist = 4 + +def media_type_from_string(media_type_str): + media_type_str = media_type_str.lower() + if 'artist' in media_type_str or media_type_str == '1': + return MediaType.Artist + elif 'album' in media_type_str or media_type_str == '2': + return MediaType.Album + elif 'track' in media_type_str or media_type_str == '3': + return MediaType.Track + elif 'playlist' in media_type_str or media_type_str == '4': + return MediaType.Playlist + else: + return None + +class ContributorRole(IntEnum): + Artist = 1 + Writer = 2 + Producer = 3 + +class AlbumType(IntEnum): + Album = 1 + Single = 2 + Compilation = 3 + +class TrackQuality(IntEnum): + LOSSY_MP3 = 0 + LOSSY_OGG = 1 + LOSSY_AAC = 2 + FLAC_LOSSLESS = 6 # 44.1/48khz 16 bits HI-RES + FLAC_LOSSLES_HI_RES_1 = 7 # 44.1/48khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_2 = 8 # 88.2/96khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_3 = 9 # 176/192khz 24 bits HI-RES + FLAC_LOSSLESS_HI_RES_4 = 10 # above 192khz 24 bits HI-RES + + +class Artist(object): + ''' representation of an artist ''' + def __init__(self): + self.item_id = None + self.name = '' + self.sort_name = '' + self.metadata = {} + self.tags = [] + self.external_ids = [] + self.provider_ids = [] + self.media_type = MediaType.Artist + self.in_library = [] + self.is_lazy = False + +class Album(object): + ''' representation of an album ''' + def __init__(self): + self.item_id = None + self.name = '' + self.metadata = {} + self.version = '' + self.external_ids = [] + self.tags = [] + self.albumtype = AlbumType.Album + self.year = 0 + self.artist = None + self.labels = [] + self.provider_ids = [] + self.media_type = MediaType.Album + self.in_library = [] + self.is_lazy = False + +class Track(object): + ''' representation of a track ''' + def __init__(self): + self.item_id = None + self.name = '' + self.duration = 0 + self.version = '' + self.external_ids = [] + self.metadata = { } + self.tags = [] + self.artists = [] + self.provider_ids = [] + self.album = None + self.disc_number = 0 + self.track_number = 0 + self.media_type = MediaType.Track + self.in_library = [] + self.is_lazy = False + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name == other.name and + self.version == other.version and + self.item_id == other.item_id) + def __ne__(self, other): + return not self.__eq__(other) + +class Playlist(object): + ''' representation of a playlist ''' + def __init__(self): + self.item_id = None + self.name = '' + self.owner = '' + self.provider_ids = [] + self.metadata = {} + self.media_type = MediaType.Playlist + self.in_library = [] + + +class MusicProvider(): + ''' + Model for a Musicprovider + Common methods usable for every provider + Provider specific get methods shoud be overriden in the provider specific implementation + Uses a form of lazy provisioning to local db as cache + ''' + + name = 'My great Music provider' # display name + prov_id = 'my_provider' # used as id + icon = '' + + def __init__(self, mass): + self.mass = mass + self.cache = mass.cache + + ### Common methods and properties #### + + async def artist(self, prov_item_id, lazy=True) -> Artist: + ''' return artist details for the given provider artist id ''' + item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Artist) + if not item_id: + # artist not yet in local database so fetch details + artist_details = await self.get_artist(prov_item_id) + if not artist_details: + LOGGER.warning('artist not found: %s' % prov_item_id) + return None + if lazy: + asyncio.create_task(self.add_artist(artist_details)) + artist_details.is_lazy = True + return artist_details + item_id = await self.add_artist(artist_details) + return await self.mass.db.artist(item_id) + + async def add_artist(self, artist_details, skip_match=False) -> int: + ''' add artist to local db and return the new database id''' + musicbrainz_id = None + for item in artist_details.external_ids: + if item.get("musicbrainz"): + musicbrainz_id = item["musicbrainz"] + if not musicbrainz_id: + musicbrainz_id = await self.get_artist_musicbrainz_id(artist_details, allow_fallback=not skip_match) + if not musicbrainz_id: + return + # grab additional metadata + if musicbrainz_id: + artist_details.external_ids.append({"musicbrainz": musicbrainz_id}) + artist_details.metadata = await self.mass.metadata.get_artist_metadata(musicbrainz_id, artist_details.metadata) + item_id = await self.mass.db.add_artist(artist_details) + # also fetch same artist on all providers + if not skip_match: + new_artist = await self.mass.db.artist(item_id) + item_provider_keys = [item['provider'] for item in new_artist.provider_ids] + for prov_id, provider in self.mass.music.providers.items(): + if not prov_id in item_provider_keys: + await provider.match_artist(new_artist) + return item_id + + async def get_artist_musicbrainz_id(self, artist_details:Artist, allow_fallback=False): + ''' fetch musicbrainz id by performing search with both the artist and one of it's albums or tracks ''' + musicbrainz_id = "" + # try with album first + lookup_albums = await self.get_artist_albums(artist_details.item_id) + for lookup_album in lookup_albums[:10]: + lookup_album_upc = None + for item in lookup_album.external_ids: + if item.get("upc"): + lookup_album_upc = item["upc"] + break + musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name, + albumname=lookup_album.name, album_upc=lookup_album_upc) + if musicbrainz_id: + break + # fallback to track + lookup_tracks = await self.get_artist_toptracks(artist_details.item_id) + for lookup_track in lookup_tracks[:10]: + lookup_track_isrc = None + for item in lookup_track.external_ids: + if item.get("isrc"): + lookup_track_isrc = item["isrc"] + break + musicbrainz_id = await self.mass.metadata.get_mb_artist_id(artist_details.name, + trackname=lookup_track.name, track_isrc=lookup_track_isrc) + if musicbrainz_id: + break + if not musicbrainz_id: + LOGGER.warning("Unable to get musicbrainz ID for artist %s !" % artist_details.name) + if allow_fallback: + musicbrainz_id = artist_details.name + return musicbrainz_id + + async def album(self, prov_item_id, lazy=True) -> Album: + ''' return album details for the given provider album id''' + item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Album) + if not item_id: + # album not yet in local database so fetch details + album_details = await self.get_album(prov_item_id) + if not album_details: + LOGGER.warning('album not found: %s' % prov_item_id) + return album_details + if lazy: + asyncio.create_task(self.add_album(album_details)) + album_details.is_lazy = True + return album_details + item_id = await self.add_album(album_details) + return await self.mass.db.album(item_id) + + async def add_album(self, album_details, skip_match=False) -> int: + ''' add album to local db and return the new database id''' + # we need to fetch album artist too + db_album_artist = await self.artist(album_details.artist.item_id, lazy=False) + album_details.artist = db_album_artist + item_id = await self.mass.db.add_album(album_details) + # also fetch same album on all providers + if not skip_match: + new_album = await self.mass.db.album(item_id) + item_provider_keys = [item['provider'] for item in new_album.provider_ids] + for prov_id, provider in self.mass.music.providers.items(): + if not prov_id in item_provider_keys: + await provider.match_album(new_album) + return item_id + + async def track(self, prov_item_id, lazy=True, track_details=None) -> Track: + ''' return track details for the given provider track id ''' + item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Track) + if not item_id: + # album not yet in local database so fetch details + if not track_details: + track_details = await self.get_track(prov_item_id) + if not track_details: + LOGGER.warning('track not found: %s' % prov_item_id) + return None + if lazy: + asyncio.ensure_future(self.add_track(track_details)) + track_details.is_lazy = True + return track_details + item_id = await self.add_track(track_details) + return await self.mass.db.track(item_id) + + async def add_track(self, track_details, prov_album_id=None, skip_match=False) -> int: + ''' add track to local db and return the new database id''' + track_artists = [] + assert(track_details) + # we need to fetch track artists too + for track_artist in track_details.artists: + prov_item_id = track_artist.item_id + db_track_artist = await self.artist(prov_item_id, lazy=False) + assert(db_track_artist) + track_artists.append(db_track_artist) + track_details.artists = track_artists + if not prov_album_id: + prov_album_id = track_details.album.item_id + track_details.album = await self.album(prov_album_id, lazy=False) + item_id = await self.mass.db.add_track(track_details) + # also fetch same track on all providers + if not skip_match: + new_track = await self.mass.db.track(item_id) + item_provider_keys = [item['provider'] for item in new_track.provider_ids] + for prov_id, provider in self.mass.music.providers.items(): + if not prov_id in item_provider_keys: + await provider.match_track(new_track) + return item_id + + async def playlist(self, prov_item_id) -> Playlist: + ''' return playlist details for the given provider playlist id ''' + item_id = await self.mass.db.get_database_id(self.prov_id, prov_item_id, MediaType.Playlist) + if item_id: + return await self.mass.db.playlist(item_id) + else: + return await self.get_playlist(prov_item_id) + + async def add_playlist(self, playlist_details) -> int: + ''' add playlist to local db and return the (new) database id''' + item_id = await self.mass.db.add_playlist(playlist_details) + return item_id + + async def album_tracks(self, prov_album_id) -> List[Track]: + ''' return album tracks for the given provider album id''' + items = [] + album = await self.get_album(prov_album_id) + for prov_track in await self.get_album_tracks(prov_album_id): + prov_track.album = album + track = await self.track(prov_track.item_id, track_details=prov_track) + items.append(track) + return items + + async def playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]: + ''' return playlist tracks for the given provider playlist id''' + items = [] + for prov_track in await self.get_playlist_tracks(prov_playlist_id, limit=limit, offset=offset): + for prov_mapping in prov_track.provider_ids: + item_prov_id = prov_mapping["provider"] + prov_item_id = prov_mapping["item_id"] + db_id = await self.mass.db.get_database_id(item_prov_id, prov_item_id, MediaType.Track) + if db_id: + items.append( await self.mass.db.track(db_id) ) + else: + items.append(prov_track) + asyncio.create_task(self.add_track(prov_track)) + return items + + async def artist_toptracks(self, prov_item_id) -> List[Track]: + ''' return top tracks for an artist ''' + items = [] + for prov_track in await self.get_artist_toptracks(prov_item_id): + db_id = await self.mass.db.get_database_id(self.prov_id, prov_track.item_id, MediaType.Track) + if db_id: + items.append( await self.mass.db.track(db_id) ) + else: + items.append(prov_track) + asyncio.create_task(self.add_track(prov_track)) + return items + + async def artist_albums(self, prov_item_id) -> List[Track]: + ''' return (all) albums for an artist ''' + items = [] + for prov_album in await self.get_artist_albums(prov_item_id): + db_id = await self.mass.db.get_database_id(self.prov_id, prov_album.item_id, MediaType.Album) + if db_id: + items.append( await self.mass.db.album(db_id) ) + else: + items.append(prov_album) + asyncio.create_task(self.add_album(prov_album)) + return items + + async def match_artist(self, searchartist:Artist): + ''' try to match artist in this provider by supplying db artist ''' + for prov_mapping in searchartist.provider_ids: + if prov_mapping["provider"] == self.prov_id: + return # we already have a mapping on this provider + search_results = await self.search(searchartist.name, [MediaType.Artist], limit=2) + for item in search_results["artists"]: + if item.name.lower() == searchartist.name.lower(): + # just lazy load this item in the database, it will be matched automagically ;-) + db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Artist) + if not db_id: + asyncio.create_task(self.add_artist(item, skip_match=True)) + + async def match_album(self, searchalbum:Album): + ''' try to match album in this provider by supplying db album ''' + for prov_mapping in searchalbum.provider_ids: + if prov_mapping["provider"] == self.prov_id: + return # we already have a mapping on this provider + searchstr = "%s - %s %s" %(searchalbum.artist.name, searchalbum.name, searchalbum.version) + search_results = await self.search(searchstr, [MediaType.Album], limit=5) + for item in search_results["albums"]: + if item.name == searchalbum.name and item.version == searchalbum.version and item.artist.name == searchalbum.artist.name: + # just lazy load this item in the database, it will be matched automagically ;-) + db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Album) + if not db_id: + asyncio.create_task(self.add_album(item, skip_match=True)) + + async def match_track(self, searchtrack:Album): + ''' try to match track in this provider by supplying db track ''' + for prov_mapping in searchtrack.provider_ids: + if prov_mapping["provider"] == self.prov_id: + return # we already have a mapping on this provider + searchstr = "%s - %s" %(searchtrack.artists[0].name, searchtrack.name) + search_results = await self.search(searchstr, [MediaType.Track], limit=5) + for item in search_results["tracks"]: + if item.name == searchtrack.name and item.version == searchtrack.version and item.album.name == searchtrack.album.name: + # just lazy load this item in the database, it will be matched automagically ;-) + db_id = await self.mass.db.get_database_id(self.prov_id, item.item_id, MediaType.Track) + if not db_id: + asyncio.create_task(self.add_track(item, skip_match=True)) + + ### Provider specific implementation ##### + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + raise NotImplementedError + + async def get_library_artists(self) -> List[Artist]: + ''' retrieve library artists from the provider ''' + raise NotImplementedError + + async def get_library_albums(self) -> List[Album]: + ''' retrieve library albums from the provider ''' + raise NotImplementedError + + async def get_library_tracks(self) -> List[Track]: + ''' retrieve library tracks from the provider ''' + raise NotImplementedError + + async def get_library_playlists(self) -> List[Playlist]: + ''' retrieve library/subscribed playlists from the provider ''' + raise NotImplementedError + + async def get_artist(self, prov_item_id) -> Artist: + ''' get full artist details by id ''' + raise NotImplementedError + + async def get_artist_albums(self, prov_item_id) -> List[Album]: + ''' get a list of albums for the given artist ''' + raise NotImplementedError + + async def get_artist_toptracks(self, prov_item_id) -> List[Track]: + ''' get a list of most popular tracks for the given artist ''' + raise NotImplementedError + + async def get_album(self, prov_item_id) -> Album: + ''' get full album details by id ''' + raise NotImplementedError + + async def get_track(self, prov_item_id) -> Track: + ''' get full track details by id ''' + raise NotImplementedError + + async def get_playlist(self, prov_item_id) -> Playlist: + ''' get full playlist details by id ''' + raise NotImplementedError + + async def get_album_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]: + ''' get album tracks for given album id ''' + raise NotImplementedError + + async def get_playlist_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]: + ''' get playlist tracks for given playlist id ''' + raise NotImplementedError + + async def add_library(self, prov_item_id, media_type:MediaType): + ''' add item to library ''' + raise NotImplementedError + + async def remove_library(self, prov_item_id, media_type:MediaType): + ''' remove item from library ''' + raise NotImplementedError + +class PlayerState(str, Enum): + Off = "off" + Stopped = "stopped" + Paused = "paused" + Playing = "playing" + +class MusicPlayer(): + ''' representation of a musicplayer ''' + def __init__(self): + self.player_id = None + self.player_provider = None + self.name = '' + self.state = PlayerState.Off + self.powered = False + self.cur_item = Track() + self.cur_item_time = 0 + self.volume_level = 0 + self.shuffle_enabled = False + self.repeat_enabled = False + self.muted = False + self.group_parent = None # set to id of REAL group/parent player + self.is_group = False # is this player a group player ? + self.disable_volume = False + self.mute_as_power = False + self.apply_group_volume = False + self.enabled = False + +class PlayerProvider(): + ''' + Model for a Playerprovider + Common methods usable for every provider + Provider specific __get methods shoud be overriden in the provider specific implementation + ''' + name = 'My great Musicplayer provider' # display name + prov_id = 'my_provider' # used as id + icon = '' + supports_queue = True # whether this provider has native support for a queue + supports_http_stream = True # whether we can fallback to http streaming + supported_musicproviders = [ # list with tuples of supported provider_id and media_types this playerprovider supports NATIVELY, order by preference/quality + ('qobuz', [MediaType.Track]), + ('file', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]), + ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]) + ] + + def __init__(self, mass): + self.mass = mass + + ### Common methods and properties #### + + + async def play_media(self, player_id, uri, queue_opt='play'): + ''' + play media on a player + params: + - player_id: id of the player + - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream) + - queue_opt: + replace: replace whatever is currently playing with this media + next: the given media will be played after the currently playing track + add: add to the end of the queue + play: keep existing queue but play the given item now + ''' + raise NotImplementedError + + + ### Provider specific implementation ##### + + async def player_command(self, player_id, cmd:str, cmd_args=None): + ''' issue command on player (play, pause, next, previous, stop, power, volume) ''' + raise NotImplementedError + + async def player_queue(self, player_id, offset=0, limit=50): + ''' return the items in the player's queue ''' + raise NotImplementedError + + diff --git a/music_assistant/modules/__init__.py b/music_assistant/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/music_assistant/modules/musicproviders/__init__.py b/music_assistant/modules/musicproviders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/music_assistant/modules/musicproviders/file.py b/music_assistant/modules/musicproviders/file.py new file mode 100644 index 00000000..149ff177 --- /dev/null +++ b/music_assistant/modules/musicproviders/file.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import sys +import time +sys.path.append("..") +from utils import run_periodic, LOGGER, parse_track_title +from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from constants import CONF_ENABLED +import taglib +from cache import use_cache + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["musicproviders"]['file'].get(CONF_ENABLED) + music_dir = mass.config["musicproviders"]['file'].get('music_dir') + playlists_dir = mass.config["musicproviders"]['file'].get('playlists_dir') + if enabled and (music_dir or playlists_dir): + file_provider = FileProvider(mass, music_dir, playlists_dir) + return file_provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + ("music_dir", "", "Path to music files"), + ("playlists_dir", "", "Path to playlists") + ] + +class FileProvider(MusicProvider): + ''' + Very basic implementation of a musicprovider for local files + Assumes files are stored on disk in format // + Reads ID3 tags from file and falls back to parsing filename + Supports m3u files only for playlists + Supports having URI's from streaming providers within m3u playlist + Should be compatible with LMS + ''' + + + def __init__(self, mass, music_dir, playlists_dir): + self.name = 'Local files and playlists' + self.prov_id = 'file' + self.mass = mass + self.cache = mass.cache + self._music_dir = music_dir + self._playlists_dir = playlists_dir + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [] + } + return result + + async def get_library_artists(self) -> List[Artist]: + ''' get artist folders in music directory ''' + if not os.path.isdir(self._music_dir): + LOGGER.error("music path does not exist: %s" % self._music_dir) + return [] + result = [] + for dirname in os.listdir(self._music_dir): + dirpath = os.path.join(self._music_dir, dirname) + if os.path.isdir(dirpath) and not dirpath.startswith('.'): + artist = await self.get_artist(dirpath) + if artist: + result.append(artist) + return result + + async def get_library_albums(self) -> List[Album]: + ''' get album folders recursively ''' + result = [] + for artist in await self.get_library_artists(): + result += await self.get_artist_albums(artist.item_id) + return result + + async def get_library_tracks(self) -> List[Track]: + ''' get all tracks recursively ''' + #TODO: support disk subfolders + result = [] + for album in await self.get_library_albums(): + result += await self.get_album_tracks(album.item_id) + return result + + async def get_library_playlists(self) -> List[Playlist]: + ''' retrieve playlists from disk ''' + if not self._playlists_dir: + return [] + result = [] + for filename in os.listdir(self._playlists_dir): + filepath = os.path.join(self._playlists_dir, filename) + if os.path.isfile(filepath) and not filename.startswith('.') and filename.lower().endswith('.m3u'): + playlist = await self.get_playlist(filepath) + if playlist: + result.append(playlist) + return result + + async def get_artist(self, prov_item_id) -> Artist: + ''' get full artist details by id ''' + if not os.path.isdir(prov_item_id): + LOGGER.error("artist path does not exist: %s" % prov_item_id) + return None + if "\\" in prov_item_id: + name = prov_item_id.split("\\")[-1] + else: + name = prov_item_id.split("/")[-1] + artist = Artist() + artist.item_id = prov_item_id # temporary id + artist.name = name + artist.provider_ids.append({ + "provider": self.prov_id, + "item_id": prov_item_id + }) + return artist + + async def get_album(self, prov_item_id) -> Album: + ''' get full album details by id ''' + if not os.path.isdir(prov_item_id): + LOGGER.error("album path does not exist: %s" % prov_item_id) + return None + if "\\" in prov_item_id: + name = prov_item_id.split("\\")[-1] + artistpath = prov_item_id.rsplit("\\", 1)[0] + else: + name = prov_item_id.split("/")[-1] + artistpath = prov_item_id.rsplit("/", 1)[0] + album = Album() + album.item_id = prov_item_id # temporary id + album.name, album.version = parse_track_title(name) + album.artist = await self.get_artist(artistpath) + if not album.artist: + raise Exception("No album artist ! %s" % artistpath) + album.provider_ids.append({ + "provider": self.prov_id, + "item_id": prov_item_id + }) + return album + + async def get_track(self, prov_item_id) -> Track: + ''' get full track details by id ''' + if not os.path.isfile(prov_item_id): + LOGGER.error("track path does not exist: %s" % prov_item_id) + return None + return await self.__parse_track(prov_item_id) + + async def get_playlist(self, prov_item_id) -> Playlist: + ''' get full playlist details by id ''' + if not os.path.isfile(prov_item_id): + LOGGER.error("playlist path does not exist: %s" % prov_item_id) + return None + filepath = prov_item_id + playlist = Playlist() + playlist.item_id = filepath # temporary id + playlist.name = filepath.split('\\')[-1].split('/')[-1].replace('.m3u', '') + playlist.provider_ids.append({ + "provider": self.prov_id, + "item_id": filepath + }) + playlist.owner = 'disk' + return playlist + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + ''' get album tracks for given album id ''' + result = [] + albumpath = prov_album_id + if not os.path.isdir(albumpath): + LOGGER.error("album path does not exist: %s" % albumpath) + return [] + album = await self.get_album(albumpath) + for filename in os.listdir(albumpath): + filepath = os.path.join(albumpath, filename) + if os.path.isfile(filepath) and not filepath.startswith('.'): + track = await self.__parse_track(filepath) + if track: + track.album = album + result.append(track) + return result + + async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]: + ''' get playlist tracks for given playlist id ''' + tracks = [] + if not os.path.isfile(prov_playlist_id): + LOGGER.error("playlist path does not exist: %s" % prov_playlist_id) + return [] + counter = 0 + with open(prov_playlist_id) as f: + for line in f.readlines(): + line = line.strip() + if line and not line.startswith('#'): + counter += 1 + if counter > offset: + track = await self.__parse_track_from_uri(line) + if track: + tracks.append(track) + if len(tracks) == limit: + break + return tracks + + async def get_artist_albums(self, prov_artist_id) -> List[Album]: + ''' get a list of albums for the given artist ''' + result = [] + artistpath = prov_artist_id + if not os.path.isdir(artistpath): + LOGGER.error("artist path does not exist: %s" % artistpath) + return [] + for dirname in os.listdir(artistpath): + dirpath = os.path.join(artistpath, dirname) + if os.path.isdir(dirpath) and not dirpath.startswith('.'): + album = await self.get_album(dirpath) + if album: + result.append(album) + return result + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + ''' get a list of 10 random tracks as we have no clue about preference ''' + tracks = [] + for album in await self.get_artist_albums(prov_artist_id): + tracks += await self.get_album_tracks(album.item_id) + return tracks[:10] + + async def get_stream_details(self, track_id): + ''' returns the stream details for the given track ''' + track = await self.track(track_id) + import socket + host = socket.gethostbyname(socket.gethostname()) + return { + 'mime_type': 'audio/flac', + 'duration': track.duration, + 'sampling_rate': 44100, + 'bit_depth': 16, + 'url': 'http://%s/stream/file/%s' % (host, track_id) + } + + async def get_stream(self, track_id): + ''' get audio stream for a track ''' + with open(track_id) as f: + while True: + line = f.readline() + if line: + yield line + else: + break + + async def __parse_track(self, filename): + ''' try to parse a track from a filename with taglib ''' + track = Track() + try: + song = taglib.File(filename) + except: + return None # not a media file ? + track.duration = song.length + track.item_id = filename # temporary id + name = song.tags['TITLE'][0] + track.name, track.version = parse_track_title(name) + if "\\" in filename: + albumpath = filename.rsplit("\\",1)[0] + else: + albumpath = filename.rsplit("/",1)[0] + track.album = await self.get_album(albumpath) + artists = [] + for artist_str in song.tags['ARTIST']: + local_artist_path = os.path.join(self._music_dir, artist_str) + if os.path.isfile(local_artist_path): + artist = await self.get_artist(local_artist_path) + else: + artist = Artist() + artist.name = artist_str + fake_artistpath = os.path.join(self._music_dir, artist_str) + artist.item_id = fake_artistpath # temporary id + artist.provider_ids.append({ + "provider": self.prov_id, + "item_id": fake_artistpath + }) + artists.append(artist) + track.artists = artists + if 'GENRE' in song.tags: + track.tags = song.tags['GENRE'] + if 'ISRC' in song.tags: + track.external_ids.append( {"isrc": song.tags['ISRC'][0]} ) + if 'DISCNUMBER' in song.tags: + track.disc_number = int(song.tags['DISCNUMBER'][0]) + if 'TRACKNUMBER' in song.tags: + track.track_number = int(song.tags['TRACKNUMBER'][0]) + if filename.endswith('.flac'): + # TODO: try to get more quality info + quality = TrackQuality.FLAC_LOSSLESS + elif filename.endswith('.ogg'): + quality = TrackQuality.LOSSY_OGG + elif filename.endswith('.m4a'): + quality = TrackQuality.LOSSY_AAC + else: + quality = TrackQuality.LOSSY_MP3 + track.provider_ids.append({ + "provider": self.prov_id, + "item_id": filename, + "quality": quality + }) + return track + + async def __parse_track_from_uri(self, uri): + ''' try to parse a track from an uri found in playlist ''' + if "://" in uri: + # track is uri from external provider? + prov_id = uri.split('://')[0] + prov_item_id = uri.split('/')[-1].split('.')[0].split(':')[-1] + try: + return await self.mass.music.providers[prov_id].track(prov_item_id, lazy=False) + except Exception as exc: + LOGGER.warning("Could not parse uri %s to track: %s" %(uri, str(exc))) + return None + # try to treat uri as filename + # TODO: filename could be related to musicdir or full path + track = await self.get_track(uri) + if track: + return track + track = await self.get_track(os.path.join(self._music_dir, uri)) + if track: + return track + return None + diff --git a/music_assistant/modules/musicproviders/qobuz.py b/music_assistant/modules/musicproviders/qobuz.py new file mode 100644 index 00000000..da5dd2ef --- /dev/null +++ b/music_assistant/modules/musicproviders/qobuz.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import sys +sys.path.append("..") +from utils import run_periodic, LOGGER, parse_track_title +from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED +import json +import aiohttp +import time +import datetime +import hashlib +from asyncio_throttle import Throttler +from cache import use_cache + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["musicproviders"]['qobuz'].get(CONF_ENABLED) + username = mass.config["musicproviders"]['qobuz'].get(CONF_USERNAME) + password = mass.config["musicproviders"]['qobuz'].get(CONF_PASSWORD) + if enabled and username and password: + spotify_provider = QobuzProvider(mass, username, password) + return spotify_provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, "", CONF_PASSWORD) + ] + +class QobuzProvider(MusicProvider): + + + def __init__(self, mass, username, password): + self.name = 'Qobuz' + self.prov_id = 'qobuz' + self._cur_user = None + self.mass = mass + self.cache = mass.cache + self.http_session = aiohttp.ClientSession(loop=mass.event_loop) + self.__username = username + self.__password = password + self.__user_auth_token = None + self.__app_id = "285473059" + self.__app_secret = "47249d0eaefa6bf43a959c09aacdbce8" + self.__logged_in = False + self.throttler = Throttler(rate_limit=1, period=0.5) + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [] + } + params = {"query": searchstring, "limit": limit } + if len(media_types) == 1: + # qobuz does not support multiple searchtypes, falls back to all if no type given + if media_types[0] == MediaType.Artist: + params["type"] = "artists" + if media_types[0] == MediaType.Album: + params["type"] = "albums" + if media_types[0] == MediaType.Track: + params["type"] = "tracks" + if media_types[0] == MediaType.Playlist: + params["type"] = "playlists" + searchresult = await self.__get_data("catalog/search", params) + if searchresult: + if "artists" in searchresult: + for item in searchresult["artists"]["items"]: + artist = await self.__parse_artist(item) + if artist: + result["artists"].append(artist) + if "albums" in searchresult: + for item in searchresult["albums"]["items"]: + album = await self.__parse_album(item) + if album: + result["albums"].append(album) + if "tracks" in searchresult: + for item in searchresult["tracks"]["items"]: + track = await self.__parse_track(item) + if track: + result["tracks"].append(track) + if "playlists" in searchresult: + for item in searchresult["playlists"]["items"]: + result["playlists"].append(await self.__parse_playlist(item)) + return result + + async def get_library_artists(self) -> List[Artist]: + ''' retrieve library artists from qobuz ''' + result = [] + params = {'type': 'artists'} + for item in await self.__get_all_items("favorite/getUserFavorites", params, key='artists'): + artist = await self.__parse_artist(item) + if artist: + result.append(artist) + return result + + async def get_library_albums(self) -> List[Album]: + ''' retrieve library albums from qobuz ''' + result = [] + params = {'type': 'albums'} + for item in await self.__get_all_items("favorite/getUserFavorites", params, key='albums'): + album = await self.__parse_album(item) + if album: + result.append(album) + return result + + async def get_library_tracks(self) -> List[Track]: + ''' retrieve library tracks from qobuz ''' + result = [] + params = {'type': 'tracks'} + for item in await self.__get_all_items("favorite/getUserFavorites", params, key='tracks'): + track = await self.__parse_track(item) + if track: + result.append(track) + return result + + async def get_library_playlists(self) -> List[Playlist]: + ''' retrieve playlists from the provider ''' + result = [] + for item in await self.__get_all_items("playlist/getUserPlaylists", key='playlists'): + playlist = await self.__parse_playlist(item) + if playlist: + result.append(playlist) + return result + + async def get_artist(self, prov_artist_id) -> Artist: + ''' get full artist details by id ''' + params = {'artist_id': prov_artist_id} + artist_obj = await self.__get_data("artist/get", params) + return await self.__parse_artist(artist_obj) + + async def get_album(self, prov_album_id) -> Album: + ''' get full album details by id ''' + params = {'album_id': prov_album_id} + album_obj = await self.__get_data("album/get", params) + return await self.__parse_album(album_obj) + + async def get_track(self, prov_track_id) -> Track: + ''' get full track details by id ''' + params = {'track_id': prov_track_id} + track_obj = await self.__get_data("track/get", params) + return await self.__parse_track(track_obj) + + async def get_playlist(self, prov_playlist_id) -> Playlist: + ''' get full playlist details by id ''' + params = {'playlist_id': prov_playlist_id} + playlist_obj = await self.__get_data("playlist/get", params) + return await self.__parse_playlist(playlist_obj) + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + ''' get album tracks for given album id ''' + params = {'album_id': prov_album_id} + track_objs = await self.__get_all_items("album/get", params, key='tracks') + tracks = [] + for track_obj in track_objs: + track = await self.__parse_track(track_obj) + if track: + tracks.append(track) + return tracks + + async def get_playlist_tracks(self, prov_playlist_id, limit=100, offset=0) -> List[Track]: + ''' get playlist tracks for given playlist id ''' + playlist_obj = await self.__get_data("playlist/get?playlist_id=%s" % prov_playlist_id, ignore_cache=True) + cache_checksum = playlist_obj["updated_at"] + params = {'playlist_id': prov_playlist_id, 'extra': 'tracks'} + track_objs = await self.__get_all_items("playlist/get", params, key='tracks', limit=limit, offset=offset, cache_checksum=cache_checksum) + tracks = [] + for track_obj in track_objs: + playlist_track = await self.__parse_track(track_obj) + if playlist_track: + tracks.append(playlist_track) + return tracks + + async def get_artist_albums(self, prov_artist_id, limit=100, offset=0) -> List[Album]: + ''' get a list of albums for the given artist ''' + params = {'artist_id': prov_artist_id, 'extra': 'albums', 'limit': limit, 'offset': offset} + result = await self.__get_data('artist/get', params) + albums = [] + for item in result['albums']['items']: + if item["streamable"] and item['artist']['id'] == int(prov_artist_id): + album = await self.__parse_album(item) + if album: + albums.append(album) + return albums + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + ''' get a list of most popular tracks for the given artist ''' + # artist toptracks not supported on Qobuz + return [] + + async def add_library(self, prov_item_id, media_type:MediaType): + ''' add item to library ''' + if media_type == MediaType.Artist: + result = await self.__get_data('favorite/create', {'artist_ids': prov_item_id}) + item = await self.artist(prov_item_id) + elif media_type == MediaType.Album: + result = await self.__get_data('favorite/create', {'album_ids': prov_item_id}) + item = await self.album(prov_item_id) + elif media_type == MediaType.Track: + result = await self.__get_data('favorite/create', {'track_ids': prov_item_id}) + item = await self.track(prov_item_id) + await self.mass.db.add_to_library(item.item_id, media_type, self.prov_id) + LOGGER.debug("added item %s to %s - %s" %(prov_item_id, self.prov_id, result)) + + async def remove_library(self, prov_item_id, media_type:MediaType): + ''' remove item from library ''' + if media_type == MediaType.Artist: + result = await self.__get_data('favorite/delete', {'artist_ids': prov_item_id}) + item = await self.artist(prov_item_id) + elif media_type == MediaType.Album: + result = await self.__get_data('favorite/delete', {'album_ids': prov_item_id}) + item = await self.album(prov_item_id) + elif media_type == MediaType.Track: + result = await self.__get_data('favorite/delete', {'track_ids': prov_item_id}) + item = await self.track(prov_item_id) + await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id) + LOGGER.debug("deleted item %s from %s - %s" %(prov_item_id, self.prov_id, result)) + + async def get_stream_details(self, track_id): + ''' returns the stream details for the provider ''' + params = {'format_id': 27, 'track_id': track_id, 'intent': 'stream'} + return await self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True) + + async def get_stream(self, track_id): + ''' get audio stream for a track ''' + track_details = await self.get_stream_details(track_id) + url = track_details['url'] + async with self.http_session.get(url) as response: + while True: + chunk = await response.content.read(262144) + if not chunk: + LOGGER.debug('end of stream') + break + yield chunk + + async def __parse_artist(self, artist_obj): + ''' parse spotify artist object to generic layout ''' + artist = Artist() + if not artist_obj.get('id'): + return None + artist.item_id = artist_obj['id'] # temporary id + artist.provider_ids.append({ + "provider": self.prov_id, + "item_id": artist_obj['id'] + }) + artist.name = artist_obj['name'] + if artist_obj.get('image'): + for key in ['extralarge', 'large', 'medium', 'small']: + if artist_obj['image'].get(key): + if not '2a96cbd8b46e442fc41c2b86b821562f' in artist_obj['image'][key]: + artist.metadata["image"] = artist_obj['image'][key] + break + if artist_obj.get('biography'): + artist.metadata["biography"] = artist_obj['biography'].get('content','') + if artist_obj.get('url'): + artist.metadata["qobuz_url"] = artist_obj['url'] + return artist + + async def __parse_album(self, album_obj): + ''' parse spotify album object to generic layout ''' + album = Album() + if not album_obj.get('id') or not album_obj["streamable"] or not album_obj["displayable"]: + # some safety checks + LOGGER.warning("invalid/unavailable album found: %s" % album_obj.get('id')) + return None + album.item_id = album_obj['id'] # temporary id + album.provider_ids.append({ + "provider": self.prov_id, + "item_id": album_obj['id'], + "details": "%skHz %sbit" %(album_obj['maximum_sampling_rate'], album_obj['maximum_bit_depth']) + }) + album.name, album.version = parse_track_title(album_obj['title']) + album.artist = await self.__parse_artist(album_obj['artist']) + if not album.artist: + raise Exception("No album artist ! %s" % album_obj) + if album_obj.get('product_type','') == 'single': + album.albumtype = AlbumType.Single + elif album_obj.get('product_type','') == 'compilation' or 'Various' in album_obj['artist']['name']: + album.albumtype = AlbumType.Compilation + else: + album.albumtype = AlbumType.Album + if 'genre' in album_obj: + album.tags = [album_obj['genre']['name']] + if album_obj.get('image'): + for key in ['extralarge', 'large', 'medium', 'small']: + if album_obj['image'].get(key): + album.metadata["image"] = album_obj['image'][key] + break + album.external_ids.append({ "upc": album_obj['upc'] }) + if 'label' in album_obj: + album.labels = album_obj['label']['name'].split('/') + if album_obj.get('released_at'): + album.year = datetime.datetime.fromtimestamp(album_obj['released_at']).year + if album_obj.get('copyright'): + album.metadata["copyright"] = album_obj['copyright'] + if album_obj.get('hires'): + album.metadata["hires"] = "true" + if album_obj.get('url'): + album.metadata["qobuz_url"] = album_obj['url'] + if album_obj.get('description'): + album.metadata["description"] = album_obj['description'] + return album + + async def __parse_track(self, track_obj): + ''' parse spotify track object to generic layout ''' + track = Track() + if not track_obj.get('id') or not track_obj["streamable"] or not track_obj["displayable"]: + # some safety checks + LOGGER.warning("invalid/unavailable track found: %s" % track_obj.get('id')) + return None + track.item_id = track_obj['id'] # temporary id + if track_obj.get('performer') and not 'Various ' in track_obj['performer']: + artist = await self.__parse_artist(track_obj['performer']) + track.artists.append(artist) + if not track.artists: + # try to grab artist from album + if track_obj.get('album') and track_obj['album'].get('artist') and not 'Various ' in track_obj['album']['artist']: + artist = await self.__parse_artist(track_obj['album']['artist']) + if artist: + track.artists.append(artist) + if not track.artists: + # last resort: parse from performers string + for performer_str in track_obj['performers'].split(' - '): + role = performer_str.split(', ')[1] + name = performer_str.split(', ')[0] + if 'artist' in role.lower(): + artist = Artist() + artist.name = name + artist.item_id = name + track.artists.append(artist) + # TODO: fix grabbing composer from details + track.name, track.version = parse_track_title(track_obj['title']) + if not track.version and track_obj['version']: + track.version = track_obj['version'] + track.duration = track_obj['duration'] + if 'album' in track_obj: + album = await self.__parse_album(track_obj['album']) + if album: + track.album = album + track.disc_number = track_obj['media_number'] + track.track_number = track_obj['track_number'] + if track_obj.get('hires'): + track.metadata["hires"] = "true" + if track_obj.get('url'): + track.metadata["qobuz_url"] = track_obj['url'] + if track_obj.get('isrc'): + track.external_ids.append({ + "isrc": track_obj['isrc'] + }) + if track_obj.get('performers'): + track.metadata["performers"] = track_obj['performers'] + if track_obj.get('copyright'): + track.metadata["copyright"] = track_obj['copyright'] + # get track quality + if track_obj['maximum_sampling_rate'] > 192: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4 + elif track_obj['maximum_sampling_rate'] > 96: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3 + elif track_obj['maximum_sampling_rate'] > 48: + quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2 + elif track_obj['maximum_bit_depth'] > 16: + quality = TrackQuality.FLAC_LOSSLES_HI_RES_1 + elif track_obj.get('format_id',0) == 5: + quality = TrackQuality.LOSSY_AAC + else: + quality = TrackQuality.FLAC_LOSSLESS + track.provider_ids.append({ + "provider": self.prov_id, + "item_id": track_obj['id'], + "quality": quality, + "details": "%skHz %sbit" %(track_obj['maximum_sampling_rate'], track_obj['maximum_bit_depth']) + }) + return track + + async def __parse_playlist(self, playlist_obj): + ''' parse spotify playlist object to generic layout ''' + playlist = Playlist() + if not playlist_obj.get('id'): + return None + playlist.item_id = playlist_obj['id'] # temporary id + playlist.provider_ids.append({ + "provider": self.prov_id, + "item_id": playlist_obj['id'] + }) + playlist.name = playlist_obj['name'] + playlist.owner = playlist_obj['owner']['name'] + if playlist_obj.get('images300'): + playlist.metadata["image"] = playlist_obj['images300'][0] + if playlist_obj.get('url'): + playlist.metadata["qobuz_url"] = playlist_obj['url'] + return playlist + + async def __auth_token(self): + ''' login to qobuz and store the token''' + if self.__user_auth_token: + return self.__user_auth_token + params = { "username": self.__username, "password": self.__password} + details = await self.__get_data("user/login", params, ignore_cache=True) + self.__user_auth_token = details["user_auth_token"] + LOGGER.info("Succesfully logged in to Qobuz as %s" % (details["user"]["display_name"])) + return details["user_auth_token"] + + async def __get_all_items(self, endpoint, params={}, key="playlists", limit=0, offset=0, cache_checksum=None): + ''' get all items from a paged list ''' + if not cache_checksum: + params["limit"] = 1 + params["offset"] = 0 + cache_checksum = await self.__get_data(endpoint, params, ignore_cache=True) + cache_checksum = cache_checksum[key]["total"] + if limit: + # partial listing + params["limit"] = limit + params["offset"] = offset + result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) + return result[key]["items"] + else: + # full listing + offset = 0 + total_items = 1 + count = 0 + items = [] + while count < total_items: + params["limit"] = 200 + params["offset"] = offset + result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) + if result and key in result: + total_items = result[key]["total"] + offset += 200 + count += len(result[key]["items"]) + items += result[key]["items"] + else: + LOGGER.error("failed to retrieve items for %s (%s) --> %s" %(endpoint, params, result)) + break + return items + + @use_cache(7) + async def __get_data(self, endpoint, params={}, sign_request=False, ignore_cache=False, cache_checksum=None): + ''' get data from api''' + url = "http://www.qobuz.com/api.json/0.2/%s" % endpoint + headers = {"X-App-Id": self.__app_id} + if endpoint != 'user/login': + headers["X-User-Auth-Token"] = await self.__auth_token() + if sign_request: + signing_data = "".join(endpoint.split('/')) + keys = list(params.keys()) + keys.sort() + for key in keys: + signing_data += "%s%s" %(key, params[key]) + request_ts = str(time.time()) + request_sig = signing_data + request_ts + self.__app_secret + request_sig = str(hashlib.md5(request_sig.encode()).hexdigest()) + params["request_ts"] = request_ts + params["request_sig"] = request_sig + params["app_id"] = self.__app_id + params["user_auth_token"] = self.__user_auth_token + + async with self.http_session.get(url, headers=headers, params=params) as response: + result = await response.json() + if 'error' in result: + LOGGER.error(url) + LOGGER.error(params) + LOGGER.error(result) + result = None + result = await response.json() + return result + diff --git a/music_assistant/modules/musicproviders/spotify.py b/music_assistant/modules/musicproviders/spotify.py new file mode 100644 index 00000000..679da482 --- /dev/null +++ b/music_assistant/modules/musicproviders/spotify.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import sys +import time +sys.path.append("..") +from utils import run_periodic, LOGGER, parse_track_title +from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED +import json +import aiohttp +from cache import use_cache + + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["musicproviders"]['spotify'].get(CONF_ENABLED) + username = mass.config["musicproviders"]['spotify'].get(CONF_USERNAME) + password = mass.config["musicproviders"]['spotify'].get(CONF_PASSWORD) + if enabled and username and password: + spotify_provider = SpotifyProvider(mass, username, password) + return spotify_provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, "", CONF_PASSWORD) + ] + +class SpotifyProvider(MusicProvider): + + + def __init__(self, mass, username, password): + self.name = 'Spotify' + self.prov_id = 'spotify' + self._cur_user = None + self.mass = mass + self.cache = mass.cache + self.http_session = aiohttp.ClientSession(loop=mass.event_loop) + self._username = username + self._password = password + self.__auth_token = {} + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [] + } + searchtypes = [] + if MediaType.Artist in media_types: + searchtypes.append("artist") + if MediaType.Album in media_types: + searchtypes.append("album") + if MediaType.Track in media_types: + searchtypes.append("track") + if MediaType.Playlist in media_types: + searchtypes.append("playlist") + searchtype = ",".join(searchtypes) + params = {"q": searchstring, "type": searchtype, "limit": limit } + searchresult = await self.__get_data("search", params=params, cache_checksum="bla") + if searchresult: + if "artists" in searchresult: + for item in searchresult["artists"]["items"]: + artist = await self.__parse_artist(item) + if artist: + result["artists"].append(artist) + if "albums" in searchresult: + for item in searchresult["albums"]["items"]: + album = await self.__parse_album(item) + if album: + result["albums"].append(album) + if "tracks" in searchresult: + for item in searchresult["tracks"]["items"]: + track = await self.__parse_track(item) + if track: + result["tracks"].append(track) + if "playlists" in searchresult: + for item in searchresult["playlists"]["items"]: + playlist = await self.__parse_playlist(item) + if playlist: + result["playlists"].append(playlist) + return result + + async def get_library_artists(self) -> List[Artist]: + ''' retrieve library artists from spotify ''' + items = [] + spotify_artists = await self.__get_data("me/following?type=artist&limit=50") + if spotify_artists: + # TODO: use cursor method to retrieve more than 50 artists + for artist_obj in spotify_artists['artists']['items']: + prov_artist = await self.__parse_artist(artist_obj) + items.append(prov_artist) + return items + + async def get_library_albums(self) -> List[Album]: + ''' retrieve library albums from the provider ''' + result = [] + for item in await self.__get_all_items("me/albums"): + album = await self.__parse_album(item) + if album: + result.append(album) + return result + + async def get_library_tracks(self) -> List[Track]: + ''' retrieve library tracks from the provider ''' + result = [] + for item in await self.__get_all_items("me/tracks"): + track = await self.__parse_track(item) + if track: + result.append(track) + return result + + async def get_library_playlists(self) -> List[Playlist]: + ''' retrieve playlists from the provider ''' + result = [] + for item in await self.__get_all_items("me/playlists"): + playlist = await self.__parse_playlist(item) + if playlist: + result.append(playlist) + return result + + async def get_artist(self, prov_artist_id) -> Artist: + ''' get full artist details by id ''' + artist_obj = await self.__get_data("artists/%s" % prov_artist_id) + return await self.__parse_artist(artist_obj) + + async def get_album(self, prov_album_id) -> Album: + ''' get full album details by id ''' + album_obj = await self.__get_data("albums/%s" % prov_album_id) + return await self.__parse_album(album_obj) + + async def get_track(self, prov_track_id) -> Track: + ''' get full track details by id ''' + track_obj = await self.__get_data("tracks/%s" % prov_track_id) + return await self.__parse_track(track_obj) + + async def get_playlist(self, prov_playlist_id) -> Playlist: + ''' get full playlist details by id ''' + playlist_obj = await self.__get_data("playlists/%s" % prov_playlist_id, ignore_cache=True) + return await self.__parse_playlist(playlist_obj) + + async def get_album_tracks(self, prov_album_id) -> List[Track]: + ''' get album tracks for given album id ''' + track_objs = await self.__get_all_items("albums/%s/tracks" % prov_album_id) + tracks = [] + for track_obj in track_objs: + track = await self.__parse_track(track_obj) + if track: + tracks.append(track) + return tracks + + async def get_playlist_tracks(self, prov_playlist_id, limit=50, offset=0) -> List[Track]: + ''' get playlist tracks for given playlist id ''' + playlist_obj = await self.__get_data("playlists/%s?fields=snapshot_id" % prov_playlist_id, ignore_cache=True) + cache_checksum = playlist_obj["snapshot_id"] + track_objs = await self.__get_all_items("playlists/%s/tracks" % prov_playlist_id, limit=limit, offset=offset, cache_checksum=cache_checksum) + tracks = [] + for track_obj in track_objs: + playlist_track = await self.__parse_track(track_obj) + if playlist_track: + tracks.append(playlist_track) + return tracks + + async def get_artist_albums(self, prov_artist_id) -> List[Album]: + ''' get a list of albums for the given artist ''' + params = {'include_groups': 'album,single,compilation'} + items = await self.__get_all_items('artists/%s/albums' % prov_artist_id, params) + albums = [] + for item in items: + album = await self.__parse_album(item) + if album: + albums.append(album) + return albums + + async def get_artist_toptracks(self, prov_artist_id) -> List[Track]: + ''' get a list of 10 most popular tracks for the given artist ''' + items = await self.__get_data('artists/%s/top-tracks' % prov_artist_id) + tracks = [] + for item in items['tracks']: + track = await self.__parse_track(item) + if track: + tracks.append(track) + return tracks + + async def add_library(self, prov_item_id, media_type:MediaType): + ''' add item to library ''' + if media_type == MediaType.Artist: + result = await self.__put_data('me/following', {'ids': prov_item_id, 'type': 'artist'}) + item = await self.artist(prov_item_id) + elif media_type == MediaType.Album: + result = await self.__put_data('me/albums', {'ids': prov_item_id}) + item = await self.album(prov_item_id) + elif media_type == MediaType.Track: + result = await self.__put_data('me/tracks', {'ids': prov_item_id}) + item = await self.track(prov_item_id) + await self.mass.db.add_to_library(item.item_id, media_type, self.prov_id) + LOGGER.debug("added item %s to %s - %s" %(prov_item_id, self.prov_id, result)) + + async def remove_library(self, prov_item_id, media_type:MediaType): + ''' remove item from library ''' + if media_type == MediaType.Artist: + result = await self.__delete_data('me/following', {'ids': prov_item_id, 'type': 'artist'}) + item = await self.artist(prov_item_id) + elif media_type == MediaType.Album: + result = await self.__delete_data('me/albums', {'ids': prov_item_id}) + item = await self.album(prov_item_id) + elif media_type == MediaType.Track: + result = await self.__delete_data('me/tracks', {'ids': prov_item_id}) + item = await self.track(prov_item_id) + await self.mass.db.remove_from_library(item.item_id, media_type, self.prov_id) + LOGGER.debug("deleted item %s from %s - %s" %(prov_item_id, self.prov_id, result)) + + async def devices(self): + ''' list all available devices ''' + items = await self.__get_data('me/player/devices') + return items['devices'] + + async def play_media(self, device_id, uri, offset_pos=None, offset_uri=None): + ''' play uri on spotify device''' + opts = {} + if isinstance(uri, list): + opts['uris'] = uri + elif uri.startswith('spotify:track'): + opts['uris'] = [uri] + else: + opts['context_uri'] = uri + if offset_pos != None: # only for playlists/albums! + opts["offset"] = {"position": offset_pos } + elif offset_uri != None: # only for playlists/albums! + opts["offset"] = {"uri": offset_uri } + return await self.__put_data('me/player/play', {"device_id": device_id}, opts) + + async def get_stream_details(self, track_id): + ''' returns the stream details for the provider ''' + track = await self.track(track_id) + import socket + host = socket.gethostbyname(socket.gethostname()) + return { + 'mime_type': 'audio/ogg', + 'duration': track.duration, + 'sampling_rate': 44100, + 'bit_depth': 16, + 'url': 'http://%s/stream/spotify/%s' % (host, track_id) + } + + async def get_stream(self, track_id): + ''' get audio stream for a track ''' + import subprocess + spotty = self.get_spotty_binary() + cmd = [spotty, '-n', 'temp', '-u', self._username, '-p', self._password, '--pass-through', '--single-track', track_id] + process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE) + while not process.stdout.at_eof(): + line = await process.stdout.readline() + if line: + yield line + await process.wait() + + async def __parse_artist(self, artist_obj): + ''' parse spotify artist object to generic layout ''' + artist = Artist() + artist.item_id = artist_obj['id'] # temporary id + artist.provider_ids.append({ + "provider": self.prov_id, + "item_id": artist_obj['id'] + }) + artist.name = artist_obj['name'] + if 'genres' in artist_obj: + artist.tags = artist_obj['genres'] + if artist_obj.get('images'): + for img in artist_obj['images']: + img_url = img['url'] + if not '2a96cbd8b46e442fc41c2b86b821562f' in img_url: + artist.metadata["image"] = img_url + break + if artist_obj.get('external_urls'): + artist.metadata["spotify_url"] = artist_obj['external_urls']['spotify'] + return artist + + async def __parse_album(self, album_obj): + ''' parse spotify album object to generic layout ''' + if 'album' in album_obj: + album_obj = album_obj['album'] + if not album_obj['id'] or album_obj.get('is_playable') == False: + return None + album = Album() + album.item_id = album_obj['id'] # temporary id + album.name, album.version = parse_track_title(album_obj['name']) + for artist in album_obj['artists']: + album.artist = await self.__parse_artist(artist) + if album.artist: + break + if not album.artist: + raise Exception("No album artist ! %s" % album_obj) + if album_obj['album_type'] == 'single': + album.albumtype = AlbumType.Single + elif album_obj['album_type'] == 'compilation': + album.albumtype = AlbumType.Compilation + else: + album.albumtype = AlbumType.Album + if 'genres' in album_obj: + album.tags = album_obj['genres'] + if album_obj.get('images'): + album.metadata["image"] = album_obj['images'][0]['url'] + if 'external_ids' in album_obj: + for key, value in album_obj['external_ids'].items(): + album.external_ids.append( { key: value } ) + if 'label' in album_obj: + album.labels = album_obj['label'].split('/') + if album_obj.get('release_date'): + album.year = int(album_obj['release_date'].split('-')[0]) + if album_obj.get('copyrights'): + album.metadata["copyright"] = album_obj['copyrights'][0]['text'] + if album_obj.get('external_urls'): + album.metadata["spotify_url"] = album_obj['external_urls']['spotify'] + if album_obj.get('explicit'): + album.metadata['explicit'] = str(album_obj['explicit']).lower() + album.provider_ids.append({ + "provider": self.prov_id, + "item_id": album_obj['id'] + }) + return album + + async def __parse_track(self, track_obj): + ''' parse spotify track object to generic layout ''' + if 'track' in track_obj: + track_obj = track_obj['track'] + if track_obj['is_local'] or not track_obj['id'] or not track_obj['is_playable']: + return None + track = Track() + track.item_id = track_obj['id'] # temporary id + for track_artist in track_obj['artists']: + artist = await self.__parse_artist(track_artist) + if artist: + track.artists.append(artist) + track.name, track.version = parse_track_title(track_obj['name']) + track.duration = track_obj['duration_ms'] / 1000 + track.metadata['explicit'] = str(track_obj['explicit']).lower() + if not track.version and track_obj['explicit']: + track.version = 'Explicit' + if 'external_ids' in track_obj: + for key, value in track_obj['external_ids'].items(): + track.external_ids.append( { key: value } ) + if 'album' in track_obj: + track.album = await self.__parse_album(track_obj['album']) + if track_obj.get('copyright'): + track.metadata["copyright"] = track_obj['copyright'] + track.disc_number = track_obj['disc_number'] + track.track_number = track_obj['track_number'] + if track_obj.get('external_urls'): + track.metadata["spotify_url"] = track_obj['external_urls']['spotify'] + track.provider_ids.append({ + "provider": self.prov_id, + "item_id": track_obj['id'], + "quality": TrackQuality.LOSSY_OGG + }) + return track + + async def __parse_playlist(self, playlist_obj): + ''' parse spotify playlist object to generic layout ''' + playlist = Playlist() + if not playlist_obj.get('id'): + return None + playlist.item_id = playlist_obj['id'] # temporary id + playlist.provider_ids.append({ + "provider": self.prov_id, + "item_id": playlist_obj['id'] + }) + playlist.name = playlist_obj['name'] + playlist.owner = playlist_obj['owner']['display_name'] + if playlist_obj.get('images'): + playlist.metadata["image"] = playlist_obj['images'][0]['url'] + if playlist_obj.get('external_urls'): + playlist.metadata["spotify_url"] = playlist_obj['external_urls']['spotify'] + return playlist + + async def get_token(self): + ''' get auth token on spotify ''' + # return existing token if we have one in memory + if self.__auth_token and (self.__auth_token['expiresAt'] > int(time.time()) + 20): + return self.__auth_token + tokeninfo = {} + if not self._username or not self._password: + return tokeninfo + # try with spotipy-token module first, fallback to spotty + try: + import spotify_token as st + data = st.start_session(self._username, self._password) + if data and len(data) == 2: + tokeninfo = {"accessToken": data[0], "expiresIn": data[1] - int(time.time()), "expiresAt":data[1] } + except Exception as exc: + LOGGER.exception(exc) + if not tokeninfo: + # fallback to spotty approach + import subprocess + scopes = [ + "user-read-playback-state", + "user-read-currently-playing", + "user-modify-playback-state", + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-public", + "playlist-modify-private", + "user-follow-modify", + "user-follow-read", + "user-library-read", + "user-library-modify", + "user-read-private", + "user-read-email", + "user-read-birthdate", + "user-top-read"] + scope = ",".join(scopes) + clientid = '2eb96f9b37494be1824999d58028a305' + args = [self.get_spotty_binary(), "-t", "--client-id", clientid, "--scope", scope, "-n", "temp-spotty", "-u", self._username, "-p", self._password, "--disable-discovery"] + spotty = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + stdout, stderr = spotty.communicate() + result = json.loads(stdout) + # transform token info to spotipy compatible format + if result and "accessToken" in result: + tokeninfo = result + tokeninfo['expiresAt'] = tokeninfo['expiresIn'] + int(time.time()) + if tokeninfo: + self.__auth_token = tokeninfo + self.sp_user = await self.__get_data("me") + LOGGER.info("Succesfully logged in to Spotify as %s" % self.sp_user["id"]) + self.__auth_token = tokeninfo + else: + raise Exception("Can't get Spotify token for user %s" % self._username) + return tokeninfo + + async def __get_all_items(self, endpoint, params={}, limit=0, offset=0, cache_checksum=None): + ''' get all items from a paged list ''' + if not cache_checksum: + params["limit"] = 1 + params["offset"] = 0 + cache_checksum = await self.__get_data(endpoint, params, ignore_cache=True) + cache_checksum = cache_checksum["total"] + if limit: + # partial listing + params["limit"] = limit + params["offset"] = offset + result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) + return result["items"] + else: + # full listing + total_items = 1 + count = 0 + items = [] + while count < total_items: + params["limit"] = 50 + params["offset"] = offset + result = await self.__get_data(endpoint, params=params, cache_checksum=cache_checksum) + total_items = result["total"] + offset += 50 + count += len(result["items"]) + items += result["items"] + return items + + @use_cache(7) + async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None): + ''' get data from api''' + url = 'https://api.spotify.com/v1/%s' % endpoint + params['market'] = 'from_token' + params['country'] = 'from_token' + token = await self.get_token() + headers = {'Authorization': 'Bearer %s' % token["accessToken"]} + async with self.http_session.get(url, headers=headers, params=params) as response: + result = await response.json() + if 'error' in result: + LOGGER.error(url) + LOGGER.error(params) + raise Exception(result['error']) + return result + + async def __delete_data(self, endpoint, params={}): + ''' get data from api''' + url = 'https://api.spotify.com/v1/%s' % endpoint + token = await self.get_token() + headers = {'Authorization': 'Bearer %s' % token["accessToken"]} + async with self.http_session.delete(url, headers=headers, params=params) as response: + return await response.text() + + async def __put_data(self, endpoint, params={}, data=None): + ''' put data on api''' + url = 'https://api.spotify.com/v1/%s' % endpoint + token = await self.get_token() + headers = {'Authorization': 'Bearer %s' % token["accessToken"]} + async with self.http_session.put(url, headers=headers, params=params, json=data) as response: + return await response.text() + + @staticmethod + def get_spotty_binary(): + '''find the correct spotty binary belonging to the platform''' + import platform + sp_binary = None + if platform.system() == "Windows": + sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "windows", "spotty.exe") + elif platform.system() == "Darwin": + # macos binary is x86_64 intel + sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "darwin", "spotty") + elif platform.system() == "Linux": + # try to find out the correct architecture by trial and error + architecture = platform.machine() + if architecture.startswith('AMD64') or architecture.startswith('x86_64'): + # generic linux x86_64 binary + sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "x86-linux", "spotty-x86_64") + else: + sp_binary = os.path.join(os.path.dirname(__file__), "spotty", "arm-linux", "spotty-muslhf") + return sp_binary + + diff --git a/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf b/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf new file mode 100755 index 00000000..c928d8a8 Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf differ diff --git a/music_assistant/modules/musicproviders/spotty/darwin/spotty b/music_assistant/modules/musicproviders/spotty/darwin/spotty new file mode 100755 index 00000000..44c6b604 Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/darwin/spotty differ diff --git a/music_assistant/modules/musicproviders/spotty/windows/spotty.exe b/music_assistant/modules/musicproviders/spotty/windows/spotty.exe new file mode 100755 index 00000000..6ce9b19e Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/windows/spotty.exe differ diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty new file mode 100755 index 00000000..b2c3f349 Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty differ diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 new file mode 100755 index 00000000..58911cf5 Binary files /dev/null and b/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 differ diff --git a/music_assistant/modules/playerproviders/__init__.py b/music_assistant/modules/playerproviders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/modules/playerproviders/chromecast.py new file mode 100644 index 00000000..569a68f1 --- /dev/null +++ b/music_assistant/modules/playerproviders/chromecast.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import random +import sys +sys.path.append("..") +from utils import run_periodic, run_background_task, LOGGER, parse_track_title, try_parse_int +from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT +import json +import aiohttp +import time +import datetime +import hashlib +from asyncio_throttle import Throttler +from aiocometd import Client, ConnectionType, Extension +from cache import use_cache +import copy +import pychromecast +from pychromecast.controllers.multizone import MultizoneController +from pychromecast.controllers import BaseController +from pychromecast.controllers.spotify import SpotifyController +import logging +logging.getLogger("pychromecast").setLevel(logging.WARNING) + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["playerproviders"]['chromecast'].get(CONF_ENABLED) + if enabled: + provider = ChromecastProvider(mass) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, True, CONF_ENABLED), + ] + +class ChromecastProvider(PlayerProvider): + ''' support for Home Assistant ''' + + def __init__(self, mass): + self.prov_id = 'chromecast' + self.name = 'Chromecast' + self.icon = '' + self.mass = mass + self._players = {} + self.supports_queue = False + self.supports_http_stream = True + self.supported_musicproviders = [ + ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]), + ('http', [MediaType.Track]) + ] + self.http_session = aiohttp.ClientSession(loop=mass.event_loop) + asyncio.ensure_future(self.__discover_chromecasts()) + + + ### Provider specific implementation ##### + + async def player_command(self, player_id, cmd:str, cmd_args=None): + ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' + if cmd == 'play': + self._players[player_id].cast.media_controller.play() + elif cmd == 'pause': + self._players[player_id].cast.media_controller.pause() + elif cmd == 'stop': + self._players[player_id].cast.media_controller.stop() + elif cmd == 'next': + self._players[player_id].cast.media_controller.queue_next() + elif cmd == 'previous': + self._players[player_id].cast.media_controller.queue_previous() + elif cmd == 'power' and cmd_args in ['on', '1', 1]: + # power is not supported + self._players[player_id].state = PlayerState.Stopped + self._players[player_id].cast.media_controller.play() + elif cmd == 'power' and cmd_args in ['off', '0', 0]: + # power is not supported + self._players[player_id].state = PlayerState.Off + self._players[player_id].cast.media_controller.stop() + elif cmd == 'volume': + self._players[player_id].cast.set_volume(try_parse_int(cmd_args)/100) + elif cmd == 'mute' and cmd_args in ['on', '1', 1]: + self._players[player_id].cast.set_volume_muted(True) + elif cmd == 'mute' and cmd_args in ['off', '0', 0]: + self._players[player_id].cast.set_volume_muted(False) + + async def play_media(self, player_id, uri, queue_opt='play'): + ''' + play media on a player + params: + - player_id: id of the player + - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream) + - queue_opt: + replace: replace whatever is currently playing with this media + next: the given media will be played after the currently playing track + add: add to the end of the queue + play: keep existing queue but play the given item now + ''' + if uri.startswith('spotify:'): + # native spotify playback + uri = uri.replace('spotify://', '') + from pychromecast.controllers.spotify import SpotifyController + spotify = self.mass.music.providers['spotify'] + token = await spotify.get_token() + sp = SpController(token['accessToken'], token['expiresIn']) + self._players[player_id].cast.register_handler(sp) + sp.launch_app() + spotify_player_id = sp.device + if spotify_player_id: + return await spotify.play_media(spotify_player_id, uri) + else: + LOGGER.error('player not found in spotify! %s' % player_id) + elif uri.startswith('http'): + self._players[player_id].cast.media_controller.play_media(uri, 'audio/flac') + else: + raise Exception("Not supported media_type or uri") + + ### Provider specific (helper) methods ##### + + async def __handle_player_state(self, chromecast, caststatus=None, mediastatus=None): + ''' handle a player state message from the socket ''' + player_id = str(chromecast.uuid) + player = self._players[player_id] + # always update player details that may change + player.name = chromecast.name + if caststatus: + player.muted = caststatus.volume_muted + player.volume_level = caststatus.volume_level * 100 + player.powered = not caststatus.is_stand_by + if mediastatus: + if mediastatus.player_state in ['PLAYING', 'BUFFERING']: + player.state = PlayerState.Playing + elif mediastatus.player_state == 'PAUSED': + player.state = PlayerState.Paused + else: + player.state = PlayerState.Stopped + player.cur_item = await self.__parse_track(mediastatus) + player.cur_item_time = try_parse_int(mediastatus.current_time) + await self.mass.player.update_player(player) + + async def __parse_track(self, mediastatus): + ''' parse track in CC to our internal format ''' + if mediastatus.content_type == 'application/x-spotify.track': + track_id = mediastatus.content_id.replace('spotify:track:','') + track = await self.mass.music.providers['spotify'].track(track_id) + else: + # TODO: match this info manually in the DB!! + track = Track() + artist = mediastatus.artist + album = mediastatus.album_name + title = mediastatus.title + track.name = "%s - %s" %(artist, title) + track.duration = try_parse_int(mediastatus.duration) + if mediastatus.media_metadata and mediastatus.media_metadata.get('images'): + track.metadata.image = mediastatus.media_metadata['images'][-1]['url'] + return track + + async def __handle_group_members_update(self, mz, added_player=None, removed_player=None): + ''' callback when cast group members update ''' + if added_player: + if added_player in self._players: + self._players[added_player].group_parent = str(mz._uuid) + elif removed_player: + if removed_player in self._players: + self._players[removed_player].group_parent = None + else: + for member in mz.members: + if member in self._players: + self._players[member].group_parent = str(mz._uuid) + + @run_periodic(600) + async def __discover_chromecasts(self): + ''' discover chromecasts on the network ''' + LOGGER.info('Starting Chromecast discovery...') + bg_task = run_background_task(self.mass.bg_executor, pychromecast.get_chromecasts) + chromecasts = await asyncio.gather(bg_task) + for chromecast in chromecasts[0]: + player_id = str(chromecast.uuid) + if not player_id in self._players: + player = MusicPlayer() + player.player_id = player_id + player.name = chromecast.name + chromecast.start() + listenerCast = StatusListener(chromecast, self.__handle_player_state, self.mass.event_loop) + chromecast.register_status_listener(listenerCast) + listenerMedia = StatusMediaListener(chromecast, self.__handle_player_state, self.mass.event_loop) + chromecast.media_controller.register_status_listener(listenerMedia) + if chromecast.cast_type == 'group': + player.is_group = True + mz = MultizoneController(chromecast.uuid) + mz.register_listener(MZListener(mz, self.__handle_group_members_update, self.mass.event_loop)) + chromecast.register_handler(mz) + chromecast.register_connection_listener(MZConnListener(mz)) + chromecast.wait() + player.cast = chromecast + player.player_provider = self.prov_id + self._players[player_id] = player + LOGGER.info('Chromecast discovery done...') + + +class StatusListener: + def __init__(self, chromecast, callback, loop): + self.chromecast = chromecast + self.__handle_player_state = callback + self.loop = loop + + def new_cast_status(self, status): + asyncio.run_coroutine_threadsafe(self.__handle_player_state(self.chromecast, caststatus=status), self.loop) + + +class StatusMediaListener: + def __init__(self, chromecast, callback, loop): + self.chromecast= chromecast + self.__handle_player_state = callback + self.loop = loop + + def new_media_status(self, status): + asyncio.run_coroutine_threadsafe(self.__handle_player_state(self.chromecast, mediastatus=status), self.loop) + + +class MZConnListener: + def __init__(self, mz): + self._mz=mz + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + if connection_status.status == 'CONNECTED': + self._mz.update_members() + +class MZListener: + def __init__(self, mz, callback, loop): + self._mz = mz + self._loop = loop + self.__handle_group_members_update = callback + + def multizone_member_added(self, uuid): + asyncio.run_coroutine_threadsafe( + self.__handle_group_members_update(self._mz, added_player=str(uuid)), self._loop) + + def multizone_member_removed(self, uuid): + asyncio.run_coroutine_threadsafe( + self.__handle_group_members_update(self._mz, removed_player=str(uuid)), self._loop) + + def multizone_status_received(self): + asyncio.run_coroutine_threadsafe( + self.__handle_group_members_update(self._mz), self._loop) + + +class SpController(SpotifyController): + """ Controller to interact with Spotify namespace. """ + def receive_message(self, message, data): + """ handle the auth flow and active player selection """ + if data['type'] == 'setCredentialsResponse': + self.send_message({'type': 'getInfo', 'payload': {}}) + if data['type'] == 'setCredentialsError': + self.device = None + if data['type'] == 'getInfoResponse': + self.device = data['payload']['deviceID'] + self.is_launched = True + return True diff --git a/music_assistant/modules/playerproviders/homeassistant.py b/music_assistant/modules/playerproviders/homeassistant.py new file mode 100644 index 00000000..a3f2dbff --- /dev/null +++ b/music_assistant/modules/playerproviders/homeassistant.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import random +import sys +sys.path.append("..") +from utils import run_periodic, LOGGER, parse_track_title, try_parse_int +from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT +import json +import aiohttp +import time +import datetime +import hashlib +from asyncio_throttle import Throttler +from aiocometd import Client, ConnectionType, Extension +from cache import use_cache +import copy + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["playerproviders"]['homeassistant'].get(CONF_ENABLED) + token = mass.config["playerproviders"]['homeassistant'].get('token') + hostname = mass.config["playerproviders"]['homeassistant'].get(CONF_HOSTNAME) + if enabled and hostname and token: + provider = HassProvider(mass, hostname, token) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_HOSTNAME, 'localhost', CONF_HOSTNAME), + ('token', '', 'Long Lived Access Token') + ] + +class HassProvider(PlayerProvider): + ''' support for Home Assistant ''' + + def __init__(self, mass, hostname, token): + self.prov_id = 'homeassistant' + self.name = 'Home Assistant' + self.icon = '' + self.mass = mass + self._players = {} + self._token = token + self._host = hostname + self.supports_queue = False + self.supports_http_stream = True # whether we can fallback to http streaming + self.supported_musicproviders = [] # we have no idea about the mediaplayers attached to hass so assume we can only do http playback + self.http_session = aiohttp.ClientSession(loop=mass.event_loop) + self.__send_ws = None + self.__last_id = 10 + asyncio.ensure_future(self.__hass_connect()) + + + ### Provider specific implementation ##### + + async def player_command(self, player_id, cmd:str, cmd_args=None): + ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' + service_data = {"entity_id": player_id} + service = None + if cmd == 'play': + service = 'media_play' + elif cmd == 'pause': + service = 'media_pause' + elif cmd == 'stop': + service = 'media_stop' + elif cmd == 'next': + service = 'media_next_track' + elif cmd == 'previous': + service = 'media_previous_track' + elif cmd == 'power' and cmd_args in ['on', '1', 1]: + service = 'turn_on' + elif cmd == 'power' and cmd_args in ['off', '0', 0]: + service = 'turn_off' + elif cmd == 'volume' and cmd_args == 'up': + service = 'volume_up' + elif cmd == 'volume' and cmd_args == 'down': + service = 'volume_down' + elif cmd == 'volume': + service = 'volume_set' + service_data['volume_level'] = try_parse_int(cmd_args) / 100 + self._players[player_id].volume_level = try_parse_int(cmd_args) + elif cmd == 'mute' and cmd_args in ['on', '1', 1]: + service = 'volume_mute' + service_data['is_volume_muted'] = True + elif cmd == 'mute' and cmd_args in ['off', '0', 0]: + service = 'volume_mute' + service_data['is_volume_muted'] = False + return await self.__call_service(service, service_data) + + async def play_media(self, player_id, uri, queue_opt='play'): + ''' + play media on a player + params: + - player_id: id of the player + - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream) + - queue_opt: + replace: replace whatever is currently playing with this media + next: the given media will be played after the currently playing track + add: add to the end of the queue + play: keep existing queue but play the given item now + ''' + service = "play_media" + service_data = { + "entity_id": player_id, + "media_content_id": uri, + "media_content_type": "music" + } + return await self.__call_service(service, service_data) + + async def __call_service(self, service, service_data=None, domain='media_player'): + ''' call service on hass ''' + if not self.__send_ws: + return False + msg = { + "type": "call_service", + "domain": domain, + "service": service, + } + if service_data: + msg['service_data'] = service_data + return await self.__send_ws(msg) + + ### Provider specific (helper) methods ##### + + async def __handle_player_state(self, data): + ''' handle a player state message from the websockets ''' + player_id = data['entity_id'] + if not player_id in self._players: + # new player + self._players[player_id] = MusicPlayer() + player = self._players[player_id] + player.player_id = player_id + player.player_provider = self.prov_id + else: + # existing player + player = self._players[player_id] + # always update player details that may change + player.name = data['attributes']['friendly_name'] + player.powered = not data['state'] == 'off' + if data['state'] == 'playing': + player.state == PlayerState.Playing + elif data['state'] == 'paused': + player.state == PlayerState.Paused + else: + player.state = PlayerState.Stopped + if 'is_volume_muted' in data['attributes']: + player.muted = data['attributes']['is_volume_muted'] + if 'volume_level' in data['attributes']: + player.volume_level = float(data['attributes']['volume_level']) * 100 + if 'media_position' in data['attributes']: + player.cur_item_time = try_parse_int(data['attributes']['media_position']) + player.cur_item = await self.__parse_track(data) + await self.mass.player.update_player(player) + + async def __parse_track(self, data): + ''' parse track in hass to our internal format ''' + track = Track() + # TODO: match this info in the DB! + if 'media_content_id' in data['attributes']: + artist = data['attributes'].get('media_artist') + album = data['attributes'].get('media_album') + title = data['attributes'].get('media_title') + track.name = "%s - %s" %(artist, title) + if 'entity_picture' in data['attributes']: + img = "https://%s%s" %(self._host, data['attributes']['entity_picture']) + track.metadata['image'] = img + track.duration = try_parse_int(data['attributes'].get('media_duration',0)) + return track + + async def __hass_connect(self): + ''' Receive events from Hass through websockets ''' + while True: + try: + async with self.http_session.ws_connect('wss://%s/api/websocket' % self._host) as ws: + + async def send_msg(msg): + ''' callback to send message to the websockets client''' + self.__last_id += 1 + msg['id'] = self.__last_id + await ws.send_json(msg) + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + if msg.data == 'close cmd': + await ws.close() + break + else: + data = msg.json() + if data['type'] == 'auth_required': + # send auth token + auth_msg = {"type": "auth", "access_token": self._token} + await ws.send_json(auth_msg) + elif data['type'] == 'auth_invalid': + raise Exception(data) + elif data['type'] == 'auth_ok': + # register callback + self.__send_ws = send_msg + # subscribe to events + subscribe_msg = {"type": "subscribe_events", "event_type": "state_changed"} + await send_msg(subscribe_msg) + subscribe_msg = {"type": "get_states"} + await send_msg(subscribe_msg) + elif data['type'] == 'event' and data['event']['event_type'] == 'state_changed': + if data['event']['data']['entity_id'].startswith('media_player'): + asyncio.ensure_future(self.__handle_player_state(data['event']['data']['new_state'])) + elif data['type'] == 'result' and data.get('result'): + # reply to our get_states request + for item in data['result']: + if item['entity_id'].startswith('media_player'): + asyncio.ensure_future(self.__handle_player_state(item)) + else: + LOGGER.info(data) + elif msg.type == aiohttp.WSMsgType.ERROR: + break + except Exception as exc: + LOGGER.exception(exc) + asyncio.sleep(10) \ No newline at end of file diff --git a/music_assistant/modules/playerproviders/lms.py b/music_assistant/modules/playerproviders/lms.py new file mode 100644 index 00000000..6df0fc05 --- /dev/null +++ b/music_assistant/modules/playerproviders/lms.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import random +import sys +sys.path.append("..") +from utils import run_periodic, LOGGER, parse_track_title +from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT +import json +import aiohttp +import time +import datetime +import hashlib +from asyncio_throttle import Throttler +from aiocometd import Client, ConnectionType, Extension +from cache import use_cache +import copy + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["playerproviders"]['lms'].get(CONF_ENABLED) + hostname = mass.config["playerproviders"]['lms'].get(CONF_HOSTNAME) + port = mass.config["playerproviders"]['lms'].get(CONF_PORT) + if enabled and hostname and port: + provider = LMSProvider(mass, hostname, port) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, True, CONF_ENABLED), + (CONF_HOSTNAME, 'localhost', CONF_HOSTNAME), + (CONF_PORT, 9000, CONF_PORT) + ] + +class LMSProvider(PlayerProvider): + ''' support for Logitech Media Server ''' + + def __init__(self, mass, hostname, port): + self.prov_id = 'lms' + self.name = 'Logitech Media Server' + self.icon = '' + self.mass = mass + self._players = {} + self._host = hostname + self._port = port + self._players = {} + self.last_msg_received = 0 + self.supports_queue = True # whether this provider has native support for a queue + self.supports_http_stream = True # whether we can fallback to http streaming + self.supported_musicproviders = [ + ('qobuz', [MediaType.Track]), + ('file', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]), + ('spotify', [MediaType.Track, MediaType.Artist, MediaType.Album, MediaType.Playlist]), + ('http', [MediaType.Track]) + ] + self.http_session = aiohttp.ClientSession(loop=mass.event_loop) + # we use a combi of active polling and subscriptions because the cometd implementation of LMS is somewhat unreliable + asyncio.ensure_future(self.__lms_events()) + asyncio.ensure_future(self.__get_players()) + + ### Provider specific implementation ##### + + async def player_command(self, player_id, cmd:str, cmd_args=None): + ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' + lms_commands = [] + if cmd == 'play': + lms_commands = ['play'] + elif cmd == 'pause': + lms_commands = ['pause', '1'] + elif cmd == 'stop': + lms_commands = ['stop'] + elif cmd == 'next': + lms_commands = ['playlist', 'index', '+1'] + elif cmd == 'previous': + lms_commands = ['playlist', 'index', '-1'] + elif cmd == 'stop': + lms_commands = ['playlist', 'stop'] + elif cmd == 'power' and cmd_args in ['on', '1', 1]: + lms_commands = ['power', '1'] + elif cmd == 'power' and cmd_args in ['off', '0', 0]: + lms_commands = ['power', '0'] + elif cmd == 'volume' and cmd_args == 'up': + lms_commands = ['mixer', 'volume', '+2'] + elif cmd == 'volume' and cmd_args == 'down': + lms_commands = ['mixer', 'volume', '-2'] + elif cmd == 'volume': + lms_commands = ['mixer', 'volume', cmd_args] + elif cmd == 'mute' and cmd_args in ['on', '1', 1]: + lms_commands = ['mixer', 'muting', '1'] + elif cmd == 'mute' and cmd_args in ['off', '0', 0]: + lms_commands = ['mixer', 'muting', '0'] + return await self.__get_data(lms_commands, player_id=player_id) + + async def play_media(self, player_id, uri, queue_opt='play'): + ''' + play media on a player + params: + - player_id: id of the player + - uri: the uri for/to the media item (e.g. spotify:track:1234 or http://pathtostream) + - queue_opt: + replace: replace whatever is currently playing with this media + next: the given media will be played after the currently playing track + add: add to the end of the queue + play: keep existing queue but play the given item now + ''' + if queue_opt == 'play': + cmd = ['playlist', 'insert', uri] + await self.__get_data(cmd, player_id=player_id) + cmd2 = ['playlist', 'index', '+1'] + return await self.__get_data(cmd2, player_id=player_id) + elif queue_opt == 'replace': + cmd = ['playlist', 'play', uri] + return await self.__get_data(cmd, player_id=player_id) + elif queue_opt == 'next': + cmd = ['playlist', 'insert', uri] + return await self.__get_data(cmd, player_id=player_id) + else: + cmd = ['playlist', 'add', uri] + return await self.__get_data(cmd, player_id=player_id) + + async def player_queue(self, player_id, offset=0, limit=50): + ''' return the items in the player's queue ''' + items = [] + cur_index = await self.__get_data(["playlist", "index", "?"], player_id=player_id) + cur_index = int(cur_index['_index']) + offset += cur_index # we do not care about already played tracks + player_details = await self.__get_data(["status", offset, limit, "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id) + for item in player_details['playlist_loop']: + track = await self.__parse_track(item) + items.append(track) + return items + + ### Provider specific (helper) methods ##### + + async def __get_players(self): + ''' update all players, used as fallback if cometd is failing and to detect removed players''' + server_info = await self.__get_data(['players', 0, 1000]) + player_ids = await self.__process_serverstatus(server_info) + for player_id in player_ids: + player_details = await self.__get_data(["status", "-","1", "tags:aAcCdegGijJKlostuxyRwk"], player_id=player_id) + await self.__process_player_details(player_id, player_details) + + async def __process_player_details(self, player_id, player_details): + ''' get state of a given player ''' + if player_id not in self._players: + return + player = self._players[player_id] + volume = player_details.get('mixer volume',0) + player.muted = volume < 0 + if volume >= 0: + player.volume_level = player_details.get('mixer volume',0) + player.shuffle_enabled = player_details.get('playlist shuffle',0) != 0 + player.repeat_enabled = player_details.get('playlist repeat',0) != 0 + # player state + player.powered = player_details['power'] == 1 + if player_details['mode'] == 'play': + player.state = PlayerState.Playing + elif player_details['mode'] == 'pause': + player.state = PlayerState.Paused + else: + player.state = PlayerState.Stopped + # current track + if player_details.get('playlist_loop'): + player.cur_item = await self.__parse_track(player_details['playlist_loop'][0]) + player.cur_item_time = player_details.get('time',0) + else: + player.cur_item = None + player.cur_item_time = 0 + await self.mass.player.update_player(player) + + async def __process_serverstatus(self, server_status): + ''' process players from server state msg (players_loop) ''' + cur_player_ids = [] + for lms_player in server_status['players_loop']: + if lms_player['isplayer'] != 1: + continue + player_id = lms_player['playerid'] + cur_player_ids.append(player_id) + if not player_id in self._players: + # new player + self._players[player_id] = MusicPlayer() + player = self._players[player_id] + player.player_id = player_id + player.player_provider = self.prov_id + else: + # existing player + player = self._players[player_id] + # always update player details that may change + player.name = lms_player['name'] + if lms_player['model'] == "group": + player.is_group = True + # player is a groupplayer, retrieve childs + group_player_child_ids = await self.__get_group_childs(player_id) + for child_player_id in group_player_child_ids: + self._players[child_player_id].group_parent = player_id + elif player.group_parent: + # check if player parent is still correct + group_player_child_ids = await self.__get_group_childs(player.group_parent) + if not player_id in group_player_child_ids: + player.group_parent = None + # process update + await self.mass.player.update_player(player) + # process removed players... + for player_id, player in self._players.items(): + if player_id not in cur_player_ids: + await self.mass.player.remove_player(player_id) + return cur_player_ids + + async def __parse_track(self, track_details): + ''' parse track in LMS to our internal format ''' + track_url = track_details.get('url','') + if track_url.startswith('qobuz://') and 'qobuz' in self.mass.music.providers: + # qobuz track! + try: + track_id = track_url.replace('qobuz://','').replace('.flac','') + return await self.mass.music.providers['qobuz'].track(track_id) + except Exception as exc: + LOGGER.error(exc) + elif track_url.startswith('spotify://track:') and 'spotify' in self.mass.music.providers: + # spotify track! + try: + track_id = track_url.replace('spotify://track:','') + return await self.mass.music.providers['spotify'].track(track_id) + except Exception as exc: + LOGGER.error(exc) + # fallback to a generic track + track = Track() + track.name = track_details['title'] + track.duration = int(track_details['duration']) + image = "http://%s:%s%s" % (self._host, self._port, track_details['artwork_url']) + track.metadata['image'] = image + return track + + async def __get_group_childs(self, group_player_id): + ''' get child players for groupplayer ''' + group_childs = [] + result = await self.__get_data('playergroup', player_id=group_player_id) + if result and 'players_loop' in result: + group_childs = [item['id'] for item in result['players_loop']] + return group_childs + + async def __lms_events(self): + # Receive events from LMS through CometD socket + while True: + try: + last_msg_received = 0 + async with Client("http://%s:%s/cometd" % (self._host, self._port), + connection_types=ConnectionType.LONG_POLLING, + extensions=[LMSExtension()]) as client: + # subscribe + watched_players = [] + await client.subscribe("/slim/subscribe/serverstatus") + + # listen for incoming messages + async for message in client: + last_msg_received = int(time.time()) + if 'playerstatus' in message['channel']: + # player state + player_id = message['channel'].split('playerstatus/')[1] + asyncio.ensure_future(self.__process_player_details(player_id, message['data'])) + elif '/slim/serverstatus' in message['channel']: + # server state with all players + player_ids = await self.__process_serverstatus(message['data']) + for player_id in player_ids: + if player_id not in watched_players: + # subscribe to player change events + watched_players.append(player_id) + await client.subscribe("/slim/subscribe/playerstatus/%s" % player_id) + except Exception as exc: + LOGGER.exception(exc) + + async def __get_data(self, cmds:List, player_id=''): + ''' get data from api''' + if not isinstance(cmds, list): + cmds = [cmds] + cmd = [player_id, cmds] + url = "http://%s:%s/jsonrpc.js" % (self._host, self._port) + params = {"id": 1, "method": "slim.request", "params": cmd} + try: + async with self.http_session.post(url, json=params) as response: + result = await response.json() + return result['result'] + except Exception as exc: + LOGGER.exception('Error executing LMS command %s' % params) + return None + + +class LMSExtension(Extension): + ''' Extension for the custom cometd implementation of LMS''' + + async def incoming(self, payload, headers=None): + pass + + async def outgoing(self, payload, headers): + ''' override outgoing messages to fit LMS custom implementation''' + + # LMS does not need/want id for the connect and handshake message + if payload[0]['channel'] == '/meta/handshake' or payload[0]['channel'] == '/meta/connect': + del payload[0]['id'] + + # handle subscriptions + if 'subscribe' in payload[0]['channel']: + client_id = payload[0]['clientId'] + if payload[0]['subscription'] == '/slim/subscribe/serverstatus': + # append additional request data to the request + payload[0]['data'] = {'response':'/%s/slim/serverstatus' % client_id, + 'request':['', ['serverstatus', 0, 100, 'subscribe:60']]} + payload[0]['channel'] = '/slim/subscribe' + if payload[0]['subscription'].startswith('/slim/subscribe/playerstatus'): + # append additional request data to the request + player_id = payload[0]['subscription'].split('/')[-1] + payload[0]['data'] = {'response':'/%s/slim/playerstatus/%s' % (client_id, player_id), + 'request':[player_id, ["status", "-", 1, "tags:aAcCdegGijJKlostuxyRwk", "subscribe:60"]]} + payload[0]['channel'] = '/slim/subscribe' \ No newline at end of file diff --git a/music_assistant/music.py b/music_assistant/music.py new file mode 100755 index 00000000..e3bb4a35 --- /dev/null +++ b/music_assistant/music.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from utils import run_periodic, run_async_background_task, LOGGER +import aiohttp +from difflib import SequenceMatcher as Matcher +from models import MediaType +from typing import List +import toolz +import operator + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MODULES_PATH = os.path.join(BASE_DIR, "modules", "musicproviders" ) + +class Music(): + ''' several helpers around the musicproviders ''' + + def __init__(self, mass): + self.sync_running = False + self.mass = mass + self.providers = {} + # dynamically load musicprovider modules + self.load_music_providers() + # schedule sync task + mass.event_loop.create_task(self.sync_music_providers()) + + async def item(self, item_id, media_type:MediaType, lazy=True): + ''' get single music item by id and media type''' + if media_type == MediaType.Artist: + return await self.artist(item_id, lazy=lazy) + elif media_type == MediaType.Album: + return await self.album(item_id, lazy=lazy) + elif media_type == MediaType.Track: + return await self.track(item_id, lazy=lazy) + elif media_type == MediaType.Playlist: + return await self.playlist(item_id) + else: + return None + + async def library_artists(self, limit=0, offset=0, orderby='name', provider_filter=None): + ''' return all library artists, optionally filtered by provider ''' + return await self.mass.db.library_artists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def library_albums(self, limit=0, offset=0, orderby='name', provider_filter=None): + ''' return all library albums, optionally filtered by provider ''' + return await self.mass.db.library_albums(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def library_tracks(self, limit=0, offset=0, orderby='name', provider_filter=None): + ''' return all library tracks, optionally filtered by provider ''' + return await self.mass.db.library_tracks(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def library_playlists(self, limit=0, offset=0, orderby='name', provider_filter=None): + ''' return all library playlists, optionally filtered by provider ''' + return await self.mass.db.library_playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + + async def library_items(self, media_type:MediaType, limit=0, offset=0, orderby='name', provider_filter=None): + ''' get multiple music items in library''' + if media_type == MediaType.Artist: + return await self.library_artists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + elif media_type == MediaType.Album: + return await self.library_albums(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + elif media_type == MediaType.Track: + return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + elif media_type == MediaType.Playlist: + return await self.library_playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + + async def artist(self, item_id, lazy=True): + ''' get artist by id ''' + artist = await self.mass.db.artist(item_id) + if artist: + return artist + # not a database id, probably a provider id + for provider in self.providers.values(): + artist = await provider.artist(item_id, lazy=lazy) + if artist: + return artist + raise Exception("Artist %s is not found" % item_id) + + async def album(self, item_id, lazy=True): + ''' get album by id ''' + album = await self.mass.db.album(item_id) + if album: + return album + # not a database id, probably a provider id + for provider in self.providers.values(): + album = await provider.album(item_id, lazy=lazy) + if album: + return album + raise Exception("Album %s is not found" % item_id) + + async def track(self, item_id, lazy=True): + ''' get track by id ''' + track = await self.mass.db.track(item_id) + if track: + return track + # not a database id, probably a provider id + for provider in self.providers.values(): + track = await provider.track(item_id, lazy=lazy) + if track: + return track + raise Exception("Track %s is not found" % item_id) + + async def playlist(self, item_id): + ''' get playlist by id ''' + playlist = await self.mass.db.playlist(item_id) + if playlist: + return playlist + # not a database id, probably a provider id + for provider in self.providers.values(): + playlist = await provider.playlist(item_id) + if playlist: + return playlist + raise Exception("Playlist %s is not found" % item_id) + + async def artist_toptracks(self, artist_id): + ''' get top tracks for given artist ''' + items = [] + artist = await self.artist(artist_id) + # always append database tracks + items += await self.mass.db.artist_tracks(artist.item_id) + for prov_mapping in artist.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + prov_obj = self.providers[prov_id] + items += await prov_obj.artist_toptracks(prov_item_id) + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + items.sort(key=lambda x: x.name, reverse=False) + return items + + async def artist_albums(self, artist_id): + ''' get (all) albums for given artist ''' + items = [] + artist = await self.artist(artist_id) + # always append database tracks + items += await self.mass.db.artist_albums(artist.item_id) + for prov_mapping in artist.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + prov_obj = self.providers[prov_id] + items += await prov_obj.artist_albums(prov_item_id) + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + items.sort(key=lambda x: x.name, reverse=False) + return items + + async def album_tracks(self, album_id): + ''' get the album tracks for given album ''' + items = [] + album = await self.album(album_id) + for prov_mapping in album.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + prov_obj = self.providers[prov_id] + items += await prov_obj.album_tracks(prov_item_id) + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + return items + + async def playlist_tracks(self, playlist_id, offset=0, limit=50): + ''' get the tracks for given playlist ''' + items = [] + playlist = await self.playlist(playlist_id) + for prov_mapping in playlist.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + prov_obj = self.providers[prov_id] + items += await prov_obj.playlist_tracks(prov_item_id, offset=offset, limit=limit) + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + return items + + async def search(self, searchquery, media_types:List[MediaType], limit=10, online=False): + ''' search database or providers ''' + # get results from database + result = await self.mass.db.search(searchquery, media_types, limit) + if online: + # include results from music providers + for prov in self.providers.values(): + prov_results = await prov.search(searchquery, media_types, limit) + for item_type, items in prov_results.items(): + result[item_type] += items + # filter out duplicates + for item_type, items in result.items(): + items = list(toolz.unique(items, key=operator.attrgetter('item_id'))) + return result + + async def item_action(self, item_id, media_type, action=None): + ''' perform action on item (such as library add/remove) ''' + result = None + item = await self.item(item_id, media_type) + if item and action in ['add', 'remove']: + # remove or add item to the library + for prov_mapping in result.provider_ids: + prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + for prov in self.providers.values(): + if prov.prov_id == prov_id: + if action == 'add': + result = await prov.add_library(prov_item_id, media_type) + elif action == 'remove': + result = await prov.remove_library(prov_item_id, media_type) + return result + + def get_music_provider(self, item_id): + ''' get musicprovider object by id ''' + prov_obj = None + if isinstance(item_id,int) or not '_' in item_id: + prov_obj = self.mass.db + else: + prov_id = item_id.split('_')[0] + item_id = item_id.split('_')[1] + prov_obj = self.providers[prov_id] + return item_id, prov_obj + + @run_periodic(3600) + async def sync_music_providers(self): + ''' periodic sync of all music providers ''' + if self.sync_running: + return + self.sync_running = True + for prov_id in self.providers.keys(): + # sync library artists + await self.sync_library_artists(prov_id) + await self.sync_library_albums(prov_id) + await self.sync_library_tracks(prov_id) + await self.sync_library_playlists(prov_id) + self.sync_running = False + + async def sync_library_artists(self, prov_id): + ''' sync library artists for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.library_artists(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_library_artists() + cur_db_ids = [] + for item in cur_items: + db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Artist) + if db_id == None: + db_id = await music_provider.add_artist(item) + cur_db_ids.append(db_id) + if not db_id in prev_db_ids: + await self.mass.db.add_to_library(db_id, MediaType.Artist, prov_id) + # process deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Artist, prov_id) + LOGGER.info("Finished syncing Artists for provider %s" % prov_id) + + async def sync_library_albums(self, prov_id): + ''' sync library albums for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.library_albums(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_library_albums() + cur_db_ids = [] + for item in cur_items: + db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Album) + if db_id == None: + db_id = await music_provider.add_album(item) + cur_db_ids.append(db_id) + if not db_id in prev_db_ids: + await self.mass.db.add_to_library(db_id, MediaType.Album, prov_id) + # process deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Album, prov_id) + LOGGER.info("Finished syncing Albums for provider %s" % prov_id) + + async def sync_library_tracks(self, prov_id): + ''' sync library tracks for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.library_tracks(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_library_tracks() + cur_db_ids = [] + for item in cur_items: + db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Track) + if db_id == None: + db_id = await music_provider.add_track(item) + cur_db_ids.append(db_id) + if not db_id in prev_db_ids: + await self.mass.db.add_to_library(db_id, MediaType.Track, prov_id) + # process deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Track, prov_id) + LOGGER.info("Finished syncing Tracks for provider %s" % prov_id) + + async def sync_library_playlists(self, prov_id): + ''' sync library playlists for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.library_playlists(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_library_playlists() + cur_db_ids = [] + for item in cur_items: + db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Playlist) + if db_id == None: + db_id = await music_provider.add_playlist(item) + cur_db_ids.append(db_id) + if not db_id in prev_db_ids: + await self.mass.db.add_to_library(db_id, MediaType.Playlist, prov_id) + # playlist tracks + #asyncio.create_task( self.sync_playlist_tracks(db_id, prov_id, item.item_id) ) + # process playlist deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Playlist, prov_id) + LOGGER.info("Finished syncing Playlists for provider %s" % prov_id) + + async def sync_playlist_tracks(self, db_playlist_id, prov_id, prov_playlist_id): + ''' sync library playlists tracks for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.playlist_tracks(db_playlist_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_playlist_tracks(prov_playlist_id, limit=0) + cur_db_ids = [] + pos = 0 + for item in cur_items: + # we need to do this the complicated way because the file provider can return tracks from other providers + for prov_mapping in item.provider_ids: + item_prov_id = prov_mapping['provider'] + prov_item_id = prov_mapping['item_id'] + db_id = await self.mass.db.get_database_id(item_prov_id, prov_item_id, MediaType.Track) + if db_id == None: + db_id = await self.providers[item_prov_id].add_track(item) + if not db_id in cur_db_ids: + cur_db_ids.append(db_id) + await self.mass.db.add_playlist_track(db_playlist_id, db_id, pos) + pos += 1 + # process playlist track deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_playlist_track(db_playlist_id, db_id) + LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id)) + + def load_music_providers(self): + ''' dynamically load musicproviders ''' + for item in os.listdir(MODULES_PATH): + if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and + item.endswith('.py') and not item.startswith('.')): + module_name = item.replace(".py","") + LOGGER.debug("Loading musicprovider module %s" % module_name) + try: + mod = __import__("modules.musicproviders." + module_name, fromlist=['']) + if not self.mass.config['musicproviders'].get(module_name): + self.mass.config['musicproviders'][module_name] = {} + self.mass.config['musicproviders'][module_name]['__desc__'] = mod.config_entries() + for key, def_value, desc in mod.config_entries(): + if not key in self.mass.config['musicproviders'][module_name]: + self.mass.config['musicproviders'][module_name][key] = def_value + mod = mod.setup(self.mass) + if mod: + self.providers[mod.prov_id] = mod + cls_name = mod.__class__.__name__ + LOGGER.info("Successfully initialized module %s" % cls_name) + except Exception as exc: + LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/player.py b/music_assistant/player.py new file mode 100755 index 00000000..829a3981 --- /dev/null +++ b/music_assistant/player.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from utils import run_periodic, LOGGER, try_parse_int +import aiohttp +from difflib import SequenceMatcher as Matcher +from models import MediaType, PlayerState, MusicPlayer +from typing import List +import toolz +import operator + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MODULES_PATH = os.path.join(BASE_DIR, "modules", "playerproviders" ) + +class Player(): + ''' several helpers to handle playback through player providers ''' + + def __init__(self, mass): + self.mass = mass + self.providers = {} + self._players = {} + # dynamically load provider modules + self.load_providers() + + async def players(self): + ''' return all players ''' + items = list(self._players.values()) + items.sort(key=lambda x: x.name, reverse=False) + return items + + async def player(self, player_id): + ''' return players by id ''' + return self._players[player_id] + + async def player_command(self, player_id, cmd, cmd_args=None): + ''' issue command on player (play, pause, next, previous, stop, power, volume, mute) ''' + if not player_id in self._players: + LOGGER.warning('Player %s not found' % player_id) + return False + player = self._players[player_id] + if player.group_parent and cmd not in ['power', 'volume', 'mute']: + # redirect playlist related commands to parent player + return await self.player_command(player.group_parent, cmd, cmd_args) + if cmd == 'power' and player.mute_as_power: + cmd = 'mute' + cmd_args = 'on' if cmd_args == 'off' else 'off' # invert logic (power ON is mute OFF) + if cmd == 'volume' and player.apply_group_volume: + # group volume, apply to childs (if any) + cur_volume = player.volume_level + if cmd_args == 'up': + new_volume = cur_volume + 2 + elif cmd_args == 'down': + new_volume = cur_volume - 2 + else: + new_volume = try_parse_int(cmd_args) + if new_volume < cur_volume: + volume_dif = new_volume - cur_volume + else: + volume_dif = cur_volume - new_volume + for child_player in await self.players(): + if child_player.group_parent == player_id: + LOGGER.debug("%s - %s - %s" % (child_player.name, child_player.state, child_player.muted)) + if child_player.group_parent == player_id and child_player.state != PlayerState.Off: + cur_child_volume = child_player.volume_level + new_child_volume = cur_child_volume + volume_dif + LOGGER.debug('apply group volume %s to child %s' %(new_child_volume, child_player.name)) + await self.player_command(child_player.player_id, 'volume', new_child_volume) + player.volume_level = new_volume + return True + else: + prov_id = self._players[player_id].player_provider + prov = self.providers[prov_id] + return await prov.player_command(player_id, cmd, cmd_args) + + async def remove_player(self, player_id): + ''' handle a player remove ''' + self._players.pop(player_id, None) + asyncio.ensure_future(self.mass.event('player removed', player_id)) + + async def update_player(self, player_details): + ''' update (or add) player ''' + LOGGER.debug('Incoming msg from %s' % player_details.name) + player_id = player_details.player_id + player_settings = await self.get_player_config(player_details) + player_changed = False + if not player_id in self._players: + self._players[player_id] = MusicPlayer() + player = self._players[player_id] + player.player_id = player_id + player.player_provider = player_details.player_provider + player_changed = True + else: + player = self._players[player_id] + + # handle basic player settings + player_details.enabled = player_settings['enabled'] + player_details.name = player_settings['name'] + player_details.disable_volume = player_settings['disable_volume'] + player_details.mute_as_power = player_settings['mute_as_power'] + player_details.apply_group_volume = player_settings['apply_group_volume'] + + # handle mute as power setting + if player_details.mute_as_power: + player_details.powered = not player_details.muted + # combine state of group parent + if player_settings['group_parent']: + player_details.group_parent = player_settings['group_parent'] + if player_details.group_parent and player_details.group_parent in self._players: + parent_player = self._players[player_details.group_parent] + if player_details.powered and player_details.state != PlayerState.Playing: + player_details.cur_item_time = parent_player.cur_item_time + player_details.cur_item = parent_player.cur_item + # handle group volume setting + if player_details.is_group and player_details.apply_group_volume: + group_volume = 0 + active_players = 0 + for child_player in self._players.values(): + if child_player.group_parent == player_id and child_player.enabled and child_player.powered: + group_volume += child_player.volume_level + active_players += 1 + group_volume = group_volume / active_players if active_players else 0 + player_details.volume_level = group_volume + # compare values to detect changes + for key, cur_value in player.__dict__.items(): + new_value = getattr(player_details, key) + if new_value != cur_value: + player_changed = True + setattr(player, key, new_value) + LOGGER.debug('key changed: %s for player %s - new value: %s' % (key, player.name, new_value)) + if player_changed: + # player is added or updated! + asyncio.ensure_future(self.mass.event('player updated', player)) + + async def get_player_config(self, player_details): + ''' get or create player config ''' + player_id = player_details.player_id + if player_id in self.mass.config['player_settings']: + return self.mass.config['player_settings'][player_id] + new_config = { + "name": player_details.name, + "group_parent": player_details.group_parent, + "mute_as_power": False, + "disable_volume": False, + "apply_group_volume": False, + "enabled": False + } + self.mass.config['player_settings'][player_id] = new_config + return new_config + + async def play_media(self, player_id, media_item, queue_opt='replace'): + ''' + play media on a player + player_id: id of the player + media_item: media item that should be played (Track, Album, Artist, Playlist) + queue_opt: replace, next or add + ''' + if not player_id in self._players: + LOGGER.warning('Player %s not found' % player_id) + return False + prov_id = self._players[player_id].player_provider + prov = self.providers[prov_id] + # check supported music providers by this player and work out how to handle playback... + musicprovider = None + item_id = None + for prov_id, supported_types in prov.supported_musicproviders: + if media_item.provider_ids.get(prov_id): + musicprovider = prov_id + prov_item_id = media_item.provider_ids[prov_id] + if media_item.media_type in supported_types: + # the provider can handle this media_type directly ! + uri = await self.get_item_uri(media_item.media_type, prov_item_id, prov_id) + return await prov.play_media(player_id, uri, queue_opt) + else: + # manually enqueue the tracks of this listing + return await self.queue_items(player_id, media_item, queue_opt) + elif prov_id == 'http': + # fallback to http streaming + if media_item.media_type == MediaType.Track: + for media_prov_id, media_prov_item_id in media_item.provider_ids.items(): + stream_details = await self.mass.music.providers[media_prov_id].get_stream_details(media_prov_item_id) + return await prov.play_media(player_id, stream_details['url'], queue_opt) + else: + return await self.queue_items(player_id, media_item, queue_opt) + raise Exception("Musicprovider %s and/or mediatype %s not supported by player %s !" % ("/".join(media_item.provider_ids.keys()), media_item.media_type, player_id) ) + + async def queue_items(self, player_id, media_item, queue_opt): + ''' extract a list of items and manually enqueue the tracks ''' + tracks = [] + #TODO: respect shuffle + if media_item.media_type == MediaType.Artist: + tracks = await self.mass.music.artist_toptracks(media_item.item_id) + elif media_item.media_type == MediaType.Album: + tracks = await self.mass.music.album_tracks(media_item.item_id) + elif media_item.media_type == MediaType.Playlist: + tracks = await self.mass.music.playlist_tracks(media_item.item_id, offset=0, limit=0) + if queue_opt == 'replace': + await self.play_media(player_id, tracks[0], 'replace') + tracks = tracks[1:] + queue_opt = 'add' + for track in tracks: + await self.play_media(player_id, track, queue_opt) + + async def player_queue(self, player_id, offset=0, limit=50): + ''' return the items in the player's queue ''' + player = self._players[player_id] + player_prov = self.providers[player.player_provider] + if player_prov.supports_queue: + return await player_prov.player_queue(player_id, offset=offset, limit=limit) + else: + # TODO: Implement 'fake' queue + raise NotImplementedError + + async def get_item_uri(self, media_type, item_id, provider): + ''' generate the URL/URI for a media item ''' + uri = "" + if provider == "spotify" and media_type == MediaType.Track: + uri = 'spotify://spotify:track:%s' % item_id + elif provider == "spotify" and media_type == MediaType.Album: + uri = 'spotify://spotify:album:%s' % item_id + elif provider == "spotify" and media_type == MediaType.Artist: + uri = 'spotify://spotify:artist:%s' % item_id + elif provider == "spotify" and media_type == MediaType.Playlist: + uri = 'spotify://spotify:playlist:%s' % item_id + elif provider == "qobuz" and media_type == MediaType.Track: + uri = 'qobuz://%s.flac' % item_id + elif provider == "file": + uri = 'file://%s' % item_id + return uri + + def load_providers(self): + ''' dynamically load providers ''' + for item in os.listdir(MODULES_PATH): + if (os.path.isfile(os.path.join(MODULES_PATH, item)) and not item.startswith("_") and + item.endswith('.py') and not item.startswith('.')): + module_name = item.replace(".py","") + LOGGER.debug("Loading playerprovider module %s" % module_name) + try: + mod = __import__("modules.playerproviders." + module_name, fromlist=['']) + if not self.mass.config['playerproviders'].get(module_name): + self.mass.config['playerproviders'][module_name] = {} + self.mass.config['playerproviders'][module_name]['__desc__'] = mod.config_entries() + for key, def_value, desc in mod.config_entries(): + if not key in self.mass.config['playerproviders'][module_name]: + self.mass.config['playerproviders'][module_name][key] = def_value + mod = mod.setup(self.mass) + if mod: + self.providers[mod.prov_id] = mod + cls_name = mod.__class__.__name__ + LOGGER.info("Successfully initialized module %s" % cls_name) + except Exception as exc: + LOGGER.exception("Error loading module %s: %s" %(module_name, exc)) diff --git a/music_assistant/utils.py b/music_assistant/utils.py new file mode 100755 index 00000000..40877518 --- /dev/null +++ b/music_assistant/utils.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import logging +from concurrent.futures import ThreadPoolExecutor + +logformat = logging.Formatter('%(asctime)-15s %(levelname)-5s %(module)s -- %(message)s') +LOGGER = logging.getLogger("music_assistant") +consolehandler = logging.StreamHandler() +consolehandler.setFormatter(logformat) +LOGGER.addHandler(consolehandler) +LOGGER.setLevel(logging.DEBUG) + +def run_periodic(period): + def scheduler(fcn): + async def wrapper(*args, **kwargs): + while True: + asyncio.create_task(fcn(*args, **kwargs)) + await asyncio.sleep(period) + return wrapper + return scheduler + +def run_background_task(executor, corofn, *args): + ''' run non-async task in background ''' + return asyncio.get_event_loop().run_in_executor(executor, corofn, *args) + +def run_async_background_task(executor, corofn, *args): + ''' run async task in background ''' + def run_task(corofn, *args): + loop = asyncio.new_event_loop() + try: + coro = corofn(*args) + asyncio.set_event_loop(loop) + return loop.run_until_complete(coro) + finally: + loop.close() + return asyncio.get_event_loop().run_in_executor(executor, run_task, corofn, *args) + +def get_sort_name(name): + ''' create a sort name for an artist/title ''' + sort_name = name + for item in ["The ", "De ", "de ", "Les "]: + if name.startswith(item): + sort_name = "".join(name.split(item)[1:]) + return sort_name + +def try_parse_int(possible_int): + try: + return int(possible_int) + except: + return 0 + +def parse_track_title(track_title): + ''' try to parse clean track title and version from the title ''' + track_title = track_title.lower() + title = track_title + version = '' + for splitter in [" (", " [", " - ", " (", " [", "-"]: + if splitter in title: + title_parts = title.split(splitter) + for title_part in title_parts: + # look for the end splitter + for end_splitter in [")", "]"]: + if end_splitter in title_part: + title_part = title_part.split(end_splitter)[0] + for ignore_str in ["feat.", "featuring", "ft.", "with ", " & "]: + if ignore_str in title_part: + title = title.split(splitter+title_part)[0] + for version_str in ["version", "live", "edit", "remix", "mix", + "acoustic", " instrumental", "karaoke", "remaster", "versie", "explicit", "radio", "unplugged", "disco"]: + if version_str in title_part: + version = title_part + title = title.split(splitter+version)[0] + title = title.strip().title() + # version substitues + + version = version.strip().title() + return title, version + diff --git a/music_assistant/web/components/headermenu.vue.js b/music_assistant/web/components/headermenu.vue.js new file mode 100755 index 00000000..d5505c75 --- /dev/null +++ b/music_assistant/web/components/headermenu.vue.js @@ -0,0 +1,53 @@ +Vue.component("headermenu", { + template: `
+ + + + + {{ item.icon }} + + + {{ item.title }} + + + + + + + + + menu + + + arrow_back + + + + + search + + + +
`, + props: [], + $_veeValidate: { + validator: "new" + }, + data() { + return { + menu: false, + items: [ + { title: "Home", icon: "home", path: "/" }, + { title: "Artists", icon: "person", path: "/artists" }, + { title: "Albums", icon: "album", path: "/albums" }, + { title: "Tracks", icon: "audiotrack", path: "/tracks" }, + { title: "Playlists", icon: "playlist_play", path: "/playlists" }, + { title: "Search", icon: "search", path: "/search" }, + { title: "Config", icon: "settings", path: "/config" } + ] + } + }, + mounted() { }, + methods: { } +}) diff --git a/music_assistant/web/components/infoheader.vue.js b/music_assistant/web/components/infoheader.vue.js new file mode 100644 index 00000000..8ccf6619 --- /dev/null +++ b/music_assistant/web/components/infoheader.vue.js @@ -0,0 +1,132 @@ +Vue.component("infoheader", { + template: ` + + + +
+ + + + + + + + +
+ + + +
+ +
+
+
+ + + + + {{ info.name }} + ({{ info.version }}) + + + + + + {{ artist.name }} + + + + {{ info.artist.name }} + + + {{ info.owner }} + + + + + {{ info.album.name }} + + + +
+ play_circle_outlinePlay + favorite_borderAdd to library + favoriteRemove from library +
+ + + +
+ +
+
+ +
+
+ + +
+ {{ tag }} +
+ + + +`, + props: ['info'], + data (){ + return{} + }, + mounted() { }, + created() { }, + methods: { + getFanartImage() { + var img = ''; + if (this.info.metadata && this.info.metadata.fanart) + img = this.info.metadata.fanart; + else if (this.info.artists) + this.info.artists.forEach(function(artist) { + if (artist.metadata && artist.metadata.fanart) + img = artist.metadata.fanart; + }); + return img; + }, + getThumb() { + var img = ''; + if (this.info.metadata && this.info.metadata.image) + img = this.info.metadata.image; + else if (this.info.album && this.info.album.metadata && this.info.album.metadata.image) + img = this.info.album.metadata.image; + else if (this.info.artists) + this.info.artists.forEach(function(artist) { + if (artist.metadata && artist.metadata.image) + img = artist.metadata.image; + }); + return img; + }, + getDescription() { + var desc = ''; + if (this.info.metadata && this.info.metadata.description) + return this.info.metadata.description; + else if (this.info.metadata && this.info.metadata.biography) + return this.info.metadata.biography; + else if (this.info.metadata && this.info.metadata.copyright) + return this.info.metadata.copyright; + else if (this.info.artists) + { + this.info.artists.forEach(function(artist) { + console.log(artist.metadata.biography); + if (artist.metadata && artist.metadata.biography) + desc = artist.metadata.biography; + }); + } + return desc; + }, + } +}) diff --git a/music_assistant/web/components/listviewItem.vue.js b/music_assistant/web/components/listviewItem.vue.js new file mode 100755 index 00000000..e54b811c --- /dev/null +++ b/music_assistant/web/components/listviewItem.vue.js @@ -0,0 +1,89 @@ +Vue.component("listviewItem", { + template: ` +
+ + + + + + audiotrack + album + person + audiotrack + + + + + + {{ item.name }} ({{ item.version }}) + + + + + {{ artist.name }} + + + - {{ item.album.name }} + + + + {{ item.artist.name }} + + + + {{ item.owner }} + + + + + + + + + + {{ provider.details }} + {{ provider.quality }} + + + + + + + Item is added to the library + Add item to the library + + + + + {{ item.duration.toString().formatDuration() }} + + + + more_vert + + + + +
+ `, +props: ['item', 'index', 'totalitems', 'hideavatar', 'hidetracknum', 'hideproviders', 'hidemenu', 'hidelibrary', 'hideduration'], +data() { + return { + selected: [2], + items: [], + offset: 0, + } + }, +methods: { + } +}) diff --git a/music_assistant/web/components/player.vue.js b/music_assistant/web/components/player.vue.js new file mode 100755 index 00000000..7b87ac31 --- /dev/null +++ b/music_assistant/web/components/player.vue.js @@ -0,0 +1,286 @@ +Vue.component("player", { + template: ` +
+ + + + + + + + + + + + + + {{ active_player.cur_item ? active_player.cur_item.name : active_player.name }} + + + {{ artist.name }} + + + + + + + + + + skip_previous + pause + play_arrow + skip_next + + + + + + + + queue_music + Queue + + + + + + + + + + + + + + + + + speaker + {{ active_player_id ? players[active_player_id].name : '' }} + + + + + + +
+ + {{ player_time_str_cur }} + + {{ player_time_str_total }} + +
+ +
+
+ + + + + Players + + + +
+ + + {{ isGroup(player.player_id) ? 'speaker_group' : 'speaker' }} + + + {{ player.name }} + + + {{ player.state }} + + + + + + + + + + + + + + + +
+
+
+ +
+ + `, + props: [], + $_veeValidate: { + validator: "new" + }, + watch: {}, + data() { + return { + menu: false, + players: {}, + active_player_id: "", + ws: null + } + }, + mounted() { }, + created() { + this.connectWS(); + this.updateProgress(); + }, + computed: { + + active_player() { + if (this.players && this.active_player_id && this.active_player_id in this.players) + return this.players[this.active_player_id]; + else + return { + name: 'no player selected', + cur_item: null, + cur_item_time: 0, + player_id: '', + volume_level: 0, + state: 'stopped' + }; + }, + progress() { + if (!this.active_player.cur_item) + return 0; + var total_sec = this.active_player.cur_item.duration; + var cur_sec = this.active_player.cur_item_time; + var cur_percent = cur_sec/total_sec*100; + return cur_percent; + }, + player_time_str_cur() { + if (!this.active_player.cur_item || !this.active_player.cur_item_time) + return "0:00"; + var cur_sec = this.active_player.cur_item_time; + return cur_sec.toString().formatDuration(); + }, + player_time_str_total() { + if (!this.active_player.cur_item) + return "0:00"; + var total_sec = this.active_player.cur_item.duration; + return total_sec.toString().formatDuration(); + } + }, + methods: { + playerCommand (cmd, cmd_opt=null, player_id=this.active_player_id) { + if (cmd_opt) + cmd = cmd + '/' + cmd_opt + cmd = 'players/' + player_id + '/cmd/' + cmd; + this.ws.send(cmd); + }, + playItem(item, queueopt) { + console.log('playItem: ' + item); + var cmd = 'players/' + this.active_player_id + '/play_media/' + item.media_type + '/' + item.item_id + '/' + queueopt; + console.log(cmd); + this.ws.send(cmd); + }, + switchPlayer (new_player_id) { + this.active_player_id = new_player_id; + }, + isGroup(player_id) { + for (var item in this.players) + if (this.players[item].group_parent == player_id && this.players[item].enabled) + return true; + return false; + }, + updateProgress: function(){ + this.intervalid2 = setInterval(function(){ + if (this.active_player.state == 'playing') + this.active_player.cur_item_time +=1; + }.bind(this), 1000); + }, + setPlayerVolume: function(player_id, new_volume) { + this.players[player_id].volume_level = new_volume; + this.playerCommand('volume', new_volume, player_id); + }, + togglePlayerPower: function(player_id) { + if (this.players[player_id].powered) + this.playerCommand('power', 'off', player_id); + else + this.playerCommand('power', 'on', player_id); + }, + connectWS() { + var loc = window.location, new_uri; + if (loc.protocol === "https:") { + new_uri = "wss:"; + } else { + new_uri = "ws:"; + } + new_uri += "/" + loc.host; + new_uri += loc.pathname + "ws"; + this.ws = new WebSocket(new_uri); + + this.ws.onopen = function() { + console.log('websocket connected!'); + this.ws.send('players'); + }.bind(this); + + this.ws.onmessage = function(e) { + var msg = JSON.parse(e.data); + var players = []; + if (msg.message == 'player updated') + players = [msg.message_details]; + else if (msg.message == 'players') + players = msg.message_details; + + for (var item of players) + if (item.player_id in this.players) + this.players[item.player_id] = Object.assign({}, this.players[item.player_id], item); + else + this.$set(this.players, item.player_id, item) + + // select new active player + // TODO: store previous player in local storage + if (!this.active_player_id) + for (var player_id in this.players) + if (this.players[player_id].state == 'playing' && this.players[player_id].enabled) { + // prefer the first playing player + this.active_player_id = player_id; + break; + } + if (!this.active_player_id) + for (var player_id in this.players) { + // fallback to just the first player + if (this.players[player_id].enabled) + { + this.active_player_id = player_id; + break; + } + } + }.bind(this); + + this.ws.onclose = function(e) { + console.log('Socket is closed. Reconnect will be attempted in 5 seconds.', e.reason); + setTimeout(function() { + this.connectWS(); + }.bind(this), 5000); + }.bind(this); + + this.ws.onerror = function(err) { + console.error('Socket encountered error: ', err.message, 'Closing socket'); + this.ws.close(); + }.bind(this); + } + } +}) diff --git a/music_assistant/web/components/playmenu.vue.js b/music_assistant/web/components/playmenu.vue.js new file mode 100644 index 00000000..54fae32f --- /dev/null +++ b/music_assistant/web/components/playmenu.vue.js @@ -0,0 +1,74 @@ +Vue.component("playmenu", { + template: ` + + + + {{ !!$globals.playmenuitem ? $globals.playmenuitem.name : 'nix' }} + Play on: beneden + + + + play_circle_outline + + + Play Now + + + + + + + queue_play_next + + + Play Next + + + + + + + playlist_add + + + Add to Queue + + + + + + + shuffle + + + Play now (shuffle) + + + + + + + info + + + Show info + + + + + + + +`, + props: ['value'], + data (){ + return{ + fav: true, + message: false, + hints: true, + } + }, + mounted() { }, + created() { }, + methods: { } + }) diff --git a/music_assistant/web/components/qualityicon.vue.js b/music_assistant/web/components/qualityicon.vue.js new file mode 100644 index 00000000..48baf2b5 --- /dev/null +++ b/music_assistant/web/components/qualityicon.vue.js @@ -0,0 +1,60 @@ +Vue.component("qualityicon", { + template: ` +
+ + + + {{ getFileFormatDesc() }} + + {{ getFileFormatDesc() }} +
+`, + props: ['item','height','compact', 'dark', 'hiresonly'], + data (){ + return{} + }, + mounted() { }, + created() { }, + methods: { + + getFileFormatLogo() { + if (this.item.quality == 0) + return 'images/icons/mp3.png' + else if (this.item.quality == 1) + return 'images/icons/vorbis.png' + else if (this.item.quality == 2) + return 'images/icons/aac.png' + else if (this.item.quality > 2) + return 'images/icons/flac.png' + }, + getFileFormatDesc() { + var desc = ''; + if (this.item && this.item.quality) + { + if (this.item.quality == 0) + desc = 'MP3'; + else if (this.item.quality == 1) + desc = 'Ogg Vorbis'; + else if (this.item.quality == 2) + desc = 'AAC'; + else if (this.item.quality > 2) + desc = 'FLAC'; + else + desc = 'unknown'; + } + // append details + if (this.item && this.item.metadata) + { + if (!!this.item.metadata && this.item.metadata.maximum_technical_specifications) + desc += ' ' + this.item.metadata.maximum_technical_specifications; + if (!!this.item.metadata && this.item.metadata.sample_rate && this.item.metadata.bit_depth) + desc += ' ' + this.item.metadata.sample_rate + 'kHz ' + this.item.metadata.bit_depth + 'bit'; + } + return desc; + } + } + }) diff --git a/music_assistant/web/components/readmore.vue.js b/music_assistant/web/components/readmore.vue.js new file mode 100644 index 00000000..6af2fd3b --- /dev/null +++ b/music_assistant/web/components/readmore.vue.js @@ -0,0 +1,63 @@ +Vue.component("read-more", { + template: ` +
+ {{moreStr}}

+ + + + + +
`, + props: { + moreStr: { + type: String, + default: 'read more' + }, + lessStr: { + type: String, + default: '' + }, + text: { + type: String, + required: true + }, + link: { + type: String, + default: '#' + }, + maxChars: { + type: Number, + default: 100 + } + }, + $_veeValidate: { + validator: "new" + }, + data (){ + return{ + isReadMore: false + } + }, + mounted() { }, + computed: { + formattedString(){ + var val_container = this.text; + if(this.text.length > this.maxChars){ + val_container = val_container.substring(0,this.maxChars) + '...'; + } + return(val_container); + } + }, + + methods: { + triggerReadMore(e, b){ + if(this.link == '#'){ + e.preventDefault(); + } + if(this.lessStr !== null || this.lessStr !== '') + { + this.isReadMore = b; + } + } + } + }) diff --git a/music_assistant/web/components/searchbar.vue.js b/music_assistant/web/components/searchbar.vue.js new file mode 100644 index 00000000..5efcf043 --- /dev/null +++ b/music_assistant/web/components/searchbar.vue.js @@ -0,0 +1,92 @@ +Vue.component("searchbar", { + template: ` + + `, + data () { + return { + data: [], + searchQuery: '', + selected: null + } + }, + props: { + recentSearch: { + type: Array, + required: true + }, + newSearchQuery: { + type: String, + required: true + }, + settings: { + type: Object, + required: true + } + }, + mounted () { + this.searchQuery = this.settings.initialSearchQuery + this.onClickSearch() + }, + watch: { + searchQuery: { + handler: _.debounce(function (val) { + if (val === '') { + this.$store.commit('CLEAR_SEARCH') + } else { + if (val !== this.newSearchQuery) { + this.onClickSearch() + } + } + }, 1000) + }, + newSearchQuery (val) { + this.searchQuery = val + } + }, + computed: { + filteredDataArray () { + return this.recentSearch.filter(option => { + return ( + option + .toString() + .toLowerCase() + .indexOf(this.searchQuery.toLowerCase()) >= 0 + ) + }) + } + }, + methods: { + onClickSearch () { + this.$emit('clickSearch', this.searchQuery) + }, + onClickClearSearch () { + this.searchQuery = '' + this.$emit('clickClearSearch') + } + } +}) +/* */ \ No newline at end of file diff --git a/music_assistant/web/components/volumecontrol.vue.js b/music_assistant/web/components/volumecontrol.vue.js new file mode 100644 index 00000000..c6d264bf --- /dev/null +++ b/music_assistant/web/components/volumecontrol.vue.js @@ -0,0 +1,74 @@ +Vue.component("volumecontrol", { + template: ` + + + + + {{ isGroup ? 'speaker_group' : 'speaker' }} + + + {{ players[player_id].name }} + {{ players[player_id].state }} + + + + + + + + + +
+ + + + + + +
+ {{ players[child_id].name }} +
+
+ + power_settings_new + +
+ + + +
+
+ +
+ +
+ + +
+`, + props: ['value', 'players', 'player_id'], + data (){ + return{ + } + }, + computed: { + volumePlayerIds() { + var volume_ids = [this.player_id]; + for (var player_id in this.players) + if (this.players[player_id].group_parent == this.player_id && this.players[player_id].enabled) + volume_ids.push(player_id); + return volume_ids; + }, + isGroup() { + return this.volumePlayerIds.length > 1; + } + }, + mounted() { }, + created() { }, + methods: {} + }) diff --git a/music_assistant/web/css/nprogress.css b/music_assistant/web/css/nprogress.css new file mode 100644 index 00000000..e4cb811e --- /dev/null +++ b/music_assistant/web/css/nprogress.css @@ -0,0 +1,74 @@ +/* Make clicks pass-through */ +#nprogress { + pointer-events: none; + } + + #nprogress .bar { + background: rgb(119, 205, 255); + + position: fixed; + z-index: 1031; + top: 0; + left: 0; + + width: 100%; + height: 10px; + } + + /* Fancy blur effect */ + #nprogress .peg { + display: block; + position: absolute; + right: 0px; + width: 100px; + height: 100%; + box-shadow: 0 0 10px #29d, 0 0 5px #29d; + opacity: 1.0; + + -webkit-transform: rotate(3deg) translate(0px, -4px); + -ms-transform: rotate(3deg) translate(0px, -4px); + transform: rotate(3deg) translate(0px, -4px); + } + + /* Remove these to get rid of the spinner */ + #nprogress .spinner { + display: block; + position: fixed; + z-index: 1031; + top: 15px; + right: 15px; + } + + #nprogress .spinner-icon { + width: 18px; + height: 18px; + box-sizing: border-box; + + border: solid 2px transparent; + border-top-color: #29d; + border-left-color: #29d; + border-radius: 50%; + + -webkit-animation: nprogress-spinner 400ms linear infinite; + animation: nprogress-spinner 400ms linear infinite; + } + + .nprogress-custom-parent { + overflow: hidden; + position: relative; + } + + .nprogress-custom-parent #nprogress .spinner, + .nprogress-custom-parent #nprogress .bar { + position: absolute; + } + + @-webkit-keyframes nprogress-spinner { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } + } + @keyframes nprogress-spinner { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + \ No newline at end of file diff --git a/music_assistant/web/css/site.css b/music_assistant/web/css/site.css new file mode 100755 index 00000000..2071f04a --- /dev/null +++ b/music_assistant/web/css/site.css @@ -0,0 +1,73 @@ +[v-cloak] { + display: none; +} + +.navbar { + margin-bottom: 20px; +} + +/*.body-content { + padding-left: 25px; + padding-right: 25px; +}*/ + +input, +select { + max-width: 30em; +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity .5s; +} + +.fade-enter, +.fade-leave-to +/* .fade-leave-active below version 2.1.8 */ + + { + opacity: 0; +} + +.bounce-enter-active { + animation: bounce-in .5s; +} + +.bounce-leave-active { + animation: bounce-in .5s reverse; +} + +@keyframes bounce-in { + 0% { + transform: scale(0); + } + 50% { + transform: scale(1.5); + } + 100% { + transform: scale(1); + } +} + +.slide-fade-enter-active { + transition: all .3s ease; +} + +.slide-fade-leave-active { + transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0); +} + +.slide-fade-enter, +.slide-fade-leave-to +/* .slide-fade-leave-active below version 2.1.8 */ + + { + transform: translateX(10px); + opacity: 0; +} + +.vertical-btn { + display: flex; + flex-direction: column; + align-items: center; + } \ No newline at end of file diff --git a/music_assistant/web/images/default_artist.png b/music_assistant/web/images/default_artist.png new file mode 100644 index 00000000..a530d5b4 Binary files /dev/null and b/music_assistant/web/images/default_artist.png differ diff --git a/music_assistant/web/images/icons/aac.png b/music_assistant/web/images/icons/aac.png new file mode 100644 index 00000000..7dafab27 Binary files /dev/null and b/music_assistant/web/images/icons/aac.png differ diff --git a/music_assistant/web/images/icons/file.png b/music_assistant/web/images/icons/file.png new file mode 100644 index 00000000..bd2df042 Binary files /dev/null and b/music_assistant/web/images/icons/file.png differ diff --git a/music_assistant/web/images/icons/flac.png b/music_assistant/web/images/icons/flac.png new file mode 100644 index 00000000..33e1f175 Binary files /dev/null and b/music_assistant/web/images/icons/flac.png differ diff --git a/music_assistant/web/images/icons/hires.png b/music_assistant/web/images/icons/hires.png new file mode 100644 index 00000000..a398c6e5 Binary files /dev/null and b/music_assistant/web/images/icons/hires.png differ diff --git a/music_assistant/web/images/icons/icon-128x128.png b/music_assistant/web/images/icons/icon-128x128.png new file mode 100755 index 00000000..b770cb49 Binary files /dev/null and b/music_assistant/web/images/icons/icon-128x128.png differ diff --git a/music_assistant/web/images/icons/icon-144x144.png b/music_assistant/web/images/icons/icon-144x144.png new file mode 100755 index 00000000..5bc3a3e7 Binary files /dev/null and b/music_assistant/web/images/icons/icon-144x144.png differ diff --git a/music_assistant/web/images/icons/icon-152x152.png b/music_assistant/web/images/icons/icon-152x152.png new file mode 100755 index 00000000..2fdb1251 Binary files /dev/null and b/music_assistant/web/images/icons/icon-152x152.png differ diff --git a/music_assistant/web/images/icons/icon-192x192.png b/music_assistant/web/images/icons/icon-192x192.png new file mode 100755 index 00000000..770f2d5a Binary files /dev/null and b/music_assistant/web/images/icons/icon-192x192.png differ diff --git a/music_assistant/web/images/icons/icon-384x384.png b/music_assistant/web/images/icons/icon-384x384.png new file mode 100755 index 00000000..f408f72c Binary files /dev/null and b/music_assistant/web/images/icons/icon-384x384.png differ diff --git a/music_assistant/web/images/icons/icon-512x512.png b/music_assistant/web/images/icons/icon-512x512.png new file mode 100755 index 00000000..f408f72c Binary files /dev/null and b/music_assistant/web/images/icons/icon-512x512.png differ diff --git a/music_assistant/web/images/icons/icon-72x72.png b/music_assistant/web/images/icons/icon-72x72.png new file mode 100755 index 00000000..efd13910 Binary files /dev/null and b/music_assistant/web/images/icons/icon-72x72.png differ diff --git a/music_assistant/web/images/icons/icon-96x96.png b/music_assistant/web/images/icons/icon-96x96.png new file mode 100755 index 00000000..055583c2 Binary files /dev/null and b/music_assistant/web/images/icons/icon-96x96.png differ diff --git a/music_assistant/web/images/icons/info_gradient.jpg b/music_assistant/web/images/icons/info_gradient.jpg new file mode 100644 index 00000000..9d0c0e3b Binary files /dev/null and b/music_assistant/web/images/icons/info_gradient.jpg differ diff --git a/music_assistant/web/images/icons/mp3.png b/music_assistant/web/images/icons/mp3.png new file mode 100644 index 00000000..b894bda2 Binary files /dev/null and b/music_assistant/web/images/icons/mp3.png differ diff --git a/music_assistant/web/images/icons/qobuz.png b/music_assistant/web/images/icons/qobuz.png new file mode 100644 index 00000000..9d7b726c Binary files /dev/null and b/music_assistant/web/images/icons/qobuz.png differ diff --git a/music_assistant/web/images/icons/spotify.png b/music_assistant/web/images/icons/spotify.png new file mode 100644 index 00000000..805f5c71 Binary files /dev/null and b/music_assistant/web/images/icons/spotify.png differ diff --git a/music_assistant/web/images/icons/vorbis.png b/music_assistant/web/images/icons/vorbis.png new file mode 100644 index 00000000..c6d69145 Binary files /dev/null and b/music_assistant/web/images/icons/vorbis.png differ diff --git a/music_assistant/web/images/info_gradient.jpg b/music_assistant/web/images/info_gradient.jpg new file mode 100644 index 00000000..9d0c0e3b Binary files /dev/null and b/music_assistant/web/images/info_gradient.jpg differ diff --git a/music_assistant/web/index.html b/music_assistant/web/index.html new file mode 100755 index 00000000..2b1b5a45 --- /dev/null +++ b/music_assistant/web/index.html @@ -0,0 +1,234 @@ + + + + + + Music Assistant + + + + + + + + + + + +
+ + + + + + + + + + Please stand by + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/music_assistant/web/manifest.json b/music_assistant/web/manifest.json new file mode 100755 index 00000000..5fbca0dc --- /dev/null +++ b/music_assistant/web/manifest.json @@ -0,0 +1,51 @@ +{ + "name": "Music Assistant", + "short_name": "MusicAssistant", + "theme_color": "#2196f3", + "background_color": "#2196f3", + "display": "standalone", + "Scope": "/", + "start_url": "/", + "icons": [{ + "src": "images/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "images/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "images/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "images/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "images/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png" + }, + { + "src": "images/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "images/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "images/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "splash_pages": null +} \ No newline at end of file diff --git a/music_assistant/web/pages/albumdetails.vue.js b/music_assistant/web/pages/albumdetails.vue.js new file mode 100755 index 00000000..b8d59af9 --- /dev/null +++ b/music_assistant/web/pages/albumdetails.vue.js @@ -0,0 +1,107 @@ +var AlbumDetails = Vue.component('AlbumDetails', { + template: ` +
+ + + Album tracks + + + + + + + + + + Versions + + + + + + + + + + +
`, + props: ['provider', 'media_id'], + data() { + return { + selected: [2], + info: {}, + albumtracks: [], + albumversions: [], + offset: 0, + active: null, + } + }, + created() { + this.$globals.windowtitle = "Album info" + this.getInfo(); + this.getAlbumTracks(); + }, + methods: { + getInfo () { + this.$globals.loading = true; + const api_url = '/api/albums/' + this.media_id + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.info = data; + this.getAlbumVersions() + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + }, + getAlbumTracks () { + const api_url = '/api/albums/' + this.media_id + '/tracks' + axios + .get(api_url, { params: { offset: this.offset, limit: 50, provider: this.provider}}) + .then(result => { + data = result.data; + this.albumtracks.push(...data); + this.offset += 50; + }) + .catch(error => { + console.log("error", error); + }); + }, + getAlbumVersions () { + const api_url = '/api/search'; + var searchstr = this.info.artist.name + " - " + this.info.name + axios + .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'albums', online: true}}) + .then(result => { + data = result.data; + this.albumversions.push(...data.albums); + this.offset += 50; + }) + .catch(error => { + console.log("error", error); + }); + }, + } +}) diff --git a/music_assistant/web/pages/artistdetails.vue.js b/music_assistant/web/pages/artistdetails.vue.js new file mode 100755 index 00000000..785e94b3 --- /dev/null +++ b/music_assistant/web/pages/artistdetails.vue.js @@ -0,0 +1,126 @@ +var ArtistDetails = Vue.component('ArtistDetails', { + template: ` +
+ + + Top tracks + + + + + + + + + + Albums + + + + + + + + + +
`, + props: ['media_id', 'provider'], + data() { + return { + selected: [2], + info: {}, + toptracks: [], + artistalbums: [], + bg_image: "../images/info_gradient.jpg", + active: null, + playmenu: false, + playmenuitem: null + } + }, + created() { + this.$globals.windowtitle = "Artist info" + this.getInfo(); + }, + methods: { + getFanartImage() { + if (this.info.metadata && this.info.metadata.fanart) + return this.info.metadata.fanart; + else if (this.info.artists) + for (artist in this.info.artists) + if (artist.info.metadata && artist.data.metadata.fanart) + return artist.metadata.fanart; + }, + getInfo (lazy=true) { + this.$globals.loading = true; + const api_url = '/api/artists/' + this.media_id + axios + .get(api_url, { params: { lazy: lazy }}) + .then(result => { + data = result.data; + this.info = data; + this.$globals.loading = false; + if (data.is_lazy == true) + // refresh the info if we got a lazy object + this.timeout1 = setTimeout(function(){ + this.getInfo(false); + }.bind(this), 1000); + else { + this.getArtistTopTracks(); + this.getArtistAlbums(); + } + }) + .catch(error => { + console.log("error", error); + this.$globals.loading = false; + }); + }, + getArtistTopTracks () { + + const api_url = '/api/artists/' + this.media_id + '/toptracks' + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.toptracks = data; + }) + .catch(error => { + console.log("error", error); + }); + + }, + getArtistAlbums () { + const api_url = '/api/artists/' + this.media_id + '/albums' + console.log('loading ' + api_url); + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.artistalbums = data; + }) + .catch(error => { + console.log("error", error); + }); + }, + } +}) diff --git a/music_assistant/web/pages/browse.vue.js b/music_assistant/web/pages/browse.vue.js new file mode 100755 index 00000000..fae6b299 --- /dev/null +++ b/music_assistant/web/pages/browse.vue.js @@ -0,0 +1,68 @@ +var Browse = Vue.component('Browse', { + template: ` +
+ + + + {{ $globals.windowtitle }} + + + + + + + +
+ `, + props: ['mediatype'], + data() { + return { + selected: [2], + items: [], + offset: 0 + } + }, + created() { + this.showavatar = true; + mediatitle = + this.$globals.windowtitle = this.mediatype.charAt(0).toUpperCase() + this.mediatype.slice(1); + this.scroll(this.Browse); + this.getItems(); + }, + methods: { + getItems () { + this.$globals.loading = true + const api_url = '/api/' + this.mediatype; + axios + .get(api_url, { params: { offset: this.offset, limit: 50 }}) + .then(result => { + data = result.data; + this.items.push(...data); + this.offset += 50; + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + this.showProgress = false; + }); + }, + scroll (Browse) { + window.onscroll = () => { + let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight; + + if (bottomOfWindow) { + this.getItems(); + } + }; + } + } +}) diff --git a/music_assistant/web/pages/config.vue.js b/music_assistant/web/pages/config.vue.js new file mode 100755 index 00000000..e9b72111 --- /dev/null +++ b/music_assistant/web/pages/config.vue.js @@ -0,0 +1,180 @@ +var Config = Vue.component('Config', { + template: ` +
+ + + + {{ $globals.windowtitle }} + + + + + + + + + + + + + + + + + + + + + + + + + Save + +
+ `, + props: [], + data() { + return { + conf: {}, + players: {} + } + }, + computed: { + playersLst() + { + var playersLst = []; + for (player_id in this.conf.player_settings) + if (player_id != '__desc__') + playersLst.push({id: player_id, name: this.conf.player_settings[player_id].name}) + return playersLst; + } + + }, + created() { + this.$globals.windowtitle = "Configuration"; + this.getPlayers(); + this.getConfig(); + console.log(this.$globals.all_players); + }, + methods: { + getConfig () { + axios + .get('/api/config') + .then(result => { + this.conf = result.data; + }) + .catch(error => { + console.log("error", error); + }); + }, + saveConfig () { + axios + .post('/api/config', this.conf) + .then(result => { + console.log(result); + }) + .catch(error => { + console.log("error", error); + }); + }, + getPlayers () { + const api_url = '/api/players'; + axios + .get(api_url) + .then(result => { + for (var item of result.data) + this.$set(this.players, item.player_id, item) + }) + .catch(error => { + console.log("error", error); + this.showProgress = false; + }); + }, + } +}) diff --git a/music_assistant/web/pages/home.vue.js b/music_assistant/web/pages/home.vue.js new file mode 100755 index 00000000..348a407d --- /dev/null +++ b/music_assistant/web/pages/home.vue.js @@ -0,0 +1,40 @@ +var home = Vue.component("Home", { + template: ` + + + + {{ item.icon }} + + + {{ item.title }} + + + +`, + props: ["title"], + $_veeValidate: { + validator: "new" + }, + data() { + return { + result: null, + showProgress: false + }; + }, + created() { + this.$globals.windowtitle = "Home" + this.items= [ + { title: 'Artists', path: '/browse/library/artists', icon: "person" }, + { title: 'Albums', path: '/browse/library/albums', icon: "album" }, + { title: 'Tracks', path: '/browse/library/tracks', icon: "audiotrack" }, + { title: 'Playlists', path: '/browse/library/playlists', icon: "playlist_play" } + ] + }, + methods: { + click (item) { + console.log("selected: "+ item.path); + router.push({path: item.path}) + } + } +}); diff --git a/music_assistant/web/pages/playlistdetails.vue.js b/music_assistant/web/pages/playlistdetails.vue.js new file mode 100755 index 00000000..b2f97dc3 --- /dev/null +++ b/music_assistant/web/pages/playlistdetails.vue.js @@ -0,0 +1,82 @@ +var PlaylistDetails = Vue.component('PlaylistDetails', { + template: ` +
+ + + Playlist tracks + + + + + + + + + +
`, + props: ['provider', 'media_id'], + data() { + return { + selected: [2], + info: {}, + items: [], + offset: 0, + } + }, + created() { + this.$globals.windowtitle = "Playlist info" + this.getInfo(); + this.getPlaylistTracks(); + this.scroll(this.Browse); + }, + methods: { + getInfo () { + const api_url = '/api/playlists/' + this.media_id + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.info = data; + }) + .catch(error => { + console.log("error", error); + }); + }, + getPlaylistTracks () { + this.$globals.loading = true + const api_url = '/api/playlists/' + this.media_id + '/tracks' + axios + .get(api_url, { params: { offset: this.offset, limit: 25, provider: this.provider}}) + .then(result => { + data = result.data; + this.items.push(...data); + this.offset += 25; + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + + }, + scroll (Browse) { + window.onscroll = () => { + let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight; + if (bottomOfWindow) { + this.getPlaylistTracks(); + } + }; + } + } +}) diff --git a/music_assistant/web/pages/queue.vue.js b/music_assistant/web/pages/queue.vue.js new file mode 100755 index 00000000..7f76632e --- /dev/null +++ b/music_assistant/web/pages/queue.vue.js @@ -0,0 +1,82 @@ +var Queue = Vue.component('Queue', { + template: ` +
+ + + Queue + + + + + + + + + +
`, + props: ['player_id'], + data() { + return { + selected: [0], + info: {}, + items: [], + offset: 0, + } + }, + created() { + this.$globals.windowtitle = "Queue" + //this.getInfo(); + this.getQueueTracks(); + this.scroll(this.Queue); + }, + methods: { + getInfo () { + const api_url = '/api/players/' + this.media_id + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.info = data; + }) + .catch(error => { + console.log("error", error); + }); + }, + getQueueTracks () { + this.$globals.loading = true + const api_url = '/api/players/' + this.player_id + '/queue' + axios + .get(api_url, { params: { offset: this.offset, limit: 50}}) + .then(result => { + data = result.data; + this.items.push(...data); + this.offset += 25; + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + + }, + scroll (Browse) { + window.onscroll = () => { + let bottomOfWindow = document.documentElement.scrollTop + window.innerHeight === document.documentElement.offsetHeight; + if (bottomOfWindow) { + this.getQueueTracks(); + } + }; + } + } +}) diff --git a/music_assistant/web/pages/search.vue.js b/music_assistant/web/pages/search.vue.js new file mode 100755 index 00000000..2bff1a6c --- /dev/null +++ b/music_assistant/web/pages/search.vue.js @@ -0,0 +1,166 @@ +var Search = Vue.component('Search', { + template: ` +
+ + + +
+ + {{ $globals.windowtitle }} + + + + + + + + + Tracks + + + + + + + + + + Artists + + + + + + + + + + Albums + + + + + + + + + + Playlists + + + + + + + + + + + +
`, + props: [], + data() { + return { + selected: [2], + artists: [], + albums: [], + tracks: [], + playlists: [], + timeout: null, + searchquery: '' + } + }, + created() { + this.$globals.windowtitle = "Search"; + }, + methods: { + toggle (index) { + const i = this.selected.indexOf(index) + if (i > -1) { + this.selected.splice(i, 1) + } else { + this.selected.push(index) + console.log("selected: "+ this.items[index].name); + } + }, + onSearchBoxInput (index) { + clearTimeout(this.timeout); + this.timeout = setTimeout(this.Search, 600); + }, + Search () { + if (!this.searchquery) { + this.artists = []; + this.albums = []; + this.tracks = []; + this.playlists = []; + } + else { + this.$globals.loading = true; + console.log(this.searchquery); + const api_url = '/api/search' + console.log('loading ' + api_url); + axios + .get(api_url, { + params: { + query: this.searchquery, + online: true, + limit: 3 + } + }) + .then(result => { + data = result.data; + this.artists = data.artists; + this.albums = data.albums; + this.tracks = data.tracks; + this.playlists = data.playlists; + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + } + + }, + } +}) diff --git a/music_assistant/web/pages/trackdetails.vue.js b/music_assistant/web/pages/trackdetails.vue.js new file mode 100755 index 00000000..497f6b35 --- /dev/null +++ b/music_assistant/web/pages/trackdetails.vue.js @@ -0,0 +1,77 @@ +var TrackDetails = Vue.component('TrackDetails', { + template: ` +
+ + + Other versions + + + + + + + + + + +
`, + props: ['provider', 'media_id'], + data() { + return { + selected: [2], + info: {}, + trackversions: [], + offset: 0, + active: null, + } + }, + created() { + this.$globals.windowtitle = "Track info" + this.getInfo(); + }, + methods: { + getInfo () { + this.$globals.loading = true; + const api_url = '/api/tracks/' + this.media_id + axios + .get(api_url, { params: { provider: this.provider }}) + .then(result => { + data = result.data; + this.info = data; + this.getTrackVersions() + this.$globals.loading = false; + }) + .catch(error => { + console.log("error", error); + }); + }, + getTrackVersions () { + const api_url = '/api/search'; + var searchstr = this.info.artists[0].name + " - " + this.info.name + axios + .get(api_url, { params: { query: searchstr, limit: 50, media_types: 'tracks', online: true}}) + .then(result => { + data = result.data; + this.trackversions.push(...data.tracks); + this.offset += 50; + }) + .catch(error => { + console.log("error", error); + }); + }, + } +}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..ee89b4aa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +cytoolz +aiohttp +spotify_token +pychromecast \ No newline at end of file