diff --git a/nixos/modules/services/audio/music-assistant.nix b/nixos/modules/services/audio/music-assistant.nix index b56da132c801..40646f33c572 100644 --- a/nixos/modules/services/audio/music-assistant.nix +++ b/nixos/modules/services/audio/music-assistant.nix @@ -125,6 +125,9 @@ in "AF_INET" "AF_INET6" "AF_NETLINK" + ] + ++ lib.optionals (lib.elem "snapcast" cfg.providers) [ + "AF_UNIX" ]; RestrictNamespaces = true; RestrictRealtime = true; @@ -132,6 +135,7 @@ in SystemCallFilter = [ "@system-service" "~@privileged @resources" + "mbind" ] ++ lib.optionals useYTMusic [ "@pkey" diff --git a/pkgs/by-name/mu/music-assistant/builtin-snapcast-server.patch b/pkgs/by-name/mu/music-assistant/builtin-snapcast-server.patch index d58183f3dffb..f361e9a7c235 100644 --- a/pkgs/by-name/mu/music-assistant/builtin-snapcast-server.patch +++ b/pkgs/by-name/mu/music-assistant/builtin-snapcast-server.patch @@ -1,8 +1,8 @@ -diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py -index 742d798183ec6fd1ac1fdbcad750912c222de5c3..8d0d95577d31f735977884280b08f384187774bd 100644 ---- a/music_assistant/providers/snapcast/__init__.py -+++ b/music_assistant/providers/snapcast/__init__.py -@@ -87,7 +87,8 @@ DEFAULT_SNAPSTREAM_IDLE_THRESHOLD = 60000 +diff --git a/music_assistant/providers/snapcast/constants.py b/music_assistant/providers/snapcast/constants.py +index eb5ecb1a..92f03212 100644 +--- a/music_assistant/providers/snapcast/constants.py ++++ b/music_assistant/providers/snapcast/constants.py +@@ -43,7 +43,8 @@ CONTROL_SOCKET_PATH_TEMPLATE = "/tmp/ma-snapcast-{queue_id}.sock" # noqa: S108 MASS_STREAM_PREFIX = "Music Assistant - " MASS_ANNOUNCEMENT_POSTFIX = " (announcement)" SNAPWEB_DIR = pathlib.Path(__file__).parent.resolve().joinpath("snapweb") @@ -12,7 +12,19 @@ index 742d798183ec6fd1ac1fdbcad750912c222de5c3..8d0d95577d31f735977884280b08f384 DEFAULT_SNAPCAST_FORMAT = AudioFormat( content_type=ContentType.PCM_S16LE, -@@ -866,6 +867,10 @@ class SnapCastProvider(PlayerProvider): +diff --git a/music_assistant/providers/snapcast/provider.py b/music_assistant/providers/snapcast/provider.py +index 94c197fc..9a344f53 100644 +--- a/music_assistant/providers/snapcast/provider.py ++++ b/music_assistant/providers/snapcast/provider.py +@@ -32,6 +32,7 @@ from music_assistant.providers.snapcast.constants import ( + CONF_STREAM_IDLE_THRESHOLD, + CONF_USE_EXTERNAL_SERVER, + CONTROL_SCRIPT, ++ CONTROL_SCRIPT_DIR, + CONTROL_SOCKET_PATH_TEMPLATE, + DEFAULT_SNAPSERVER_PORT, + SNAPWEB_DIR, +@@ -215,6 +216,10 @@ class SnapCastProvider(PlayerProvider): args = [ "snapserver", @@ -23,7 +35,7 @@ index 742d798183ec6fd1ac1fdbcad750912c222de5c3..8d0d95577d31f735977884280b08f384 # config settings taken from # https://raw.githubusercontent.com/badaix/snapcast/86cd4b2b63e750a72e0dfe6a46d47caf01426c8d/server/etc/snapserver.conf f"--server.datadir={self.mass.storage_path}", -@@ -878,6 +883,7 @@ class SnapCastProvider(PlayerProvider): +@@ -227,6 +232,7 @@ class SnapCastProvider(PlayerProvider): f"--stream.buffer={self._snapcast_server_buffer_size}", f"--stream.chunk_ms={self._snapcast_server_chunk_ms}", f"--stream.codec={self._snapcast_server_transport_codec}", @@ -31,3 +43,14 @@ index 742d798183ec6fd1ac1fdbcad750912c222de5c3..8d0d95577d31f735977884280b08f384 f"--stream.send_to_muted={str(self._snapcast_server_send_to_muted).lower()}", f"--streaming_client.initial_volume={self._snapcast_server_initial_volume}", ] +@@ -243,9 +249,7 @@ class SnapCastProvider(PlayerProvider): + # Copy control script after snapserver starts + # (run in executor to avoid blocking) + loop = asyncio.get_running_loop() +- self._controlscript_available = await loop.run_in_executor( +- None, self._setup_controlscript +- ) ++ self._controlscript_available = True + + def _get_ma_id(self, snap_client_id: str) -> str: + search_dict = self._ids_map.inverse diff --git a/pkgs/by-name/mu/music-assistant/dont-install-deps.patch b/pkgs/by-name/mu/music-assistant/dont-install-deps.patch index 21e9522a9bfe..0c6569078400 100644 --- a/pkgs/by-name/mu/music-assistant/dont-install-deps.patch +++ b/pkgs/by-name/mu/music-assistant/dont-install-deps.patch @@ -1,8 +1,8 @@ diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py -index 74540dd3..14f8f864 100644 +index bc14912b..0527a831 100644 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py -@@ -434,30 +434,11 @@ async def load_provider_module(domain: str, requirements: list[str]) -> Provider +@@ -495,30 +495,11 @@ async def load_provider_module(domain: str, requirements: list[str]) -> Provider "ProviderModuleType", importlib.import_module(f".{domain}", "music_assistant.providers") ) @@ -35,10 +35,10 @@ index 74540dd3..14f8f864 100644 async def has_tmpfs_mount() -> bool: """Check if we have a tmpfs mount.""" diff --git a/music_assistant/providers/ytmusic/__init__.py b/music_assistant/providers/ytmusic/__init__.py -index 52a7544a..816d0425 100644 +index 9cc5981a..dc424b0b 100644 --- a/music_assistant/providers/ytmusic/__init__.py +++ b/music_assistant/providers/ytmusic/__init__.py -@@ -197,7 +197,6 @@ class YoutubeMusicProvider(MusicProvider): +@@ -200,7 +200,6 @@ class YoutubeMusicProvider(MusicProvider): async def handle_async_init(self) -> None: """Set up the YTMusic provider.""" logging.getLogger("yt_dlp").setLevel(self.logger.level + 10) @@ -46,3 +46,12 @@ index 52a7544a..816d0425 100644 self._cookie = self.config.get_value(CONF_COOKIE) self._po_token_server_url = ( self.config.get_value(CONF_PO_TOKEN_SERVER_URL) or DEFAULT_PO_TOKEN_SERVER_URL +@@ -1091,8 +1090,6 @@ class YoutubeMusicProvider(MusicProvider): + # NOTE: Google breaks things quite often which requires us to update + # some packages very frequently. Installing them dynamically prevents + # us from having to update MA to ensure this provider works. +- for package_name in PACKAGES_TO_INSTALL: +- await install_package(package_name) + # verify if the yt_dlp package is usable + try: + await asyncio.to_thread(importlib.import_module, "yt_dlp") diff --git a/pkgs/by-name/mu/music-assistant/ffmpeg.patch b/pkgs/by-name/mu/music-assistant/ffmpeg.patch index e0f8f1360979..7f503117877e 100644 --- a/pkgs/by-name/mu/music-assistant/ffmpeg.patch +++ b/pkgs/by-name/mu/music-assistant/ffmpeg.patch @@ -1,17 +1,21 @@ +diff --git a/music_assistant/controllers/streams/smart_fades/fades.py b/music_assistant/controllers/streams/smart_fades/fades.py +index 03c11ef9..460d53a6 100644 +--- a/music_assistant/controllers/streams/smart_fades/fades.py ++++ b/music_assistant/controllers/streams/smart_fades/fades.py +@@ -76,7 +76,7 @@ class SmartFade(ABC): + async with aiofiles.open(fadeout_filename, "wb") as outfile: + await outfile.write(fade_out_part) + args = [ +- "ffmpeg", ++ "@ffmpeg@", + "-hide_banner", + "-loglevel", + "error", diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py -index 482ab8e8..a02fc435 100644 +index 0674b009..53158f4d 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py -@@ -80,7 +80,7 @@ async def crossfade_pcm_parts( - await outfile.write(fade_out_part) - args = [ - # generic args -- "ffmpeg", -+ "@ffmpeg@", - "-hide_banner", - "-loglevel", - "quiet", -@@ -141,7 +141,7 @@ async def strip_silence( +@@ -103,7 +103,7 @@ async def strip_silence( reverse: bool = False, ) -> bytes: """Strip silence from begin or end of pcm audio using ffmpeg.""" @@ -20,7 +24,7 @@ index 482ab8e8..a02fc435 100644 args += [ "-acodec", pcm_format.content_type.name.lower(), -@@ -912,7 +912,7 @@ async def get_silence( +@@ -1134,7 +1134,7 @@ async def get_silence( return # use ffmpeg for all other encodings args = [ @@ -30,11 +34,11 @@ index 482ab8e8..a02fc435 100644 "-loglevel", "quiet", diff --git a/music_assistant/helpers/ffmpeg.py b/music_assistant/helpers/ffmpeg.py -index 7c1c8d83..501a7370 100644 +index 9ea5ea0c..51b1d62b 100644 --- a/music_assistant/helpers/ffmpeg.py +++ b/music_assistant/helpers/ffmpeg.py -@@ -218,7 +218,7 @@ def get_ffmpeg_args( - extra_args = [] +@@ -249,7 +249,7 @@ def get_ffmpeg_args( # noqa: PLR0915 + extra_output_args = [] # generic args generic_args = [ - "ffmpeg", @@ -42,7 +46,7 @@ index 7c1c8d83..501a7370 100644 "-hide_banner", "-loglevel", loglevel, -@@ -370,7 +370,7 @@ async def check_ffmpeg_version() -> None: +@@ -416,7 +416,7 @@ async def check_ffmpeg_version() -> None: """Check if ffmpeg is present (with libsoxr support).""" # check for FFmpeg presence try: @@ -52,10 +56,10 @@ index 7c1c8d83..501a7370 100644 raise AudioError( "FFmpeg binary is missing from system. " diff --git a/music_assistant/helpers/tags.py b/music_assistant/helpers/tags.py -index 06c8bcb5..a703aacd 100644 +index fe83301c..d35760b6 100644 --- a/music_assistant/helpers/tags.py +++ b/music_assistant/helpers/tags.py -@@ -438,7 +438,7 @@ def parse_tags(input_file: str, file_size: int | None = None) -> AudioTags: +@@ -484,7 +484,7 @@ def parse_tags( Input_file may be a (local) filename or URL accessible by ffmpeg. """ args = ( @@ -64,11 +68,20 @@ index 06c8bcb5..a703aacd 100644 "-hide_banner", "-loglevel", "fatal", -@@ -553,7 +553,7 @@ async def get_embedded_image(input_file: str) -> bytes | None: - Input_file may be a (local) filename or URL accessible by ffmpeg. +@@ -551,7 +551,7 @@ def get_file_duration(input_file: str) -> float: + NOT Async friendly. """ args = ( - "ffmpeg", ++ "@ffmpeg@", + "-hide_banner", + "-loglevel", + "info", +@@ -707,7 +707,7 @@ async def get_embedded_image(input_file: str) -> bytes | None: + + # Use FFmpeg for all other cases (URLs, ID3 tags, Vorbis comments, etc.) + args = [ +- "ffmpeg", + "@ffmpeg@", "-hide_banner", "-loglevel", diff --git a/pkgs/by-name/mu/music-assistant/fix-webserver-tests-in-sandbox.patch b/pkgs/by-name/mu/music-assistant/fix-webserver-tests-in-sandbox.patch new file mode 100644 index 000000000000..b930b57f8010 --- /dev/null +++ b/pkgs/by-name/mu/music-assistant/fix-webserver-tests-in-sandbox.patch @@ -0,0 +1,13 @@ +diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py +index bc14912b..d207b855 100644 +--- a/music_assistant/helpers/util.py ++++ b/music_assistant/helpers/util.py +@@ -274,7 +274,7 @@ async def get_ip_addresses(include_ipv6: bool = False) -> tuple[str, ...]: + if ip.is_IPv6 and not include_ipv6: + continue + ip_str = str(ip.ip) +- if ip_str.startswith(("127", "169.254")): ++ if ip_str.startswith(("127", "169.254")) and "PYTEST_VERSION" not in os.environ: + # filter out IPv4 loopback/APIPA address + continue + if ip_str.startswith(("::1", "::ffff:", "fe80")): diff --git a/pkgs/by-name/mu/music-assistant/frontend.nix b/pkgs/by-name/mu/music-assistant/frontend.nix index 2732e5d4bdac..1213c1b1fa7a 100644 --- a/pkgs/by-name/mu/music-assistant/frontend.nix +++ b/pkgs/by-name/mu/music-assistant/frontend.nix @@ -7,13 +7,13 @@ buildPythonPackage rec { pname = "music-assistant-frontend"; - version = "2.15.3"; + version = "2.17.55"; pyproject = true; src = fetchPypi { pname = "music_assistant_frontend"; inherit version; - hash = "sha256-atwFGd6KplVPw4e6rHrNlXmMCsozL56lCVYVWCg9RPs="; + hash = "sha256-fRn2hLg0y+G2atyxpzMq86mJJA41nfSS5UweNZL+0ew="; }; build-system = [ setuptools ]; diff --git a/pkgs/by-name/mu/music-assistant/librespot.patch b/pkgs/by-name/mu/music-assistant/librespot.patch index eabb310dd5f6..268b7339834c 100644 --- a/pkgs/by-name/mu/music-assistant/librespot.patch +++ b/pkgs/by-name/mu/music-assistant/librespot.patch @@ -1,16 +1,16 @@ diff --git a/music_assistant/providers/spotify/helpers.py b/music_assistant/providers/spotify/helpers.py -index 6f87b44f46b64805ef6799a54d596fd1684017f1..23de7fc2145619dd4f02551e81cb383f2b79fa15 100644 +index fe7af648..3f5121de 100644 --- a/music_assistant/providers/spotify/helpers.py +++ b/music_assistant/providers/spotify/helpers.py -@@ -4,6 +4,7 @@ from __future__ import annotations - +@@ -6,6 +6,7 @@ import asyncio import os import platform + import time +from shutil import which + from typing import TYPE_CHECKING, Any + from urllib.parse import urlencode - from music_assistant.helpers.process import check_output - -@@ -20,12 +21,8 @@ async def get_librespot_binary() -> str: +@@ -35,12 +36,8 @@ async def get_librespot_binary() -> str: except OSError: return None diff --git a/pkgs/by-name/mu/music-assistant/package.nix b/pkgs/by-name/mu/music-assistant/package.nix index b30abadac299..3133cb4026f8 100644 --- a/pkgs/by-name/mu/music-assistant/package.nix +++ b/pkgs/by-name/mu/music-assistant/package.nix @@ -15,13 +15,13 @@ let music-assistant-frontend = self.callPackage ./frontend.nix { }; music-assistant-models = super.music-assistant-models.overridePythonAttrs (oldAttrs: rec { - version = "1.1.47"; + version = "1.1.86"; src = fetchFromGitHub { owner = "music-assistant"; repo = "models"; tag = version; - hash = "sha256-NNKF61CRBe+N9kY+JUa77ClHSJ9RhpsiheMg7Ytyq2M="; + hash = "sha256-dQwFsuelp/3s2CO/5jxNrZcmWxE9xYhrpx0O37Tq/TQ="; }; postPatch = '' @@ -47,14 +47,14 @@ assert python.pkgs.buildPythonApplication rec { pname = "music-assistant"; - version = "2.6.3"; + version = "2.7.2"; pyproject = true; src = fetchFromGitHub { owner = "music-assistant"; repo = "server"; tag = version; - hash = "sha256-vvhynBor5tj5n53Dm3K4ZOkFZ5LM7bFevOCdZjJsbbM="; + hash = "sha256-JOS7t4vXkxHXvcI0tCAw3KfcrohUD4NV4w6oYIncQv4="; }; patches = [ @@ -71,12 +71,26 @@ python.pkgs.buildPythonApplication rec { # Fix running the built-in snapcast server ./builtin-snapcast-server.patch + + # Fix running the webserver pytests in our nix sandbox, which only has a loopback interface, + # by not skipping over its loopback IPv4 address: + # + # """Return all Config Entries for this core module (if any).""" + # ip_addresses = await get_ip_addresses() + # > default_publish_ip = ip_addresses[0] + # ^^^^^^^^^^^^^^^ + # E IndexError: tuple index out of range + ./fix-webserver-tests-in-sandbox.patch ]; postPatch = '' substituteInPlace pyproject.toml \ --replace-fail "0.0.0" "${version}" + # get-mac is a deprecated alias of getmac since 2018 + substituteInPlace pyproject.toml \ + --replace-fail "get-mac" "getmac" + rm -rv music_assistant/providers/spotify/bin ''; @@ -87,13 +101,16 @@ python.pkgs.buildPythonApplication rec { pythonRelaxDeps = [ "aiohttp" "aiosqlite" + "aiovban" # PyPi and GitHub versioning is out of sync "certifi" "colorlog" "cryptography" + "getmac" "mashumaro" "orjson" "pillow" "podcastparser" + "pycares" "xmltodict" "zeroconf" ]; @@ -103,68 +120,73 @@ python.pkgs.buildPythonApplication rec { "uv" ]; - dependencies = - with python.pkgs; - [ - aiohttp - chardet - mashumaro - orjson - ] - ++ optional-dependencies.server; + dependencies = with python.pkgs; [ + # Only packages required in pyproject.toml + aiodns + aiofiles + aiohttp + aiohttp-asyncmdnsresolver + aiohttp-fast-zlib + aiortc + aiorun + aiosqlite + aiovban + awesomeversion + brotli + certifi + chardet + colorlog + cryptography + getmac + gql + ifaddr + librosa + mashumaro + music-assistant-frontend + music-assistant-models + mutagen + orjson + pillow + podcastparser + propcache + python-slugify + shortuuid + unidecode + xmltodict + zeroconf + + # Used in music_assistant/controllers/webserver/helpers/auth_providers.py + # but somehow not part of pyproject.toml + hass-client + ]; optional-dependencies = with python.pkgs; { - server = [ - aiodns - aiofiles - aiohttp - aiorun - aiosqlite - asyncio-throttle - brotli - certifi - colorlog - cryptography - eyed3 - faust-cchardet - ifaddr - mashumaro - memory-tempfile - music-assistant-frontend - music-assistant-models - mutagen - orjson - pillow - podcastparser - python-slugify - shortuuid - unidecode - xmltodict - zeroconf + # Required subset of optional-dependencies in pyproject.toml + test = [ + pytest-aiohttp + pytest-cov-stub + syrupy ]; }; nativeCheckInputs = with python.pkgs; [ - pytest-aiohttp - pytest-cov-stub - pytest-timeout pytestCheckHook - syrupy - pytest-timeout ] ++ lib.concatAttrValues optional-dependencies ++ (providerPackages.jellyfin python.pkgs) - ++ (providerPackages.opensubsonic python.pkgs); + ++ (providerPackages.opensubsonic python.pkgs) + ++ (providerPackages.tidal python.pkgs); disabledTestPaths = [ - # blocks in poll() + # no multicast support in build sandbox: + # "OSError: [Errno 19] No such device" "tests/providers/jellyfin/test_init.py::test_initial_sync" "tests/core/test_server_base.py::test_start_and_stop_server" "tests/core/test_server_base.py::test_events" - # not compatible with the required py-subsonic version - "tests/providers/opensubsonic/test_parsers.py" + # provider is missing dependencies + "tests/providers/nicovideo" ]; pythonImportsCheck = [ "music_assistant" ]; diff --git a/pkgs/by-name/mu/music-assistant/providers.nix b/pkgs/by-name/mu/music-assistant/providers.nix index a80079c45771..5768277224ed 100644 --- a/pkgs/by-name/mu/music-assistant/providers.nix +++ b/pkgs/by-name/mu/music-assistant/providers.nix @@ -1,16 +1,22 @@ # Do not edit manually, run ./update-providers.py { - version = "2.6.3"; + version = "2.7.2"; providers = { airplay = ps: [ ]; + airplay_receiver = ps: [ + ]; alexa = ps: with ps; [ alexapy ]; apple_music = ps: [ ]; # missing pywidevine + ard_audiothek = + ps: with ps; [ + gql + ]; audible = ps: with ps; [ audible @@ -19,14 +25,16 @@ ps: with ps; [ aioaudiobookshelf ]; + bbc_sounds = + ps: with ps; [ + pytz + ]; # missing auntie-sounds bluesound = ps: with ps; [ pyblu ]; builtin = ps: [ ]; - builtin_player = ps: [ - ]; chromecast = ps: with ps; [ pychromecast @@ -36,6 +44,8 @@ deezer-python-async pycryptodome ]; + digitally_incorporated = ps: [ + ]; dlna = ps: with ps; [ async-upnp-client @@ -50,6 +60,8 @@ ps: with ps; [ python-fullykiosk ]; + genius_lyrics = ps: [ + ]; # missing lyricsgenius gpodder = ps: [ ]; hass = @@ -60,6 +72,8 @@ ]; ibroadcast = ps: [ ]; # missing ibroadcastaio + internet_archive = ps: [ + ]; itunes_podcasts = ps: [ ]; jellyfin = @@ -70,8 +84,10 @@ ps: with ps; [ pylast ]; - listenbrainz_scrobble = ps: [ - ]; # missing liblistenbrainz + listenbrainz_scrobble = + ps: with ps; [ + liblistenbrainz + ]; lrclib = ps: [ ]; musicbrainz = ps: [ @@ -79,20 +95,27 @@ musiccast = ps: with ps; [ aiomusiccast - setuptools ]; + nicovideo = ps: [ + ]; # missing niconico.py-ma nugs = ps: [ ]; opensubsonic = ps: with ps; [ py-opensonic ]; - player_group = ps: [ + phishin = ps: [ ]; plex = ps: with ps; [ plexapi ]; + plex_connect = + ps: with ps; [ + plexapi + ]; + podcast_index = ps: [ + ]; podcastfeed = ps: [ ]; qobuz = ps: [ @@ -101,6 +124,17 @@ ps: with ps; [ radios ]; + radioparadise = ps: [ + ]; + roku_media_assistant = + ps: with ps; [ + async-upnp-client + rokuecp + ]; + sendspin = + ps: with ps; [ + aiosendspin + ]; siriusxm = ps: [ ]; # missing sxm snapcast = @@ -132,8 +166,6 @@ ]; subsonic_scrobble = ps: [ ]; - template_player_provider = ps: [ - ]; test = ps: [ ]; theaudiodb = ps: [ @@ -144,6 +176,10 @@ ]; tunein = ps: [ ]; + universal_group = ps: [ + ]; + vban_receiver = ps: [ + ]; ytmusic = ps: with ps; [ bgutil-ytdlp-pot-provider diff --git a/pkgs/by-name/mu/music-assistant/update-providers.py b/pkgs/by-name/mu/music-assistant/update-providers.py index 981ca38cd40d..cf581050e43c 100755 --- a/pkgs/by-name/mu/music-assistant/update-providers.py +++ b/pkgs/by-name/mu/music-assistant/update-providers.py @@ -123,7 +123,9 @@ async def get_provider_manifests(version: str = "master") -> List: basedir = Path(os.path.join(tmp, f"server-{version}")) sys.path.append(str(basedir)) - for fn in basedir.glob("**/manifest.json"): + for fn in basedir.glob("**/providers/*/manifest.json"): + if "_demo_" in str(fn): + continue try: manifests.append(await ProviderManifest.parse(str(fn))) except MissingField as ex: