diff --git a/pkgs/by-name/mu/music-assistant/ffmpeg.patch b/pkgs/by-name/mu/music-assistant/ffmpeg.patch new file mode 100644 index 000000000000..d14e16e37ea1 --- /dev/null +++ b/pkgs/by-name/mu/music-assistant/ffmpeg.patch @@ -0,0 +1,71 @@ +diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py +index 42011923..1e5dc112 100644 +--- a/music_assistant/server/helpers/audio.py ++++ b/music_assistant/server/helpers/audio.py +@@ -218,7 +218,7 @@ async def crossfade_pcm_parts( + await outfile.write(fade_out_part) + args = [ + # generic args +- "ffmpeg", ++ "@ffmpeg@", + "-hide_banner", + "-loglevel", + "quiet", +@@ -281,7 +281,7 @@ async def strip_silence( + ) -> bytes: + """Strip silence from begin or end of pcm audio using ffmpeg.""" + fmt = ContentType.from_bit_depth(bit_depth) +- args = ["ffmpeg", "-hide_banner", "-loglevel", "quiet"] ++ args = ["@ffmpeg@", "-hide_banner", "-loglevel", "quiet"] + args += [ + "-acodec", + fmt.name.lower(), +@@ -823,7 +823,7 @@ async def get_ffmpeg_stream( + async def check_audio_support() -> tuple[bool, bool, str]: + """Check if ffmpeg is present (with/without libsoxr support).""" + # check for FFmpeg presence +- returncode, output = await check_output("ffmpeg -version") ++ returncode, output = await check_output("@ffmpeg@ -version") + ffmpeg_present = returncode == 0 and "FFmpeg" in output.decode() + + # use globals as in-memory cache +@@ -877,7 +877,7 @@ async def get_silence( + return + # use ffmpeg for all other encodings + args = [ +- "ffmpeg", ++ "@ffmpeg@", + "-hide_banner", + "-loglevel", + "quiet", +@@ -971,7 +971,7 @@ def get_ffmpeg_args( + + # generic args + generic_args = [ +- "ffmpeg", ++ "@ffmpeg@", + "-hide_banner", + "-loglevel", + loglevel, +diff --git a/music_assistant/server/helpers/tags.py b/music_assistant/server/helpers/tags.py +index dc38e4c0..f4f3e2fe 100644 +--- a/music_assistant/server/helpers/tags.py ++++ b/music_assistant/server/helpers/tags.py +@@ -368,7 +368,7 @@ async def parse_tags( + file_path = input_file if isinstance(input_file, str) else "-" + + args = ( +- "ffprobe", ++ "@ffprobe@", + "-hide_banner", + "-loglevel", + "fatal", +@@ -440,7 +440,7 @@ async def get_embedded_image(input_file: str | AsyncGenerator[bytes, None]) -> b + """ + file_path = input_file if isinstance(input_file, str) else "-" + args = ( +- "ffmpeg", ++ "@ffmpeg@", + "-hide_banner", + "-loglevel", + "error", diff --git a/pkgs/by-name/mu/music-assistant/frontend.nix b/pkgs/by-name/mu/music-assistant/frontend.nix new file mode 100644 index 000000000000..1db07591408b --- /dev/null +++ b/pkgs/by-name/mu/music-assistant/frontend.nix @@ -0,0 +1,35 @@ +{ lib +, buildPythonPackage +, fetchPypi +, setuptools +}: + +buildPythonPackage rec { + pname = "music-assistant-frontend"; + version = "2.5.15"; + pyproject = true; + + src = fetchPypi { + inherit pname version; + hash = "sha256-D8VFdXgaVXSxk7c24kvb9TflFztS1zLwW4qGqV32nLo="; + }; + + postPatch = '' + substituteInPlace pyproject.toml \ + --replace-fail "~=" ">=" + ''; + + build-system = [ setuptools ]; + + doCheck = false; + + pythonImportsCheck = [ "music_assistant_frontend" ]; + + meta = with lib; { + changelog = "https://github.com/music-assistant/frontend/releases/tag/${version}"; + description = "The Music Assistant frontend"; + homepage = "https://github.com/music-assistant/frontend"; + license = licenses.asl20; + maintainers = with maintainers; [ hexa ]; + }; +} diff --git a/pkgs/by-name/mu/music-assistant/package.nix b/pkgs/by-name/mu/music-assistant/package.nix new file mode 100644 index 000000000000..ebc6e953a1d8 --- /dev/null +++ b/pkgs/by-name/mu/music-assistant/package.nix @@ -0,0 +1,117 @@ +{ lib +, python3 +, fetchFromGitHub +, ffmpeg-headless +, substituteAll +, providers ? [ ] +}: + +let + python = python3.override { + packageOverrides = self: super: { + music-assistant-frontend = self.callPackage ./frontend.nix { }; + }; + }; + + providerPackages = (import ./providers.nix).providers; + providerNames = lib.attrNames providerPackages; + providerDependencies = lib.concatMap (provider: (providerPackages.${provider} python.pkgs)) providers; + + pythonPath = python.pkgs.makePythonPath providerDependencies; +in + +python.pkgs.buildPythonApplication rec { + pname = "music-assistant"; + version = "2.0.7"; + pyproject = true; + + src = fetchFromGitHub { + owner = "music-assistant"; + repo = "server"; + rev = version; + hash = "sha256-JtdlZ3hH4fRU5TjmMUlrdSSCnLrIGCuSwSSrnLgjYEs="; + }; + + patches = [ + (substituteAll { + src = ./ffmpeg.patch; + ffmpeg = "${lib.getBin ffmpeg-headless}/bin/ffmpeg"; + ffprobe = "${lib.getBin ffmpeg-headless}/bin/ffprobe"; + }) + ]; + + postPatch = '' + sed -i "/--cov/d" pyproject.toml + + substituteInPlace pyproject.toml \ + --replace-fail "0.0.0" "${version}" + ''; + + build-system = with python.pkgs; [ + setuptools + ]; + + dependencies = with python.pkgs; [ + aiohttp + mashumaro + orjson + ] ++ optional-dependencies.server; + + optional-dependencies = with python.pkgs; { + server = [ + aiodns + aiofiles + aiohttp + aiorun + aiosqlite + asyncio-throttle + brotli + certifi + colorlog + cryptography + faust-cchardet + ifaddr + mashumaro + memory-tempfile + music-assistant-frontend + orjson + pillow + python-slugify + shortuuid + unidecode + xmltodict + zeroconf + ]; + }; + + nativeCheckInputs = with python.pkgs; [ + ffmpeg-headless + pytest-aiohttp + pytestCheckHook + ] ++ lib.flatten (lib.attrValues optional-dependencies); + + pythonImportsCheck = [ "music_assistant" ]; + + passthru = { + inherit + python + pythonPath + providerPackages + providerNames + ; + }; + + meta = with lib; { + changelog = "https://github.com/music-assistant/server/releases/tag/${version}"; + description = "Music Assistant is a music library manager for various music sources which can easily stream to a wide range of supported players"; + longDescription = '' + Music Assistant is a free, opensource Media library manager that connects to your streaming services and a wide + range of connected speakers. The server is the beating heart, the core of Music Assistant and must run on an + always-on device like a Raspberry Pi, a NAS or an Intel NUC or alike. + ''; + homepage = "https://github.com/music-assistant/server"; + license = licenses.asl20; + maintainers = with maintainers; [ hexa ]; + mainProgram = "mass"; + }; +} diff --git a/pkgs/by-name/mu/music-assistant/providers.nix b/pkgs/by-name/mu/music-assistant/providers.nix new file mode 100644 index 000000000000..945d570dca30 --- /dev/null +++ b/pkgs/by-name/mu/music-assistant/providers.nix @@ -0,0 +1,78 @@ +# Do not edit manually, run ./update-providers.py + +{ + version = "2.0.7"; + providers = { + airplay = [ + ]; + builtin = [ + ]; + chromecast = ps: with ps; [ + pychromecast + ]; + deezer = ps: with ps; [ + pycryptodome + ]; # missing deezer-python-async + dlna = ps: with ps; [ + async-upnp-client + ]; + fanarttv = [ + ]; + filesystem_local = [ + ]; + filesystem_smb = [ + ]; + fully_kiosk = ps: with ps; [ + python-fullykiosk + ]; + hass = [ + ]; # missing hass-client + hass_players = [ + ]; + jellyfin = [ + ]; # missing jellyfin_apiclient_python + musicbrainz = [ + ]; + opensubsonic = ps: with ps; [ + py-opensonic + ]; + plex = ps: with ps; [ + plexapi + ]; + qobuz = [ + ]; + radiobrowser = ps: with ps; [ + radios + ]; + slimproto = ps: with ps; [ + aioslimproto + ]; + snapcast = ps: with ps; [ + snapcast + ]; + sonos = ps: with ps; [ + defusedxml + soco + sonos-websocket + ]; + soundcloud = [ + ]; # missing soundcloudpy + spotify = [ + ]; + test = [ + ]; + theaudiodb = [ + ]; + tidal = ps: with ps; [ + tidalapi + ]; + tunein = [ + ]; + ugp = [ + ]; + ytmusic = ps: with ps; [ + pytube + ytmusicapi + ]; + }; +} diff --git a/pkgs/by-name/mu/music-assistant/update-providers.py b/pkgs/by-name/mu/music-assistant/update-providers.py new file mode 100755 index 000000000000..301a5041a2e7 --- /dev/null +++ b/pkgs/by-name/mu/music-assistant/update-providers.py @@ -0,0 +1,218 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i python3 -p "python3.withPackages (ps: with ps; [ jinja2 mashumaro orjson aiofiles packaging ])" -p pyright ruff isort +import asyncio +import json +import os.path +import re +import sys +import tarfile +import tempfile +from dataclasses import dataclass, field +from functools import cache +from io import BytesIO +from pathlib import Path +from subprocess import check_output, run +from typing import Dict, Final, List, Optional, Set, Union, cast +from urllib.request import urlopen + +from jinja2 import Environment +from packaging.requirements import Requirement + +TEMPLATE = """# Do not edit manually, run ./update-providers.py + +{ + version = "{{ version }}"; + providers = { +{%- for provider in providers | sort(attribute='domain') %} + {{ provider.domain }} = {% if provider.available %}ps: with ps; {% endif %}[ +{%- for requirement in provider.available | sort %} + {{ requirement }} +{%- endfor %} + ];{% if provider.missing %} # missing {{ ", ".join(provider.missing) }}{% endif %} +{%- endfor %} + }; +} + +""" + + +ROOT: Final = ( + check_output( + [ + "git", + "rev-parse", + "--show-toplevel", + ] + ) + .decode() + .strip() +) + +PACKAGE_MAP = { + "git+https://github.com/MarvinSchenkel/pytube.git": "pytube", +} + + +def run_sync(cmd: List[str]) -> None: + print(f"$ {' '.join(cmd)}") + process = run(cmd) + + if process.returncode != 0: + sys.exit(1) + + +async def check_async(cmd: List[str]) -> str: + print(f"$ {' '.join(cmd)}") + process = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process.returncode != 0: + error = stderr.decode() + raise RuntimeError(f"{cmd[0]} failed: {error}") + + return stdout.decode().strip() + + +class Nix: + base_cmd: Final = [ + "nix", + "--show-trace", + "--extra-experimental-features", + "nix-command", + ] + + @classmethod + async def _run(cls, args: List[str]) -> Optional[str]: + return await check_async(cls.base_cmd + args) + + @classmethod + async def eval(cls, expr: str) -> Union[List, Dict, int, float, str, bool]: + response = await cls._run(["eval", "-f", f"{ROOT}/default.nix", "--json", expr]) + if response is None: + raise RuntimeError("Nix eval expression returned no response") + try: + return json.loads(response) + except (TypeError, ValueError): + raise RuntimeError("Nix eval response could not be parsed from JSON") + + +async def get_provider_manifests(version: str = "master") -> List: + manifests = [] + with tempfile.TemporaryDirectory() as tmp: + with urlopen( + f"https://github.com/music-assistant/music-assistant/archive/{version}.tar.gz" + ) as response: + tarfile.open(fileobj=BytesIO(response.read())).extractall( + tmp, filter="data" + ) + + basedir = Path(os.path.join(tmp, f"server-{version}")) + sys.path.append(str(basedir)) + from music_assistant.common.models.provider import ProviderManifest # type: ignore + + for fn in basedir.glob("**/manifest.json"): + manifests.append(await ProviderManifest.parse(fn)) + + return manifests + + +@cache +def packageset_attributes(): + output = check_output( + [ + "nix-env", + "-f", + ROOT, + "-qa", + "-A", + "music-assistant.python.pkgs", + "--arg", + "config", + "{ allowAliases = false; }", + "--json", + ] + ) + return json.loads(output) + + +class TooManyMatches(Exception): + pass + + +class NoMatch(Exception): + pass + + +def resolve_package_attribute(package: str) -> str: + pattern = re.compile(rf"^music-assistant\.python\.pkgs\.{package}$", re.I) + packages = packageset_attributes() + matches = [] + for attr in packages.keys(): + if pattern.match(attr): + matches.append(attr.split(".")[-1]) + + if len(matches) > 1: + raise TooManyMatches( + f"Too many matching attributes for {package}: {' '.join(matches)}" + ) + if not matches: + raise NoMatch(f"No matching attribute for {package}") + + return matches.pop() + + +@dataclass +class Provider: + domain: str + available: list[str] = field(default_factory=list) + missing: list[str] = field(default_factory=list) + + def __eq__(self, other): + return self.domain == other.domain + + def __hash__(self): + return hash(self.domain) + + +def resolve_providers(manifests) -> Set: + providers = set() + for manifest in manifests: + provider = Provider(manifest.domain) + for requirement in manifest.requirements: + # allow substituting requirement specifications that packaging cannot parse + if requirement in PACKAGE_MAP: + requirement = PACKAGE_MAP[requirement] + requirement = Requirement(requirement) + try: + provider.available.append(resolve_package_attribute(requirement.name)) + except TooManyMatches as ex: + print(ex, file=sys.stderr) + provider.missing.append(requirement.name) + except NoMatch: + provider.missing.append(requirement.name) + providers.add(provider) + return providers + + +def render(version: str, providers: Set): + path = os.path.join(ROOT, "pkgs/by-name/mu/music-assistant/providers.nix") + env = Environment() + template = env.from_string(TEMPLATE) + template.stream(version=version, providers=providers).dump(path) + + +async def main(): + version: str = cast(str, await Nix.eval("music-assistant.version")) + manifests = await get_provider_manifests(version) + providers = resolve_providers(manifests) + render(version, providers) + + +if __name__ == "__main__": + run_sync(["pyright", __file__]) + run_sync(["ruff", "check", "--ignore=E501", __file__]) + run_sync(["isort", __file__]) + run_sync(["ruff", "format", __file__]) + asyncio.run(main())