/
/
/
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