Release 0.5.1: OpenSubsonic servers (Airsonic Pulse), client name

- SERVER_TYPE subsonic/opensubsonic: API key auth, password auth as fallback
  for servers that store hashed passwords (Airsonic Pulse)
- SUBSONIC_ALIASES for public domain vs. internal server address
- Covers for the player served by the bridge (no credentials on the player)
- CLIENT_NAME: configurable client name shown by the music server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 15:13:52 +01:00
co-authored by Claude Opus 5.5
parent 6abc971ad2
commit e813e3ed56
4 changed files with 141 additions and 32 deletions
+9
View File
@@ -11,9 +11,18 @@ FRIENDLY_NAME=
# --- Subsonic (optional, recommended) ----------------------------------------
# Local (non-LDAP) user with only the "stream" and "cover art" roles
# subsonic = classic Subsonic (token auth), opensubsonic = Airsonic Pulse, Navidrome, ...
SERVER_TYPE=subsonic
# as reached from the bridge
SUBSONIC_URL=https://music.example.com
# further hostnames your app uses for the same server, comma-separated
SUBSONIC_ALIASES=
SUBSONIC_USER=bluos_stream
SUBSONIC_PASSWORD=change-me
# opensubsonic only: API key of the service account (preferred over the password)
SUBSONIC_API_KEY=
# shown by the server as "<user>@<client name>"; {player} = BluOS player name
CLIENT_NAME=bluos-upnp-bridge
# --- Behaviour ----------------------------------------------------------------
REPEAT_OFF=true
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## 0.5.1 – 2026-10-25
- `CLIENT_NAME`: configurable client name towards the music server (`{player}` = BluOS player name)
## 0.5.0 – 2026-10-25
- **Server types**: `subsonic` (token auth) and `opensubsonic` (Airsonic Pulse, Navidrome, …) with
API key authentication or password authentication as fallback
- `SUBSONIC_ALIASES`: further hostnames of the music server (public domain vs. internal address)
- Covers for the player are served by the bridge – no server credentials in the player's image URL
- Playback refused if password authentication is used without the stream proxy
- Connection test shows server type and version
## 0.4.0 – 2026-10-24
- **Gapless playback** (experimental, `GAPLESS=true`): the bridge serves one continuous stream and appends
+16 -1
View File
@@ -86,6 +86,17 @@ roles. Why a separate account?
- The stream URL is stored on the player and visible in the BluOS app – it must not contain your own password.
- Subsonic only supports token authentication for **local** users. LDAP users would need the password in the URL.
### Airsonic Pulse, Navidrome & other OpenSubsonic servers
Set `SERVER_TYPE=opensubsonic`. Preferably create an **API key** for the service account (Airsonic Pulse:
*Personal settings → API keys*) and put it into `SUBSONIC_API_KEY`. Without a key the bridge uses password
authentication – Airsonic Pulse stores passwords hashed, so token auth isn't possible there. The password only
travels between bridge and server: streams and covers reach the player through the bridge, so `PROXY_STREAMS`
must stay enabled in this mode.
If your app talks to the server via a public domain while the bridge uses the internal address, add the domain
to `SUBSONIC_ALIASES` so the bridge recognises the app's URLs.
## Configuration
All settings come from environment variables (`.env`). Settings saved in the web UI are stored in
@@ -98,9 +109,13 @@ All settings come from environment variables (`.env`). Settings saved in the web
| `ADVERTISE_IP` | – | IP of the Docker host, announced via SSDP (**required**) |
| `HTTP_PORT` | `49494` | Port for UPnP and the web UI |
| `FRIENDLY_NAME` | player name + ` (BluOS Bridge)` | Name shown in your UPnP app |
| `SUBSONIC_URL` | – | e.g. `https://music.example.com` – enables credential rewriting |
| `SERVER_TYPE` | `subsonic` | `subsonic` (classic Subsonic, token auth) or `opensubsonic` (Airsonic Pulse, Navidrome, … – API key, without key password auth) |
| `SUBSONIC_URL` | – | Music server as reached **from the bridge**, e.g. `http://192.168.1.20:4040` – enables credential rewriting |
| `SUBSONIC_ALIASES` | – | Comma-separated further hostnames your app uses for the same server (e.g. its public domain) |
| `SUBSONIC_USER` | – | Stream-only service account |
| `SUBSONIC_PASSWORD` | – | Its password |
| `SUBSONIC_API_KEY` | – | API key of the service account (`opensubsonic` only, preferred) |
| `CLIENT_NAME` | `bluos-upnp-bridge` | Client name shown by the server (e.g. `user@BluOS Player`); `{player}` = BluOS player name |
| `PROXY_STREAMS` | `true` | Serve Subsonic streams through the bridge (Content-Length + byte ranges → seeking, clean track end) |
| `GAPLESS` | `false` | Experimental gapless playback (needs `PROXY_STREAMS`), see [Gapless playback](#gapless-playback) |
| `REPEAT_OFF` | `true` | Switch BluOS repeat off when the bridge starts playback (your app handles repeat) |
+103 -31
View File
@@ -33,7 +33,7 @@ import xml.etree.ElementTree as ET
from email.utils import formatdate
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "0.4.0"
VERSION = "0.5.1"
STARTED = time.time()
# --------------------------------------------------------------------------- config
@@ -74,6 +74,11 @@ FRIENDLY_NAME = cfg("FRIENDLY_NAME")
SUBSONIC_URL = cfg("SUBSONIC_URL").rstrip("/")
SUBSONIC_USER = cfg("SUBSONIC_USER")
SUBSONIC_PASSWORD = cfg("SUBSONIC_PASSWORD")
SERVER_TYPE = cfg("SERVER_TYPE", "subsonic").lower()
SUBSONIC_API_KEY = cfg("SUBSONIC_API_KEY")
SUBSONIC_ALIASES = cfg("SUBSONIC_ALIASES")
CLIENT_NAME = cfg("CLIENT_NAME", "bluos-upnp-bridge")
PLAYER_NAME = "" # BluOS player name, filled in at start for {player} in CLIENT_NAME
SSDP_INTERVAL = int(cfg("SSDP_INTERVAL", "60"))
LOG_LEVEL = cfg("LOG_LEVEL", "INFO").upper()
REPEAT_OFF = as_bool(cfg("REPEAT_OFF", "true"))
@@ -181,19 +186,44 @@ def parse_didl(meta):
# --------------------------------------------------------------------------- subsonic
class Subsonic:
"""Rewrites Subsonic URLs to the bridge's own service account (token auth)."""
"""Rewrites Subsonic URLs to the bridge's own service account.
server_type "subsonic": classic token auth (u + t + s).
server_type "opensubsonic": OpenSubsonic servers (Airsonic Pulse, Navidrome, ...) - uses the
apiKey extension if an API key is set, otherwise the (hex encoded) password. Airsonic Pulse
stores passwords hashed, so token auth can't work there. The password never reaches the
player as long as streams are proxied.
aliases: further hostnames under which the control point reaches the same server
(e.g. a public domain while the bridge talks to the internal address).
"""
AUTH_PARAMS = {"u", "p", "t", "s", "apiKey", "c", "v", "f"}
def __init__(self, base, user, password):
def __init__(self, base, user, password, server_type="subsonic", api_key="", aliases="",
client="bluos-upnp-bridge"):
self.client = client or "bluos-upnp-bridge"
self.base = base
self.user = user
self.password = password
self.server_type = server_type if server_type in ("subsonic", "opensubsonic") else "subsonic"
self.api_key = api_key if self.server_type == "opensubsonic" else ""
self.host = (urllib.parse.urlsplit(base).hostname or "").lower() if base else ""
self.hosts = {self.host} | {h.strip().lower() for h in (aliases or "").replace(";", ",").split(",") if h.strip()}
self.hosts.discard("")
@property
def enabled(self):
return bool(self.base and self.user and self.password)
return bool(self.base and (self.api_key or (self.user and self.password)))
@property
def password_auth(self):
return self.server_type == "opensubsonic" and not self.api_key
@property
def auth_label(self):
if self.api_key:
return "API-Key"
return f"{self.user} ({'Passwort' if self.password_auth else 'Token'})"
@staticmethod
def has_credentials(url):
@@ -201,15 +231,20 @@ class Subsonic:
return any(k in q for k in ("p", "t", "apiKey"))
def _auth(self):
if self.api_key:
return {"apiKey": self.api_key, "v": "1.16.1", "c": self.client}
if self.password_auth:
return {"u": self.user, "p": "enc:" + self.password.encode("utf-8").hex(), "v": "1.16.1",
"c": self.client}
salt = secrets.token_hex(6)
token = hashlib.md5((self.password + salt).encode("utf-8")).hexdigest()
return {"u": self.user, "t": token, "s": salt, "v": "1.13.0", "c": "bluos-upnp-bridge"}
return {"u": self.user, "t": token, "s": salt, "v": "1.13.0", "c": self.client}
def is_subsonic(self, url):
parts = urllib.parse.urlsplit(url or "")
if "/rest/" not in parts.path:
return False
return not self.host or (parts.hostname or "").lower() == self.host
return not self.hosts or (parts.hostname or "").lower() in self.hosts
def song_id(self, url):
q = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
@@ -383,11 +418,13 @@ class Renderer:
def _art(self, uri, info):
if self.subsonic.is_subsonic(uri):
cover = self.subsonic.cover_url(self.subsonic.song_id(uri))
if cover:
return cover
song_id = self.subsonic.song_id(uri)
if song_id and self.subsonic.enabled:
# served by the bridge, so no server credentials end up on the player
return f"http://{ADVERTISE_IP}:{HTTP_PORT}/cover?id={urllib.parse.quote(song_id)}"
return ""
art = info.get("art", "")
return self.subsonic.rewrite(art, path="getCoverArt") if art else ""
return "" if self.subsonic.has_credentials(art) else art
def _is_ours(self, stream_url, st):
"""BluOS may re-encode the URL, so compare the significant query params only."""
@@ -466,6 +503,11 @@ class Renderer:
if proxied and GAPLESS:
return self._start_gapless(seek)
self._close_session()
if not proxied and self.subsonic.password_auth and self.subsonic.is_subsonic(self.current_uri):
# without the proxy the player would see (and store) the service account password
log.error("password auth needs PROXY_STREAMS=true or an API key - refusing to play")
self._set_state("STOPPED")
raise UPnPError(716, "Resource not found (enable stream proxy or set an API key)")
offset_supported = not proxied and self.subsonic.is_subsonic(self.current_uri) and self.subsonic.enabled
upstream = self.subsonic.rewrite(self.current_uri, offset=seek if offset_supported else 0)
if upstream == self.current_uri and self.subsonic.has_credentials(upstream):
@@ -1145,10 +1187,20 @@ SETTING_FIELDS = [
("ADVERTISE_IP", "IP-Adresse der Brücke", "text", True,
"Unter dieser Adresse kündigt sich die Brücke per SSDP im Netz an."),
("SSDP_INTERVAL", "SSDP-Ankündigung alle … Sekunden", "number", True, ""),
("SUBSONIC_URL", "Subsonic-Server", "text", False, "z. B. https://music.example.com"),
("SUBSONIC_USER", "Subsonic-Benutzer (Service-Account)", "text", False,
"Lokaler Benutzer, der nur streamen darf. Wird in jede Stream-URL für den Player eingesetzt."),
("SUBSONIC_PASSWORD", "Subsonic-Passwort", "password", False, "Leer lassen = unverändert."),
("SERVER_TYPE", "Servertyp", "select:subsonic,opensubsonic", False,
"subsonic = klassischer Subsonic (Token-Anmeldung). opensubsonic = Airsonic Pulse, Navidrome & Co. "
"(API-Key, ohne Key Passwort-Anmeldung – dann muss „Streams über die Brücke ausliefern“ an sein)."),
("SUBSONIC_URL", "Musikserver (von der Brücke aus)", "text", False,
"z. B. http://192.168.1.20:4040 oder https://music.example.com"),
("SUBSONIC_ALIASES", "Weitere Hostnamen des Servers", "text", False,
"Kommagetrennt. Hostnamen, unter denen Symfonium denselben Server anspricht, z. B. die öffentliche Domain."),
("CLIENT_NAME", "Client-Name am Musikserver", "text", False,
"So erscheint die Brücke beim Server, z. B. „benutzer@BluOS Player“. {player} = Name des BluOS-Players."),
("SUBSONIC_USER", "Service-Benutzer", "text", False,
"Lokaler Benutzer (kein LDAP), der nur streamen darf."),
("SUBSONIC_PASSWORD", "Passwort des Service-Benutzers", "password", False, "Leer lassen = unverändert."),
("SUBSONIC_API_KEY", "API-Key des Service-Benutzers (nur opensubsonic)", "password", False,
"Wird statt Benutzer/Passwort genutzt. Leer lassen = unverändert."),
("REPEAT_OFF", "BluOS-Wiederholung beim Abspielen ausschalten", "bool", False,
"Empfohlen: Symfonium kümmert sich selbst um Wiederholen und Zufallswiedergabe."),
("PROXY_STREAMS", "Subsonic-Streams über die Brücke ausliefern", "bool", False,
@@ -1161,6 +1213,12 @@ SETTING_FIELDS = [
RUNNING = {k: str(globals()[k]) for k, *_ in SETTING_FIELDS} # values active since start
def make_subsonic(values):
return Subsonic(values["SUBSONIC_URL"].rstrip("/"), values["SUBSONIC_USER"], values["SUBSONIC_PASSWORD"],
values["SERVER_TYPE"].lower(), values["SUBSONIC_API_KEY"], values["SUBSONIC_ALIASES"],
values["CLIENT_NAME"].replace("{player}", PLAYER_NAME or "BluOS").strip())
def current_settings():
return {k: cfg(k, RUNNING[k]) for k, *_ in SETTING_FIELDS}
@@ -1179,7 +1237,7 @@ def render_settings(notice=""):
parts.append(f'<label class="check"><input type="checkbox" name="{key}" value="true"{checked}>'
f'<span>{html.escape(label)}{badge}<br>{help_html}</span></label>')
elif kind.startswith("select:"):
opts = "".join(f'<option{" selected" if o == value.upper() else ""}>{o}</option>'
opts = "".join(f'<option{" selected" if o.upper() == value.upper() else ""}>{o}</option>'
for o in kind[7:].split(","))
parts.append(f'<label>{html.escape(label)}{badge}<select name="{key}">{opts}</select>{help_html}</label>')
else:
@@ -1236,7 +1294,7 @@ def save_settings(form, renderer):
GAPLESS = as_bool(new["GAPLESS"])
logging.getLogger().setLevel(new["LOG_LEVEL"])
with renderer.lock:
renderer.subsonic = Subsonic(new["SUBSONIC_URL"], new["SUBSONIC_USER"], new["SUBSONIC_PASSWORD"])
renderer.subsonic = make_subsonic(new)
changed = [k for k in new if new[k] != old.get(k)]
log.info("settings saved via web UI (changed: %s)", ", ".join(changed) or "nothing")
restart = [label for key, label, _, needs, _ in SETTING_FIELDS if needs and new[key] != RUNNING[key]]
@@ -1260,7 +1318,12 @@ def run_action(action, renderer):
with urllib.request.urlopen(f"{sub.base}/rest/ping.view?{q}", timeout=10) as r:
root = ET.fromstring(r.read())
if root.get("status") == "ok":
return True, f"Anmeldung als {sub.user} ok (API {root.get('version')})."
server = root.get("type") or "subsonic"
extra = f", {server} {root.get('serverVersion')}" if root.get("serverVersion") else ""
hint = ""
if root.get("openSubsonic") == "true" and sub.server_type == "subsonic":
hint = " Hinweis: Der Server kann OpenSubsonic – Servertyp „opensubsonic“ wählen."
return True, f"Anmeldung mit {sub.auth_label} ok (API {root.get('version')}{extra}).{hint}"
err = root.find("{http://subsonic.org/restapi}error")
return False, f"Subsonic meldet: {err.get('message') if err is not None else 'unbekannter Fehler'}"
if action == "pause":
@@ -1277,21 +1340,28 @@ def run_action(action, renderer):
class CoverCache:
"""Serves the current cover without exposing the Subsonic token to the browser."""
"""Serves covers to the browser and the player without exposing server credentials."""
def __init__(self):
self.key = None
self.data = (b"", "")
def __init__(self, size=20):
self.items = collections.OrderedDict()
self.size = size
self.lock = threading.Lock()
def get(self, subsonic, song_id):
with self.lock:
if self.key != song_id:
url = subsonic.cover_url(song_id, 300)
with urllib.request.urlopen(url, timeout=10) as r:
self.data = (r.read(), r.headers.get("Content-Type", "image/jpeg"))
self.key = song_id
return self.data
if song_id in self.items:
self.items.move_to_end(song_id)
return self.items[song_id]
with urllib.request.urlopen(subsonic.cover_url(song_id, 600), timeout=10) as r:
ctype = r.headers.get("Content-Type", "image/jpeg")
if not ctype.startswith("image/"):
raise ValueError(f"no image ({ctype})")
data = (r.read(), ctype)
with self.lock:
self.items[song_id] = data
while len(self.items) > self.size:
self.items.popitem(last=False)
return data
COVERS = CoverCache()
@@ -1386,7 +1456,8 @@ class Handler(BaseHTTPRequestHandler):
"version": VERSION, "uptime": int(time.time() - STARTED), "name": self.name,
"bluos_host": BLUOS_HOST, "bluos_port": BLUOS_PORT,
"location": f"http://{ADVERTISE_IP}:{HTTP_PORT}/description.xml",
"subsonic": {"enabled": r.subsonic.enabled, "user": r.subsonic.user, "url": r.subsonic.base},
"subsonic": {"enabled": r.subsonic.enabled, "user": r.subsonic.auth_label, "url": r.subsonic.base,
"type": r.subsonic.server_type},
"repeat_off": REPEAT_OFF, "subscriptions": len(self.events.subs),
})
return data
@@ -1412,8 +1483,7 @@ class Handler(BaseHTTPRequestHandler):
return self._stream(path[len("/stream/"):])
if path == "/cover":
song_id = (query.get("id") or [""])[0]
snap = self.renderer.snapshot()["current"]
if not snap or snap["song_id"] != song_id or not song_id:
if not song_id or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", song_id):
return self._send(404, "", "text/plain")
try:
data, ctype = COVERS.get(self.renderer.subsonic, song_id)
@@ -1680,8 +1750,10 @@ class SSDP:
def main():
bluos = BluOS(BLUOS_HOST, BLUOS_PORT)
subsonic = Subsonic(SUBSONIC_URL, SUBSONIC_USER, SUBSONIC_PASSWORD)
subsonic = make_subsonic(current_settings())
global PLAYER_NAME
player_name = bluos.name()
PLAYER_NAME = player_name
name = FRIENDLY_NAME or (player_name or BLUOS_HOST) + " (BluOS Bridge)"
renderer = Renderer(bluos, subsonic)