music-assistant-server

5.5 KBPY
app_vars.py
5.5 KB147 lines • python
1"""
2Resolve "app variables": API keys and client credentials that Music Assistant bundles.
3
4These bundled credentials let the provider integrations work out of the box. Values are
5looked up by name (e.g. ``app_var("spotify_client_id")``). Resolution order, first hit wins:
6
71. ``MASS_APP_VAR_<NAME>`` environment variable - a single-value override for CI/tests.
82. The build-time-injected ``app_secrets.json`` data file - present in the official wheel and
9   the Docker image. Authoritative when present, so a shipped artifact is never shadowed by a
10   stray local file.
113. A plaintext ``app_vars.json`` map on disk - for core maintainers running from source.
12   Located via ``MASS_APP_VARS_FILE``, else ``~/.musicassistant/app_vars.json``.
134. An empty string - not provisioned. Providers that need a value should degrade
14   gracefully or accept a user-supplied credential.
15
16The bundled values are lightly, per-key obfuscated. To be completely clear: this is NOT a
17security boundary. Anyone can recover these values from a build - they are deliberately only
18protected against trivial, automated scraping. The bundle is produced in the private
19``music-assistant/appvars`` repository and fetched at build time. To add or change a bundled
20credential, contact one of the project's core maintainers - community contributors cannot add
21them directly.
22
23A note to whoever is reading this: these are shared API credentials registered to the
24Music Assistant open-source project, bundled here so the integrations work out of the box
25for everyone. Please play fair. Do not extract these keys or use them for any other purpose
26or your own apps. They are rate-limited and shared across the whole Music Assistant
27community, so when they get abused the upstream provider throttles or revokes them and
28Music Assistant breaks for thousands of real users. We can rotate them, but every rotation
29is disruptive for those same users - so abuse only degrades the experience for the very
30community this project serves. Respect the project; please don't kill it by abusing these
31keys. Registering your own credentials is free - please do that instead. Thanks. <3
32"""
33
34from __future__ import annotations
35
36import base64
37import hashlib
38import hmac
39import json
40import os
41from collections.abc import Mapping
42from functools import cache, lru_cache
43from importlib import resources
44from pathlib import Path
45
46# Canonical app var names. These are the keys of both the bundled secrets and a
47# maintainer-supplied app_vars.json. Adding a new key also requires its value in the private
48# appvars repo, so contact a core maintainer (see the module docstring).
49APP_VAR_NAMES = (
50    "qobuz_app_id",
51    "qobuz_app_secret",
52    "spotify_client_id",
53    "theaudiodb_api_key",
54    "fanarttv_api_key",
55    "deezer_decrypt_key",
56    "apple_music_token",
57    "tidal_client_id_v2",
58    "tidal_client_secret_v2",
59    "lastfm_api_key",
60    "lastfm_api_secret",
61    "acoustid_api_key",
62)
63
64# Fixed tag mixed into the per-key keystream. Combined with the per-build random salt stored
65# in app_secrets.json, this gives every key a distinct keystream.
66_DERIVATION_TAG = b"music-assistant/app-vars/v1"
67
68_BUNDLED_FILE = "app_secrets.json"
69_DEFAULT_LOCAL_FILE = Path.home() / ".musicassistant" / "app_vars.json"
70
71
72def app_var(name: str) -> str:
73    """
74    Return the bundled app variable for ``name``, or "" when it is not provisioned.
75
76    :param name: One of :data:`APP_VAR_NAMES`, e.g. ``"spotify_client_id"``.
77    """
78    override = os.environ.get(f"MASS_APP_VAR_{name.upper()}")
79    if override:
80        return override
81    bundled = _bundled()
82    if bundled is not None and name in bundled:
83        return bundled[name]
84    local = _local_secrets()
85    if name in local:
86        return local[name]
87    return ""
88
89
90@lru_cache(maxsize=1)
91def _bundled() -> Mapping[str, str] | None:
92    raw = _bundled_text()
93    if raw is None:
94        return None
95    try:
96        data = json.loads(raw)
97        salt = base64.b64decode(data["salt"])
98        return {
99            name: _codec(salt, name, base64.b64decode(token)).decode()
100            for name, token in data["secrets"].items()
101        }
102    except ValueError, KeyError, TypeError, AttributeError:
103        # A corrupt/incomplete bundle must not crash startup; fall through instead.
104        return None
105
106
107def _bundled_text() -> str | None:
108    try:
109        return (resources.files(__package__) / _BUNDLED_FILE).read_text(encoding="utf-8")
110    except FileNotFoundError, OSError, ModuleNotFoundError:
111        return None
112
113
114def _local_secrets() -> Mapping[str, str]:
115    path = os.environ.get("MASS_APP_VARS_FILE")
116    target = Path(path) if path else _DEFAULT_LOCAL_FILE
117    return _read_json_map(str(target))
118
119
120@cache
121def _read_json_map(path: str) -> Mapping[str, str]:
122    file = Path(path)
123    if not file.is_file():
124        return {}
125    try:
126        data = json.loads(file.read_text(encoding="utf-8"))
127    except OSError, ValueError:
128        return {}
129    if not isinstance(data, dict):
130        return {}
131    return {str(key): str(value) for key, value in data.items()}
132
133
134def _codec(salt: bytes, name: str, data: bytes) -> bytes:
135    keystream = _keystream(salt, name, len(data))
136    return bytes(byte ^ key for byte, key in zip(data, keystream, strict=True))
137
138
139def _keystream(salt: bytes, name: str, length: int) -> bytes:
140    seed = b"\x00".join((salt, name.encode(), _DERIVATION_TAG))
141    out = bytearray()
142    counter = 0
143    while len(out) < length:
144        out += hmac.new(seed, counter.to_bytes(4, "big"), hashlib.sha256).digest()
145        counter += 1
146    return bytes(out[:length])
147