music-assistant: init at 2.0.7

This commit is contained in:
Martin Weinelt 2024-05-15 14:24:44 +02:00
parent 622256abb5
commit 241fe52147
No known key found for this signature in database
GPG Key ID: 87C1E9888F856759
5 changed files with 519 additions and 0 deletions

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,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";
};
}

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