--- /dev/null
+include *.txt
+include README.rst
+include LICENSE.md
+graft music_assistant
+recursive-exclude * *.py[co]
\ No newline at end of file
+MANIFEST.in
README.md
+requirements.txt
+requirements_dev.txt
+requirements_lint.txt
+requirements_test.txt
setup.cfg
setup.py
+.tox/lint/lib/python3.7/site-packages/appdirs-1.4.4.dist-info/LICENSE.txt
+.tox/lint/lib/python3.7/site-packages/appdirs-1.4.4.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/astroid-2.3.3.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/attrs-20.2.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/black-19.10b0.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/black-19.10b0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/blib2to3/Grammar.txt
+.tox/lint/lib/python3.7/site-packages/blib2to3/PatternGrammar.txt
+.tox/lint/lib/python3.7/site-packages/click-7.1.2.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/coverage-5.2.1.dist-info/LICENSE.txt
+.tox/lint/lib/python3.7/site-packages/coverage-5.2.1.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/coverage-5.2.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/flake8-3.7.9.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/flake8-3.7.9.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/importlib_metadata-1.7.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/isort-4.3.21.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/isort-4.3.21.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/lazy_object_proxy-1.4.3.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/mccabe-0.6.1.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/mccabe-0.6.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/more_itertools-8.5.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/mypy-0.770.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/mypy-0.770.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/mypy_extensions-0.4.3.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/packaging-20.4.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pathspec-0.8.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pip-20.2.2.dist-info/LICENSE.txt
+.tox/lint/lib/python3.7/site-packages/pip-20.2.2.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/pip-20.2.2.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pip/_vendor/vendor.txt
+.tox/lint/lib/python3.7/site-packages/pluggy-0.13.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/py-1.9.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/py/_vendored_packages/apipkg-1.4.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/py/_vendored_packages/iniconfig-1.0.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pycodestyle-2.5.0.dist-info/LICENSE.txt
+.tox/lint/lib/python3.7/site-packages/pycodestyle-2.5.0.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/pycodestyle-2.5.0.dist-info/namespace_packages.txt
+.tox/lint/lib/python3.7/site-packages/pycodestyle-2.5.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pydocstyle-5.0.2.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/pydocstyle-5.0.2.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pydocstyle/data/imperatives.txt
+.tox/lint/lib/python3.7/site-packages/pydocstyle/data/imperatives_blacklist.txt
+.tox/lint/lib/python3.7/site-packages/pyflakes-2.1.1.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/pyflakes-2.1.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pylint-2.4.4.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/pylint-2.4.4.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pyparsing-2.4.7.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pytest-5.4.1.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/pytest-5.4.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pytest_cov-2.8.1.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/pytest_cov-2.8.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/pytest_timeout-1.3.4.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/pytest_timeout-1.3.4.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/regex-2020.7.14.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/dependency_links.txt
+.tox/lint/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/six-1.15.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/snowballstemmer-2.0.0.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/toml-0.10.1.dist-info/LICENSE.txt
+.tox/lint/lib/python3.7/site-packages/toml-0.10.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/typed_ast-1.4.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/typing_extensions-3.7.4.3.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/wcwidth-0.2.5.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/wheel-0.35.1.dist-info/LICENSE.txt
+.tox/lint/lib/python3.7/site-packages/wheel-0.35.1.dist-info/entry_points.txt
+.tox/lint/lib/python3.7/site-packages/wheel-0.35.1.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/wrapt-1.11.2.dist-info/top_level.txt
+.tox/lint/lib/python3.7/site-packages/zipp-3.1.0.dist-info/top_level.txt
+.tox/mypy/lib/python3.7/site-packages/pip-20.2.2.dist-info/LICENSE.txt
+.tox/mypy/lib/python3.7/site-packages/pip-20.2.2.dist-info/entry_points.txt
+.tox/mypy/lib/python3.7/site-packages/pip-20.2.2.dist-info/top_level.txt
+.tox/mypy/lib/python3.7/site-packages/pip/_vendor/vendor.txt
+.tox/mypy/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/dependency_links.txt
+.tox/mypy/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/entry_points.txt
+.tox/mypy/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/top_level.txt
+.tox/mypy/lib/python3.7/site-packages/wheel-0.35.1.dist-info/LICENSE.txt
+.tox/mypy/lib/python3.7/site-packages/wheel-0.35.1.dist-info/entry_points.txt
+.tox/mypy/lib/python3.7/site-packages/wheel-0.35.1.dist-info/top_level.txt
+.tox/py37/lib/python3.7/site-packages/pip-20.2.2.dist-info/LICENSE.txt
+.tox/py37/lib/python3.7/site-packages/pip-20.2.2.dist-info/entry_points.txt
+.tox/py37/lib/python3.7/site-packages/pip-20.2.2.dist-info/top_level.txt
+.tox/py37/lib/python3.7/site-packages/pip/_vendor/vendor.txt
+.tox/py37/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/dependency_links.txt
+.tox/py37/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/entry_points.txt
+.tox/py37/lib/python3.7/site-packages/setuptools-49.6.0.dist-info/top_level.txt
+.tox/py37/lib/python3.7/site-packages/wheel-0.35.1.dist-info/LICENSE.txt
+.tox/py37/lib/python3.7/site-packages/wheel-0.35.1.dist-info/entry_points.txt
+.tox/py37/lib/python3.7/site-packages/wheel-0.35.1.dist-info/top_level.txt
music_assistant/__init__.py
music_assistant/__main__.py
music_assistant/app_vars.py
music_assistant/providers/demo/__init__.py
music_assistant/providers/demo/demo_musicprovider.py
music_assistant/providers/demo/demo_playerprovider.py
+music_assistant/providers/file/file.py
music_assistant/providers/home_assistant/__init__.py
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/sonos/__init__.py
music_assistant/providers/sonos/sonos.py
music_assistant/providers/spotify/__init__.py
+music_assistant/providers/spotify/spotty/arm-linux/spotty-hf
+music_assistant/providers/spotify/spotty/darwin/spotty
+music_assistant/providers/spotify/spotty/windows/spotty.exe
+music_assistant/providers/spotify/spotty/x86-linux/spotty
+music_assistant/providers/spotify/spotty/x86-linux/spotty-x86_64
music_assistant/providers/squeezebox/__init__.py
music_assistant/providers/squeezebox/constants.py
music_assistant/providers/squeezebox/discovery.py
music_assistant/providers/squeezebox/socket_client.py
-music_assistant/providers/tunein/__init__.py
\ No newline at end of file
+music_assistant/providers/tunein/__init__.py
+music_assistant/providers/webplayer/todo.py
+venv/lib/python3.7/site-packages/Pillow-7.2.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/PyChromecast-7.2.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/PyJWT-1.7.1.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/PyJWT-1.7.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/SoundFile-0.10.3.post1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/Unidecode-1.1.1.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/Unidecode-1.1.1.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/Unidecode-1.1.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/aiodns-2.0.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/aiohttp-3.6.2.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/aiohttp-3.6.2.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/aiohttp_cors-0.7.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/aiohttp_jwt-0.6.1.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/aiohttp_jwt-0.6.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/appdirs-1.4.4.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/appdirs-1.4.4.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/argparse-1.4.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/astroid-2.3.3.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/async_timeout-3.0.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/asyncio_throttle-1.0.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/attrs-20.2.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/black-19.10b0.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/black-19.10b0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/blib2to3/Grammar.txt
+venv/lib/python3.7/site-packages/blib2to3/PatternGrammar.txt
+venv/lib/python3.7/site-packages/brotlipy-0.7.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/casttube-0.2.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/cchardet-2.1.6.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/certifi-2020.6.20.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/cffi-1.14.2.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/cffi-1.14.2.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/chardet-3.0.4.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/chardet-3.0.4.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/click-7.1.2.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/coverage-5.2.1.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/coverage-5.2.1.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/coverage-5.2.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/cryptography-3.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/cytoolz-0.10.1-py3.7.egg-info/SOURCES.txt
+venv/lib/python3.7/site-packages/cytoolz-0.10.1-py3.7.egg-info/dependency_links.txt
+venv/lib/python3.7/site-packages/cytoolz-0.10.1-py3.7.egg-info/installed-files.txt
+venv/lib/python3.7/site-packages/cytoolz-0.10.1-py3.7.egg-info/requires.txt
+venv/lib/python3.7/site-packages/cytoolz-0.10.1-py3.7.egg-info/top_level.txt
+venv/lib/python3.7/site-packages/filelock-3.0.12.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/flake8-3.7.9.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/flake8-3.7.9.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/future-0.18.2-py3.7.egg-info/SOURCES.txt
+venv/lib/python3.7/site-packages/future-0.18.2-py3.7.egg-info/dependency_links.txt
+venv/lib/python3.7/site-packages/future-0.18.2-py3.7.egg-info/entry_points.txt
+venv/lib/python3.7/site-packages/future-0.18.2-py3.7.egg-info/installed-files.txt
+venv/lib/python3.7/site-packages/future-0.18.2-py3.7.egg-info/top_level.txt
+venv/lib/python3.7/site-packages/hass_client-0.0.6.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/idna-2.10.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/ifaddr-0.1.7.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/ifaddr-0.1.7.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/importlib_metadata-1.7.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/isort-4.3.21.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/isort-4.3.21.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/lazy_object_proxy-1.4.3.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/mccabe-0.6.1.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/mccabe-0.6.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/memory_tempfile-2.2.3.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/more_itertools-8.5.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/multidict-4.7.6.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/music_assistant-1.0.0-py3.7.egg/EGG-INFO/SOURCES.txt
+venv/lib/python3.7/site-packages/music_assistant-1.0.0-py3.7.egg/EGG-INFO/dependency_links.txt
+venv/lib/python3.7/site-packages/music_assistant-1.0.0-py3.7.egg/EGG-INFO/entry_points.txt
+venv/lib/python3.7/site-packages/music_assistant-1.0.0-py3.7.egg/EGG-INFO/requires.txt
+venv/lib/python3.7/site-packages/music_assistant-1.0.0-py3.7.egg/EGG-INFO/top_level.txt
+venv/lib/python3.7/site-packages/mypy-0.770.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/mypy-0.770.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/mypy_extensions-0.4.3.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/numpy/LICENSE.txt
+venv/lib/python3.7/site-packages/numpy-1.19.2.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/numpy-1.19.2.dist-info/LICENSES_bundled.txt
+venv/lib/python3.7/site-packages/numpy-1.19.2.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/numpy-1.19.2.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/numpy/core/include/numpy/multiarray_api.txt
+venv/lib/python3.7/site-packages/numpy/core/include/numpy/ufunc_api.txt
+venv/lib/python3.7/site-packages/packaging-20.4.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/passlib-1.7.2.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/passlib/_data/wordsets/bip39.txt
+venv/lib/python3.7/site-packages/passlib/_data/wordsets/eff_long.txt
+venv/lib/python3.7/site-packages/passlib/_data/wordsets/eff_prefixed.txt
+venv/lib/python3.7/site-packages/passlib/_data/wordsets/eff_short.txt
+venv/lib/python3.7/site-packages/pathspec-0.8.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pip-19.2.3.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/pip-19.2.3.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/pip-19.2.3.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pluggy-0.13.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/protobuf-3.13.0.dist-info/namespace_packages.txt
+venv/lib/python3.7/site-packages/protobuf-3.13.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/py-1.9.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/py/_vendored_packages/apipkg-1.4.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/py/_vendored_packages/iniconfig-1.0.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pycares-3.1.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pycodestyle-2.5.0.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/pycodestyle-2.5.0.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/pycodestyle-2.5.0.dist-info/namespace_packages.txt
+venv/lib/python3.7/site-packages/pycodestyle-2.5.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pycparser-2.20.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pydocstyle-5.0.2.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/pydocstyle-5.0.2.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pydocstyle/data/imperatives.txt
+venv/lib/python3.7/site-packages/pydocstyle/data/imperatives_blacklist.txt
+venv/lib/python3.7/site-packages/pyflakes-2.1.1.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/pyflakes-2.1.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pylint-2.4.4.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/pylint-2.4.4.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pyloudnorm-0.1.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pyparsing-2.4.7.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pytaglib-1.4.6-py3.7.egg-info/SOURCES.txt
+venv/lib/python3.7/site-packages/pytaglib-1.4.6-py3.7.egg-info/dependency_links.txt
+venv/lib/python3.7/site-packages/pytaglib-1.4.6-py3.7.egg-info/entry_points.txt
+venv/lib/python3.7/site-packages/pytaglib-1.4.6-py3.7.egg-info/installed-files.txt
+venv/lib/python3.7/site-packages/pytaglib-1.4.6-py3.7.egg-info/top_level.txt
+venv/lib/python3.7/site-packages/pytest-5.4.1.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/pytest-5.4.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pytest_cov-2.8.1.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/pytest_cov-2.8.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/pytest_timeout-1.3.4.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/pytest_timeout-1.3.4.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/python_slugify-4.0.1.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/python_slugify-4.0.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/python_vlc-3.0.11115.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/regex-2020.7.14.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/requests-2.24.0.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/requests-2.24.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/scipy/HACKING.rst.txt
+venv/lib/python3.7/site-packages/scipy/INSTALL.rst.txt
+venv/lib/python3.7/site-packages/scipy/LICENSE.txt
+venv/lib/python3.7/site-packages/scipy/LICENSES_bundled.txt
+venv/lib/python3.7/site-packages/scipy/THANKS.txt
+venv/lib/python3.7/site-packages/scipy/mypy_requirements.txt
+venv/lib/python3.7/site-packages/scipy-1.5.2.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/scipy-1.5.2.dist-info/LICENSES_bundled.txt
+venv/lib/python3.7/site-packages/scipy-1.5.2.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/scipy/io/matlab/tests/data/japanese_utf8.txt
+venv/lib/python3.7/site-packages/scipy/ndimage/tests/data/README.txt
+venv/lib/python3.7/site-packages/scipy/ndimage/tests/data/label_inputs.txt
+venv/lib/python3.7/site-packages/scipy/ndimage/tests/data/label_results.txt
+venv/lib/python3.7/site-packages/scipy/ndimage/tests/data/label_strels.txt
+venv/lib/python3.7/site-packages/scipy/sparse/linalg/dsolve/SuperLU/License.txt
+venv/lib/python3.7/site-packages/scipy/spatial/qhull_src/COPYING.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/cdist-X1.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/cdist-X2.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-boolean-inp.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-chebyshev-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-chebyshev-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-cityblock-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-cityblock-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-correlation-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-correlation-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-cosine-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-cosine-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-double-inp.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-euclidean-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-euclidean-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-hamming-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-jaccard-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-jensenshannon-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-jensenshannon-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-minkowski-3.2-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-minkowski-3.2-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-minkowski-5.8-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-seuclidean-ml-iris.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-seuclidean-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/pdist-spearman-ml.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/random-bool-data.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/random-double-data.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/random-int-data.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/random-uint-data.txt
+venv/lib/python3.7/site-packages/scipy/spatial/tests/data/selfdual-4d-polytope.txt
+venv/lib/python3.7/site-packages/setuptools-41.2.0.dist-info/dependency_links.txt
+venv/lib/python3.7/site-packages/setuptools-41.2.0.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/setuptools-41.2.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/six-1.15.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/snowballstemmer-2.0.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/soco-0.19.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/text_unidecode-1.3.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/text_unidecode-1.3.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/toml-0.10.1.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/toml-0.10.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/toolz-0.10.0-py3.7.egg-info/SOURCES.txt
+venv/lib/python3.7/site-packages/toolz-0.10.0-py3.7.egg-info/dependency_links.txt
+venv/lib/python3.7/site-packages/toolz-0.10.0-py3.7.egg-info/installed-files.txt
+venv/lib/python3.7/site-packages/toolz-0.10.0-py3.7.egg-info/top_level.txt
+venv/lib/python3.7/site-packages/tox-3.14.6.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/tox-3.14.6.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/typed_ast-1.4.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/typing_extensions-3.7.4.3.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/urllib3-1.25.10.dist-info/LICENSE.txt
+venv/lib/python3.7/site-packages/urllib3-1.25.10.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/uvloop-0.14.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/virtualenv-20.0.31.dist-info/entry_points.txt
+venv/lib/python3.7/site-packages/virtualenv-20.0.31.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/wcwidth-0.2.5.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/wrapt-1.11.2.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/xmltodict-0.12.0.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/yarl-1.5.1.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/zeroconf-0.28.4.dist-info/top_level.txt
+venv/lib/python3.7/site-packages/zipp-3.1.0.dist-info/top_level.txt
\ No newline at end of file
name: str = ""
metadata: dict = field(default_factory=dict)
tags: List[str] = field(default_factory=list)
- external_ids: List[ExternalId] = field(default_factory=dict)
+ external_ids: dict = field(default_factory=dict)
provider_ids: List[MediaItemProviderId] = field(default_factory=list)
in_library: List[str] = field(default_factory=list)
is_lazy: bool = False
--- /dev/null
+"""Filesystem musicprovider support for MusicAssistant."""
+import base64
+import logging
+import os
+from typing import List, Optional
+
+import taglib
+from music_assistant.models.config_entry import ConfigEntry, ConfigEntryType
+from music_assistant.models.media_types import (
+ Album,
+ Artist,
+ MediaItemProviderId,
+ MediaType,
+ Playlist,
+ SearchResult,
+ Track,
+ TrackQuality,
+)
+from music_assistant.models.musicprovider import MusicProvider
+from music_assistant.models.streamdetails import ContentType, StreamDetails, StreamType
+from music_assistant.utils import parse_title_and_version
+
+PROV_ID = "file"
+PROV_NAME = "Local files and playlists"
+
+LOGGER = logging.getLogger(PROV_ID)
+
+CONF_MUSIC_DIR = "music_dir"
+CONF_PLAYLISTS_DIR = "playlists_dir"
+
+CONFIG_ENTRIES = [
+ ConfigEntry(
+ entry_key=CONF_MUSIC_DIR,
+ entry_type=ConfigEntryType.STRING,
+ description_key="file_prov_music_path",
+ ),
+ ConfigEntry(
+ entry_key=CONF_PLAYLISTS_DIR,
+ entry_type=ConfigEntryType.STRING,
+ description_key="file_prov_playlists_path",
+ ),
+]
+
+
+async def async_setup(mass):
+ """Perform async setup of this Plugin/Provider."""
+ prov = FileProvider()
+ await mass.async_register_provider(prov)
+
+
+class FileProvider(MusicProvider):
+ """
+ Very basic implementation of a musicprovider for local files.
+
+ Assumes files are stored on disk in format <artist>/<album>/<track.ext>
+ Reads ID3 tags from file and falls back to parsing filename
+ Supports m3u files only for playlists
+ Supports having URI's from streaming providers within m3u playlist
+ Should be compatible with LMS
+ """
+
+ _music_dir = None
+ _playlists_dir = None
+
+ @property
+ def id(self) -> str:
+ """Return provider ID for this provider."""
+ return PROV_ID
+
+ @property
+ def name(self) -> str:
+ """Return provider Name for this provider."""
+ return PROV_NAME
+
+ @property
+ def config_entries(self) -> List[ConfigEntry]:
+ """Return Config Entries for this provider."""
+ return CONFIG_ENTRIES
+
+ @property
+ def supported_mediatypes(self) -> List[MediaType]:
+ """Return MediaTypes the provider supports."""
+ return [MediaType.Album, MediaType.Artist, MediaType.Playlist, MediaType.Track]
+
+ async def async_on_start(self) -> bool:
+ """Handle initialization of the provider based on config."""
+ conf = self.mass.config.get_provider_config(self.id)
+ if not os.path.isdir(conf[CONF_MUSIC_DIR]):
+ raise FileNotFoundError(f"Directory {conf[CONF_MUSIC_DIR]} does not exist")
+ self._music_dir = conf["music_dir"]
+ if os.path.isdir(conf[CONF_PLAYLISTS_DIR]):
+ self._playlists_dir = conf[CONF_PLAYLISTS_DIR]
+ else:
+ self._playlists_dir = None
+
+ async def async_on_stop(self):
+ """Handle correct close/cleanup of the provider on exit."""
+ # nothing to be done
+
+ async def async_search(
+ self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
+ ) -> SearchResult:
+ """
+ Perform search on musicprovider.
+
+ :param search_query: Search query.
+ :param media_types: A list of media_types to include. All types if None.
+ :param limit: Number of items to return in the search (per type).
+ """
+ result = SearchResult()
+ # TODO !
+ return result
+
+ async def async_get_library_artists(self) -> List[Artist]:
+ """Retrieve all library artists."""
+ if not os.path.isdir(self._music_dir):
+ LOGGER.error("music path does not exist: %s" % self._music_dir)
+ return
+ yield
+ for dirname in os.listdir(self._music_dir):
+ dirpath = os.path.join(self._music_dir, dirname)
+ if os.path.isdir(dirpath) and not dirpath.startswith("."):
+ artist = await self.get_artist(dirpath)
+ if artist:
+ yield artist
+
+ async def async_get_library_albums(self) -> List[Album]:
+ """Get album folders recursively."""
+ async for artist in self.get_library_artists():
+ async for album in self.get_artist_albums(artist.item_id):
+ yield album
+
+ async def async_get_library_tracks(self) -> List[Track]:
+ """Get all tracks recursively."""
+ # TODO: support disk subfolders
+ async for album in self.get_library_albums():
+ async for track in self.get_album_tracks(album.item_id):
+ yield track
+
+ async def async_get_library_playlists(self) -> List[Playlist]:
+ """Retrieve playlists from disk."""
+ if not self._playlists_dir:
+ return
+ yield
+ for filename in os.listdir(self._playlists_dir):
+ filepath = os.path.join(self._playlists_dir, filename)
+ if (
+ os.path.isfile(filepath)
+ and not filename.startswith(".")
+ and filename.lower().endswith(".m3u")
+ ):
+ playlist = await self.get_playlist(filepath)
+ if playlist:
+ yield playlist
+
+ async def async_get_artist(self, prov_item_id: str) -> Artist:
+ """Get full artist details by id."""
+ if os.sep not in prov_item_id:
+ itempath = base64.b64decode(prov_item_id).decode("utf-8")
+ else:
+ itempath = prov_item_id
+ prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
+ if not os.path.isdir(itempath):
+ LOGGER.error("Artist path does not exist: %s" % itempath)
+ return None
+ name = itempath.split(os.sep)[-1]
+ artist = Artist()
+ artist.item_id = prov_item_id
+ artist.provider = PROV_ID
+ artist.name = name
+ artist.provider_ids.append(
+ MediaItemProviderId(provider=PROV_ID, item_id=artist.item_id)
+ )
+ return artist
+
+ async def async_get_album(self, prov_item_id: str) -> Album:
+ """Get full album details by id."""
+ if os.sep not in prov_item_id:
+ itempath = base64.b64decode(prov_item_id).decode("utf-8")
+ else:
+ itempath = prov_item_id
+ prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
+ if not os.path.isdir(itempath):
+ LOGGER.error("album path does not exist: %s" % itempath)
+ return None
+ name = itempath.split(os.sep)[-1]
+ artistpath = itempath.rsplit(os.sep, 1)[0]
+ album = Album()
+ album.item_id = prov_item_id
+ album.provider = self.prov_id
+ album.name, album.version = parse_title_and_version(name)
+ album.artist = await self.get_artist(artistpath)
+ if not album.artist:
+ raise Exception("No album artist ! %s" % artistpath)
+ album.provider_ids.append(
+ MediaItemProviderId(provider=PROV_ID, item_id=prov_item_id)
+ )
+ return album
+
+ async def async_get_track(self, prov_item_id: str) -> Track:
+ """Get full track details by id."""
+ if os.sep not in prov_item_id:
+ itempath = base64.b64decode(prov_item_id).decode("utf-8")
+ else:
+ itempath = prov_item_id
+ if not os.path.isfile(itempath):
+ LOGGER.error("track path does not exist: %s", itempath)
+ return None
+ return await self.__parse_track(itempath)
+
+ async def async_get_playlist(self, prov_item_id: str) -> Playlist:
+ """Get full playlist details by id."""
+ if os.sep not in prov_item_id:
+ itempath = base64.b64decode(prov_item_id).decode("utf-8")
+ else:
+ itempath = prov_item_id
+ prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
+ if not os.path.isfile(itempath):
+ LOGGER.error("playlist path does not exist: %s" % itempath)
+ return None
+ playlist = Playlist()
+ playlist.item_id = prov_item_id
+ playlist.provider = self.prov_id
+ playlist.name = itempath.split(os.sep)[-1].replace(".m3u", "")
+ playlist.is_editable = True
+ playlist.provider_ids.append(
+ MediaItemProviderId(provider=PROV_ID, item_id=prov_item_id)
+ )
+ playlist.owner = "disk"
+ playlist.checksum = os.path.getmtime(itempath)
+ return playlist
+
+ async def async_get_album_tracks(self, prov_album_id) -> List[Track]:
+ """Get album tracks for given album id."""
+ if os.sep not in prov_album_id:
+ albumpath = base64.b64decode(prov_album_id).decode("utf-8")
+ else:
+ albumpath = prov_album_id
+ if not os.path.isdir(albumpath):
+ LOGGER.error("album path does not exist: %s" % albumpath)
+ return
+ album = await self.get_album(albumpath)
+ for filename in os.listdir(albumpath):
+ filepath = os.path.join(albumpath, filename)
+ if os.path.isfile(filepath) and not filepath.startswith("."):
+ track = await self.__parse_track(filepath)
+ if track:
+ track.album = album
+ yield track
+
+ async def async_get_playlist_tracks(
+ self, prov_playlist_id: str, limit: int = 50, offset: int = 0
+ ) -> List[Track]:
+ """Get playlist tracks for given playlist id."""
+ if os.sep not in prov_playlist_id:
+ itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
+ else:
+ itempath = prov_playlist_id
+ if not os.path.isfile(itempath):
+ LOGGER.error("playlist path does not exist: %s" % itempath)
+ return
+ counter = 0
+ index = 0
+ with open(itempath) as f:
+ for line in f.readlines():
+ line = line.strip()
+ if line and not line.startswith("#"):
+ counter += 1
+ if counter > offset:
+ track = await self.__parse_track_from_uri(line)
+ if track:
+ yield track
+ index += 1
+ if limit and index == limit:
+ break
+
+ async def async_get_artist_albums(self, prov_artist_id: str) -> List[Album]:
+ """Get a list of albums for the given artist."""
+ if os.sep not in prov_artist_id:
+ artistpath = base64.b64decode(prov_artist_id).decode("utf-8")
+ else:
+ artistpath = prov_artist_id
+ if not os.path.isdir(artistpath):
+ LOGGER.error("artist path does not exist: %s" % artistpath)
+ return
+ for dirname in os.listdir(artistpath):
+ dirpath = os.path.join(artistpath, dirname)
+ if os.path.isdir(dirpath) and not dirpath.startswith("."):
+ album = await self.get_album(dirpath)
+ if album:
+ yield album
+
+ async def async_get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
+ """Get a list of random tracks as we have no clue about preference."""
+ async for album in self.get_artist_albums(prov_artist_id):
+ async for track in self.get_album_tracks(album.item_id):
+ yield track
+
+ async def async_get_stream_details(self, track_id):
+ """Return the content details for the given track when it will be streamed."""
+ if os.sep not in track_id:
+ track_id = base64.b64decode(track_id).decode("utf-8")
+ if not os.path.isfile(track_id):
+ return None
+ # TODO: retrieve sanple rate and bitdepth
+ return StreamDetails(
+ type=StreamType.FILE,
+ provider=PROV_ID,
+ item_id=track_id,
+ content_type=ContentType(track_id.split(".")[-1]),
+ path=track_id,
+ sample_rate=44100,
+ bit_depth=16,
+ )
+
+ async def __async_parse_track(self, filename):
+ """Try to parse a track from a filename with taglib."""
+ track = Track()
+ # pylint: disable=broad-except
+ try:
+ song = taglib.File(filename)
+ except Exception:
+ return None # not a media file ?
+ prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8")
+ track.duration = song.length
+ track.item_id = prov_item_id
+ track.provider = self.prov_id
+ name = song.tags["TITLE"][0]
+ track.name, track.version = parse_title_and_version(name)
+ albumpath = filename.rsplit(os.sep, 1)[0]
+ track.album = await self.get_album(albumpath)
+ artists = []
+ for artist_str in song.tags["ARTIST"]:
+ local_artist_path = os.path.join(self._music_dir, artist_str)
+ if os.path.isfile(local_artist_path):
+ artist = await self.get_artist(local_artist_path)
+ else:
+ artist = Artist()
+ artist.name = artist_str
+ fake_artistpath = os.path.join(self._music_dir, artist_str)
+ artist.item_id = fake_artistpath # temporary id
+ artist.provider_ids.append(
+ MediaItemProviderId(
+ provider=PROV_ID,
+ item_id=base64.b64encode(
+ fake_artistpath.encode("utf-8")
+ ).decode("utf-8"),
+ )
+ )
+ artists.append(artist)
+ track.artists = artists
+ if "GENRE" in song.tags:
+ track.tags = song.tags["GENRE"]
+ if "ISRC" in song.tags:
+ track.external_ids["isrc"] = song.tags["ISRC"][0]
+ if "DISCNUMBER" in song.tags:
+ track.disc_number = int(song.tags["DISCNUMBER"][0])
+ if "TRACKNUMBER" in song.tags:
+ track.track_number = int(song.tags["TRACKNUMBER"][0])
+ quality_details = ""
+ if filename.endswith(".flac"):
+ # TODO: get bit depth
+ quality = TrackQuality.FLAC_LOSSLESS
+ if song.sampleRate > 192000:
+ quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
+ elif song.sampleRate > 96000:
+ quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
+ elif song.sampleRate > 48000:
+ quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
+ quality_details = "%s Khz" % (song.sampleRate / 1000)
+ elif filename.endswith(".ogg"):
+ quality = TrackQuality.LOSSY_OGG
+ quality_details = "%s kbps" % (song.bitrate)
+ elif filename.endswith(".m4a"):
+ quality = TrackQuality.LOSSY_AAC
+ quality_details = "%s kbps" % (song.bitrate)
+ else:
+ quality = TrackQuality.LOSSY_MP3
+ quality_details = "%s kbps" % (song.bitrate)
+ track.provider_ids.append(
+ MediaItemProviderId(
+ provider=PROV_ID,
+ item_id=prov_item_id,
+ quality=quality,
+ details=quality_details,
+ )
+ )
+ return track
+
+ async def __async_parse_track_from_uri(self, uri):
+ """Try to parse a track from an uri found in playlist."""
+ if "://" in uri:
+ # track is uri from external provider?
+ prov_id = uri.split("://")[0]
+ prov_item_id = uri.split("/")[-1].split(".")[0].split(":")[-1]
+ try:
+ return await self.mass.music_manager.async_get_track(
+ prov_item_id, prov_id, lazy=False
+ )
+ except Exception as exc:
+ LOGGER.warning("Could not parse uri %s to track: %s" % (uri, str(exc)))
+ return None
+ # try to treat uri as filename
+ # TODO: filename could be related to musicdir or full path
+ track = await self.async_get_track(uri)
+ if track:
+ return track
+ track = await self.async_get_track(os.path.join(self._music_dir, uri))
+ if track:
+ return track
+ return None
+++ /dev/null
-"""Filesystem musicprovider support for MusicAssistant."""
-# pylint: skip-file
-# flake8: noqa
-import base64
-import os
-from typing import List
-
-import taglib
-from music_assistant.constants import CONF_ENABLED
-from music_assistant.models.media_types import (
- Album,
- Artist,
- MediaType,
- Playlist,
- Track,
- TrackQuality,
-)
-from music_assistant.models.musicprovider import MusicProvider
-from music_assistant.utils import LOGGER, parse_title_and_version
-
-PROV_NAME = "Local files and playlists"
-PROV_CLASS = "FileProvider"
-
-CONFIG_ENTRIES = [
- (CONF_ENABLED, False, CONF_ENABLED),
- ("music_dir", "", "file_prov_music_path"),
- ("playlists_dir", "", "file_prov_playlists_path"),
-]
-
-
-class FileProvider(MusicProvider):
- """
- Very basic implementation of a musicprovider for local files
- Assumes files are stored on disk in format <artist>/<album>/<track.ext>
- Reads ID3 tags from file and falls back to parsing filename
- Supports m3u files only for playlists
- Supports having URI's from streaming providers within m3u playlist
- Should be compatible with LMS
- """
-
- _music_dir = None
- _playlists_dir = None
-
- async def async_setup(self, conf):
- """setup the provider, return True if succesfull"""
- if not os.path.isdir(conf["music_dir"]):
- raise FileNotFoundError(f"Directory {conf['music_dir']} does not exist")
- self._music_dir = conf["music_dir"]
- if os.path.isdir(conf["playlists_dir"]):
- self._playlists_dir = conf["playlists_dir"]
- else:
- self._playlists_dir = None
-
- async def async_search(self, searchstring, media_types=List[MediaType], limit=5):
- """perform search on the provider"""
- result = {"artists": [], "albums": [], "tracks": [], "playlists": []}
- return result
-
- async def async_get_library_artists(self) -> List[Artist]:
- """get artist folders in music directory"""
- if not os.path.isdir(self._music_dir):
- LOGGER.error("music path does not exist: %s" % self._music_dir)
- return
- yield
- for dirname in os.listdir(self._music_dir):
- dirpath = os.path.join(self._music_dir, dirname)
- if os.path.isdir(dirpath) and not dirpath.startswith("."):
- artist = await self.get_artist(dirpath)
- if artist:
- yield artist
-
- async def async_get_library_albums(self) -> List[Album]:
- """get album folders recursively"""
- async for artist in self.get_library_artists():
- async for album in self.get_artist_albums(artist.item_id):
- yield album
-
- async def async_get_library_tracks(self) -> List[Track]:
- """get all tracks recursively"""
- # TODO: support disk subfolders
- async for album in self.get_library_albums():
- async for track in self.get_album_tracks(album.item_id):
- yield track
-
- async def async_get_library_playlists(self) -> List[Playlist]:
- """retrieve playlists from disk"""
- if not self._playlists_dir:
- return
- yield
- for filename in os.listdir(self._playlists_dir):
- filepath = os.path.join(self._playlists_dir, filename)
- if (
- os.path.isfile(filepath)
- and not filename.startswith(".")
- and filename.lower().endswith(".m3u")
- ):
- playlist = await self.get_playlist(filepath)
- if playlist:
- yield playlist
-
- async def async_get_artist(self, prov_item_id) -> Artist:
- """get full artist details by id"""
- if not os.sep in prov_item_id:
- itempath = base64.b64decode(prov_item_id).decode("utf-8")
- else:
- itempath = prov_item_id
- prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
- if not os.path.isdir(itempath):
- LOGGER.error("artist path does not exist: %s" % itempath)
- return None
- name = itempath.split(os.sep)[-1]
- artist = Artist()
- artist.item_id = prov_item_id
- artist.provider = self.prov_id
- artist.name = name
- artist.ids.append({"provider": self.prov_id, "item_id": artist.item_id})
- return artist
-
- async def async_get_album(self, prov_item_id) -> Album:
- """get full album details by id"""
- if not os.sep in prov_item_id:
- itempath = base64.b64decode(prov_item_id).decode("utf-8")
- else:
- itempath = prov_item_id
- prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
- if not os.path.isdir(itempath):
- LOGGER.error("album path does not exist: %s" % itempath)
- return None
- name = itempath.split(os.sep)[-1]
- artistpath = itempath.rsplit(os.sep, 1)[0]
- album = Album()
- album.item_id = prov_item_id
- album.provider = self.prov_id
- album.name, album.version = parse_title_and_version(name)
- album.artist = await self.get_artist(artistpath)
- if not album.artist:
- raise Exception("No album artist ! %s" % artistpath)
- album.ids.append({"provider": self.prov_id, "item_id": prov_item_id})
- return album
-
- async def async_get_track(self, prov_item_id) -> Track:
- """get full track details by id"""
- if not os.sep in prov_item_id:
- itempath = base64.b64decode(prov_item_id).decode("utf-8")
- else:
- itempath = prov_item_id
- if not os.path.isfile(itempath):
- LOGGER.error("track path does not exist: %s" % itempath)
- return None
- return await self.__parse_track(itempath)
-
- async def async_get_playlist(self, prov_item_id) -> Playlist:
- """get full playlist details by id"""
- if not os.sep in prov_item_id:
- itempath = base64.b64decode(prov_item_id).decode("utf-8")
- else:
- itempath = prov_item_id
- prov_item_id = base64.b64encode(itempath.encode("utf-8")).decode("utf-8")
- if not os.path.isfile(itempath):
- LOGGER.error("playlist path does not exist: %s" % itempath)
- return None
- playlist = Playlist()
- playlist.item_id = prov_item_id
- playlist.provider = self.prov_id
- playlist.name = itempath.split(os.sep)[-1].replace(".m3u", "")
- playlist.is_editable = True
- playlist.ids.append({"provider": self.prov_id, "item_id": prov_item_id})
- playlist.owner = "disk"
- playlist.checksum = os.path.getmtime(itempath)
- return playlist
-
- async def async_get_album_tracks(self, prov_album_id) -> List[Track]:
- """get album tracks for given album id"""
- if not os.sep in prov_album_id:
- albumpath = base64.b64decode(prov_album_id).decode("utf-8")
- else:
- albumpath = prov_album_id
- if not os.path.isdir(albumpath):
- LOGGER.error("album path does not exist: %s" % albumpath)
- return
- album = await self.get_album(albumpath)
- for filename in os.listdir(albumpath):
- filepath = os.path.join(albumpath, filename)
- if os.path.isfile(filepath) and not filepath.startswith("."):
- track = await self.__parse_track(filepath)
- if track:
- track.album = album
- yield track
-
- async def async_get_playlist_tracks(
- self, prov_playlist_id, limit=50, offset=0
- ) -> List[Track]:
- """get playlist tracks for given playlist id"""
- if not os.sep in prov_playlist_id:
- itempath = base64.b64decode(prov_playlist_id).decode("utf-8")
- else:
- itempath = prov_playlist_id
- if not os.path.isfile(itempath):
- LOGGER.error("playlist path does not exist: %s" % itempath)
- return
- counter = 0
- index = 0
- with open(itempath) as f:
- for line in f.readlines():
- line = line.strip()
- if line and not line.startswith("#"):
- counter += 1
- if counter > offset:
- track = await self.__parse_track_from_uri(line)
- if track:
- yield track
- index += 1
- if limit and index == limit:
- break
-
- async def async_get_artist_albums(self, prov_artist_id) -> List[Album]:
- """get a list of albums for the given artist"""
- if not os.sep in prov_artist_id:
- artistpath = base64.b64decode(prov_artist_id).decode("utf-8")
- else:
- artistpath = prov_artist_id
- if not os.path.isdir(artistpath):
- LOGGER.error("artist path does not exist: %s" % artistpath)
- return
- for dirname in os.listdir(artistpath):
- dirpath = os.path.join(artistpath, dirname)
- if os.path.isdir(dirpath) and not dirpath.startswith("."):
- album = await self.get_album(dirpath)
- if album:
- yield album
-
- async def async_get_artist_toptracks(self, prov_artist_id) -> List[Track]:
- """get a list of random tracks as we have no clue about preference"""
- async for album in self.get_artist_albums(prov_artist_id):
- async for track in self.get_album_tracks(album.item_id):
- yield track
-
- async def async_get_stream_details(self, track_id):
- """return the content details for the given track when it will be streamed"""
- if not os.sep in track_id:
- track_id = base64.b64decode(track_id).decode("utf-8")
- if not os.path.isfile(track_id):
- return None
- # TODO: retrieve sanple rate and bitdepth
- return {
- "type": "file",
- "path": track_id,
- "content_type": track_id.split(".")[-1],
- "sample_rate": 44100,
- "bit_depth": 16,
- }
-
- async def __async_parse_track(self, filename):
- """try to parse a track from a filename with taglib"""
- track = Track()
- try:
- song = taglib.File(filename)
- except:
- return None # not a media file ?
- prov_item_id = base64.b64encode(filename.encode("utf-8")).decode("utf-8")
- track.duration = song.length
- track.item_id = prov_item_id
- track.provider = self.prov_id
- name = song.tags["TITLE"][0]
- track.name, track.version = parse_title_and_version(name)
- albumpath = filename.rsplit(os.sep, 1)[0]
- track.album = await self.get_album(albumpath)
- artists = []
- for artist_str in song.tags["ARTIST"]:
- local_artist_path = os.path.join(self._music_dir, artist_str)
- if os.path.isfile(local_artist_path):
- artist = await self.get_artist(local_artist_path)
- else:
- artist = Artist()
- artist.name = artist_str
- fake_artistpath = os.path.join(self._music_dir, artist_str)
- artist.item_id = fake_artistpath # temporary id
- artist.ids.append(
- {
- "provider": self.prov_id,
- "item_id": base64.b64encode(
- fake_artistpath.encode("utf-8")
- ).decode("utf-8"),
- }
- )
- artists.append(artist)
- track.artists = artists
- if "GENRE" in song.tags:
- track.tags = song.tags["GENRE"]
- if "ISRC" in song.tags:
- track.external_ids["isrc"] = song.tags["ISRC"][0]
- if "DISCNUMBER" in song.tags:
- track.disc_number = int(song.tags["DISCNUMBER"][0])
- if "TRACKNUMBER" in song.tags:
- track.track_number = int(song.tags["TRACKNUMBER"][0])
- quality_details = ""
- if filename.endswith(".flac"):
- # TODO: get bit depth
- quality = TrackQuality.FLAC_LOSSLESS
- if song.sampleRate > 192000:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_4
- elif song.sampleRate > 96000:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_3
- elif song.sampleRate > 48000:
- quality = TrackQuality.FLAC_LOSSLESS_HI_RES_2
- quality_details = "%s Khz" % (song.sampleRate / 1000)
- elif filename.endswith(".ogg"):
- quality = TrackQuality.LOSSY_OGG
- quality_details = "%s kbps" % (song.bitrate)
- elif filename.endswith(".m4a"):
- quality = TrackQuality.LOSSY_AAC
- quality_details = "%s kbps" % (song.bitrate)
- else:
- quality = TrackQuality.LOSSY_MP3
- quality_details = "%s kbps" % (song.bitrate)
- track.ids.append(
- {
- "provider": self.prov_id,
- "item_id": prov_item_id,
- "quality": quality,
- "details": quality_details,
- }
- )
- return track
-
- async def __async_parse_track_from_uri(self, uri):
- """try to parse a track from an uri found in playlist"""
- if "://" in uri:
- # track is uri from external provider?
- prov_id = uri.split("://")[0]
- prov_item_id = uri.split("/")[-1].split(".")[0].split(":")[-1]
- try:
- return await self.mass.music_manager.providers[prov_id].track(
- prov_item_id, lazy=False
- )
- except Exception as exc:
- LOGGER.warning("Could not parse uri %s to track: %s" % (uri, str(exc)))
- return None
- # try to treat uri as filename
- # TODO: filename could be related to musicdir or full path
- track = await self.get_track(uri)
- if track:
- return track
- track = await self.get_track(os.path.join(self._music_dir, uri))
- if track:
- return track
- return None
--- /dev/null
+"""Webplayer support."""
+import logging
+import time
+from typing import List
+
+from music_assistant.models.config_entry import ConfigEntry
+from music_assistant.models.player import Player, PlayerState
+from music_assistant.models.playerprovider import PlayerProvider
+from music_assistant.utils import run_periodic
+
+PROV_ID = "webplayer"
+PROV_NAME = "WebPlayer"
+LOGGER = logging.getLogger(PROV_ID)
+
+CONFIG_ENTRIES = []
+
+EVENT_WEBPLAYER_CMD = "webplayer command"
+EVENT_WEBPLAYER_STATE = "webplayer state"
+EVENT_WEBPLAYER_REGISTER = "webplayer register"
+
+
+async def async_setup(mass):
+ """Perform async setup of this Plugin/Provider."""
+ prov = WebPlayerProvider()
+ await mass.async_register_provider(prov)
+
+
+class WebPlayerProvider(PlayerProvider):
+ """
+ Implementation of a player using pure HTML/javascript.
+
+ Used in the front-end.
+ Communication is handled through the websocket connection
+ and our internal event bus.
+ """
+
+ _players = {}
+
+ ### Provider specific implementation #####
+
+ @property
+ def id(self) -> str:
+ """Return provider ID for this provider."""
+ return PROV_ID
+
+ @property
+ def name(self) -> str:
+ """Return provider Name for this provider."""
+ return PROV_NAME
+
+ @property
+ def config_entries(self) -> List[ConfigEntry]:
+ """Return Config Entries for this provider."""
+ return CONFIG_ENTRIES
+
+ async def async_on_start(self) -> bool:
+ """Handle initialization of the provider based on config."""
+ self.mass.add_event_listener(
+ self.async_handle_mass_event,
+ [EVENT_WEBPLAYER_STATE, EVENT_WEBPLAYER_REGISTER],
+ )
+ self.mass.add_job(self.async_check_players())
+
+ async def async_handle_mass_event(self, msg, msg_details):
+ """Handle received event for the webplayer component."""
+ if msg == EVENT_WEBPLAYER_REGISTER:
+ # register new player
+ player_id = msg_details["player_id"]
+ player = Player(
+ player_id=player_id,
+ provider_id=PROV_ID,
+ name=msg_details["name"],
+ powered=True,
+ )
+ await self.mass.player_manager.async_add_player(player)
+
+ elif msg == EVENT_WEBPLAYER_STATE:
+ await self.__handle_player_state(msg_details)
+
+ @run_periodic(30)
+ async def async_check_players(self):
+ """Invalidate players that did not send a heartbeat message in a while."""
+ cur_time = time.time()
+ offline_players = []
+ for player in self._players.values():
+ if cur_time - player._last_message > 30:
+ offline_players.append(player.player_id)
+ for player_id in offline_players:
+ await self.mass.player_manager.async_remove_player(player_id)
+ self._players.pop(player_id, None)
+
+ async def async_cmd_stop(self, player_id: str):
+ """Send stop command to player."""
+ data = {"player_id": player_id, "cmd": "stop"}
+ self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+ async def async_cmd_play(self, player_id: str):
+ """Send play command to player."""
+ data = {"player_id": player_id, "cmd": "play"}
+ self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+ async def async_cmd_pause(self, player_id: str):
+ """Send pause command to player."""
+ data = {"player_id": player_id, "cmd": "pause"}
+ self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+ async def async_cmd_power_on(self, player_id: str):
+ """Send power ON command to player."""
+ self._players[player_id].powered = True # not supported on webplayer
+ data = {"player_id": player_id, "cmd": "stop"}
+ self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+ async def async_cmd_power_off(self, player_id: str):
+ """Send power OFF command to player."""
+ await self.async_cmd_stop(player_id)
+ self._players[player_id].powered = False
+
+ async def async_cmd_volume_set(self, volume_level, player_id: str):
+ """Send new volume level command to player."""
+ data = {
+ "player_id": player_id,
+ "cmd": "volume_set",
+ "volume_level": volume_level,
+ }
+ self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+ async def async_cmd_volume_mute(self, player_id: str, is_muted=False):
+ """Send mute command to player."""
+ data = {"player_id": player_id, "cmd": "volume_mute", "is_muted": is_muted}
+ self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+ async def async_cmd_play_uri(self, player_id: str, uri: str):
+ """Play single uri on player."""
+ data = {"player_id": player_id, "cmd": "play_uri", "uri": uri}
+ self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
+
+ async def __async_handle_player_state(self, data):
+ """Handle state event from player."""
+ player_id = data["player_id"]
+ player = self._players[player_id]
+ if "volume_level" in data:
+ player.volume_level = data["volume_level"]
+ if "muted" in data:
+ player.muted = data["muted"]
+ if "state" in data:
+ player.state = PlayerState(data["state"])
+ if "cur_time" in data:
+ player.elapsed_time = data["elapsed_time"]
+ if "current_uri" in data:
+ player.current_uri = data["current_uri"]
+ if "powered" in data:
+ player.powered = data["powered"]
+ if "name" in data:
+ player.name = data["name"]
+ player._last_message = time.time()
+ self.mass.add_job(self.mass.player_manager.async_update_player(player))
+++ /dev/null
-# pylint: skip-file
-# flake8: noqa
-import asyncio
-import decimal
-import os
-import random
-import socket
-import struct
-import sys
-import time
-from collections import OrderedDict
-from typing import List
-
-from music_assistant.constants import CONF_ENABLED
-from music_assistant.models.player import Player, PlayerState
-from music_assistant.models.player_queue import QueueItem
-from music_assistant.models.playerprovider import PlayerProvider
-from music_assistant.utils import (
- LOGGER,
- get_hostname,
- get_ip,
- run_periodic,
- try_parse_int,
-)
-
-PROV_ID = "webplayer"
-PROV_NAME = "WebPlayer"
-PROV_CLASS = "WebPlayerProvider"
-
-CONFIG_ENTRIES = [(CONF_ENABLED, True, CONF_ENABLED)]
-
-EVENT_WEBPLAYER_CMD = "webplayer command"
-EVENT_WEBPLAYER_STATE = "webplayer state"
-EVENT_WEBPLAYER_REGISTER = "webplayer register"
-
-
-class WebPlayerProvider(PlayerProvider):
- """
- Implementation of a player using pure HTML/javascript
- used in the front-end.
- Communication is handled through the websocket connection
- and our internal event bus
- """
-
- ### Provider specific implementation #####
-
- async def async_setup(self, conf):
- """async initialize of module"""
- await self.mass.add_event_listener(
- self.handle_mass_event, EVENT_WEBPLAYER_STATE
- )
- await self.mass.add_event_listener(
- self.handle_mass_event, EVENT_WEBPLAYER_REGISTER
- )
- self.mass.add_job(self.check_players())
-
- async def async_handle_mass_event(self, msg, msg_details):
- """received event for the webplayer component"""
- if msg == EVENT_WEBPLAYER_REGISTER:
- # register new player
- player_id = msg_details["player_id"]
- player = WebPlayer(self.mass, player_id, self.prov_id)
- player.supports_crossfade = False
- player.supports_gapless = False
- player.supports_queue = False
- player.name = msg_details["name"]
- await self.add_player(player)
- elif msg == EVENT_WEBPLAYER_STATE:
- player_id = msg_details["player_id"]
- player = await self.get_player(player_id)
- if player:
- await player.handle_state(msg_details)
-
- @run_periodic(30)
- async def async_check_players(self):
- """invalidate players that did not send a heartbeat message in a while"""
- cur_time = time.time()
- offline_players = []
- for player in self.players:
- if cur_time - player._last_message > 30:
- offline_players.append(player.player_id)
- for player_id in offline_players:
- await self.remove_player(player_id)
-
-
-class WebPlayer(Player):
- """Web player object"""
-
- def __init__(self, mass, player_id, prov_id):
- self._last_message = time.time()
- super().__init__(mass, player_id, prov_id)
-
- async def async_cmd_stop(self):
- """Send stop command to player."""
- data = {"player_id": self.player_id, "cmd": "stop"}
- self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
-
- async def async_cmd_play(self):
- """Send play command to player."""
- data = {"player_id": self.player_id, "cmd": "play"}
- self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
-
- async def async_cmd_pause(self):
- """Send pause command to player."""
- data = {"player_id": self.player_id, "cmd": "pause"}
- self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
-
- async def async_cmd_power_on(self):
- """Send power ON command to player."""
- self.powered = True # not supported on webplayer
- data = {"player_id": self.player_id, "cmd": "stop"}
- self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
-
- async def async_cmd_power_off(self):
- """Send power OFF command to player."""
- self.powered = False
-
- async def async_cmd_volume_set(self, volume_level):
- """Send new volume level command to player."""
- data = {
- "player_id": self.player_id,
- "cmd": "volume_set",
- "volume_level": volume_level,
- }
- self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
-
- async def async_cmd_volume_mute(self, is_muted=False):
- """Send mute command to player."""
- data = {"player_id": self.player_id, "cmd": "volume_mute", "is_muted": is_muted}
- self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
-
- async def async_cmd_play_uri(self, uri: str):
- """Play single uri on player."""
- data = {"player_id": self.player_id, "cmd": "play_uri", "uri": uri}
- self.mass.signal_event(EVENT_WEBPLAYER_CMD, data)
-
- async def async_handle_state(self, data):
- """handle state event from player."""
- if "volume_level" in data:
- self.volume_level = data["volume_level"]
- if "muted" in data:
- self.muted = data["muted"]
- if "state" in data:
- self.state = PlayerState(data["state"])
- if "cur_time" in data:
- self.cur_time = data["cur_time"]
- if "current_uri" in data:
- self.current_uri = data["current_uri"]
- if "powered" in data:
- self.powered = data["powered"]
- if "name" in data:
- self.name = data["name"]
- self._last_message = time.time()
persistent=no
[BASIC]
-good-names=id,i,j,k,ex,Run,_,fp
+good-names=id,i,j,k,ex,Run,_,fp,T,ev
[MESSAGES CONTROL]
# Reasons disabled:
+# format - handled by black
# locally-disabled - it spams too much
+# duplicate-code - unavoidable
+# cyclic-import - doesn't test if both import on load
+# abstract-class-little-used - prevents from setting right foundation
+# unused-argument - generic callbacks and setup methods create a lot of warnings
# too-many-* - are not enforced for the sake of readability
# too-few-* - same as too-many-*
-# import-outside-toplevel - TODO
+# abstract-method - with intro of async there are always methods missing
+# inconsistent-return-statements - doesn't handle raise
+# too-many-ancestors - it's too strict.
+# wrong-import-order - isort guards this
disable=
- bad-continuation,
- fixme,
- import-outside-toplevel,
+ format,
+ abstract-class-little-used,
+ abstract-method,
+ cyclic-import,
+ duplicate-code,
+ inconsistent-return-statements,
locally-disabled,
+ not-context-manager,
too-few-public-methods,
+ too-many-ancestors,
+ too-many-arguments,
+ too-many-branches,
+ too-many-instance-attributes,
+ too-many-lines,
+ too-many-locals,
too-many-public-methods,
+ too-many-return-statements,
+ too-many-statements,
+ too-many-boolean-expressions,
+ unused-argument,
+ wrong-import-order
+# enable useless-suppression temporarily every now and then to clean them up
+enable=
+ use-symbolic-message-instead
[REPORTS]
score=no
ignore_errors = True
commands =
black --check ./
- flake8 music_assistant test
- pylint music_assistant test
- pydocstyle music_assistant test
+ flake8 music_assistant tests
+ pylint music_assistant tests
+ pydocstyle music_assistant tests
deps =
-rrequirements_lint.txt
-rrequirements_test.txt
-# [testenv:mypy]
-# basepython = python3
-# ignore_errors = True
-# commands =
-# mypy music_assistant
-# deps =
-# -rrequirements_lint.txt
+[testenv:mypy]
+basepython = python3
+ignore_errors = True
+commands =
+ mypy music_assistant
+deps =
+ -rrequirements_lint.txt
\ No newline at end of file