Merge pull request #311927 from mweinelt/music-assistant

music-assistant: init at 2.0.7
This commit is contained in:
Martin Weinelt 2024-07-18 02:31:18 +02:00 committed by GitHub
commit bf8439efae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 781 additions and 0 deletions

View File

@ -33,6 +33,8 @@
- [Renovate](https://github.com/renovatebot/renovate), a dependency updating tool for various git forges and language ecosystems. Available as [services.renovate](#opt-services.renovate.enable).
- [Music Assistant](https://music-assistant.io/), a music library manager for your offline and online music sources which can easily stream your favourite music to a wide range of supported players. Available as [services.music-assistant](#opt-services.music-assistant.enable).
- [wg-access-server](https://github.com/freifunkMUC/wg-access-server/), an all-in-one WireGuard VPN solution with a web ui for connecting devices. Available at [services.wg-access-server](#opt-services.wg-access-server.enable).
- [Envision](https://gitlab.com/gabmus/envision), a UI for building, configuring and running Monado, the open source OpenXR runtime. Available as [programs.envision](#opt-programs.envision.enable).

View File

@ -376,6 +376,7 @@
./services/audio/mopidy.nix
./services/audio/mpd.nix
./services/audio/mpdscribble.nix
./services/audio/music-assistant.nix
./services/audio/mympd.nix
./services/audio/navidrome.nix
./services/audio/networkaudiod.nix

View File

@ -0,0 +1,113 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
inherit (lib)
mkIf
mkEnableOption
mkOption
mkPackageOption
types
;
inherit (types)
listOf
enum
str
;
cfg = config.services.music-assistant;
finalPackage = cfg.package.override {
inherit (cfg) providers;
};
in
{
meta.buildDocsInSandbox = false;
options.services.music-assistant = {
enable = mkEnableOption "Music Assistant";
package = mkPackageOption pkgs "music-assistant" { };
extraOptions = mkOption {
type = listOf str;
default = [ "--config" "/var/lib/music-assistant" ];
example = [
"--log-level"
"DEBUG"
];
description = ''
List of extra options to pass to the music-assistant executable.
'';
};
providers = mkOption {
type = listOf (enum cfg.package.providerNames);
default = [];
example = [
"opensubsonic"
"snapcast"
];
description = ''
List of provider names for which dependencies will be installed.
'';
};
};
config = mkIf cfg.enable {
systemd.services.music-assistant = {
description = "Music Assistant";
documentation = [ "https://music-assistant.io" ];
wantedBy = [ "multi-user.target" ];
environment = {
HOME = "/var/lib/music-assistant";
PYTHONPATH = finalPackage.pythonPath;
};
serviceConfig = {
ExecStart = utils.escapeSystemdExecArgs ([
(lib.getExe cfg.package)
] ++ cfg.extraOptions);
DynamicUser = true;
StateDirectory = "music-assistant";
AmbientCapabilities = "";
CapabilityBoundingSet = [ "" ];
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged @resources"
];
RestrictSUIDSGID = true;
UMask = "0077";
};
};
};
}

View File

@ -598,6 +598,7 @@ in {
# Fails on aarch64-linux at the PDF creation step - need to debug this on an
# aarch64 machine..
musescore = handleTestOn ["x86_64-linux"] ./musescore.nix {};
music-assistant = runTest ./music-assistant.nix;
munin = handleTest ./munin.nix {};
mutableUsers = handleTest ./mutable-users.nix {};
mycelium = handleTest ./mycelium {};

View File

@ -0,0 +1,21 @@
{
lib,
...
}:
{
name = "music-assistant";
meta.maintainers = with lib.maintainers; [ hexa ];
nodes.machine = {
services.music-assistant = {
enable = true;
};
};
testScript = ''
machine.wait_for_unit("music-assistant.service")
machine.wait_until_succeeds("curl --fail http://localhost:8095")
machine.log(machine.succeed("systemd-analyze security music-assistant.service | grep -v "))
'';
}

View File

@ -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",

View File

@ -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 ];
};
}

View File

@ -0,0 +1,119 @@
{ lib
, python3
, fetchFromGitHub
, ffmpeg-headless
, nixosTests
, 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
;
tests = nixosTests.music-assistant;
};
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";
};
}

View File

@ -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
];
};
}

View File

@ -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())

View File

