Initial release 0.2.0: UPnP/DLNA bridge for BluOS players with web UI

This commit is contained in:
2026-10-24 15:55:03 +02:00
parent 85fd5397dd
commit 393639543f
11 changed files with 1837 additions and 31 deletions
+8
View File
@@ -0,0 +1,8 @@
.env
.env.*
data/
.git/
__pycache__/
README.md
CHANGELOG.md
LICENSE
+24
View File
@@ -0,0 +1,24 @@
# --- BluOS player -------------------------------------------------------------
BLUOS_HOST=192.168.1.50
BLUOS_PORT=11000
# --- Bridge -------------------------------------------------------------------
# IP of the Docker host - announced via SSDP, must be reachable from your phone
ADVERTISE_IP=192.168.1.10
HTTP_PORT=49494
# Name in your UPnP app (empty = "<player name> (BluOS Bridge)")
FRIENDLY_NAME=
# --- Subsonic (optional, recommended) ----------------------------------------
# Local (non-LDAP) user with only the "stream" and "cover art" roles
SUBSONIC_URL=https://music.example.com
SUBSONIC_USER=bluos_stream
SUBSONIC_PASSWORD=change-me
# --- Behaviour ----------------------------------------------------------------
REPEAT_OFF=true
LOG_LEVEL=INFO
# --- Web UI (settings page, basic auth) --------------------------------------
WEB_USER=admin
WEB_PASSWORD=change-me-too
+4
View File
@@ -0,0 +1,4 @@
.env
data/
__pycache__/
*.pyc
+14
View File
@@ -0,0 +1,14 @@
# Changelog
## 0.2.0 – 2026-10-24
- Web UI: live status page (now playing, player and bridge status, event log) and settings page with basic auth
- Settings persisted in `data/settings.json`, runtime changes for Subsonic, repeat and log level
- Tools: test player connection, test Subsonic login, pause/resume, restart
## 0.1.x – 2026-10-24
- UPnP/DLNA MediaRenderer for BluOS players (SSDP, AVTransport, RenderingControl, ConnectionManager, GENA)
- Subsonic credential rewriting to a stream-only service account; `download.view` → `stream.view`
- URLs with foreign credentials are refused instead of being passed to the player
- Track end detection incl. BluOS queue fallback and "repeat one" loops; BluOS repeat switched off on playback
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
LABEL org.opencontainers.image.title="bluos-upnp-bridge" \
org.opencontainers.image.description="UPnP/DLNA MediaRenderer bridge for BluOS (Bluesound) players"
RUN useradd --system --uid 10001 --no-create-home bridge \
&& mkdir -p /data && chown bridge /data
WORKDIR /app
COPY bridge.py web_ui.py /app/
USER bridge
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data
VOLUME /data
HEALTHCHECK --interval=60s --timeout=5s --retries=3 \
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('HTTP_PORT','49494'), timeout=3)" || exit 1
CMD ["python", "/app/bridge.py"]
View File
-26
View File
@@ -1,26 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
---
Copyright © Patrick Asmus (scriptos)
Name: Patrick Asmus (scriptos)
Email: support@techniverse.net
Website: https://www.patrick-asmus.de
Blog: https://www.cleveradmin.de
---
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
+131 -5
View File
@@ -4,10 +4,10 @@
</a>
</p>
<h1 align="center">Name des Projekts</h1>
<h1 align="center">bluos-upnp-bridge</h1>
<h4 align="center">
Kurzbeschreibung des Projekts/Anwendung, um die es geht
Play your Subsonic / DLNA music on Bluesound (BluOS) players – controlled from Symfonium or any other UPnP app
</h4>
<h6 align="center">
@@ -21,9 +21,135 @@
</h6>
<br><br>
## Why?
CONTENT BEREICH
BluOS players (Bluesound, NAD, …) can't be extended with your own music services, and BluOS deliberately does
not act as a UPnP/DLNA renderer. If your music lives in **Subsonic, Airsonic, Navidrome, gonic** or any other
DLNA-capable library, the only "native" ways are SMB shares (no playlists, no favourites) or AirPlay (16 bit,
phone has to stay in charge).
**bluos-upnp-bridge** makes a BluOS player show up as a regular **UPnP/DLNA MediaRenderer** on your network.
Pick it in Symfonium (or BubbleUPnP, Home Assistant, …), browse your own library with your playlists and
favourites – and the Bluesound plays the **original file directly from your server**. No transcoding, no AirPlay,
no extra app.
```
Symfonium (phone) ── UPnP: "play URL X" ──▶ bluos-upnp-bridge ── BluOS API :11000 ──▶ Bluesound player
│ │
└── rewrites Subsonic credentials └── fetches the stream
to a stream-only service account directly from Subsonic
```
## Features
- 🎵 **UPnP/DLNA MediaRenderer** (AVTransport, RenderingControl, ConnectionManager, GENA events, SSDP discovery)
- 🔐 **Credential rewriting for Subsonic**: the URLs your app hands over contain *your* login. The bridge replaces
them with a dedicated, stream-only service account (token auth) – your password never reaches the player.
URLs with credentials that can't be replaced are refused instead of being passed on.
- 🎚️ Play, pause, stop, next track (queued via `SetNextAVTransportURI`, short gap between tracks), volume and mute
- 🖼️ Title and cover art in the BluOS app
- 🔁 Handles BluOS quirks: falls back to its own queue at the end of a stream, "repeat one" loops URLs, `download.view`
is refused by BluOS (rewritten to `stream.view`)
- 📊 **Web UI**: live status page (now playing, player state, event log) and a settings page behind basic auth
- 🐳 Single Python file, standard library only, tiny Docker image
## Requirements
- A BluOS player reachable on TCP **11000** from the bridge
- Docker host in the same network as your control point, or an **SSDP relay** (UDP 1900, `239.255.255.250`)
between the VLANs – see [Networking](#networking)
- For Subsonic: a **local (non-LDAP) user** that is only allowed to stream (see below)
## Quick start
```bash
git clone https://git.techniverse.net/scriptos/bluos-upnp-bridge.git
cd bluos-upnp-bridge
cp .env.example .env
nano .env # player IP, bridge IP, Subsonic service account, web password
mkdir -p data && sudo chown 10001 data
docker compose up -d --build
docker compose logs -f
```
Open `http://<bridge-ip>:49494/` for the status page. Your player now appears in Symfonium as
**"<player name> (BluOS Bridge)"**.
### Subsonic service account
Create a local user in your Subsonic server (e.g. `bluos_stream`) with **only** the *stream* and *cover art*
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.
## Configuration
All settings come from environment variables (`.env`). Settings saved in the web UI are stored in
`data/settings.json` and take precedence.
| Variable | Default | Description |
| :--- | :--- | :--- |
| `BLUOS_HOST` | – | IP or hostname of the BluOS player (**required**) |
| `BLUOS_PORT` | `11000` | BluOS API port |
| `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 |
| `SUBSONIC_USER` | – | Stream-only service account |
| `SUBSONIC_PASSWORD` | – | Its password |
| `REPEAT_OFF` | `true` | Switch BluOS repeat off when the bridge starts playback (your app handles repeat) |
| `WEB_USER` / `WEB_PASSWORD` | `admin` / – | Basic auth for `/settings`. Settings are disabled without a password |
| `SSDP_INTERVAL` | `60` | Seconds between SSDP announcements |
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO` or `WARNING` |
| `DATA_DIR` | `/data` | Where `settings.json` is stored |
## Web UI
| URL | Auth | Content |
| :--- | :--- | :--- |
| `/` | – | Now playing, player and bridge status, live event log |
| `/settings` | Basic auth | Change settings, test player and Subsonic login, pause/resume, restart the bridge |
| `/api/status`, `/api/log` | – | JSON used by the status page |
The status page never shows credentials or player URLs; the cover is proxied through the bridge.
The UI texts are currently German. Basic auth runs over plain HTTP – put a reverse proxy with TLS in front
if your network isn't trusted.
## Networking
- **Host network is required** (`network_mode: host`): SSDP discovery needs multicast on the LAN interface.
- **VLANs**: if your phone and the bridge live in different networks, relay SSDP (UDP 1900, `239.255.255.250`)
between them. On OPNsense/pfSense the `udpbroadcastrelay` plugin does the job. mDNS repeaters are *not* enough.
- **Firewall**: bridge → player TCP 11000, player → your music server (HTTP/HTTPS), control point → bridge TCP 49494.
- ⚠️ **Docker subnets**: if a Docker network on the host overlaps the player's subnet (e.g. a default
`172.20.0.0/16` bridge network and the player in `172.20.x.x`), the bridge gets `No route to host`.
Check with `ip route get <player-ip>`.
## Known limitations
- **Seeking**: BluOS' own seek on plain HTTP streams restarts the file while the position counter jumps ahead.
The bridge restarts the stream with Subsonic's `timeOffset` instead, which only works if your server transcodes.
Proper byte-range seeking through the bridge is planned.
- **Metadata**: BluOS only accepts a single title line and a cover image for URL playback, so the bridge shows
`Title – Artist`. Album is not shown in the BluOS app.
- **One player per instance**: run one container per player (different `HTTP_PORT`). Groups created in the
BluOS app work: if the controlled player is the group master, everything plays in sync.
## Tested with
| Component | Version |
| :--- | :--- |
| Bluesound PULSE MINI 2i | BluOS 4.16.6 |
| Symfonium (Android) | current Play Store version |
| Subsonic | 6.1.6 |
Other UPnP control points and Subsonic-compatible servers should work as well – feedback is welcome.
## Disclaimer
This project is not affiliated with, endorsed or supported by Lenbrook (Bluesound, NAD, BluOS).
It uses the local BluOS HTTP API as available on the player. All trademarks belong to their respective owners.
<br><br>
<p align="center">
@@ -32,6 +158,6 @@ CONTENT BEREICH
<p align="center">
<sub>
© Patrick Asmus · Techniverse Network · <a href="./LICENSE">Lizenz</a>
© Patrick Asmus · Techniverse Network · <a href="./LICENSE">License</a>
</sub>
</p>
</p>
+1488
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
services:
bluos-upnp-bridge:
build: .
image: bluos-upnp-bridge:local
container_name: bluos-upnp-bridge
restart: unless-stopped
# SSDP discovery needs multicast on the LAN interface -> host network is required
network_mode: host
env_file: .env
environment:
TZ: Europe/Berlin
volumes:
# settings saved in the web UI (owned by uid 10001)
- ./data:/data
+137
View File
@@ -0,0 +1,137 @@
"""HTML templates for the bluos-upnp-bridge web UI (German UI texts)."""
BASE_CSS = """
:root{--bg:#f4f5f7;--card:#fff;--text:#1d2129;--muted:#6b7280;--line:#e5e7eb;--accent:#2563eb;
--ok:#16a34a;--warn:#d97706;--err:#dc2626;--chip:#eef2ff;--mono:ui-monospace,SFMono-Regular,Consolas,monospace}
@media (prefers-color-scheme:dark){:root{--bg:#0f1115;--card:#181b22;--text:#e6e8ec;--muted:#9aa3b2;
--line:#2a2f3a;--accent:#60a5fa;--chip:#1e293b}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;
border-bottom:1px solid var(--line);background:var(--card)}
header h1{font-size:18px;margin:0}header nav a{color:var(--accent);text-decoration:none;margin-left:14px}
main{max-width:1100px;margin:0 auto;padding:20px 16px;display:grid;gap:16px;
grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px}
.card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 12px}
.wide{grid-column:1/-1}.muted{color:var(--muted)}.mono{font-family:var(--mono);font-size:13px}
.chip{display:inline-block;padding:2px 10px;border-radius:999px;background:var(--chip);font-size:12px;font-weight:600}
.ok{color:var(--ok)}.warn{color:var(--warn)}.err{color:var(--err)}
dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin:0}dt{color:var(--muted)}dd{margin:0;word-break:break-word}
button,.btn{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--text);
border-radius:8px;padding:7px 14px;cursor:pointer}button.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
button.danger{color:var(--err)}button:disabled{opacity:.5;cursor:default}
"""
STATUS_HTML = """<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>BluOS-Brücke</title>
<style>__CSS__
.now{display:flex;gap:16px;align-items:center}.cover{width:120px;height:120px;border-radius:10px;object-fit:cover;
background:var(--line);flex:none}.title{font-size:20px;font-weight:650;margin:0}.artist{margin:2px 0}
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:12px}
.bar>div{height:100%;background:var(--accent);width:0;transition:width .9s linear}
.times{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-top:4px}
.log{max-height:380px;overflow:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55}
.log div{padding:1px 0;border-bottom:1px dashed var(--line);white-space:pre-wrap;word-break:break-word}
.log .t{color:var(--muted);margin-right:8px}.log .WARNING{color:var(--warn)}.log .ERROR{color:var(--err)}
</style></head><body>
<header><h1 id="name">BluOS-Brücke</h1><nav><span class="muted mono" id="ver"></span><a href="/settings">Einstellungen</a></nav></header>
<main>
<section class="card wide"><h2>Läuft gerade</h2>
<div class="now"><img class="cover" id="cover" alt="">
<div style="flex:1;min-width:0"><span class="chip" id="state">–</span>
<p class="title" id="title">Nichts ausgewählt</p><p class="artist" id="artist"></p><p class="muted" id="album"></p></div></div>
<div class="bar"><div id="bar"></div></div><div class="times"><span id="pos">0:00</span><span id="dur">0:00</span></div>
<p class="muted" id="next" style="margin:10px 0 0"></p>
</section>
<section class="card"><h2>Bluesound-Player</h2><dl id="player"></dl></section>
<section class="card"><h2>Brücke</h2><dl id="bridge"></dl></section>
<section class="card wide"><h2>Ereignisse</h2><div class="log" id="log"></div></section>
</main>
<script>
const $=id=>document.getElementById(id);
const STATES={PLAYING:["Spielt","ok"],PAUSED_PLAYBACK:["Pausiert","warn"],STOPPED:["Gestoppt",""],
NO_MEDIA_PRESENT:["Bereit",""],TRANSITIONING:["Lädt …","warn"]};
const fmt=s=>{s=Math.max(0,Math.floor(s||0));const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=String(s%60).padStart(2,"0");
return h?`${h}:${String(m).padStart(2,"0")}:${x}`:`${m}:${x}`};
const ago=t=>{if(!t)return"nie";const d=Math.round(Date.now()/1000-t);return d<5?"gerade eben":d<120?`vor ${d} s`:`vor ${Math.round(d/60)} min`};
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const dl=(el,rows)=>{el.innerHTML=rows.map(([k,v])=>`<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")};
let coverKey=null;
async function status(){
try{const s=await (await fetch("/api/status",{cache:"no-store"})).json();
$("name").textContent=s.name;document.title=s.name;$("ver").textContent="v"+s.version;
const [label,cls]=STATES[s.transport_state]||[s.transport_state,""];
$("state").textContent=label+(s.owned||!s.current?"":" (Player woanders aktiv)");$("state").className="chip "+cls;
const c=s.current;$("title").textContent=c?(c.title||"Unbekannter Titel"):"Nichts ausgewählt";
$("artist").textContent=c?c.artist:"";$("album").textContent=c?c.album:"";
const key=c&&c.song_id?c.song_id:null;if(key!==coverKey){coverKey=key;$("cover").src=key?"/cover?id="+encodeURIComponent(key):"";}
$("cover").style.visibility=key?"visible":"hidden";
const d=s.duration||0;$("pos").textContent=fmt(s.position);$("dur").textContent=d?fmt(d):"–";
$("bar").style.width=d?Math.min(100,s.position/d*100)+"%":"0";
$("next").textContent=s.next?`Als Nächstes: ${s.next.title}${s.next.artist?" – "+s.next.artist:""}`:"";
const p=s.player||{},rep={"0":"Warteschlange","1":"Ein Titel","2":"Aus"}[p.repeat]||"–";
dl($("player"),[["Name",esc(p.name||s.bluos_host)],["Adresse",`<span class="mono">${esc(s.bluos_host)}:${s.bluos_port}</span>`],
["Verbindung",s.player_error?`<span class="err">Fehler: ${esc(s.player_error)}</span>`:`<span class="ok">ok</span> <span class="muted">(${ago(s.player_ok)})</span>`],
["Status",esc(p.state||"–")],["Quelle",esc(p.service||"–")],["Anzeige",esc([p.title1,p.title2].filter(Boolean).join(" – ")||"–")],
["Format",esc(p.streamFormat||"–")],["Lautstärke",`${s.volume}${s.mute?" (stumm)":""}`],["Wiederholen",rep]]);
const cps=Object.entries(s.control_points||{}).sort((a,b)=>b[1]-a[1]).map(([ip,t])=>`<span class="mono">${esc(ip)}</span> <span class="muted">${ago(t)}</span>`).join("<br>")||"–";
dl($("bridge"),[["Version",esc(s.version)],["Läuft seit",fmt(s.uptime)],["Angekündigt als",`<span class="mono">${esc(s.location)}</span>`],
["Subsonic",s.subsonic.enabled?`<span class="ok">aktiv</span> als <span class="mono">${esc(s.subsonic.user)}</span>`:`<span class="warn">aus</span>`],
["Wiederholung aus",s.repeat_off?"ja":"nein"],["Steuernde Geräte",cps],["Abos (Events)",s.subscriptions]]);
}catch(e){$("state").textContent="Brücke nicht erreichbar";$("state").className="chip err";}
}
let lastId=0;
async function logs(){
try{const r=await (await fetch("/api/log?after="+lastId,{cache:"no-store"})).json();const box=$("log");
const atBottom=box.scrollHeight-box.scrollTop-box.clientHeight<30;
for(const l of r){lastId=l.id;const d=document.createElement("div");
d.innerHTML=`<span class="t">${new Date(l.t*1000).toLocaleTimeString("de-DE")}</span><span class="${l.level}">${esc(l.msg)}</span>`;box.appendChild(d);}
while(box.children.length>300)box.removeChild(box.firstChild);if(atBottom)box.scrollTop=box.scrollHeight;
}catch(e){}
}
status();logs();setInterval(status,1000);setInterval(logs,2000);
</script></body></html>
""".replace("__CSS__", BASE_CSS)
SETTINGS_HTML = """<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Einstellungen · BluOS-Brücke</title>
<style>__CSS__
main{grid-template-columns:1fr}form{display:grid;gap:14px}
label{display:grid;gap:4px;font-weight:600}label small{font-weight:400;color:var(--muted)}
input[type=text],input[type=number],input[type=password],select{font:inherit;padding:8px 10px;border-radius:8px;
border:1px solid var(--line);background:var(--bg);color:var(--text);width:100%}
.check{display:flex;gap:10px;align-items:flex-start;font-weight:600}.check input{margin-top:5px}
.restart{color:var(--warn);font-weight:400;font-size:12px}
.notice{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}.notice.ok{border-color:var(--ok)}.notice.err{border-color:var(--err)}
.actions{display:flex;flex-wrap:wrap;gap:10px}#out{white-space:pre-wrap;margin-top:12px}
</style></head><body>
<header><h1>Einstellungen</h1><nav><a href="/">Zur Statusseite</a></nav></header>
<main>
__NOTICE__
<section class="card"><h2>Brücke &amp; Subsonic</h2>
<form method="post" action="/settings"><input type="hidden" name="csrf" value="__CSRF__">
__FIELDS__
<div class="actions"><button class="primary" type="submit">Speichern</button></div>
<p class="muted" style="margin:0">Gespeichert wird in <span class="mono">__FILE__</span>. Felder mit
<span class="restart">Neustart</span> werden erst nach einem Neustart der Brücke aktiv.</p>
</form></section>
<section class="card"><h2>Werkzeuge</h2>
<div class="actions">
<button data-a="test_player">Player-Verbindung testen</button>
<button data-a="test_subsonic">Subsonic-Anmeldung testen</button>
<button data-a="pause">Pause</button><button data-a="play">Fortsetzen</button>
<button class="danger" data-a="restart">Brücke neu starten</button></div>
<div id="out" class="mono muted"></div></section>
</main>
<script>
document.querySelectorAll("button[data-a]").forEach(b=>b.addEventListener("click",async()=>{
const a=b.dataset.a;if(a==="restart"&&!confirm("Brücke wirklich neu starten? Die laufende Wiedergabe bleibt, Symfonium muss sich aber neu verbinden."))return;
const out=document.getElementById("out");out.textContent="…";b.disabled=true;
try{const r=await fetch("/api/action",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":"__CSRF__"},body:JSON.stringify({action:a})});
const j=await r.json();out.textContent=(j.ok?"✔ ":"✖ ")+j.message;out.className="mono "+(j.ok?"ok":"err");
if(a==="restart"&&j.ok)setTimeout(()=>location.reload(),6000);
}catch(e){out.textContent="✖ "+e;out.className="mono err";}finally{b.disabled=false;}
}));
</script></body></html>
""".replace("__CSS__", BASE_CSS)