Initial release 0.2.0: UPnP/DLNA bridge for BluOS players with web UI
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.env.*
|
||||
data/
|
||||
.git/
|
||||
__pycache__/
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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
@@ -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"]
|
||||
-26
@@ -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/>.
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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=>({"&":"&","<":"<",">":">",'"':"""}[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 & 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)
|
||||
Reference in New Issue
Block a user