@ -0,0 +1,42 @@
{ lib
, buildPythonPackage
, fetchFromGitHub
, fetchpatch2
, poetry-core
}:
buildPythonPackage rec {
pname = "memory-tempfile";
version = "2.2.3";
pyproject = true;
src = fetchFromGitHub {
owner = "mbello";
repo = "memory-tempfile";
rev = "v${version}";
hash = "sha256-4fz2CLkZdy2e1GwGw/afG54LkUVJ4cza70jcbX3rVlQ=";
};
patches = [
(fetchpatch2 {
# Migrate to poetry-core build backend
# https://github.com/mbello/memory-tempfile/pull/13
name = "poetry-core.patch";
url = "https://github.com/mbello/memory-tempfile/commit/938a3a3abf01756b1629eca6c69e970021bbc7c0.patch";
hash = "sha256-q3027MwKXtX09MH7T2UrX19BImK1FJo+YxADfxcdTME=";
})
];
build-system = [ poetry-core ];
doCheck = false; # constrained selection of memory backed filesystems due to build sandbox
pythonImportsCheck = [ "memory_tempfile" ];
meta = with lib; {
description = "Create temporary files and temporary dirs in memory-based filesystems on Linux";
homepage = "https://github.com/mbello/memory-tempfile";
license = licenses.mit;
maintainers = with maintainers; [ hexa ];
};
}

View File

@ -0,0 +1,37 @@
{ lib
, buildPythonPackage
, fetchFromGitHub
, setuptools
, requests
}:
buildPythonPackage rec {
pname = "py-opensonic";
version = "5.1.1";
pyproject = true;
src = fetchFromGitHub {
owner = "khers";
repo = "py-opensonic";
rev = "v${version}";
hash = "sha256-wXTXuX+iIMEoALxsciopucmvBxAyEeiOgjJPrbD63gM=";
};
build-system = [ setuptools ];
dependencies = [ requests ];
doCheck = false; # no tests
pythonImportsCheck = [
"libopensonic"
];
meta = with lib; {
description = "Python library to wrap the Open Subsonic REST API";
homepage = "https://github.com/khers/py-opensonic";
changelog = "https://github.com/khers/py-opensonic/blob/${src.rev}/CHANGELOG.md";
license = licenses.gpl3Only;
maintainers = with maintainers; [ hexa ];
};
}

View File

@ -27,6 +27,9 @@ home-assistant.python.pkgs.buildPythonPackage (
mkdir $out
cp -r ./custom_components/ $out/
# optionally copy sentences, if they exist
cp -r ./custom_sentences/ $out/ || true
runHook postInstall
'';

View File

@ -32,6 +32,8 @@
localtuya = callPackage ./localtuya {};
mass = callPackage ./mass { };
midea_ac_lan = callPackage ./midea_ac_lan {};
midea-air-appliances-lan = callPackage ./midea-air-appliances-lan {};

View File

@ -0,0 +1,34 @@
{ lib
, buildHomeAssistantComponent
, fetchFromGitHub
, toPythonModule
, async-timeout
, music-assistant
}:
buildHomeAssistantComponent rec {
owner = "music-assistant";
domain = "mass";
version = "2024.6.2";
src = fetchFromGitHub {
owner = "music-assistant";
repo = "hass-music-assistant";
rev = version;
hash = "sha256-Wvc+vUYkUJmS4U34Sh/sDCVXmQA0AtEqIT8MNXd++3M=";
};
dependencies = [
async-timeout
(toPythonModule music-assistant)
];
dontCheckManifest = true; # expects music-assistant 2.0.6, we have 2.0.7
meta = with lib; {
description = "Turn your Home Assistant instance into a jukebox, hassle free streaming of your favorite media to Home Assistant media players";
homepage = "https://github.com/music-assistant/hass-music-assistant";
license = licenses.asl20;
maintainers = with maintainers; [ hexa ];
};
}

View File

@ -7524,6 +7524,8 @@ self: super: with self; {
memory-profiler = callPackage ../development/python-modules/memory-profiler { };
memory-tempfile = callPackage ../development/python-modules/memory-tempfile { };
meraki = callPackage ../development/python-modules/meraki { };
mercadopago = callPackage ../development/python-modules/mercadopago { };
@ -9195,6 +9197,8 @@ self: super: with self; {
py-expression-eval = callPackage ../development/python-modules/py-expression-eval { };
py-opensonic = callPackage ../development/python-modules/py-opensonic { };
py-radix-sr = callPackage ../development/python-modules/py-radix-sr { };
nwdiag = callPackage ../development/python-modules/nwdiag { };