From f42636b46add1a22aa94881d773b389655634fdf Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Sat, 12 Oct 2019 02:28:58 +0200 Subject: [PATCH] refactor in progress --- .vscode/.ropeproject/config.py | 114 +++++++ Dockerfile | 3 +- main.py | 14 + music_assistant/__init__.py | 137 ++++++++ music_assistant/{modules => }/cache.py | 2 +- music_assistant/database.py | 5 +- .../{modules => }/homeassistant.py | 13 +- .../{modules => }/http_streamer.py | 295 +++++++---------- music_assistant/main.py | 130 -------- music_assistant/main.spec | 33 -- music_assistant/{modules => }/metadata.py | 5 +- music_assistant/models/__init__.py | 5 + music_assistant/models/media_types.py | 74 ++--- music_assistant/models/musicprovider.py | 15 +- music_assistant/models/player.py | 311 ++++++++++-------- music_assistant/models/player_queue.py | 154 ++++++--- music_assistant/models/playerprovider.py | 24 +- music_assistant/modules/player_manager.py | 89 ----- .../modules/playerproviders/__init__.py | 0 .../{modules => }/music_manager.py | 8 +- .../{modules => musicproviders}/__init__.py | 0 .../{modules => }/musicproviders/file.py | 12 +- .../{modules => }/musicproviders/qobuz.py | 11 +- .../{modules => }/musicproviders/spotify.py | 16 +- .../musicproviders/spotty/arm-linux/spotty-hf | Bin .../musicproviders/spotty/darwin/spotty | Bin .../musicproviders/spotty/windows/spotty.exe | Bin .../musicproviders/spotty/x86-linux/spotty | Bin .../spotty/x86-linux/spotty-x86_64 | Bin .../{modules => }/musicproviders/tunein.py | 38 ++- music_assistant/player_manager.py | 145 ++++++++ .../__init__.py | 0 .../playerproviders/chromecast.py | 218 ++++++------ .../{modules => }/playerproviders/lms.py | 14 +- .../{modules => }/playerproviders/pylms.py | 17 +- music_assistant/{modules => }/web.py | 60 ++-- run.sh | 3 +- .../web => web}/components/headermenu.vue.js | 0 .../web => web}/components/infoheader.vue.js | 0 .../components/listviewItem.vue.js | 0 .../web => web}/components/player.vue.js | 11 +- .../web => web}/components/playmenu.vue.js | 0 .../components/providericons.vue.js | 0 .../web => web}/components/readmore.vue.js | 0 .../web => web}/components/searchbox.vue.js | 0 .../components/volumecontrol.vue.js | 0 .../web => web}/css/nprogress.css | 0 {music_assistant/web => web}/css/site.css | 0 .../web => web}/css/vue-loading.css | 0 .../web => web}/images/default_artist.png | Bin .../web => web}/images/icons/aac.png | Bin .../web => web}/images/icons/chromecast.png | Bin .../web => web}/images/icons/file.png | Bin .../web => web}/images/icons/flac.png | Bin .../web => web}/images/icons/hires.png | Bin .../images/icons/homeassistant.png | Bin .../images/icons/http_streamer.png | Bin .../web => web}/images/icons/icon-128x128.png | Bin .../web => web}/images/icons/icon-256x256.png | Bin .../web => web}/images/icons/icon-apple.png | Bin .../images/icons/info_gradient.jpg | Bin .../web => web}/images/icons/lms.png | Bin .../web => web}/images/icons/mp3.png | Bin .../web => web}/images/icons/pylms.png | Bin .../web => web}/images/icons/qobuz.png | Bin .../web => web}/images/icons/spotify.png | Bin .../web => web}/images/icons/tunein.png | Bin .../web => web}/images/icons/vorbis.png | Bin .../web => web}/images/icons/web.png | Bin .../web => web}/images/info_gradient.jpg | Bin {music_assistant/web => web}/index.html | 0 .../web => web}/lib/vue-loading-overlay.js | 0 {music_assistant/web => web}/manifest.json | 0 .../web => web}/pages/albumdetails.vue.js | 0 .../web => web}/pages/artistdetails.vue.js | 0 .../web => web}/pages/browse.vue.js | 0 .../web => web}/pages/config.vue.js | 0 .../web => web}/pages/home.vue.js | 0 .../web => web}/pages/playlistdetails.vue.js | 0 .../web => web}/pages/queue.vue.js | 0 .../web => web}/pages/search.vue.js | 0 .../web => web}/pages/trackdetails.vue.js | 0 {music_assistant/web => web}/strings.js | 0 83 files changed, 1088 insertions(+), 888 deletions(-) create mode 100644 .vscode/.ropeproject/config.py create mode 100755 main.py rename music_assistant/{modules => }/cache.py (99%) rename music_assistant/{modules => }/homeassistant.py (97%) rename music_assistant/{modules => }/http_streamer.py (57%) delete mode 100755 music_assistant/main.py delete mode 100644 music_assistant/main.spec rename music_assistant/{modules => }/metadata.py (99%) delete mode 100755 music_assistant/modules/player_manager.py delete mode 100644 music_assistant/modules/playerproviders/__init__.py rename music_assistant/{modules => }/music_manager.py (98%) rename music_assistant/{modules => musicproviders}/__init__.py (100%) rename music_assistant/{modules => }/musicproviders/file.py (98%) rename music_assistant/{modules => }/musicproviders/qobuz.py (98%) rename music_assistant/{modules => }/musicproviders/spotify.py (98%) rename music_assistant/{modules => }/musicproviders/spotty/arm-linux/spotty-hf (100%) rename music_assistant/{modules => }/musicproviders/spotty/darwin/spotty (100%) rename music_assistant/{modules => }/musicproviders/spotty/windows/spotty.exe (100%) rename music_assistant/{modules => }/musicproviders/spotty/x86-linux/spotty (100%) rename music_assistant/{modules => }/musicproviders/spotty/x86-linux/spotty-x86_64 (100%) rename music_assistant/{modules => }/musicproviders/tunein.py (84%) create mode 100755 music_assistant/player_manager.py rename music_assistant/{modules/musicproviders => playerproviders}/__init__.py (100%) rename music_assistant/{modules => }/playerproviders/chromecast.py (67%) rename music_assistant/{modules => }/playerproviders/lms.py (96%) rename music_assistant/{modules => }/playerproviders/pylms.py (97%) rename music_assistant/{modules => }/web.py (88%) rename {music_assistant/web => web}/components/headermenu.vue.js (100%) rename {music_assistant/web => web}/components/infoheader.vue.js (100%) rename {music_assistant/web => web}/components/listviewItem.vue.js (100%) rename {music_assistant/web => web}/components/player.vue.js (98%) rename {music_assistant/web => web}/components/playmenu.vue.js (100%) rename {music_assistant/web => web}/components/providericons.vue.js (100%) rename {music_assistant/web => web}/components/readmore.vue.js (100%) rename {music_assistant/web => web}/components/searchbox.vue.js (100%) rename {music_assistant/web => web}/components/volumecontrol.vue.js (100%) rename {music_assistant/web => web}/css/nprogress.css (100%) rename {music_assistant/web => web}/css/site.css (100%) rename {music_assistant/web => web}/css/vue-loading.css (100%) rename {music_assistant/web => web}/images/default_artist.png (100%) rename {music_assistant/web => web}/images/icons/aac.png (100%) rename {music_assistant/web => web}/images/icons/chromecast.png (100%) rename {music_assistant/web => web}/images/icons/file.png (100%) rename {music_assistant/web => web}/images/icons/flac.png (100%) rename {music_assistant/web => web}/images/icons/hires.png (100%) rename {music_assistant/web => web}/images/icons/homeassistant.png (100%) rename {music_assistant/web => web}/images/icons/http_streamer.png (100%) rename {music_assistant/web => web}/images/icons/icon-128x128.png (100%) rename {music_assistant/web => web}/images/icons/icon-256x256.png (100%) rename {music_assistant/web => web}/images/icons/icon-apple.png (100%) rename {music_assistant/web => web}/images/icons/info_gradient.jpg (100%) rename {music_assistant/web => web}/images/icons/lms.png (100%) rename {music_assistant/web => web}/images/icons/mp3.png (100%) rename {music_assistant/web => web}/images/icons/pylms.png (100%) rename {music_assistant/web => web}/images/icons/qobuz.png (100%) rename {music_assistant/web => web}/images/icons/spotify.png (100%) rename {music_assistant/web => web}/images/icons/tunein.png (100%) rename {music_assistant/web => web}/images/icons/vorbis.png (100%) rename {music_assistant/web => web}/images/icons/web.png (100%) rename {music_assistant/web => web}/images/info_gradient.jpg (100%) rename {music_assistant/web => web}/index.html (100%) rename {music_assistant/web => web}/lib/vue-loading-overlay.js (100%) rename {music_assistant/web => web}/manifest.json (100%) rename {music_assistant/web => web}/pages/albumdetails.vue.js (100%) rename {music_assistant/web => web}/pages/artistdetails.vue.js (100%) rename {music_assistant/web => web}/pages/browse.vue.js (100%) rename {music_assistant/web => web}/pages/config.vue.js (100%) rename {music_assistant/web => web}/pages/home.vue.js (100%) rename {music_assistant/web => web}/pages/playlistdetails.vue.js (100%) rename {music_assistant/web => web}/pages/queue.vue.js (100%) rename {music_assistant/web => web}/pages/search.vue.js (100%) rename {music_assistant/web => web}/pages/trackdetails.vue.js (100%) rename {music_assistant/web => web}/strings.js (100%) diff --git a/.vscode/.ropeproject/config.py b/.vscode/.ropeproject/config.py new file mode 100644 index 00000000..dee2d1ae --- /dev/null +++ b/.vscode/.ropeproject/config.py @@ -0,0 +1,114 @@ +# The default ``config.py`` +# flake8: noqa + + +def set_prefs(prefs): + """This function is called before opening the project""" + + # Specify which files and folders to ignore in the project. + # Changes to ignored resources are not added to the history and + # VCSs. Also they are not returned in `Project.get_files()`. + # Note that ``?`` and ``*`` match all characters but slashes. + # '*.pyc': matches 'test.pyc' and 'pkg/test.pyc' + # 'mod*.pyc': matches 'test/mod1.pyc' but not 'mod/1.pyc' + # '.svn': matches 'pkg/.svn' and all of its children + # 'build/*.o': matches 'build/lib.o' but not 'build/sub/lib.o' + # 'build//*.o': matches 'build/lib.o' and 'build/sub/lib.o' + prefs['ignored_resources'] = ['*.pyc', '*~', '.ropeproject', + '.hg', '.svn', '_svn', '.git', '.tox'] + + # Specifies which files should be considered python files. It is + # useful when you have scripts inside your project. Only files + # ending with ``.py`` are considered to be python files by + # default. + # prefs['python_files'] = ['*.py'] + + # Custom source folders: By default rope searches the project + # for finding source folders (folders that should be searched + # for finding modules). You can add paths to that list. Note + # that rope guesses project source folders correctly most of the + # time; use this if you have any problems. + # The folders should be relative to project root and use '/' for + # separating folders regardless of the platform rope is running on. + # 'src/my_source_folder' for instance. + # prefs.add('source_folders', 'src') + + # You can extend python path for looking up modules + # prefs.add('python_path', '~/python/') + + # Should rope save object information or not. + prefs['save_objectdb'] = True + prefs['compress_objectdb'] = False + + # If `True`, rope analyzes each module when it is being saved. + prefs['automatic_soa'] = True + # The depth of calls to follow in static object analysis + prefs['soa_followed_calls'] = 0 + + # If `False` when running modules or unit tests "dynamic object + # analysis" is turned off. This makes them much faster. + prefs['perform_doa'] = True + + # Rope can check the validity of its object DB when running. + prefs['validate_objectdb'] = True + + # How many undos to hold? + prefs['max_history_items'] = 32 + + # Shows whether to save history across sessions. + prefs['save_history'] = True + prefs['compress_history'] = False + + # Set the number spaces used for indenting. According to + # :PEP:`8`, it is best to use 4 spaces. Since most of rope's + # unit-tests use 4 spaces it is more reliable, too. + prefs['indent_size'] = 4 + + # Builtin and c-extension modules that are allowed to be imported + # and inspected by rope. + prefs['extension_modules'] = [] + + # Add all standard c-extensions to extension_modules list. + prefs['import_dynload_stdmods'] = True + + # If `True` modules with syntax errors are considered to be empty. + # The default value is `False`; When `False` syntax errors raise + # `rope.base.exceptions.ModuleSyntaxError` exception. + prefs['ignore_syntax_errors'] = False + + # If `True`, rope ignores unresolvable imports. Otherwise, they + # appear in the importing namespace. + prefs['ignore_bad_imports'] = False + + # If `True`, rope will insert new module imports as + # `from import ` by default. + prefs['prefer_module_from_imports'] = False + + # If `True`, rope will transform a comma list of imports into + # multiple separate import statements when organizing + # imports. + prefs['split_imports'] = False + + # If `True`, rope will remove all top-level import statements and + # reinsert them at the top of the module when making changes. + prefs['pull_imports_to_top'] = True + + # If `True`, rope will sort imports alphabetically by module name instead + # of alphabetically by import statement, with from imports after normal + # imports. + prefs['sort_imports_alphabetically'] = False + + # Location of implementation of + # rope.base.oi.type_hinting.interfaces.ITypeHintingFactory In general + # case, you don't have to change this value, unless you're an rope expert. + # Change this value to inject you own implementations of interfaces + # listed in module rope.base.oi.type_hinting.providers.interfaces + # For example, you can add you own providers for Django Models, or disable + # the search type-hinting in a class hierarchy, etc. + prefs['type_hinting_factory'] = ( + 'rope.base.oi.type_hinting.factory.default_type_hinting_factory') + + +def project_opened(project): + """This function is called after opening the project""" + # Do whatever you like here! diff --git a/Dockerfile b/Dockerfile index 5c1ed6b8..5a0d990a 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,8 @@ RUN apk --no-cache add --virtual .builddeps build-base python3-dev taglib-dev && # copy files RUN mkdir -p /usr/src/app WORKDIR /usr/src/app -COPY music_assistant /usr/src/app +COPY music_assistant /usr/src/app/music_assistant +COPY main.py /usr/src/app/main.py RUN chmod a+x /usr/src/app/main.py VOLUME ["/data"] diff --git a/main.py b/main.py new file mode 100755 index 00000000..04620168 --- /dev/null +++ b/main.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import sys +import os + +from music_assistant import MusicAssistant + +if __name__ == "__main__": + datapath = sys.argv[1] + if not datapath: + datapath = os.path.dirname(os.path.abspath(__file__)) + MusicAssistant(datapath) + \ No newline at end of file diff --git a/music_assistant/__init__.py b/music_assistant/__init__.py index e69de29b..984e33b8 100644 --- a/music_assistant/__init__.py +++ b/music_assistant/__init__.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# import os, sys; sys.path.append(os.path.dirname(os.path.realpath(__file__))) + +import sys +import asyncio +from concurrent.futures import ThreadPoolExecutor +from contextlib import suppress +import re +import uvloop +import os +import shutil +import slugify as unicode_slug +import uuid +import json +import time +# import stackimpact + +# __package__ = 'music_assistant' + +from .database import Database +from .utils import run_periodic, LOGGER +from .metadata import MetaData +from .cache import Cache +from .music_manager import Music +from .player_manager import PlayerManager +from .http_streamer import HTTPStreamer +from .homeassistant import setup as hass_setup +from .web import setup as web_setup + +def handle_exception(loop, context): + # context["message"] will always be there; but context["exception"] may not + msg = context.get("exception", context["message"]) + LOGGER.error(f"Caught exception: {msg}") + +class MusicAssistant(): + + def __init__(self, datapath): + uvloop.install() + self.datapath = datapath + self.parse_config() + self.event_loop = asyncio.get_event_loop() + self.bg_executor = ThreadPoolExecutor() + self.event_loop.set_default_executor(self.bg_executor) + self.event_loop.set_exception_handler(handle_exception) + self.event_listeners = {} + + # init database and metadata modules + self.db = Database(datapath, self.event_loop) + # allow some time for the database to initialize + while not self.db.db_ready: + time.sleep(0.15) + self.cache = Cache(datapath) + self.metadata = MetaData(self.event_loop, self.db, self.cache) + + # init modules + self.web = web_setup(self) + self.hass = hass_setup(self) + self.music = Music(self) + self.player = PlayerManager(self) + self.http_streamer = HTTPStreamer(self) + + # agent = stackimpact.start( + # agent_key = '4a00b6f2c7da20f692807d204ab3760318978ba3', + # app_name = 'MusicAssistant') + # print("profiler started...") + + # start the event loop + try: + self.event_loop.run_forever() + except (KeyboardInterrupt, SystemExit): + LOGGER.info('Exit requested!') + self.signal_event("system_shutdown") + self.event_loop.stop() + self.save_config() + time.sleep(5) + self.event_loop.close() + LOGGER.info('Shutdown complete.') + + def signal_event(self, msg, msg_details=None): + ''' signal (systemwide) event ''' + LOGGER.debug("Event: %s - %s" %(msg, msg_details)) + listeners = list(self.event_listeners.values()) + for callback, eventfilter in listeners: + if not eventfilter or eventfilter in msg: + if not asyncio.iscoroutinefunction(callback): + callback(msg, msg_details) + else: + self.event_loop.create_task(callback(msg, msg_details)) + + def add_event_listener(self, cb, eventfilter=None): + ''' add callback to our event listeners ''' + cb_id = str(uuid.uuid4()) + self.event_listeners[cb_id] = (cb, eventfilter) + return cb_id + + def remove_event_listener(self, cb_id): + ''' remove callback from 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.backup') + if os.path.isfile(conf_file): + shutil.move(conf_file, conf_file_backup) + # remove description keys from config + final_conf = {} + for key, value in self.config.items(): + final_conf[key] = {} + for subkey, subvalue in value.items(): + if subkey != "__desc__": + final_conf[key][subkey] = subvalue + with open(conf_file, 'w') as f: + f.write(json.dumps(final_conf, indent=4)) + + def parse_config(self): + '''get config from config file''' + config = { + "base": {}, + "musicproviders": {}, + "playerproviders": {}, + "player_settings": {} + } + conf_file = os.path.join(self.datapath, 'config.json') + if os.path.isfile(conf_file): + with open(conf_file) as f: + data = f.read() + if data: + data = json.loads(data) + for key, value in data.items(): + config[key] = value + self.config = config + + \ No newline at end of file diff --git a/music_assistant/modules/cache.py b/music_assistant/cache.py similarity index 99% rename from music_assistant/modules/cache.py rename to music_assistant/cache.py index 85945daa..583080f2 100644 --- a/music_assistant/modules/cache.py +++ b/music_assistant/cache.py @@ -8,10 +8,10 @@ import time import sqlite3 from functools import reduce import os -from utils import run_periodic, LOGGER, parse_track_title import functools import asyncio +from .utils import run_periodic, LOGGER, parse_track_title class Cache(object): '''basic stateless caching system ''' diff --git a/music_assistant/database.py b/music_assistant/database.py index dcd3715c..7503eb37 100755 --- a/music_assistant/database.py +++ b/music_assistant/database.py @@ -3,12 +3,13 @@ import asyncio import os -from utils import run_periodic, LOGGER, get_sort_name, try_parse_int -from models import MediaType, Artist, Album, Track, Playlist, Radio from typing import List import aiosqlite import operator +from .utils import run_periodic, LOGGER, get_sort_name, try_parse_int +from .models.media_types import MediaType, Artist, Album, Track, Playlist, Radio + class Database(): def __init__(self, datapath, event_loop): diff --git a/music_assistant/modules/homeassistant.py b/music_assistant/homeassistant.py similarity index 97% rename from music_assistant/modules/homeassistant.py rename to music_assistant/homeassistant.py index 32961b47..ae30d1f6 100644 --- a/music_assistant/modules/homeassistant.py +++ b/music_assistant/homeassistant.py @@ -5,19 +5,20 @@ import asyncio import os from typing import List import random -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 modules.cache import use_cache import copy import slugify as slug +import json +from .utils import run_periodic, LOGGER, parse_track_title, try_parse_int +from .models.media_types import Track +from .constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT +from .cache import use_cache + ''' Homeassistant integration @@ -187,7 +188,7 @@ class HomeAssistant(): "volume_level": player.volume_level/100, "is_volume_muted": player.muted, "media_duration": player.cur_item.duration if player.cur_item else 0, - "media_position": player.cur_item_time, + "media_position": player.cur_time, "media_title": player.cur_item.name if player.cur_item else "", "media_artist": player.cur_item.artists[0].name if player.cur_item and player.cur_item.artists else "", "media_album_name": player.cur_item.album.name if player.cur_item and player.cur_item.album else "", diff --git a/music_assistant/modules/http_streamer.py b/music_assistant/http_streamer.py similarity index 57% rename from music_assistant/modules/http_streamer.py rename to music_assistant/http_streamer.py index e4c02612..0c5a3433 100755 --- a/music_assistant/modules/http_streamer.py +++ b/music_assistant/http_streamer.py @@ -3,8 +3,6 @@ import asyncio import os -from utils import LOGGER, try_parse_int, get_ip, run_async_background_task, run_periodic, get_folder_size -from models import TrackQuality, MediaType, PlayerState import operator from aiohttp import web import threading @@ -14,6 +12,9 @@ import io import soundfile as sf import pyloudnorm as pyln import aiohttp +from .utils import LOGGER, try_parse_int, get_ip, run_async_background_task, run_periodic, get_folder_size +from .models.media_types import TrackQuality, MediaType +from .models.player import PlayerState class HTTPStreamer(): @@ -21,118 +22,40 @@ class HTTPStreamer(): def __init__(self, mass): self.mass = mass - self.create_config_entries() self.local_ip = get_ip() self.analyze_jobs = {} - async def stream_track(self, http_request): - ''' start streaming track from provider ''' - player_id = http_request.query.get('player_id') - track_id = http_request.query.get('track_id') - provider = http_request.query.get('provider') - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'audio/flac'}) - await resp.prepare(http_request) - if http_request.method.upper() != 'HEAD': - # stream audio - cancelled = threading.Event() - queue = asyncio.Queue() - - async def fill_buffer(): - ''' fill buffer runs in background process to prevent deadlocks of the sox executable ''' - audio_stream = self.__get_audio_stream(track_id, provider, player_id, cancelled) - async for is_last_chunk, audio_chunk in audio_stream: - if not cancelled.is_set(): - await queue.put(audio_chunk) - # wait for the queue to consume the data - # this prevents that the entire track is sitting in memory - while queue.qsize() > 1 and not cancelled.is_set(): - await asyncio.sleep(1) - await queue.put(b'') # EOF - run_async_background_task(self.mass.bg_executor, fill_buffer) - - try: - while True: - chunk = await queue.get() - if not chunk: - queue.task_done() - break - await resp.write(chunk) - queue.task_done() - except (asyncio.CancelledError, asyncio.TimeoutError): - cancelled.set() - LOGGER.info("stream_track interrupted for %s" % track_id) - raise asyncio.CancelledError() - else: - LOGGER.info("stream_track fininished for %s" % track_id) - return resp - - async def stream_radio(self, http_request): - ''' start streaming radio from provider ''' - player_id = http_request.query.get('player_id') - radio_id = http_request.query.get('radio_id') - provider = http_request.query.get('provider') - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'audio/flac'}) - await resp.prepare(http_request) - if http_request.method.upper() != 'HEAD': - # stream audio with sox - sox_effects = await self.__get_player_sox_options(radio_id, provider, player_id, True) - if self.mass.config['base']['http_streamer']['volume_normalisation']: - gain_correct = await self.__get_track_gain_correct(radio_id, provider) - gain_correct = 'vol %s dB ' % gain_correct - else: - gain_correct = '' - media_item = await self.mass.music.item(radio_id, MediaType.Radio, provider) - stream = sorted(media_item.provider_ids, key=operator.itemgetter('quality'), reverse=True)[0] - stream_url = stream["details"] - if stream["quality"] == TrackQuality.LOSSY_AAC: - input_content_type = "aac" - elif stream["quality"] == TrackQuality.LOSSY_OGG: - input_content_type = "ogg" - else: - input_content_type = "mp3" - if input_content_type == "aac": - args = 'ffmpeg -i "%s" -f flac - | sox -t flac - -t flac -C 0 - %s %s' % (stream_url, gain_correct, sox_effects) - else: - args = 'sox -t %s "%s" -t flac -C 0 - %s %s' % (input_content_type, stream_url, gain_correct, sox_effects) - LOGGER.info("Running sox with args: %s" % args) - process = await asyncio.create_subprocess_shell(args, stdout=asyncio.subprocess.PIPE) - try: - while not process.stdout.at_eof(): - chunk = await process.stdout.read(128000) - if not chunk: - break - await resp.write(chunk) - await process.wait() - LOGGER.info("streaming of radio_id %s completed" % radio_id) - except asyncio.CancelledError: - process.terminate() - await process.wait() - LOGGER.info("streaming of radio_id %s interrupted" % radio_id) - raise asyncio.CancelledError() - return resp - async def stream(self, http_request): ''' - stream queue track(s) for player with http + start stream for a player ''' - player_id = request.match_info.get('player_id','') - #startindex = int(http_request.query.get('startindex')) - cancelled = threading.Event() - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'audio/flac'}) + # make sure we have a valid player + player_id = http_request.match_info.get('player_id','') + player = await self.mass.player.get_player(player_id) + if not player: + LOGGER.error("Received stream request for non-existing player %s" %(player_id)) + return + queue_item_id = http_request.query.get('queue_item_id') + queue_item = await player.queue.by_item_id(queue_item_id) + # prepare headers as audio/flac content + resp = web.StreamResponse(status=200, reason='OK', headers={'Content-Type': 'audio/flac'}) await resp.prepare(http_request) + # send content only on GET request if http_request.method.upper() != 'HEAD': # stream audio queue = asyncio.Queue() cancelled = threading.Event() - run_async_background_task( - self.mass.bg_executor, - self.__stream_queue, player_id, startindex, queue, cancelled) + if queue_item: + # single stream requested + run_async_background_task( + self.mass.bg_executor, + self.__stream_single, player, queue_item, queue, cancelled) + else: + # no item is given, start queue stream + run_async_background_task( + self.mass.bg_executor, + self.__stream_queue, player, queue, cancelled) + await asyncio.sleep(2) try: while True: chunk = await queue.get() @@ -141,17 +64,35 @@ class HTTPStreamer(): break await resp.write(chunk) queue.task_done() - LOGGER.info("stream fininished for %s" % player_id) + LOGGER.info("stream fininished for player %s" % player.name) except asyncio.CancelledError: cancelled.set() - LOGGER.info("stream interrupted for %s" % player_id) + LOGGER.warning("stream interrupted for player %s" % player.name) raise asyncio.CancelledError() return resp + + async def __stream_single(self, player, queue_item, buffer, cancelled): + ''' start streaming single track from provider ''' + try: + audio_stream = self.__get_audio_stream(player, queue_item, cancelled) + async for is_last_chunk, audio_chunk in audio_stream: + await buffer.put(audio_chunk) + # wait for the queue to consume the data + # this prevents that the entire track is sitting in memory + # while buffer.qsize() > 1 and not cancelled.is_set(): + # await asyncio.sleep(1) + await buffer.put(b'') # EOF + except (asyncio.CancelledError, asyncio.TimeoutError): + cancelled.set() + LOGGER.info("stream_track interrupted for %s" % queue_item.name) + raise asyncio.CancelledError() + else: + LOGGER.info("stream_track fininished for %s" % queue_item.name) - async def __stream_queue(self, player_id, startindex, buffer, cancelled): + async def __stream_queue(self, player, buffer, cancelled): ''' start streaming all queue tracks ''' - sample_rate = self.mass.config['player_settings'][player_id]['max_sample_rate'] - fade_length = self.mass.config['player_settings'][player_id]["crossfade_duration"] + sample_rate = player.settings['max_sample_rate'] + fade_length = player.settings["crossfade_duration"] fade_bytes = int(sample_rate * 4 * 2 * fade_length) pcm_args = 'raw -b 32 -c 2 -e signed-integer -r %s' % sample_rate args = 'sox -t %s - -t flac -C 0 -' % pcm_args @@ -167,32 +108,23 @@ class HTTPStreamer(): await buffer.put(b'') # indicate EOF asyncio.create_task(fill_buffer()) - # retrieve player object - player = await self.mass.player.player(player_id) - queue_index = startindex - LOGGER.info("Start Queue Stream for player %s at index %s" %(player.name, queue_index)) + LOGGER.info("Start Queue Stream for player %s" %(player.name)) last_fadeout_data = b'' # report start of queue playback so we can calculate current track/duration etc. # self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, True)) while True: # get the (next) track in queue - try: - queue_tracks = await self.mass.player.player_queue(player_id, queue_index, queue_index+1) - queue_track = queue_tracks[0] - except IndexError: - LOGGER.warning("queue index out of range or end reached") + queue_track = player.queue.next_item + LOGGER.info("got queue track %s" % queue_track.name) + if not queue_track: break - - params = urllib.parse.parse_qs(queue_track.uri.split('?')[1]) - track_id = params['track_id'][0] - provider = params['provider'][0] - LOGGER.debug("Start Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name)) + LOGGER.debug("Start Streaming queue track: %s (%s) on player %s" % (queue_track.item_id, queue_track.name, player.name)) fade_in_part = b'' cur_chunk = 0 prev_chunk = None bytes_written = 0 async for is_last_chunk, chunk in self.__get_audio_stream( - track_id, provider, player_id, cancelled, chunksize=fade_bytes, resample=sample_rate): + player, queue_track, cancelled, chunksize=fade_bytes, resample=sample_rate): cur_chunk += 1 if cur_chunk <= 2 and not last_fadeout_data: # fade-in part but no fadeout_part available so just pass it to the output directly @@ -269,16 +201,19 @@ class HTTPStreamer(): # end of the track reached if cancelled.is_set(): # break out the loop if the http session is cancelled + LOGGER.warning("session cancelled") break else: # WIP: update actual duration to the queue for more accurate now playing info accurate_duration = bytes_written / int(sample_rate * 4 * 2) queue_track.duration = accurate_duration - self.mass.player.providers[player.player_provider]._player_queue[player_id][queue_index] = queue_track + #self.mass.player.providers[player.player_provider]._player_queue[player_id][queue_index] = queue_track # move to next queue index - queue_index += 1 - self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, False)) - LOGGER.debug("Finished Streaming queue track: %s (%s) on player %s" % (track_id, queue_track.name, player.name)) + #queue_index += 1 + #self.mass.event_loop.create_task(self.mass.player.player_queue_stream_update(player_id, queue_index, False)) + LOGGER.info("Finished Streaming queue track: %s (%s) on player %s" % (queue_track.item_id, queue_track.name, player.name)) + LOGGER.info("bytes written: %s - duration: %s" % (bytes_written, accurate_duration)) + break # end of queue reached, pass last fadeout bits to final output if last_fadeout_data and not cancelled.is_set(): sox_proc.stdin.write(last_fadeout_data) @@ -287,53 +222,54 @@ class HTTPStreamer(): await sox_proc.wait() LOGGER.info("streaming of queue for player %s completed" % player.name) - async def __get_audio_stream(self, track_id, provider, player_id, cancelled, + async def __get_audio_stream(self, player, queue_item, cancelled, chunksize=512000, resample=None): ''' get audio stream from provider and apply additional effects/processing where/if needed''' - if self.mass.config['base']['http_streamer']['volume_normalisation']: - gain_correct = await self.__get_track_gain_correct(track_id, provider) - gain_correct = 'vol %s dB ' % gain_correct - else: - gain_correct = '' - sox_effects = await self.__get_player_sox_options(track_id, provider, player_id, False) + sox_effects = await self.__get_player_sox_options(player, queue_item) outputfmt = 'flac -C 0' if resample: outputfmt = 'raw -b 32 -c 2 -e signed-integer' sox_effects += ' rate -v %s' % resample # stream audio from provider streamdetails = asyncio.run_coroutine_threadsafe( - self.mass.music.providers[provider].get_stream_details(track_id), + self.mass.music.providers[queue_item.provider].get_stream_details(queue_item.item_id), self.mass.event_loop).result() if not streamdetails: + LOGGER.warning("no stream details!") yield (True, b'') return - # TODO: add support for AAC streams (which sox doesn't natively support) - if streamdetails['type'] == 'url': - args = 'sox -t %s "%s" -t %s - %s %s' % (streamdetails["content_type"], - streamdetails["path"], outputfmt, gain_correct, sox_effects) + if streamdetails["content_type"] == 'aac': + # support for AAC created with ffmpeg in between + args = 'ffmpeg -i "%s" -f flac - | sox -t flac - -t %s - %s' % (streamdetails["path"], outputfmt, sox_effects) + elif streamdetails['type'] == 'url': + args = 'sox -t %s "%s" -t %s - %s' % (streamdetails["content_type"], + streamdetails["path"], outputfmt, sox_effects) elif streamdetails['type'] == 'executable': - args = '%s | sox -t %s - -t %s - %s %s' % (streamdetails["path"], - streamdetails["content_type"], outputfmt, gain_correct, sox_effects) - LOGGER.debug("Running sox with args: %s" % args) + args = '%s | sox -t %s - -t %s - %s' % (streamdetails["path"], + streamdetails["content_type"], outputfmt, sox_effects) + + LOGGER.info("Running sox with args: %s" % args) process = await asyncio.create_subprocess_shell(args, stdout=asyncio.subprocess.PIPE) # fire event that streaming has started for this track (needed by some streaming providers) - streamdetails["provider"] = provider - streamdetails["track_id"] = track_id - streamdetails["player_id"] = player_id + streamdetails["provider"] = queue_item.provider + streamdetails["track_id"] = queue_item.item_id + streamdetails["player_id"] = player.player_id self.mass.signal_event('streaming_started', streamdetails) # yield chunks from stdout # we keep 1 chunk behind to detect end of stream properly prev_chunk = b'' bytes_sent = 0 while not process.stdout.at_eof(): + if cancelled.is_set(): + process.terminate() try: chunk = await process.stdout.readexactly(chunksize) except asyncio.streams.IncompleteReadError: chunk = await process.stdout.read(chunksize) if not chunk: break - if prev_chunk and not cancelled.is_set(): + if prev_chunk: yield (False, prev_chunk) bytes_sent += len(prev_chunk) prev_chunk = chunk @@ -341,11 +277,11 @@ class HTTPStreamer(): if not cancelled.is_set(): yield (True, prev_chunk) bytes_sent += len(prev_chunk) - #await process.wait() + await process.wait() if cancelled.is_set(): - LOGGER.warning("__get_audio_stream for track_id %s interrupted" % track_id) + LOGGER.warning("__get_audio_stream for track_id %s interrupted - bytes_sent: %s" % (queue_item.item_id, bytes_sent)) else: - LOGGER.debug("__get_audio_stream for track_id %s completed" % track_id) + LOGGER.info("__get_audio_stream for track_id %s completed- bytes_sent: %s" % (queue_item.item_id, bytes_sent)) # fire event that streaming has ended for this track (needed by some streaming providers) if resample: bytes_per_second = resample * (32/8) * 2 @@ -355,34 +291,39 @@ class HTTPStreamer(): streamdetails["seconds"] = seconds_streamed self.mass.signal_event('streaming_ended', streamdetails) # send task to background to analyse the audio - self.mass.event_loop.create_task(self.__analyze_audio(track_id, provider)) + self.mass.event_loop.create_task(self.__analyze_audio(queue_item.item_id, queue_item.provider)) - async def __get_player_sox_options(self, track_id, provider, player_id, is_radio): - ''' get player specific sox options ''' - sox_effects = '' - if player_id and not is_radio and self.mass.config['player_settings'][player_id]['max_sample_rate']: - # downsample if needed - max_sample_rate = try_parse_int(self.mass.config['player_settings'][player_id]['max_sample_rate']) + async def __get_player_sox_options(self, player, queue_item): + ''' get player specific sox effect options ''' + sox_effects = [] + # volume normalisation enabled but not natively handled by player so handle with sox + if not player.supports_replay_gain and player.settings['volume_normalisation']: + target_gain = int(player.settings['target_volume']) + fallback_gain = int(player.settings['fallback_gain_correct']) + track_loudness = await self.mass.db.get_track_loudness( + queue_item.item_id, queue_item.provider) + if track_loudness == None: + gain_correct = fallback_gain + else: + gain_correct = target_gain - track_loudness + gain_correct = round(gain_correct,2) + sox_effects.append('vol %s dB ' % gain_correct) + else: + gain_correct = '' + # downsample if needed + if player.settings['max_sample_rate']: + max_sample_rate = try_parse_int(player.settings['max_sample_rate']) if max_sample_rate: - quality = TrackQuality.LOSSY_MP3 - track_future = asyncio.run_coroutine_threadsafe( - self.mass.music.track(track_id, provider), - self.mass.event_loop - ) - track = track_future.result() - for item in track.provider_ids: - if item['provider'] == provider and item['item_id'] == track_id: - quality = item['quality'] - break + quality = queue_item.quality if quality > TrackQuality.FLAC_LOSSLESS_HI_RES_3 and max_sample_rate == 192000: - sox_effects += 'rate -v 192000' + sox_effects.append('rate -v 192000') elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_2 and max_sample_rate == 96000: - sox_effects += 'rate -v 96000' + sox_effects.append('rate -v 96000') elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_1 and max_sample_rate == 48000: - sox_effects += 'rate -v 48000' - if player_id and self.mass.config['player_settings'][player_id]['sox_effects']: - sox_effects += ' ' + self.mass.config['player_settings'][player_id]['sox_effects'] - return sox_effects + sox_effects.append('rate -v 48000') + if player.settings.get('sox_effects'): + sox_effects.append(player.settings['sox_effects']) + return " ".join(sox_effects) async def __analyze_audio(self, track_id, provider): ''' analyze track audio, for now we only calculate EBU R128 loudness ''' @@ -416,16 +357,6 @@ class HTTPStreamer(): LOGGER.debug('Finished analyzing track %s' % track_id) self.analyze_jobs.pop(track_key, None) - async def __get_track_gain_correct(self, track_id, provider): - ''' get the gain correction that should be applied to a track ''' - target_gain = int(self.mass.config['base']['http_streamer']['target_volume']) - fallback_gain = int(self.mass.config['base']['http_streamer']['fallback_gain_correct']) - track_loudness = await self.mass.db.get_track_loudness(track_id, provider) - if track_loudness == None: - return fallback_gain - gain_correct = target_gain - track_loudness - return round(gain_correct,2) - async def __crossfade_pcm_parts(self, fade_in_part, fade_out_part, pcm_args, fade_length): ''' crossfade two chunks of audio using sox ''' # create fade-in part diff --git a/music_assistant/main.py b/music_assistant/main.py deleted file mode 100755 index 8b31d17f..00000000 --- a/music_assistant/main.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import sys -import asyncio -from concurrent.futures import ThreadPoolExecutor -from contextlib import suppress -import re -import uvloop -import os -import shutil -import slugify as unicode_slug -import uuid -import json -import time -# import stackimpact - -from database import Database -from utils import run_periodic, LOGGER -from modules.metadata import MetaData -from modules.cache import Cache -from modules.music import Music -from modules.playermanager import PlayerManager -from modules.http_streamer import HTTPStreamer -from modules.homeassistant import setup as hass_setup -from modules.web import setup as web_setup - -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() - self.event_loop.set_default_executor(self.bg_executor) - self.event_listeners = {} - - # init database and metadata modules - self.db = Database(datapath, self.event_loop) - # allow some time for the database to initialize - while not self.db.db_ready: - time.sleep(0.15) - self.cache = Cache(datapath) - self.metadata = MetaData(self.event_loop, self.db, self.cache) - - # init modules - self.web = web_setup(self) - self.hass = hass_setup(self) - self.music = Music(self) - self.player = PlayerManager(self) - self.http_streamer = HTTPStreamer(self) - - # agent = stackimpact.start( - # agent_key = '4a00b6f2c7da20f692807d204ab3760318978ba3', - # app_name = 'MusicAssistant') - # print("profiler started...") - - # start the event loop - try: - self.event_loop.run_forever() - except (KeyboardInterrupt, SystemExit): - LOGGER.info('Exit requested!') - self.signal_event("system_shutdown") - self.event_loop.stop() - self.save_config() - time.sleep(5) - self.event_loop.close() - LOGGER.info('Shutdown complete.') - - def signal_event(self, msg, msg_details=None): - ''' signal (systemwide) event ''' - LOGGER.debug("Event: %s - %s" %(msg, msg_details)) - listeners = list(self.event_listeners.values()) - for callback, eventfilter in listeners: - if not eventfilter or eventfilter in msg: - if not asyncio.iscoroutinefunction(callback): - callback(msg, msg_details) - else: - self.event_loop.create_task(callback(msg, msg_details)) - - def add_event_listener(self, cb, eventfilter=None): - ''' add callback to our event listeners ''' - cb_id = str(uuid.uuid4()) - self.event_listeners[cb_id] = (cb, eventfilter) - return cb_id - - def remove_event_listener(self, cb_id): - ''' remove callback from 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.backup') - if os.path.isfile(conf_file): - shutil.move(conf_file, conf_file_backup) - # remove description keys from config - final_conf = {} - for key, value in self.config.items(): - final_conf[key] = {} - for subkey, subvalue in value.items(): - if subkey != "__desc__": - final_conf[key][subkey] = subvalue - with open(conf_file, 'w') as f: - f.write(json.dumps(final_conf, indent=4)) - - def parse_config(self): - '''get config from config file''' - config = { - "base": {}, - "musicproviders": {}, - "playerproviders": {}, - "player_settings": {} - } - conf_file = os.path.join(self.datapath, 'config.json') - if os.path.isfile(conf_file): - with open(conf_file) as f: - data = f.read() - if data: - config = json.loads(data) - self.config = config - -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/main.spec b/music_assistant/main.spec deleted file mode 100644 index 9a97d322..00000000 --- a/music_assistant/main.spec +++ /dev/null @@ -1,33 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -block_cipher = None - - -a = Analysis(['main.py'], - pathex=['/Users/marcelveldt/Workdir/musicassistant/music_assistant'], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False) -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) -exe = EXE(pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='main', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True ) diff --git a/music_assistant/modules/metadata.py b/music_assistant/metadata.py similarity index 99% rename from music_assistant/modules/metadata.py rename to music_assistant/metadata.py index 9765d665..0f087890 100755 --- a/music_assistant/modules/metadata.py +++ b/music_assistant/metadata.py @@ -3,15 +3,16 @@ 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 modules.cache import use_cache from yarl import URL import re +from .utils import run_periodic, LOGGER +from .cache import use_cache + LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])' class MetaData(): diff --git a/music_assistant/models/__init__.py b/music_assistant/models/__init__.py index e69de29b..05b72219 100644 --- a/music_assistant/models/__init__.py +++ b/music_assistant/models/__init__.py @@ -0,0 +1,5 @@ +from .media_types import * +from .musicprovider import * +from .player_queue import * +from .player import * +from .playerprovider import * \ No newline at end of file diff --git a/music_assistant/models/media_types.py b/music_assistant/models/media_types.py index 1c968a65..13d98bd3 100755 --- a/music_assistant/models/media_types.py +++ b/music_assistant/models/media_types.py @@ -45,96 +45,70 @@ class TrackQuality(IntEnum): 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 ''' +class MediaItem(object): + ''' representation of a media item ''' def __init__(self): self.item_id = None self.provider = 'database' 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 + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name == other.name and + self.item_id == other.item_id and + self.provider == other.provider) + def __ne__(self, other): + return not self.__eq__(other) -class Album(object): +class Artist(MediaItem): + ''' representation of an artist ''' + def __init__(self): + super().__init__() + self.sort_name = '' + self.media_type = MediaType.Artist + +class Album(MediaItem): ''' representation of an album ''' def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' - self.metadata = {} + super().__init__() 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): +class Track(MediaItem): ''' representation of a track ''' def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' + super().__init__() self.duration = 0 self.version = '' - self.external_ids = [] - self.metadata = { } - self.tags = [] self.artists = [] - self.provider_ids = [] self.album = None self.disc_number = 1 self.track_number = 1 self.media_type = MediaType.Track - self.in_library = [] - self.is_lazy = False - self.uri = "" - 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 and - self.provider == other.provider) - def __ne__(self, other): - return not self.__eq__(other) -class Playlist(object): +class Playlist(MediaItem): ''' representation of a playlist ''' def __init__(self): - self.item_id = None - self.provider = 'database' - self.name = '' + super().__init__() self.owner = '' - self.provider_ids = [] - self.metadata = {} self.media_type = MediaType.Playlist - self.in_library = [] self.is_editable = False -class Radio(Track): +class Radio(MediaItem): ''' representation of a radio station ''' def __init__(self): super().__init__() - self.item_id = None - self.provider = 'database' - self.name = '' - self.provider_ids = [] - self.metadata = {} self.media_type = MediaType.Radio - self.in_library = [] - self.is_editable = False self.duration = 0 diff --git a/music_assistant/models/musicprovider.py b/music_assistant/models/musicprovider.py index c75e16a5..41c1ca2b 100755 --- a/music_assistant/models/musicprovider.py +++ b/music_assistant/models/musicprovider.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- +import asyncio from typing import List from ..utils import run_periodic, LOGGER, parse_track_title -import asyncio -from ..modules.cache import use_cache -from media_types import * +from ..cache import use_cache +from ..constants import CONF_ENABLED +from .media_types import Album, Artist, Track, Playlist, MediaType, Radio class MusicProvider(): @@ -373,20 +374,20 @@ class PlayerProvider(): async def players(self): ''' return all players for this provider ''' - return self.mass.provider_players(self.prov_id) + return await self.mass.provider_players(self.prov_id) async def get_player(self, player_id): ''' return player by id ''' - return self.mass.get_player(player_id) + return await self.mass.get_player(player_id) async def add_player(self, player_id, name='', is_group=False): ''' register a new player ''' - return self.mass.player.add_player(player_id, + return await self.mass.player.add_player(player_id, self.prov_id, name=name, is_group=is_group) async def remove_player(self, player_id): ''' remove a player ''' - return self.mass.player.remove_player(player_id) + return await self.mass.player.remove_player(player_id) ### Provider specific implementation ##### diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index 05f7ad55..4ae2c4f6 100755 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- +import asyncio from enum import Enum from typing import List +import operator from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, try_parse_bool, try_parse_float from ..constants import CONF_ENABLED -from ..modules.cache import use_cache -from media_types import Track, MediaType -from player_queue import PlayerQueue, QueueItem +from ..cache import use_cache +from .media_types import Track, MediaType +from .player_queue import PlayerQueue, QueueItem class PlayerState(str, Enum): @@ -21,57 +23,104 @@ class Player(): #### Provider specific implementation, should be overridden #### - async def get_config_entries(self): - ''' [MAY OVERRIDE] get the player-specific config entries for this player (list with key/value pairs)''' - return [] - - async def __stop(self): + async def cmd_stop(self): ''' [MUST OVERRIDE] send stop command to player ''' raise NotImplementedError - async def __play(self): + async def cmd_play(self): ''' [MUST OVERRIDE] send play (unpause) command to player ''' raise NotImplementedError - async def __pause(self): + async def cmd_pause(self): ''' [MUST OVERRIDE] send pause command to player ''' raise NotImplementedError + + async def cmd_next(self): + ''' [CAN OVERRIDE] send next track command to player ''' + return await self.queue.play_index(self.queue.cur_index+1) + + async def cmd_previous(self): + ''' [CAN OVERRIDE] send previous track command to player ''' + return await self.queue.play_index(self.queue.cur_index-1) - async def __power_on(self): + async def cmd_power_on(self): ''' [MUST OVERRIDE] send power ON command to player ''' raise NotImplementedError - async def __power_off(self): + async def cmd_power_off(self): ''' [MUST OVERRIDE] send power TOGGLE command to player ''' raise NotImplementedError - async def __volume_set(self, volume_level): + async def cmd_volume_set(self, volume_level): ''' [MUST OVERRIDE] send new volume level command to player ''' raise NotImplementedError - async def __volume_mute(self, is_muted=False): + async def cmd_volume_mute(self, is_muted=False): ''' [MUST OVERRIDE] send mute command to player ''' raise NotImplementedError - async def __play_queue(self): - ''' [MUST OVERRIDE] tell player to start playing the queue ''' + async def cmd_queue_play_index(self, index:int): + ''' + [OVERRIDE IF SUPPORTED] + play item at index X on player's queue + :attrib index: (int) index of the queue item that should start playing + ''' + raise NotImplementedError + + async def cmd_queue_load(self, queue_items): + ''' + [OVERRIDE IF SUPPORTED] + load/overwrite given items in the player's own queue implementation + :param queue_items: a list of QueueItems + ''' + raise NotImplementedError + + async def cmd_queue_insert(self, queue_items, offset=0): + ''' + [OVERRIDE IF SUPPORTED] + insert new items at position X into existing queue + if offset 0 or None, will start playing newly added item(s) + :param queue_items: a list of QueueItems + :param offset: offset from current queue position to insert new items + ''' + raise NotImplementedError + + async def cmd_queue_append(self, queue_items): + ''' + append new items at the end of the queue + :param queue_items: a list of QueueItems + ''' + raise NotImplementedError + + async def cmd_play_uri(self, uri:str): + ''' + [MUST OVERRIDE] + tell player to start playing a single uri + ''' raise NotImplementedError #### Common implementation, should NOT be overrridden ##### def __init__(self, mass, player_id, prov_id): + # private attributes self.mass = mass - self._player_id = player_id - self._prov_id = prov_id + self._player_id = player_id # unique id for this player + self._prov_id = prov_id # unique provider id for the player self._name = '' - self._is_group = False - self._state = PlayerState.Stopped - self._powered = False + self._is_group = False + self._state = PlayerState.Stopped + self._powered = False self._cur_time = 0 + self._cur_uri = '' self._volume_level = 0 self._muted = False self._group_parent = None self._queue = PlayerQueue(mass, self) + # public attributes + self.supports_queue = True # has native support for a queue + self.supports_gapless = True # has native gapless support + self.supports_crossfade = False # has native crossfading support + self.supports_replay_gain = False # has native support for replaygain volume leveling @property def player_id(self): @@ -116,8 +165,8 @@ class Player(): if not self.powered: return PlayerState.Off if self.group_parent: - group_player = self.mass.event_loop.run_until_complete( - self.mass.player.get_player(self.group_parent)) + group_player = self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_player(self.group_parent)).result() if group_player: return group_player.state return self._state @@ -125,7 +174,7 @@ class Player(): @state.setter def state(self, state:PlayerState): ''' [PROTECTED] set state property of this player ''' - if state != self.state: + if state != self._state: self._state = state self.mass.event_loop.create_task(self.update()) @@ -134,18 +183,18 @@ class Player(): ''' [PROTECTED] return power state for this player ''' # homeassistant integration if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): - hass_state = self.mass.event_loop.run_until_complete( + hass_state = self.mass.bg_executor.submit(asyncio.run, self.mass.hass.get_state( self.settings['hass_power_entity'], attribute='source', - register_listener=self.update())) + register_listener=self.update())).result() return hass_state == self.settings['hass_power_entity_source'] elif self.settings.get('hass_power_entity'): - hass_state = self.mass.event_loop.run_until_complete( + hass_state = self.mass.bg_executor.submit(asyncio.run, self.mass.hass.get_state( self.settings['hass_power_entity'], attribute='state', - register_listener=self.update())) + register_listener=self.update())).result() return hass_state != 'off' # mute as power elif self.settings.get('mute_as_power'): @@ -156,15 +205,17 @@ class Player(): @powered.setter def powered(self, powered): ''' [PROTECTED] set (real) power state for this player ''' - self._powered = powered + if powered != self._powered: + self._powered = powered + self.mass.event_loop.create_task(self.update()) @property def cur_time(self): ''' [PROTECTED] cur_time (player's elapsed time) property of this player ''' # handle group player if self.group_parent: - group_player = self.mass.event_loop.run_until_complete( - self.mass.player.get_player(self.group_parent)) + group_player = self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_player(self.group_parent)).result() if group_player: return group_player.cur_time return self._cur_time @@ -176,6 +227,24 @@ class Player(): self._cur_time = cur_time self.mass.event_loop.create_task(self.update()) + @property + def cur_uri(self): + ''' [PROTECTED] cur_uri (uri loaded in player) property of this player ''' + # handle group player + if self.group_parent: + group_player = self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_player(self.group_parent)).result() + if group_player: + return group_player.cur_uri + return self._cur_uri + + @cur_uri.setter + def cur_uri(self, cur_uri:str): + ''' [PROTECTED] set cur_uri (uri loaded in player) property of this player ''' + if cur_uri != self._cur_uri: + self._cur_uri = cur_uri + self.mass.event_loop.create_task(self.update()) + @property def volume_level(self): ''' [PROTECTED] volume_level property of this player ''' @@ -192,11 +261,11 @@ class Player(): return group_volume # handle hass integration elif self.mass.hass and self.settings.get('hass_volume_entity'): - hass_state = self.mass.event_loop.run_until_complete( + hass_state = self.mass.bg_executor.submit(asyncio.run, self.mass.hass.get_state( - self.settings['hass_volume_entity'], + self.settings['hass_volume_entity'], attribute='volume_level', - register_listener=self.update())) + register_listener=self.update())).result() return int(try_parse_float(hass_state)*100) else: return self._volume_level @@ -246,7 +315,9 @@ class Player(): ''' [PROTECTED] get the player config settings ''' player_settings = self.mass.config['player_settings'].get(self.player_id) if not player_settings: - return self.mass.event_loop.run_until_complete(self.__update_player_settings()) + player_settings = self.mass.bg_executor.submit(asyncio.run, + self.__update_player_settings()).result() + return player_settings @property def enabled(self): @@ -258,12 +329,17 @@ class Player(): ''' [PROTECTED] player's queue ''' # handle group player if self.group_parent: - group_player = self.mass.event_loop.run_until_complete( - self.mass.player.get_player(self.group_parent)) + group_player = self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_player(self.group_parent)).result() if group_player: return group_player.queue return self._queue + @property + def cur_item(self): + ''' current item in the player's queue ''' + return self.queue.cur_item + async def stop(self): ''' [PROTECTED] send stop command to player ''' if self.group_parent: @@ -272,7 +348,7 @@ class Player(): if group_player: return await group_player.stop() else: - return await self.__stop() + return await self.cmd_stop() async def play(self): ''' [PROTECTED] send play (unpause) command to player ''' @@ -282,9 +358,9 @@ class Player(): if group_player: return await group_player.play() elif self.state == PlayerState.Paused: - return await self.__play() + return await self.cmd_play() elif self.state != PlayerState.Playing: - return await self.play_queue() + return await self.queue.resume() async def pause(self): ''' [PROTECTED] send pause command to player ''' @@ -294,14 +370,42 @@ class Player(): if group_player: return await group_player.pause() else: - return await self.__pause() + return await self.cmd_pause() + async def next(self): + ''' [PROTECTED] send next command to player ''' + if self.group_parent: + # redirect playback related commands to parent player + group_player = await self.mass.player.get_player(self.group_parent) + if group_player: + return await group_player.next() + else: + return await self.queue.next() + + async def previous(self): + ''' [PROTECTED] send previous command to player ''' + if self.group_parent: + # redirect playback related commands to parent player + group_player = await self.mass.player.get_player(self.group_parent) + if group_player: + return await group_player.previous() + else: + return await self.queue.previous() + + async def power(self, power): + ''' [PROTECTED] send power ON command to player ''' + power = try_parse_bool(power) + if power: + return await self.power_on() + else: + return await self.power_off() + async def power_on(self): ''' [PROTECTED] send power ON command to player ''' - self.__power_on() + await self.cmd_power_on() # handle mute as power if self.settings['mute_as_power']: - self.volume_mute(False) + await self.volume_mute(False) # handle hass integration if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source') @@ -317,7 +421,7 @@ class Player(): await self.mass.hass.call_service(domain, 'turn_on', service_data) # handle play on power on if self.settings['play_power_on']: - self.play() + await self.play() # handle group power if self.group_parent: # player has a group parent, check if it should be turned on @@ -327,10 +431,10 @@ class Player(): async def power_off(self): ''' [PROTECTED] send power TOGGLE command to player ''' - self.__power_off() + await self.cmd_power_off() # handle mute as power if self.settings['mute_as_power']: - self.volume_mute(True) + await self.volume_mute(True) # handle hass integration if self.mass.hass and self.settings.get('hass_power_entity') and self.settings.get('hass_power_entity_source'): cur_source = await self.mass.hass.get_state(self.settings['hass_power_entity'], attribute='source') @@ -390,119 +494,46 @@ class Player(): 'volume_level': volume_level/100 } await self.mass.hass.call_service('media_player', 'volume_set', service_data) - await self.__volume_set(100) # just force full volume on actual player if volume is outsourced to hass + await self.cmd_volume_set(100) # just force full volume on actual player if volume is outsourced to hass else: - await self.__volume_set(volume_level) + await self.cmd_volume_set(volume_level) async def volume_up(self): - ''' [MAY OVERRIDE] send volume up command to player ''' + ''' [PROTECTED] send volume up command to player ''' new_level = self.volume_level + 1 return await self.volume_set(new_level) async def volume_down(self): - ''' [MAY OVERRIDE] send volume down command to player ''' + ''' [PROTECTED] send volume down command to player ''' new_level = self.volume_level - 1 if new_level < 0: new_level = 0 return await self.volume_set(new_level) async def volume_mute(self, is_muted=False): - ''' [MUST OVERRIDE] send mute command to player ''' - return await self.__volume_mute(is_muted) - - async def play_queue(self): - ''' [PROTECTED] send play_queue (start stream) command to player ''' - if self.group_parent: - # redirect playback related commands to parent player - group_player = await self.mass.player.get_player(self.group_parent) - if group_player: - return await group_player.play_queue() - elif self.queue.items: - return await self.__play_queue() + ''' [PROTECTED] send mute command to player ''' + return await self.cmd_volume_mute(is_muted) - async def play_media(self, media_item, queue_opt='play'): - ''' - play media item(s) on this player - media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio) - single item or list of items - queue_opt: - play -> insert new items in queue and start playing at the inserted position - replace -> replace queue contents with these items - next -> play item(s) after current playing item - add -> append new items at end of the queue - ''' - # a single item or list of items may be provided - media_items = media_item if isinstance(media_item, list) else [media_item] - queue_tracks = [] - for media_item in media_items: - # collect tracks to play - if media_item.media_type == MediaType.Artist: - tracks = await self.mass.music.artist_toptracks(media_item.item_id, - provider=media_item.provider) - elif media_item.media_type == MediaType.Album: - tracks = await self.mass.music.album_tracks(media_item.item_id, - provider=media_item.provider) - elif media_item.media_type == MediaType.Playlist: - tracks = await self.mass.music.playlist_tracks(media_item.item_id, - provider=media_item.provider, offset=0, limit=0) - else: - tracks = [media_item] # single track - for track in tracks: - queue_item = QueueItem() - queue_item.name = track.name - queue_item.artists = track.artists - queue_item.album = track.album - queue_item.duration = track.duration - queue_item.version = track.version - queue_item.metadata = track.metadata - queue_item.media_type = track.media_type - queue_item.uri = 'http://%s:%s/stream_queue?player_id=%s'% ( - self.local_ip, self.mass.config['base']['web']['http_port'], player_id) - # sort by quality and check track availability - for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True): - media_provider = prov_media['provider'] - media_item_id = prov_media['item_id'] - player_supported_provs = player_prov.supported_musicproviders - if media_provider in player_supported_provs and not self.mass.config['player_settings'][player_id]['force_http_streamer']: - # the provider can handle this media_type directly ! - track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, is_radio=is_radio) - playable_tracks.append(track) - match_found = True - elif 'http' in player_prov.supported_musicproviders: - # fallback to http streaming if supported - track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True, is_radio=is_radio) - queue_tracks.append(track) - match_found = True - if match_found: - break - if queue_tracks: - if self._players[player_id].shuffle_enabled: - random.shuffle(playable_tracks) - if queue_opt in ['next', 'play'] and len(playable_tracks) > 1: - queue_opt = 'replace' # always assume playback of multiple items as new queue - return await player_prov.play_media(player_id, playable_tracks, queue_opt) - else: - raise Exception("Musicprovider and/or media not supported by player %s !" % (player_id) ) - async def update(self): ''' [PROTECTED] signal player updated ''' - self.__update_player_settings() + await self.__update_player_settings() LOGGER.info("player updated: %s" % self.name) self.mass.signal_event('player changed', self) async def __update_player_settings(self): ''' [PROTECTED] get (or create) player config settings ''' config_entries = [ # default config entries for a player - ("enabled", False, "player_enabled"), + ("enabled", True, "player_enabled"), ("name", "", "player_name"), ("mute_as_power", False, "player_mute_power"), ("max_sample_rate", 96000, "max_sample_rate"), ('volume_normalisation', True, 'enable_r128_volume_normalisation'), ('target_volume', '-23', 'target_volume_lufs'), - ('fallback_gain_correct', '-12', 'fallback_gain_correct') + ('fallback_gain_correct', '-12', 'fallback_gain_correct'), + ("crossfade_duration", 0, "crossfade_duration"), ] # append player specific settings - config_entries += await self.get_config_entries() + config_entries += await self.mass.player.providers[self._prov_id].get_player_config_entries() if self.is_group or not self.group_parent: config_entries += [ # play on power on setting ("play_power_on", False, "player_power_play"), @@ -522,4 +553,22 @@ class Player(): self.mass.config['player_settings'][self.player_id] = player_settings self.mass.config['player_settings'][self.player_id]['__desc__'] = config_entries return player_settings - \ No newline at end of file + + @property + def __dict__(self): + ''' instance attributes as dict so it can be serialized to json ''' + return { + "player_id": self.player_id, + "player_provider": self.player_provider, + "name": self.name, + "is_group": self.is_group, + "state": self.state, + "powered": self.powered, + "cur_time": self.cur_time, + "cur_uri": self.cur_uri, + "volume_level": self.volume_level, + "muted": self.muted, + "group_parent": self.group_parent, + "enabled": self.enabled, + "cur_item": self.cur_item.__dict__ if self.cur_item else None + } \ No newline at end of file diff --git a/music_assistant/models/player_queue.py b/music_assistant/models/player_queue.py index 95d64375..ccf495d8 100755 --- a/music_assistant/models/player_queue.py +++ b/music_assistant/models/player_queue.py @@ -1,37 +1,28 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- -from ..utils import LOGGER -from ..constants import CONF_ENABLED +import asyncio from typing import List -from player import PlayerState -from media_types import Track, TrackQuality import operator import random +import uuid + +from ..utils import LOGGER +from ..constants import CONF_ENABLED +from .media_types import Track, TrackQuality + -class QueueItem(object): +class QueueItem(Track): ''' representation of a queue item, simplified version of track ''' - def __init__(self): - self.item_id = None - self.provider = None - self.name = '' - self.duration = 0 - self.version = '' + def __init__(self, media_item=None): + super().__init__() self.quality = TrackQuality.FLAC_LOSSLESS - self.metadata = {} - self.artists = [] - self.album = None self.uri = "" - self.is_radio = 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 and - self.provider == other.provider) - def __ne__(self, other): - return not self.__eq__(other) + self.queue_item_id = str(uuid.uuid4()) + # if existing media_item given, load those values + if media_item: + for attribute, value in media_item.__dict__.items(): + setattr(self, attribute, value) class PlayerQueue(): ''' @@ -45,7 +36,7 @@ class PlayerQueue(): self._player = player self._items = [] self._shuffle_enabled = True - self._repeat_enabled = True + self._repeat_enabled = False self._cur_index = None @property @@ -56,18 +47,30 @@ class PlayerQueue(): def repeat_enabled(self): return self._repeat_enabled + @property + def crossfade_enabled(self): + return self._player.settings['crossfade_duration'] + + @property + def gapless_enabled(self): + return self._player.settings.get('gapless_enabled', True) + @property def cur_index(self): + ''' match current uri with queue items to determine queue index ''' + for index, queue_item in enumerate(self.items): + if queue_item.uri == self._player.cur_uri: + return index return self._cur_index @property def cur_item(self): - if self._cur_index == None: + if self.cur_index == None: return None - return self.mass.event_loop.run_until_complete(self.get_item(self._cur_index)) + return self.mass.bg_executor.submit(asyncio.run,self.get_item(self.cur_index)).result() @property - async def next_index(self): + def next_index(self): ''' return the next queue index for this player ''' @@ -87,63 +90,128 @@ class PlayerQueue(): return None @property - async def next_item(self): + def next_item(self): ''' return the next item in the queue ''' - return self.mass.event_loop.run_until_complete( - self.get_item(self.next_index)) + return self.mass.bg_executor.submit( + asyncio.run, self.get_item(self.next_index)).result() @property - async def items(self): + def items(self): ''' return all queue items for this player ''' return self._items + @property + def use_queue_stream(self): + ''' + bool to indicate that we need to use the queue stream + for example if crossfading is requested but a player doesn't natively support it + it will send a constant stream of audio to the player and all tracks + ''' + return ((self.crossfade_enabled and not self._player.supports_crossfade) or + (self.gapless_enabled and not self._player.supports_gapless)) + async def get_item(self, index): ''' get item by index from queue ''' - if len(self._items) > index: - return self._items[index] + if index != None and len(self.items) > index: + return self.items[index] return None + async def by_item_id(self, queue_item_id:str): + ''' get item by queue_item_id from queue ''' + if not queue_item_id: + return None + for item in self.items: + if item.queue_item_id == queue_item_id: + return item + return None + async def shuffle(self, enable_shuffle:bool): ''' enable/disable shuffle ''' if not self._shuffle_enabled and enable_shuffle: # shuffle requested self._shuffle_enabled = True - self._items = await self.__shuffle_items(self._items) - self._cur_index = None - await self._player.play_queue() + await self.load(self._items) self.mass.event_loop.create_task(self._player.update()) elif self._shuffle_enabled and not enable_shuffle: self._shuffle_enabled = False # TODO: Unshuffle the list ? self.mass.event_loop.create_task(self._player.update()) + async def next(self): + ''' request next track in queue ''' + if self.use_queue_stream: + return await self.play_index(self.cur_index+1) + else: + return await self._player.cmd_next() + + async def previous(self): + ''' request previous track in queue ''' + if self.use_queue_stream: + return await self.play_index(self.cur_index-1) + else: + return await self._player.cmd_previous() + + async def resume(self): + ''' resume previous queue ''' + if self.items: + prev_index = self.cur_index + await self.load(self._items) + if prev_index: + await self.play_index(prev_index) + else: + LOGGER.warning("resume queue requested for %s but queue is empty" % self._player.name) + + async def play_index(self, index): + ''' play item at index X in queue ''' + if not len(self.items) > index: + return + if self.use_queue_stream: + self._cur_index = index -1 + queue_stream_uri = 'http://%s:%s/stream/%s'% ( + self.mass.web.local_ip, self.mass.web.http_port, self._player.player_id) + return await self._player.cmd_play_uri(queue_stream_uri) + elif self._player.supports_queue: + return await self._player.cmd_queue_play_index(index) + else: + return await self._player.cmd_play_uri(self._items[index].uri) + async def load(self, queue_items:List[QueueItem]): ''' load (overwrite) queue with new items ''' if self._shuffle_enabled: queue_items = await self.__shuffle_items(queue_items) self._items = queue_items self._cur_index = None - await self._player.play_queue() + if self.use_queue_stream or not self._player.supports_queue: + return await self.play_index(0) + else: + return await self._player.cmd_queue_load(queue_items) async def insert(self, queue_items:List[QueueItem], offset=0): ''' insert new items at offset x from current position keeps remaining items in queue if offset 0 or None, will start playing newly added item(s) + :param queue_items: a list of QueueItem + :param offset: offset from current queue position ''' - insert_at_index = self.cur_index + offset + if self.cur_index: + insert_at_index = self.cur_index + offset + else: + insert_at_index = 0 if not self.items or insert_at_index >= len(self.items): return await self.load(queue_items) if self.shuffle_enabled: queue_items = await self.__shuffle_items(queue_items) self._items = self._items[:insert_at_index] + queue_items + self._items[insert_at_index:] - if not offset: - await self._player.stop() - await self._player.play_queue() + if self.use_queue_stream or not self._player.supports_queue: + if offset == 0: + return await self.play_index(0) + else: + return await self._player.cmd_queue_insert(queue_items, offset) async def append(self, queue_items:List[QueueItem]): ''' @@ -152,6 +220,8 @@ class PlayerQueue(): if self.shuffle_enabled: queue_items = await self.__shuffle_items(queue_items) self._items = self._items + queue_items + if self._player.supports_queue: + return await self._player.cmd_queue_append(queue_items) async def __shuffle_items(self, queue_items): ''' shuffle a list of tracks ''' diff --git a/music_assistant/models/playerprovider.py b/music_assistant/models/playerprovider.py index d2868b01..2b45955f 100755 --- a/music_assistant/models/playerprovider.py +++ b/music_assistant/models/playerprovider.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 # -*- coding:utf-8 -*- +import asyncio from enum import Enum from typing import List from ..utils import run_periodic, LOGGER, parse_track_title from ..constants import CONF_ENABLED -from ..modules.cache import use_cache -from player_queue import PlayerQueue -from media_types import Track -from player import Player +from ..cache import use_cache +from .player_queue import PlayerQueue +from .media_types import Track +from .player import Player class PlayerProvider(): @@ -26,22 +27,27 @@ class PlayerProvider(): ### Common methods and properties #### + async def get_player_config_entries(self): + ''' [CAN OVERRIDE] get the player-specific config entries for this provider (list with key/value pairs)''' + return [] + @property - async def players(self): + def players(self): ''' return all players for this provider ''' - return self.mass.player.get_provider_players(self.prov_id) + return self.mass.bg_executor.submit(asyncio.run, + self.mass.player.get_provider_players(self.prov_id)).result() async def get_player(self, player_id:str): ''' return player by id ''' - return self.mass.player.get_player(player_id) + return await self.mass.player.get_player(player_id) async def add_player(self, player:Player): ''' register a new player ''' - return self.mass.player.add_player(player) + return await self.mass.player.add_player(player) async def remove_player(self, player_id:str): ''' remove a player ''' - return self.mass.player.remove_player(player_id) + return await self.mass.player.remove_player(player_id) ### Provider specific implementation ##### diff --git a/music_assistant/modules/player_manager.py b/music_assistant/modules/player_manager.py deleted file mode 100755 index 438ce7b2..00000000 --- a/music_assistant/modules/player_manager.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding:utf-8 -*- - -import asyncio -import os -from enum import Enum -from ..utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task -from ..models.media_types import MediaType, TrackQuality -from ..models.player_queue import QueueItem -from ..models.player import PlayerState -import operator -import random -import functools -import urllib - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" ) - - - -class PlayerManager(): - ''' several helpers to handle playback through player providers ''' - - def __init__(self, mass): - self.mass = mass - self.providers = {} - self._players = {} - self.local_ip = get_ip() - # dynamically load provider modules - self.load_providers() - - @property - def players(self): - ''' all players as property ''' - return self.mass.event_loop.run_until_complete(self.get_players()) - - async def get_players(self): - ''' return all players as a list ''' - items = list(self._players.values()) - items.sort(key=lambda x: x.name, reverse=False) - return items - - async def get_player(self, player_id): - ''' return player by id ''' - return self._players.get(player_id, None) - - async def get_provider_players(self, player_provider): - ''' return all players for given provider_id ''' - return [item for item in self._players.values() if item.player_provider == player_provider] - - async def add_player(self, player): - ''' register a new player ''' - self._players[player.player_id] = player - self.mass.signal_event('player added', player) - # TODO: turn on player if it was previously turned on ? - return player - - async def remove_player(self, player_id): - ''' handle a player remove ''' - self._players.pop(player_id, None) - self.mass.signal_event('player removed', player_id) - - async def trigger_update(self, player_id): - ''' manually trigger update for a player ''' - if player_id in self._players: - await self._players[player_id].update() - - 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/modules/playerproviders/__init__.py b/music_assistant/modules/playerproviders/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/music_assistant/modules/music_manager.py b/music_assistant/music_manager.py similarity index 98% rename from music_assistant/modules/music_manager.py rename to music_assistant/music_manager.py index f0689915..4c4a2f85 100755 --- a/music_assistant/modules/music_manager.py +++ b/music_assistant/music_manager.py @@ -6,8 +6,10 @@ from typing import List import toolz import operator import os -from ..utils import run_periodic, LOGGER, try_supported -from ..models.media_types import MediaType, Track, Artist, Album, Playlist, Radio +import importlib + +from .utils import run_periodic, LOGGER, try_supported +from .models.media_types import MediaType, Track, Artist, Album, Playlist, Radio BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -398,7 +400,7 @@ class Music(): module_name = item.replace(".py","") LOGGER.debug("Loading musicprovider module %s" % module_name) try: - mod = __import__("modules.musicproviders." + module_name, fromlist=['']) + mod = importlib.import_module("." + module_name, "music_assistant.musicproviders") 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() diff --git a/music_assistant/modules/__init__.py b/music_assistant/musicproviders/__init__.py similarity index 100% rename from music_assistant/modules/__init__.py rename to music_assistant/musicproviders/__init__.py diff --git a/music_assistant/modules/musicproviders/file.py b/music_assistant/musicproviders/file.py similarity index 98% rename from music_assistant/modules/musicproviders/file.py rename to music_assistant/musicproviders/file.py index 8f7900ab..a6ffabb4 100644 --- a/music_assistant/modules/musicproviders/file.py +++ b/music_assistant/musicproviders/file.py @@ -6,12 +6,14 @@ import os from typing import List import sys import time -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 modules.cache import use_cache import base64 +import taglib + +from ..cache import use_cache +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 + def setup(mass): diff --git a/music_assistant/modules/musicproviders/qobuz.py b/music_assistant/musicproviders/qobuz.py similarity index 98% rename from music_assistant/modules/musicproviders/qobuz.py rename to music_assistant/musicproviders/qobuz.py index a8e21c36..40bab64d 100644 --- a/music_assistant/modules/musicproviders/qobuz.py +++ b/music_assistant/musicproviders/qobuz.py @@ -4,17 +4,18 @@ import asyncio import os from typing import List -from utils import run_periodic, LOGGER, parse_track_title -from app_vars import get_app_var -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 modules.cache import use_cache + +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..app_vars import get_app_var +from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED def setup(mass): diff --git a/music_assistant/modules/musicproviders/spotify.py b/music_assistant/musicproviders/spotify.py similarity index 98% rename from music_assistant/modules/musicproviders/spotify.py rename to music_assistant/musicproviders/spotify.py index d2bcef79..43a56fc8 100644 --- a/music_assistant/modules/musicproviders/spotify.py +++ b/music_assistant/musicproviders/spotify.py @@ -6,15 +6,17 @@ import os from typing import List import sys import time -from utils import run_periodic, LOGGER, parse_track_title -from app_vars import get_app_var -from models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED +import concurrent from asyncio_throttle import Throttler import json import aiohttp -from modules.cache import use_cache -import concurrent + +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..app_vars import get_app_var +from ..models import MusicProvider, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED + def setup(mass): ''' setup the provider''' @@ -394,7 +396,7 @@ class SpotifyProvider(MusicProvider): 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) + LOGGER.debug(exc) if not tokeninfo: # fallback to spotty approach import subprocess diff --git a/music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf b/music_assistant/musicproviders/spotty/arm-linux/spotty-hf similarity index 100% rename from music_assistant/modules/musicproviders/spotty/arm-linux/spotty-hf rename to music_assistant/musicproviders/spotty/arm-linux/spotty-hf diff --git a/music_assistant/modules/musicproviders/spotty/darwin/spotty b/music_assistant/musicproviders/spotty/darwin/spotty similarity index 100% rename from music_assistant/modules/musicproviders/spotty/darwin/spotty rename to music_assistant/musicproviders/spotty/darwin/spotty diff --git a/music_assistant/modules/musicproviders/spotty/windows/spotty.exe b/music_assistant/musicproviders/spotty/windows/spotty.exe similarity index 100% rename from music_assistant/modules/musicproviders/spotty/windows/spotty.exe rename to music_assistant/musicproviders/spotty/windows/spotty.exe diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty b/music_assistant/musicproviders/spotty/x86-linux/spotty similarity index 100% rename from music_assistant/modules/musicproviders/spotty/x86-linux/spotty rename to music_assistant/musicproviders/spotty/x86-linux/spotty diff --git a/music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 b/music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64 similarity index 100% rename from music_assistant/modules/musicproviders/spotty/x86-linux/spotty-x86_64 rename to music_assistant/musicproviders/spotty/x86-linux/spotty-x86_64 diff --git a/music_assistant/modules/musicproviders/tunein.py b/music_assistant/musicproviders/tunein.py similarity index 84% rename from music_assistant/modules/musicproviders/tunein.py rename to music_assistant/musicproviders/tunein.py index 1350df4e..cd9c7ba1 100644 --- a/music_assistant/modules/musicproviders/tunein.py +++ b/music_assistant/musicproviders/tunein.py @@ -6,14 +6,15 @@ import os from typing import List import sys import time -from utils import run_periodic, LOGGER, parse_track_title -from models import MusicProvider, MediaType, TrackQuality, Radio -from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED from asyncio_throttle import Throttler import json import aiohttp -from modules.cache import use_cache -import concurrent + +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..models import MusicProvider, MediaType, TrackQuality, Radio +from ..constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED + def setup(mass): ''' setup the provider''' @@ -105,7 +106,7 @@ class TuneInProvider(MusicProvider): quality = TrackQuality.LOSSY_MP3 radio.provider_ids.append({ "provider": self.prov_id, - "item_id": details['preset_id'], + "item_id": "%s--%s" % (details['preset_id'], stream["media_type"]), "quality": quality, "details": stream['url'] }) @@ -122,17 +123,20 @@ class TuneInProvider(MusicProvider): res = await self.__get_data("Tune.ashx", params) return res - # async def get_stream_content_type(self, radio_id): - # ''' return the content type for the given radio when it will be streamed''' - # return 'flac' #TODO handle other file formats on qobuz? - - # async def get_audio_stream(self, track_id): - # ''' get audio stream for a track ''' - # params = {'format_id': 27, 'track_id': track_id, 'intent': 'stream'} - # # we are called from other thread - # streamdetails_future = asyncio.run_coroutine_threadsafe( - # self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True), - # self.mass.event_loop + async def get_stream_details(self, stream_id): + ''' return the content details for the given track when it will be streamed''' + radio_id, media_type = stream_id.split('--') + stream_info = await self.__get_stream_urls(radio_id) + for stream in stream_info["body"]: + if stream['media_type'] == media_type: + return { + "type": "url", + "path": stream['url'], + "content_type": media_type, + "sample_rate": 44100, + "bit_depth": 16 + } + return {} @use_cache(7) async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None): diff --git a/music_assistant/player_manager.py b/music_assistant/player_manager.py new file mode 100755 index 00000000..de6026d8 --- /dev/null +++ b/music_assistant/player_manager.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from enum import Enum +import operator +import random +import functools +import urllib +import importlib + +from .utils import run_periodic, LOGGER, try_parse_int, try_parse_float, get_ip, run_async_background_task +from .models.media_types import MediaType, TrackQuality +from .models.player_queue import QueueItem +from .models.player import PlayerState + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MODULES_PATH = os.path.join(BASE_DIR, "playerproviders" ) + + + +class PlayerManager(): + ''' 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() + + @property + def players(self): + ''' all players as property ''' + return self.mass.bg_executor.submit(asyncio.run, + self.get_players()).result() + + async def get_players(self): + ''' return all players as a list ''' + items = list(self._players.values()) + items.sort(key=lambda x: x.name, reverse=False) + return items + + async def get_player(self, player_id): + ''' return player by id ''' + return self._players.get(player_id, None) + + async def get_provider_players(self, player_provider): + ''' return all players for given provider_id ''' + return [item for item in self._players.values() if item.player_provider == player_provider] + + async def add_player(self, player): + ''' register a new player ''' + self._players[player.player_id] = player + self.mass.signal_event('player added', player) + # TODO: turn on player if it was previously turned on ? + return player + + async def remove_player(self, player_id): + ''' handle a player remove ''' + self._players.pop(player_id, None) + self.mass.signal_event('player removed', player_id) + + async def trigger_update(self, player_id): + ''' manually trigger update for a player ''' + if player_id in self._players: + await self._players[player_id].update() + + async def play_media(self, player_id, media_item, queue_opt='play'): + ''' + play media item(s) on the given player + :param media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio) + single item or list of items + :param queue_opt: + play -> insert new items in queue and start playing at the inserted position + replace -> replace queue contents with these items + next -> play item(s) after current playing item + add -> append new items at end of the queue + ''' + player = await self.get_player(player_id) + if not player: + return + # a single item or list of items may be provided + media_items = media_item if isinstance(media_item, list) else [media_item] + queue_items = [] + for media_item in media_items: + # collect tracks to play + if media_item.media_type == MediaType.Artist: + tracks = await self.mass.music.artist_toptracks(media_item.item_id, + provider=media_item.provider) + elif media_item.media_type == MediaType.Album: + tracks = await self.mass.music.album_tracks(media_item.item_id, + provider=media_item.provider) + elif media_item.media_type == MediaType.Playlist: + tracks = await self.mass.music.playlist_tracks(media_item.item_id, + provider=media_item.provider, offset=0, limit=0) + else: + tracks = [media_item] # single track + for track in tracks: + queue_item = QueueItem(track) + # generate uri for this queue item + queue_item.uri = 'http://%s:%s/stream/%s?queue_item_id=%s'% ( + self.mass.web.local_ip, self.mass.web.http_port, player_id, queue_item.queue_item_id) + # sort by quality and check track availability + for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True): + queue_item.provider = prov_media['provider'] + queue_item.item_id = prov_media['item_id'] + queue_item.quality = prov_media['quality'] + # TODO: check track availability + # TODO: handle direct stream capability + queue_items.append(queue_item) + break + # load items into the queue + if queue_opt == 'replace' or (queue_opt in ['next', 'play'] and len(queue_items) > 50): + return await player.queue.load(queue_items) + elif queue_opt == 'next': + return await player.queue.insert(queue_items, 1) + elif queue_opt == 'play': + return await player.queue.insert(queue_items, 0) + elif queue_opt == 'add': + return await player.queue.append(queue_items) + + 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 = importlib.import_module("." + module_name, "music_assistant.playerproviders") + 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/modules/musicproviders/__init__.py b/music_assistant/playerproviders/__init__.py similarity index 100% rename from music_assistant/modules/musicproviders/__init__.py rename to music_assistant/playerproviders/__init__.py diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/playerproviders/chromecast.py similarity index 67% rename from music_assistant/modules/playerproviders/chromecast.py rename to music_assistant/playerproviders/chromecast.py index 736528d4..7441b6c4 100644 --- a/music_assistant/modules/playerproviders/chromecast.py +++ b/music_assistant/playerproviders/chromecast.py @@ -2,26 +2,19 @@ # -*- coding:utf-8 -*- import asyncio -# import os -# from typing import List -# import random -# import sys -# import json import aiohttp -# import time -# import datetime -# import hashlib +from typing import List import pychromecast from pychromecast.controllers.multizone import MultizoneController from pychromecast.controllers import BaseController from pychromecast.controllers.media import MediaController import types -# import urllib -# import select -from ...utils import run_periodic, LOGGER, try_parse_int -from ...models.playerprovider import PlayerProvider -from ...models.player import Player, PlayerState -from ...constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT + +from ..utils import run_periodic, LOGGER, try_parse_int +from ..models.playerprovider import PlayerProvider +from ..models.player import Player, PlayerState +from ..models.player_queue import QueueItem, PlayerQueue +from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT def setup(mass): ''' setup the provider''' @@ -39,113 +32,98 @@ def config_entries(): class ChromecastPlayer(Player): ''' Chromecast player object ''' - cc = None - - async def __stop(self): + + async def cmd_stop(self): ''' send stop command to player ''' self.cc.media_controller.stop() - async def __play(self): + async def cmd_play(self): ''' send play command to player ''' self.cc.media_controller.play() - async def __pause(self): + async def cmd_pause(self): ''' send pause command to player ''' self.cc.media_controller.pause() - async def __power_on(self): + async def cmd_next(self): + ''' send next track command to player ''' + return await self.cc.media_controller.queue_next() + + async def cmd_previous(self): + ''' [CAN OVERRIDE] send previous track command to player ''' + return await self.cc.media_controller.queue_prev() + + async def cmd_power_on(self): ''' send power ON command to player ''' self.powered = True - async def __power_off(self): + async def cmd_power_off(self): ''' send power OFF command to player ''' self.powered = False # power is not supported so send quit_app instead if not self.group_parent: self.cc.quit_app() - async def __volume_set(self, volume_level): + async def cmd_volume_set(self, volume_level): ''' send new volume level command to player ''' self.cc.set_volume(volume_level/100) self.volume_level = volume_level - async def __volume_mute(self, is_muted=False): + async def cmd_volume_mute(self, is_muted=False): ''' send mute command to player ''' self.cc.set_volume_muted(is_muted) + async def cmd_play_uri(self, uri:str): + ''' play single uri on player ''' + self.cc.play_media(uri, 'audio/flac') -class ChromecastProvider(PlayerProvider): - ''' support for ChromeCast Audio ''' - - def __init__(self, mass): - self.prov_id = 'chromecast' - self.name = 'Chromecast' - self.mass = mass - self._discovery_running = False - self.mass.event_loop.create_task(self.__periodic_chromecast_discovery()) - - async def __queue_load(self, player_id, new_tracks, startindex=None): - ''' load queue on player with given queue items ''' - castplayer = self._chromecasts[player_id] - player = self._players[player_id] - queue_items = await self.__create_queue_items(new_tracks[:50]) - self._player_queue_index[player_id] = 0 + async def cmd_queue_load(self, queue_items:List[QueueItem]): + ''' load (overwrite) queue with new items ''' + cc_queue_items = await self.__create_queue_items(queue_items[:50]) queuedata = { "type": 'QUEUE_LOAD', - "repeatMode": "REPEAT_ALL" if player.repeat_enabled else "REPEAT_OFF", - "shuffle": player.shuffle_enabled, + "repeatMode": "REPEAT_ALL" if self.queue.repeat_enabled else "REPEAT_OFF", + "shuffle": self.queue.shuffle_enabled, "queueType": "PLAYLIST", - "startIndex": startindex, # Item index to play after this request or keep same item if undefined - "items": queue_items # only load 50 tracks at once or the socket will crash + "startIndex": 0, # Item index to play after this request or keep same item if undefined + "items": cc_queue_items # only load 50 tracks at once or the socket will crash } - await self.__send_player_queue(castplayer, queuedata) + await self.__send_player_queue(queuedata) await asyncio.sleep(0.2) - if len(new_tracks) > 50: - await self.__queue_insert(player_id, new_tracks[51:]) + if len(queue_items) > 50: + await self.cmd_queue_append(queue_items[51:]) await asyncio.sleep(0.2) - async def __play_stream_queue(self, player_id, startindex=0): - ''' tell the cast player to stream our special queue (crossfaded) stream ''' - castplayer = self._chromecasts[player_id] - uri = 'http://%s:%s/stream_queue?player_id=%s&startindex=%s'% ( - self.mass.player.local_ip, self.mass.config['base']['web']['http_port'], player_id, startindex) - castplayer.play_media(uri, 'audio/flac') - - async def __queue_insert(self, player_id, new_tracks, insert_before=None): - ''' insert item into the player queue ''' - castplayer = self._chromecasts[player_id] - queue_items = await self.__create_queue_items(new_tracks) - for chunk in chunks(queue_items, 50): + async def cmd_queue_insert(self, queue_items:List[QueueItem], offset=0): + ''' + insert new items at offset x from current position + keeps remaining items in queue + if offset 0 or None, will start playing newly added item(s) + :param queue_items: a list of QueueItem + :param offset: offset from current queue position + ''' + insert_before = self.queue.cur_index + offset + cc_queue_items = await self.__create_queue_items(queue_items) + for chunk in chunks(cc_queue_items, 50): queuedata = { "type": 'QUEUE_INSERT', "insertBefore": insert_before, "items": chunk } - await self.__send_player_queue(castplayer, queuedata) - - async def __queue_update(self, player_id, queue_items_to_update): - ''' update the cast player queue ''' - castplayer = self._chromecasts[player_id] - queuedata = { - "type": 'QUEUE_UPDATE', - "items": queue_items_to_update - } - await self.__send_player_queue(castplayer, queuedata) - - async def __queue_remove(self, player_id, queue_item_ids): - ''' remove items from the cast player queue ''' - castplayer = self._chromecasts[player_id] - queuedata = { - "type": 'QUEUE_REMOVE', - "items": queue_item_ids - } - await self.__send_player_queue(castplayer, queuedata) - - async def __resume_queue(self, player_id): - ''' resume queue play after power off ''' - LOGGER.info('resuming queue....') - tracks = self._player_queue[player_id] - await self.play_media(player_id, tracks) + await self.__send_player_queue(queuedata) + + async def cmd_queue_append(self, queue_items:List[QueueItem]): + ''' + append new items at the end of the queue + ''' + cc_queue_items = await self.__create_queue_items(queue_items) + for chunk in chunks(cc_queue_items, 50): + queuedata = { + "type": 'QUEUE_INSERT', + "insertBefore": None, + "items": chunk + } + await self.__send_player_queue(queuedata) async def __create_queue_items(self, tracks): ''' create list of CC queue items from tracks ''' @@ -156,7 +134,7 @@ class ChromecastProvider(PlayerProvider): return queue_items async def __create_queue_item(self, track): - '''create queue item from track info ''' + '''create CC queue item from track info ''' return { 'autoplay' : True, 'preloadTime' : 10, @@ -180,9 +158,9 @@ class ChromecastProvider(PlayerProvider): } } - async def __send_player_queue(self, castplayer, queuedata): + async def __send_player_queue(self, queuedata): '''send new data to the CC queue''' - media_controller = castplayer.media_controller + media_controller = self.cc.media_controller receiver_ctrl = media_controller._socket_client.receiver_controller def send_queue(): """Plays media after chromecast has switched to requested app.""" @@ -194,10 +172,26 @@ class ChromecastProvider(PlayerProvider): send_queue() await asyncio.sleep(0.2) +class ChromecastProvider(PlayerProvider): + ''' support for ChromeCast Audio ''' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.prov_id = 'chromecast' + self.name = 'Chromecast' + self._discovery_running = False + self.mass.event_loop.create_task(self.__periodic_chromecast_discovery()) + + async def get_player_config_entries(self): + ''' get the player config entries for this provider (list with key/value pairs)''' + return [ + ("gapless_enabled", False, "gapless_enabled") + ] + 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.get_player(player_id) + player = await self.get_player(player_id) # always update player details that may change player.name = chromecast.name if caststatus: @@ -211,51 +205,26 @@ class ChromecastProvider(PlayerProvider): player.state = PlayerState.Paused else: player.state = PlayerState.Stopped - if not mediastatus.content_id: - player.cur_item = None - player.cur_item_time = 0 - elif not 'stream_queue' in mediastatus.content_id: - player.cur_item = await self.__parse_track(mediastatus) - player.cur_item_time = mediastatus.adjusted_current_time - self._player_queue_index[player_id] = await self.__get_cur_queue_index(player_id, mediastatus.content_id) - elif 'stream_queue' in mediastatus.content_id: - # player is playing our special queue continuous stream - # try to work out the current time - # player is playing a constant stream of the queue so we need to do this the hard way - cur_time_queue = mediastatus.adjusted_current_time - total_time = 0 - track_time = 0 - queue_index = self._player_queue_stream_startindex[player_id] - queue_track = None - while True: - queue_track = self._player_queue[player_id][queue_index] - if cur_time_queue > (queue_track.duration + total_time): - total_time += queue_track.duration - queue_index += 1 - else: - track_time = cur_time_queue - total_time - break - player.cur_item = queue_track - player.cur_item_time = track_time - self._player_queue_index[player_id] = queue_index + player.cur_uri = mediastatus.content_id + player.cur_time = mediastatus.adjusted_current_time async def __handle_group_members_update(self, mz, added_player=None, removed_player=None): ''' callback when cast group members update ''' if added_player: - player = self.get_player(added_player) - group_player = self.get_player(str(mz._uuid)) + player = await self.get_player(added_player) + group_player = await self.get_player(str(mz._uuid)) if player and group_player: player.group_parent = str(mz._uuid) LOGGER.debug("player %s added to group %s" %(player.name, group_player.name)) elif removed_player: - player = self.get_player(added_player) - group_player = self.get_player(str(mz._uuid)) + player = await self.get_player(added_player) + group_player = await self.get_player(str(mz._uuid)) if player and group_player: player.group_parent = None LOGGER.debug("player %s removed from group %s" %(player.name, group_player.name)) else: for member in mz.members: - player = self.get_player(member) + player = await self.get_player(member) if player: player.group_parent = str(mz._uuid) @@ -288,7 +257,9 @@ class ChromecastProvider(PlayerProvider): discovery_info = listener.services[name] ip_address, port, uuid, model_name, friendly_name = discovery_info player_id = str(uuid) - if not self.get_player(player_id): + player = self.mass.bg_executor.submit(asyncio.run, + self.get_player(player_id)).result() + if not player: LOGGER.info("discovered chromecast: %s - %s:%s" % (friendly_name, ip_address, port)) asyncio.run_coroutine_threadsafe( self.__chromecast_discovered(player_id, discovery_info), self.mass.event_loop) @@ -324,15 +295,16 @@ class ChromecastProvider(PlayerProvider): chromecast.mz = mz player.cc = chromecast player.cc.wait() - self.add_player(player) - self.update_all_group_members() + await self.add_player(player) + await self.update_all_group_members() - def update_all_group_members(self): + async def update_all_group_members(self): ''' force member update of all cast groups ''' for player in self.players: if player.cc.cast_type == 'group': player.cc.mz.update_members() + def chunks(l, n): """Yield successive n-sized chunks from l.""" for i in range(0, len(l), n): diff --git a/music_assistant/modules/playerproviders/lms.py b/music_assistant/playerproviders/lms.py similarity index 96% rename from music_assistant/modules/playerproviders/lms.py rename to music_assistant/playerproviders/lms.py index 13db3f78..ad5b5e38 100644 --- a/music_assistant/modules/playerproviders/lms.py +++ b/music_assistant/playerproviders/lms.py @@ -6,9 +6,6 @@ import os from typing import List import random import sys -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 @@ -16,10 +13,15 @@ import datetime import hashlib from asyncio_throttle import Throttler from aiocometd import Client, ConnectionType, Extension -from modules.cache import use_cache import copy import urllib +from ..cache import use_cache +from ..utils import run_periodic, LOGGER, parse_track_title +from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_ENABLED, CONF_HOSTNAME, CONF_PORT + + def setup(mass): ''' setup the provider''' enabled = mass.config["playerproviders"]['lms'].get(CONF_ENABLED) @@ -161,10 +163,10 @@ class LMSProvider(PlayerProvider): # 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) + player.cur_time = player_details.get('time',0) else: player.cur_item = None - player.cur_item_time = 0 + player.cur_time = 0 await self.mass.player.update_player(player) async def __process_serverstatus(self, server_status): diff --git a/music_assistant/modules/playerproviders/pylms.py b/music_assistant/playerproviders/pylms.py similarity index 97% rename from music_assistant/modules/playerproviders/pylms.py rename to music_assistant/playerproviders/pylms.py index 8c67b9b9..0a83f533 100644 --- a/music_assistant/modules/playerproviders/pylms.py +++ b/music_assistant/playerproviders/pylms.py @@ -11,9 +11,9 @@ from typing import List import random import sys import socket -from utils import run_periodic, LOGGER, parse_track_title, try_parse_int, get_ip, get_hostname -from models import PlayerProvider, MusicPlayer, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist -from constants import CONF_ENABLED +from ..utils import run_periodic, LOGGER, parse_track_title, try_parse_int, get_ip, get_hostname +from ..models import PlayerProvider, Player, PlayerState, MediaType, TrackQuality, AlbumType, Artist, Album, Track, Playlist +from ..constants import CONF_ENABLED def setup(mass): @@ -41,7 +41,6 @@ class PyLMSServer(PlayerProvider): self._lmsplayers = {} self.buffer = b'' self.last_msg_received = 0 - self.supported_musicproviders = ['http'] # start slimproto server mass.event_loop.create_task(asyncio.start_server(self.__handle_socket_client, '0.0.0.0', 3483)) @@ -61,12 +60,6 @@ class PyLMSServer(PlayerProvider): finally: transport.close() - async def player_config_entries(self): - ''' get the player config entries for this provider (list with key/value pairs)''' - return [ - ("crossfade_duration", 0, "crossfade_duration"), - ] - 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': @@ -97,7 +90,7 @@ class PyLMSServer(PlayerProvider): ''' play media on a player ''' - player = self.get_player(player_id) + player = await self.get_player(player_id) cur_index = player.cur_queue_index if queue_opt == 'replace' or not player.queue: @@ -183,7 +176,7 @@ class PyLMSServer(PlayerProvider): # update player properties player.name = lms_player.player_name player.volume_level = lms_player.volume_level - player.cur_item_time = lms_player._elapsed_seconds + player.cur_time = lms_player._elapsed_seconds if event == "disconnected": return await self.mass.player.remove_player(player_id) elif event == "power": diff --git a/music_assistant/modules/web.py b/music_assistant/web.py similarity index 88% rename from music_assistant/modules/web.py rename to music_assistant/web.py index 6761d6cd..739e894d 100755 --- a/music_assistant/modules/web.py +++ b/music_assistant/web.py @@ -3,16 +3,28 @@ import asyncio import os -from utils import run_periodic, LOGGER, run_async_background_task 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__) import ssl import concurrent import threading +from .models.media_types import MediaItem, MediaType, media_type_from_string +from .models.player import Player +from .utils import run_periodic, LOGGER, run_async_background_task, get_ip + +#json_serializer = partial(json.dumps, default=lambda x: x.__dict__) + +def json_serializer(obj): + # if isinstance(obj, list): + # lst = [] + # for item in obj: + # json_obj = json.dumps(item, skipkeys=True, default=lambda x: x.__dict__) + # lst.append(json_obj) + # return '[' + ','.join(lst) + ']' + return json.dumps(obj, skipkeys=True, default=lambda x: x.__dict__) + def setup(mass): ''' setup the module and read/apply config''' @@ -34,7 +46,7 @@ def setup(mass): def create_config_entries(config): ''' get the config entries for this module (list with key/value pairs)''' config_entries = [ - ('http_port', 8095, 'web_http_port'), + ('http_port', 8095, 'webhttp_port'), ('https_port', 8096, 'web_https_port'), ('ssl_certificate', '', 'web_ssl_cert'), ('ssl_key', '', 'web_ssl_key'), @@ -52,7 +64,8 @@ class Web(): def __init__(self, mass, http_port, https_port, ssl_cert, ssl_key, cert_fqdn_host): self.mass = mass - self._http_port = http_port + self.local_ip = get_ip() + self.http_port = http_port self._https_port = https_port self._ssl_cert = ssl_cert self._ssl_key = ssl_key @@ -71,7 +84,7 @@ class Web(): app.add_routes([web.get('/ws', self.websocket_handler)]) # app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)]) # app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)]) - app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.stream_queue)]) + app.add_routes([web.get('/stream/{player_id}', self.mass.http_streamer.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)]) @@ -89,10 +102,10 @@ class Web(): 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") + app.router.add_static("/", "./web/") self.runner = web.AppRunner(app, access_log=None) await self.runner.setup() - http_site = web.TCPSite(self.runner, '0.0.0.0', self._http_port) + http_site = web.TCPSite(self.runner, '0.0.0.0', self.http_port) await http_site.start() if self._ssl_cert and self._ssl_key: ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) @@ -181,8 +194,7 @@ class Web(): async def players(self, request): ''' get all players ''' - players = await self.mass.player.players() - return web.json_response(players, dumps=json_serializer) + return web.json_response(self.mass.player.players, dumps=json_serializer) async def player_command(self, request): ''' issue player command''' @@ -194,13 +206,13 @@ class Web(): cmd_args = request.match_info.get('cmd_args') player_cmd = getattr(player, cmd, None) if player_cmd and cmd_args: - result = await player_cmd(player_id, cmd, cmd_args) - elif player_cmd and cmd_args: - result = await player_cmd(player_id, cmd, cmd_args) + result = await player_cmd(cmd_args) + elif player_cmd: + result = await player_cmd() else: LOGGER.error("Received non-existing command %s for player %s" %(cmd, player.name)) else: - LOGGER.error("Received command dor non-existing player %s" %(player_id)) + LOGGER.error("Received command for non-existing player %s" %(player_id)) return web.json_response(result, dumps=json_serializer) async def play_media(self, request): @@ -220,8 +232,12 @@ class Web(): 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) + player = await self.mass.player.get_player(player_id) + # queue_items = player.queue.items + # queue_items = [item.__dict__ for item in queue_items] + # print(queue_items) + # result = queue_items[offset:limit] + return web.json_response(player.queue.items, dumps=json_serializer) async def index(self, request): return web.FileResponse("./web/index.html") @@ -244,8 +260,7 @@ class Web(): continue # for now we only use WS for (simple) player commands if msg.data == 'players': - players = await self.mass.player.players() - ws_msg = {'message': 'players', 'message_details': players} + ws_msg = {'message': 'players', 'message_details': self.mass.player.players} await ws.send_json(ws_msg, dumps=json_serializer) elif msg.data.startswith('players') and '/cmd/' in msg.data: # players/{player_id}/cmd/{cmd} or players/{player_id}/cmd/{cmd}/{cmd_args} @@ -253,7 +268,14 @@ class Web(): 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) + player = await self.mass.player.get_player(player_id) + player_cmd = getattr(player, cmd, None) + if player_cmd and cmd_args: + result = await player_cmd(cmd_args) + elif player_cmd: + result = await player_cmd() + except Exception as exc: + LOGGER.exception(exc) finally: self.mass.remove_event_listener(cb_id) LOGGER.debug('websocket connection closed') diff --git a/run.sh b/run.sh index 3e73292e..92cbdc4c 100755 --- a/run.sh +++ b/run.sh @@ -7,7 +7,8 @@ if [ "$autoupdate" == "true" ]; then cd /tmp curl -LOks "https://github.com/marcelveldt/musicassistant/archive/master.zip" unzip -q master.zip - cp -rf musicassistant-master/music_assistant/. /usr/src/app + cp -rf musicassistant-master/music_assistant /usr/src/app + cp -f musicassistant-master/main.py /usr/src/app rm -R /tmp/musicassistant-master fi diff --git a/music_assistant/web/components/headermenu.vue.js b/web/components/headermenu.vue.js similarity index 100% rename from music_assistant/web/components/headermenu.vue.js rename to web/components/headermenu.vue.js diff --git a/music_assistant/web/components/infoheader.vue.js b/web/components/infoheader.vue.js similarity index 100% rename from music_assistant/web/components/infoheader.vue.js rename to web/components/infoheader.vue.js diff --git a/music_assistant/web/components/listviewItem.vue.js b/web/components/listviewItem.vue.js similarity index 100% rename from music_assistant/web/components/listviewItem.vue.js rename to web/components/listviewItem.vue.js diff --git a/music_assistant/web/components/player.vue.js b/web/components/player.vue.js similarity index 98% rename from music_assistant/web/components/player.vue.js rename to web/components/player.vue.js index 840b0575..33247fe1 100755 --- a/music_assistant/web/components/player.vue.js +++ b/web/components/player.vue.js @@ -168,7 +168,7 @@ Vue.component("player", { return { name: 'no player selected', cur_item: null, - cur_item_time: 0, + cur_time: 0, player_id: '', volume_level: 0, state: 'stopped' @@ -178,14 +178,14 @@ Vue.component("player", { 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_sec = this.active_player.cur_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) + if (!this.active_player.cur_item || !this.active_player.cur_time) return "0:00"; - var cur_sec = this.active_player.cur_item_time; + var cur_sec = this.active_player.cur_time; return cur_sec.toString().formatDuration(); }, player_time_str_total() { @@ -233,7 +233,7 @@ Vue.component("player", { updateProgress: function(){ this.intervalid2 = setInterval(function(){ if (this.active_player.state == 'playing') - this.active_player.cur_item_time +=1; + this.active_player.cur_time +=1; }.bind(this), 1000); }, setPlayerVolume: function(player_id, new_volume) { @@ -265,6 +265,7 @@ Vue.component("player", { this.ws.onmessage = function(e) { var msg = JSON.parse(e.data); var players = []; + console.log(msg); if (msg.message == 'player updated') players = [msg.message_details]; else if (msg.message == 'player removed') diff --git a/music_assistant/web/components/playmenu.vue.js b/web/components/playmenu.vue.js similarity index 100% rename from music_assistant/web/components/playmenu.vue.js rename to web/components/playmenu.vue.js diff --git a/music_assistant/web/components/providericons.vue.js b/web/components/providericons.vue.js similarity index 100% rename from music_assistant/web/components/providericons.vue.js rename to web/components/providericons.vue.js diff --git a/music_assistant/web/components/readmore.vue.js b/web/components/readmore.vue.js similarity index 100% rename from music_assistant/web/components/readmore.vue.js rename to web/components/readmore.vue.js diff --git a/music_assistant/web/components/searchbox.vue.js b/web/components/searchbox.vue.js similarity index 100% rename from music_assistant/web/components/searchbox.vue.js rename to web/components/searchbox.vue.js diff --git a/music_assistant/web/components/volumecontrol.vue.js b/web/components/volumecontrol.vue.js similarity index 100% rename from music_assistant/web/components/volumecontrol.vue.js rename to web/components/volumecontrol.vue.js diff --git a/music_assistant/web/css/nprogress.css b/web/css/nprogress.css similarity index 100% rename from music_assistant/web/css/nprogress.css rename to web/css/nprogress.css diff --git a/music_assistant/web/css/site.css b/web/css/site.css similarity index 100% rename from music_assistant/web/css/site.css rename to web/css/site.css diff --git a/music_assistant/web/css/vue-loading.css b/web/css/vue-loading.css similarity index 100% rename from music_assistant/web/css/vue-loading.css rename to web/css/vue-loading.css diff --git a/music_assistant/web/images/default_artist.png b/web/images/default_artist.png similarity index 100% rename from music_assistant/web/images/default_artist.png rename to web/images/default_artist.png diff --git a/music_assistant/web/images/icons/aac.png b/web/images/icons/aac.png similarity index 100% rename from music_assistant/web/images/icons/aac.png rename to web/images/icons/aac.png diff --git a/music_assistant/web/images/icons/chromecast.png b/web/images/icons/chromecast.png similarity index 100% rename from music_assistant/web/images/icons/chromecast.png rename to web/images/icons/chromecast.png diff --git a/music_assistant/web/images/icons/file.png b/web/images/icons/file.png similarity index 100% rename from music_assistant/web/images/icons/file.png rename to web/images/icons/file.png diff --git a/music_assistant/web/images/icons/flac.png b/web/images/icons/flac.png similarity index 100% rename from music_assistant/web/images/icons/flac.png rename to web/images/icons/flac.png diff --git a/music_assistant/web/images/icons/hires.png b/web/images/icons/hires.png similarity index 100% rename from music_assistant/web/images/icons/hires.png rename to web/images/icons/hires.png diff --git a/music_assistant/web/images/icons/homeassistant.png b/web/images/icons/homeassistant.png similarity index 100% rename from music_assistant/web/images/icons/homeassistant.png rename to web/images/icons/homeassistant.png diff --git a/music_assistant/web/images/icons/http_streamer.png b/web/images/icons/http_streamer.png similarity index 100% rename from music_assistant/web/images/icons/http_streamer.png rename to web/images/icons/http_streamer.png diff --git a/music_assistant/web/images/icons/icon-128x128.png b/web/images/icons/icon-128x128.png similarity index 100% rename from music_assistant/web/images/icons/icon-128x128.png rename to web/images/icons/icon-128x128.png diff --git a/music_assistant/web/images/icons/icon-256x256.png b/web/images/icons/icon-256x256.png similarity index 100% rename from music_assistant/web/images/icons/icon-256x256.png rename to web/images/icons/icon-256x256.png diff --git a/music_assistant/web/images/icons/icon-apple.png b/web/images/icons/icon-apple.png similarity index 100% rename from music_assistant/web/images/icons/icon-apple.png rename to web/images/icons/icon-apple.png diff --git a/music_assistant/web/images/icons/info_gradient.jpg b/web/images/icons/info_gradient.jpg similarity index 100% rename from music_assistant/web/images/icons/info_gradient.jpg rename to web/images/icons/info_gradient.jpg diff --git a/music_assistant/web/images/icons/lms.png b/web/images/icons/lms.png similarity index 100% rename from music_assistant/web/images/icons/lms.png rename to web/images/icons/lms.png diff --git a/music_assistant/web/images/icons/mp3.png b/web/images/icons/mp3.png similarity index 100% rename from music_assistant/web/images/icons/mp3.png rename to web/images/icons/mp3.png diff --git a/music_assistant/web/images/icons/pylms.png b/web/images/icons/pylms.png similarity index 100% rename from music_assistant/web/images/icons/pylms.png rename to web/images/icons/pylms.png diff --git a/music_assistant/web/images/icons/qobuz.png b/web/images/icons/qobuz.png similarity index 100% rename from music_assistant/web/images/icons/qobuz.png rename to web/images/icons/qobuz.png diff --git a/music_assistant/web/images/icons/spotify.png b/web/images/icons/spotify.png similarity index 100% rename from music_assistant/web/images/icons/spotify.png rename to web/images/icons/spotify.png diff --git a/music_assistant/web/images/icons/tunein.png b/web/images/icons/tunein.png similarity index 100% rename from music_assistant/web/images/icons/tunein.png rename to web/images/icons/tunein.png diff --git a/music_assistant/web/images/icons/vorbis.png b/web/images/icons/vorbis.png similarity index 100% rename from music_assistant/web/images/icons/vorbis.png rename to web/images/icons/vorbis.png diff --git a/music_assistant/web/images/icons/web.png b/web/images/icons/web.png similarity index 100% rename from music_assistant/web/images/icons/web.png rename to web/images/icons/web.png diff --git a/music_assistant/web/images/info_gradient.jpg b/web/images/info_gradient.jpg similarity index 100% rename from music_assistant/web/images/info_gradient.jpg rename to web/images/info_gradient.jpg diff --git a/music_assistant/web/index.html b/web/index.html similarity index 100% rename from music_assistant/web/index.html rename to web/index.html diff --git a/music_assistant/web/lib/vue-loading-overlay.js b/web/lib/vue-loading-overlay.js similarity index 100% rename from music_assistant/web/lib/vue-loading-overlay.js rename to web/lib/vue-loading-overlay.js diff --git a/music_assistant/web/manifest.json b/web/manifest.json similarity index 100% rename from music_assistant/web/manifest.json rename to web/manifest.json diff --git a/music_assistant/web/pages/albumdetails.vue.js b/web/pages/albumdetails.vue.js similarity index 100% rename from music_assistant/web/pages/albumdetails.vue.js rename to web/pages/albumdetails.vue.js diff --git a/music_assistant/web/pages/artistdetails.vue.js b/web/pages/artistdetails.vue.js similarity index 100% rename from music_assistant/web/pages/artistdetails.vue.js rename to web/pages/artistdetails.vue.js diff --git a/music_assistant/web/pages/browse.vue.js b/web/pages/browse.vue.js similarity index 100% rename from music_assistant/web/pages/browse.vue.js rename to web/pages/browse.vue.js diff --git a/music_assistant/web/pages/config.vue.js b/web/pages/config.vue.js similarity index 100% rename from music_assistant/web/pages/config.vue.js rename to web/pages/config.vue.js diff --git a/music_assistant/web/pages/home.vue.js b/web/pages/home.vue.js similarity index 100% rename from music_assistant/web/pages/home.vue.js rename to web/pages/home.vue.js diff --git a/music_assistant/web/pages/playlistdetails.vue.js b/web/pages/playlistdetails.vue.js similarity index 100% rename from music_assistant/web/pages/playlistdetails.vue.js rename to web/pages/playlistdetails.vue.js diff --git a/music_assistant/web/pages/queue.vue.js b/web/pages/queue.vue.js similarity index 100% rename from music_assistant/web/pages/queue.vue.js rename to web/pages/queue.vue.js diff --git a/music_assistant/web/pages/search.vue.js b/web/pages/search.vue.js similarity index 100% rename from music_assistant/web/pages/search.vue.js rename to web/pages/search.vue.js diff --git a/music_assistant/web/pages/trackdetails.vue.js b/web/pages/trackdetails.vue.js similarity index 100% rename from music_assistant/web/pages/trackdetails.vue.js rename to web/pages/trackdetails.vue.js diff --git a/music_assistant/web/strings.js b/web/strings.js similarity index 100% rename from music_assistant/web/strings.js rename to web/strings.js -- 2.34.1