Release 0.7.0: OpenHome, fades, gapless covers/conversion, monitoring
Build & Push Docker Image / Build & push image (push) Successful in 8m57s

- OpenHome renderer (Product, Playlist, Time, Info, Volume): the app hands
  the whole queue to the bridge, which plays it on its own
- Soft fades on pause, stop and user track changes (FADE_MS)
- Gapless: cover per track, ffmpeg conversion of mixed formats, resume at
  the paused position after BluOS reconnects
- Monitoring: /api/health, /api/prtg, /metrics
- English web UI (UI_LANG=en|de)
- Multi-arch image build via Gitea Actions, image includes ffmpeg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 12:53:46 +01:00
co-authored by Claude Opus 5.5
parent 23314ebf33
commit 04cd7c44b8
10 changed files with 1180 additions and 111 deletions
+6
View File
@@ -30,6 +30,12 @@ REPEAT_OFF=true
PROXY_STREAMS=true
# experimental: stitch MP3/FLAC tracks into one continuous stream
GAPLESS=false
# fade in/out on pause, stop and track change (ms), 0 = off
FADE_MS=500
# offer OpenHome (app hands the whole queue to the bridge)
OPENHOME=true
# web UI language: en or de
UI_LANG=en
LOG_LEVEL=INFO
# --- Web UI (settings page, basic auth) --------------------------------------
+67
View File
@@ -0,0 +1,67 @@
# Builds the multi-arch Docker image (linux/amd64, linux/arm64) and pushes it to the
# Gitea container registry - on every version tag (v*) and on demand.
name: Build & Push Docker Image
on:
push:
tags:
- 'v*'
workflow_dispatch:
env:
IMAGE_NAME: bluos-upnp-bridge
jobs:
build:
name: Build & push image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine version and registry
id: meta
run: |
set -e
if [ "${{ github.ref_type }}" = "tag" ]; then
TAG="${{ github.ref_name }}"
else
TAG="v$(grep -m1 '^VERSION = ' bridge.py | sed -E 's/VERSION = "([^"]+)"/\1/')"
fi
echo "Version: ${TAG}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
REGISTRY="${{ vars.REGISTRY_URL || github.server_url }}"
REGISTRY="${REGISTRY#https://}"
REGISTRY="${REGISTRY#http://}"
echo "registry=${REGISTRY}" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to container registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_URL || github.server_url }}
username: ${{ secrets.REGISTRY_USER || github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
- name: Build & push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.meta.outputs.registry }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest
${{ steps.meta.outputs.registry }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.tag }}
labels: |
org.opencontainers.image.title=bluos-upnp-bridge
org.opencontainers.image.description=UPnP/DLNA and OpenHome renderer bridge for BluOS (Bluesound) players
org.opencontainers.image.version=${{ steps.meta.outputs.tag }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.licenses=MIT
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 0.7.0 – 2026-10-26
- **OpenHome renderer** (Product, Playlist, Time, Info, Volume): the app hands the whole queue to the bridge,
which plays it on its own – gapless mode included
- **Soft fades** (`FADE_MS`) on pause, stop and track changes by the user
- Gapless: **cover per track** in the BluOS app (ICY `StreamUrl` with an image URL)
- Gapless: **mixed formats** – follow-up tracks in another format/sample rate are converted with ffmpeg
- Gapless: **resume after long pauses** continues at the paused position (BluOS reconnects after ~2 min)
- **Monitoring**: `/api/health` (Uptime Kuma), `/api/prtg` (PRTG HTTP Data Advanced), `/metrics` (Prometheus)
- **English web UI** (`UI_LANG=en|de`)
- Docker image with ffmpeg, multi-arch build via Gitea Actions (`git.techniverse.net/scriptos/bluos-upnp-bridge`)
- Fix: stale player status right after pause/play no longer flips the state back
- Fix: upstream connections are closed when the player disconnects mid-stream
## 0.6.1 – 2026-10-26
- Gapless: detect the end of the sent audio when BluOS freezes its counter or reports "connecting"
+6 -2
View File
@@ -3,10 +3,14 @@ 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 \
# ffmpeg converts follow-up tracks of another format in gapless mode (MP3 <-> FLAC)
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --no-create-home bridge \
&& mkdir -p /data && chown bridge /data
WORKDIR /app
COPY bridge.py web_ui.py gapless.py /app/
COPY bridge.py web_ui.py gapless.py openhome.py /app/
USER bridge
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data
+43 -11
View File
@@ -28,7 +28,7 @@ not act as a UPnP/DLNA renderer. If your music lives in **Subsonic, Airsonic, Na
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.
**bluos-upnp-bridge** makes a BluOS player show up as a regular **UPnP/DLNA and OpenHome renderer** 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.
@@ -43,18 +43,23 @@ no extra app.
## Features
- 🎵 **UPnP/DLNA MediaRenderer** (AVTransport, RenderingControl, ConnectionManager, GENA events, SSDP discovery)
- 📜 **OpenHome renderer** (Product, Playlist, Time, Info, Volume): your app hands over the **whole queue** and the
bridge plays it on its own – the phone may sleep or leave the house
- 🔐 **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
- ⏩ **Seeking** through a built-in stream proxy (Content-Length + byte ranges)
- 🔗 **Gapless playback** (experimental) for MP3 and FLAC – live albums, DJ mixes and audio plays run
seamlessly from track to track
- 🖼️ Title and cover art in the BluOS app
seamlessly from track to track; mixed formats are converted with ffmpeg
- 🖼️ Title and cover art in the BluOS app – updated per track, also in gapless mode
- 🌗 **Soft fades** on pause, stop and track changes
- 📈 **Monitoring endpoints** for Uptime Kuma, PRTG and Prometheus
- 🔁 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
- 📊 **Web UI** (English / German): live status page (now playing, player state, event log) and a settings page
behind basic auth
- 🐳 Python standard library only, multi-arch Docker image (amd64, arm64)
## Requirements
@@ -71,10 +76,13 @@ 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 up -d # pulls git.techniverse.net/scriptos/bluos-upnp-bridge:latest
docker compose logs -f
```
Prefer building yourself? Replace `image:` with `build: .` in `docker-compose.yaml` and run
`docker compose up -d --build`.
Open `http://<bridge-ip>:49494/` for the status page. Your player now appears in Symfonium as
**"<player name> (BluOS Bridge)"**.
@@ -118,6 +126,9 @@ All settings come from environment variables (`.env`). Settings saved in the web
| `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) |
| `FADE_MS` | `500` | Fade in/out on pause, stop and track change in milliseconds, `0` = off |
| `OPENHOME` | `true` | Offer the OpenHome services (restart required) |
| `UI_LANG` | `en` | Language of the web UI: `en` or `de` |
| `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 |
@@ -131,10 +142,25 @@ All settings come from environment variables (`.env`). Settings saved in the web
| `/` | – | 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 |
| `/api/health` | – | Health JSON – HTTP 200 if bridge, player and music server are fine, otherwise 503 |
| `/api/prtg` | – | PRTG "HTTP Data Advanced" sensor format (bridge, player, server, latency, playing, uptime) |
| `/metrics` | – | Prometheus metrics |
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.
Basic auth runs over plain HTTP – put a reverse proxy with TLS in front if your network isn't trusted.
**Uptime Kuma:** monitor type *HTTP(s)* on `http://<bridge-ip>:49494/api/health` (accepted status code 200).
**PRTG:** sensor *HTTP Data Advanced* with URL `http://<bridge-ip>:49494/api/prtg`.
## OpenHome
Apps with OpenHome support (e.g. Symfonium, BubbleUPnP, Kazoo) can send the **complete play queue** to the
bridge instead of one track at a time. The bridge then plays the queue by itself, keeps the BluOS player fed
with the current and the following track (gapless mode included) and reports position, track and volume
back to every connected app. Your phone can go to sleep – the music keeps playing.
Supported services: Product (single "Playlist" source), Playlist (incl. repeat), Time, Info, Volume.
Shuffle is reported but left to the app (it sends the queue in shuffled order).
## Gapless playback
@@ -149,9 +175,13 @@ changes in the app until then are respected:
| FLAC | The first track's STREAMINFO is marked open-ended (total samples / MD5 unknown), follow-up tracks are appended without their metadata blocks. Requires identical sample rate, channels and bit depth. |
The bridge maps BluOS' playback position to the audible track and reports every track change to your app.
The BluOS app shows the current track in its second line (ICY metadata); the cover stays at the first track.
Seeking restarts the stream at the new position (short pause). If two tracks can't be stitched
(e.g. MP3 → FLAC), the bridge falls back to a normal track change.
The BluOS app shows the current track in its second line and its cover (ICY metadata). Seeking restarts the
stream at the new position (short pause). Tracks in another format (MP3 ↔ FLAC, other sample rate) are
converted with **ffmpeg** to the stream's format (FLAC → MP3 with 320 kbit/s); without ffmpeg the bridge falls
back to a normal track change.
BluOS drops paused "radio" streams after about two minutes and reconnects on resume – the bridge then
continues exactly where playback was paused.
## Networking
@@ -170,6 +200,7 @@ Seeking restarts the stream at the new position (short pause). If two tracks can
- **Seeking** needs the stream proxy (`PROXY_STREAMS=true`, default) – without it BluOS can't seek Subsonic streams.
- **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.
- **Image size**: ffmpeg (for gapless format conversion) makes the image ~550 MB.
- **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.
@@ -180,6 +211,7 @@ Seeking restarts the stream at the new position (short pause). If two tracks can
| Bluesound PULSE MINI 2i | BluOS 4.16.6 |
| Symfonium (Android) | current Play Store version |
| Subsonic | 6.1.6 |
| Airsonic Pulse | 13.2.1 |
Other UPnP control points and Subsonic-compatible servers should work as well – feedback is welcome.
+312 -43
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.6.1"
VERSION = "0.7.0"
STARTED = time.time()
# --------------------------------------------------------------------------- config
@@ -84,6 +84,14 @@ LOG_LEVEL = cfg("LOG_LEVEL", "INFO").upper()
REPEAT_OFF = as_bool(cfg("REPEAT_OFF", "true"))
PROXY_STREAMS = as_bool(cfg("PROXY_STREAMS", "true"))
GAPLESS = as_bool(cfg("GAPLESS", "false"))
FADE_MS = int(cfg("FADE_MS", "500") or 0)
OPENHOME = as_bool(cfg("OPENHOME", "true"))
UI_LANG = cfg("UI_LANG", "en").lower()
def L(de, en):
"""Pick the UI language for user-facing messages."""
return de if UI_LANG == "de" else en
WEB_USER = os.environ.get("WEB_USER", "admin")
WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
@@ -387,6 +395,9 @@ class Renderer:
self.mute = False
self.player = {} # last BluOS /Status values (web UI)
self.player_name = ""
self.fading = False # volume ramp in progress (ignore volume changes from the player)
self.on_change = None # OpenHome hook, called on every state change
self.hold_state_until = 0.0 # ignore play/pause from status polls started before our own action
self.session = None # gapless.Session while gapless playback runs
self.seg_index = 0
self._last_secs = -1 # stall detection for gapless streams
@@ -429,7 +440,8 @@ class Renderer:
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)}"
# ".jpg" matters: BluOS only takes an ICY StreamUrl as artwork if it looks like an image
return f"http://{ADVERTISE_IP}:{HTTP_PORT}/cover/{urllib.parse.quote(song_id)}.jpg"
return ""
art = info.get("art", "")
return "" if self.subsonic.has_credentials(art) else art
@@ -451,7 +463,12 @@ class Renderer:
def duration(self):
return self.current_info["duration"] or self.player_duration
def _set_state(self, state):
def _hold_state(self):
self.hold_state_until = time.time() + 1.5
def _set_state(self, state, from_player=False):
if from_player and time.time() < self.hold_state_until:
return
if state != self.transport_state:
log.info("transport state %s -> %s", self.transport_state, state)
self.transport_state = state
@@ -460,6 +477,8 @@ class Renderer:
def _notify_avt(self):
if self.events:
self.events.notify_avt()
if self.on_change:
self.on_change()
def _proxied(self, uri=None):
uri = self.current_uri if uri is None else uri
@@ -470,7 +489,7 @@ class Renderer:
song = self.subsonic.song_info(self.subsonic.song_id(uri))
info["duration"] = info["duration"] or song["duration"]
info["content_type"] = song["content_type"]
return gapless.Segment(self.subsonic.rewrite(uri), uri, meta, info, self._title(info))
return gapless.Segment(self.subsonic.rewrite(uri), uri, meta, info, self._title(info), self._art(uri, info))
def _close_session(self):
if self.session:
@@ -563,7 +582,7 @@ class Renderer:
self.position = 0
log.info("SetAVTransportURI %r %s", self._title(self.current_info), mask_url(uri))
if was_playing:
self._start()
self._switch(self._start)
else:
self._set_state("STOPPED")
self._notify_avt()
@@ -581,27 +600,108 @@ class Renderer:
log.warning("gapless: next track already sent to the player, queue change applies after it")
self._notify_avt()
# ---- fades (volume ramps via the BluOS API)
def _fade_steps(self):
return max(2, min(20, FADE_MS // 50))
def _fade_out(self):
"""Ramp the volume down; returns the level to restore afterwards (None if no fade)."""
if FADE_MS <= 0 or not self.owned or self.transport_state != "PLAYING" or self.mute:
return None
level = self.volume
if level <= 0:
return None
self.fading = True
try:
steps = self._fade_steps()
for i in range(1, steps + 1):
self.bluos.volume(level=round(level * (1 - i / steps)))
time.sleep(FADE_MS / 1000 / steps)
except Exception as exc:
log.warning("fade out failed: %s", exc)
return level
def _restore_volume(self, level):
if level is None:
return
try:
self.bluos.volume(level=level)
except Exception as exc:
log.warning("restoring volume %s failed: %s", level, exc)
finally:
self.fading = False
def _fade_in_async(self, level, wait_for_audio=False):
"""Ramp the volume up from 0 in the background (player must already be at 0)."""
def run():
try:
if wait_for_audio:
deadline = time.time() + 4
while time.time() < deadline:
st = self.bluos.xml("/Status", timeout=3).findtext("state", "")
if st in ("play", "stream"):
break
time.sleep(0.2)
steps = self._fade_steps()
for i in range(1, steps + 1):
self.bluos.volume(level=round(level * i / steps))
time.sleep(FADE_MS / 1000 / steps)
except Exception as exc:
log.warning("fade in failed: %s", exc)
finally:
self._restore_volume(level)
threading.Thread(target=run, name="fade-in", daemon=True).start()
def _silence_for_fade_in(self):
"""Set the volume to 0 before audio starts; returns the target level (None if no fade)."""
if FADE_MS <= 0 or self.mute or self.volume <= 0:
return None
level = self.volume
self.fading = True
try:
self.bluos.volume(level=0)
except Exception as exc:
log.warning("fade preparation failed: %s", exc)
self.fading = False
return None
return level
def play(self):
with self.lock:
if not self.current_uri:
raise UPnPError(701, "Transition not available")
if self.transport_state == "PAUSED_PLAYBACK" and self.owned:
level = self._silence_for_fade_in()
self.bluos.resume()
self._hold_state()
self._set_state("PLAYING")
if level is not None:
self._fade_in_async(level)
elif self.transport_state != "PLAYING" or not self.owned:
self._start()
level = self._silence_for_fade_in()
try:
self._start()
finally:
if level is not None:
self._fade_in_async(level, wait_for_audio=True)
def pause(self):
with self.lock:
if self.owned:
level = self._fade_out()
self.bluos.pause()
self._restore_volume(level)
self._hold_state()
self._set_state("PAUSED_PLAYBACK")
def stop(self):
with self.lock:
self.expect_stop = True
if self.owned:
level = self._fade_out()
self.bluos.pause() # /Stop would clear the queue display; pause is enough
self._restore_volume(level)
self._hold_state()
self._close_session()
self.position = 0
self._set_state("STOPPED")
@@ -633,7 +733,16 @@ class Renderer:
with self.lock:
if not self.next_uri:
raise UPnPError(711, "No next track")
self._advance()
self._switch(self._advance)
def _switch(self, start):
"""Track change by the user: fade out, start the new track, fade in."""
level = self._fade_out()
try:
start()
finally:
if level is not None:
self._fade_in_async(level, wait_for_audio=True)
def _advance(self):
self.current_uri, self.current_meta = self.next_uri, self.next_meta
@@ -670,7 +779,7 @@ class Renderer:
stream_url = st.findtext("streamUrl", "")
vol = int(st.findtext("volume", "0") or 0)
mute = st.findtext("mute", "0") == "1"
if (vol, mute) != (self.volume, self.mute):
if not self.fading and (vol, mute) != (self.volume, self.mute):
self.volume, self.mute = vol, mute
if self.events:
self.events.notify_rcs()
@@ -712,19 +821,33 @@ class Renderer:
self._set_state("STOPPED")
return
if state in ("play", "stream"):
self._set_state("PLAYING")
self._set_state("PLAYING", from_player=True)
elif state == "pause":
self._set_state("PAUSED_PLAYBACK")
self._set_state("PAUSED_PLAYBACK", from_player=True)
elif state == "stop" and not settling:
if self.expect_stop:
self._set_state("STOPPED")
else:
self._track_ended()
def rebase_session(self, session):
with self.lock:
if session is not self.session:
return
log.info("gapless: player reconnected, continuing %r at %s",
self._title(self.current_info), hms(self.position))
session.rebase(self.seg_index, self.position)
self.seg_index = 0
self.started_at = time.time()
self._last_secs, self._last_secs_at = -1, time.time()
def _gapless_status(self, st, state):
"""Map BluOS' stream position to the track that is audible right now."""
sess = self.session
secs = int(float(st.findtext("secs", "0") or 0))
if state in ("pause", "stop", "connecting") and secs == 0 and self.position > 0:
# BluOS resets the counter when it drops a paused stream - keep the known position
return
sess.playback_secs = secs
starts = sess.starts()
streamed = [i for i, s in enumerate(sess.segments) if s.streamed or i == 0]
@@ -748,9 +871,9 @@ class Renderer:
stalled = state == "connecting" or (state in ("play", "stream") and now - self._last_secs_at >= 2.5)
drained = seg.duration and self.position >= seg.duration - 2 and stalled
if state in ("play", "stream"):
self._set_state("PLAYING")
self._set_state("PLAYING", from_player=True)
elif state == "pause":
self._set_state("PAUSED_PLAYBACK")
self._set_state("PAUSED_PLAYBACK", from_player=True)
if self.seg_index == last and not self.expect_stop and (
(seg.duration and self.position >= seg.duration + 1) or state == "stop" or drained):
# end of the stitched stream; a follow-up that couldn't be stitched starts normally
@@ -792,6 +915,8 @@ def poll_status(renderer, bluos, stop_event):
st = bluos.xml(path, timeout=40)
etag = st.get("etag", "")
renderer.on_status(st)
if renderer.on_change:
renderer.on_change()
if renderer.owned and renderer.transport_state == "PLAYING":
# long-poll returns on change only; refresh position regularly while playing
time.sleep(1)
@@ -928,13 +1053,16 @@ CM_ACTIONS = {
_arg("Status", "out", "A_ARG_TYPE_ConnectionStatus")],
}
SCPD_SPECS = {
SCPD_SPECS_AV = {
"AVTransport": (AVT_ACTIONS, AVT_VARS),
"RenderingControl": (RCS_ACTIONS, RCS_VARS),
"ConnectionManager": (CM_ACTIONS, CM_VARS),
}
SCPD_SPECS = dict(SCPD_SPECS_AV)
def build_scpd(actions, variables):
out = ['<?xml version="1.0" encoding="utf-8"?>',
'<scpd xmlns="urn:schemas-upnp-org:service-1-0">',
@@ -961,7 +1089,8 @@ def build_scpd(actions, variables):
def build_description(name):
services = "".join(
f"<service><serviceType>{st}</serviceType><serviceId>urn:upnp-org:serviceId:{sid}</serviceId>"
f"<service><serviceType>{st}</serviceType><serviceId>"
f"{openhome.SERVICE_ID_PREFIX if sid in openhome.SERVICES else 'urn:upnp-org:serviceId:'}{sid}</serviceId>"
f"<SCPDURL>/{sid}.xml</SCPDURL><controlURL>/ctl/{sid}</controlURL>"
f"<eventSubURL>/evt/{sid}</eventSubURL></service>"
for sid, st in SERVICES.items()
@@ -1020,6 +1149,11 @@ class EventManager:
def _body(self, service):
r = self.renderer
if service in openhome.SERVICES:
props = OH.state(service) if OH else {}
inner = "".join(f"<e:property><{k}>{html.escape(v)}</{k}></e:property>" for k, v in props.items())
return ('<?xml version="1.0" encoding="utf-8"?>'
f'<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">{inner}</e:propertyset>')
if service == "AVTransport":
vals = avt_state(r)
inner = "".join(f'<{k} val="{html.escape(str(v))}"/>' for k, v in vals.items())
@@ -1105,8 +1239,21 @@ def avt_state(r):
# --------------------------------------------------------------------------- SOAP dispatch
OH = None # openhome.OpenHome instance (if enabled)
def handle_action(renderer, service, action, args):
r = renderer
if service in openhome.SERVICES:
if not OH:
raise UPnPError(401, "Invalid Action")
try:
return OH.handle(service, action, args)
except openhome.OHError as exc:
raise UPnPError(exc.code, exc.desc)
if service == "AVTransport" and action in ("SetAVTransportURI", "SetNextAVTransportURI") and OH and OH.active:
log.info("AVTransport control took over from OpenHome")
OH.active = False
if service == "AVTransport":
if action == "SetAVTransportURI":
r.set_uri(args.get("CurrentURI", ""), args.get("CurrentURIMetaData", ""))
@@ -1198,12 +1345,14 @@ def soap_fault(code, desc):
# --------------------------------------------------------------------------- web UI
import gapless # noqa: E402
import openhome # noqa: E402
import web_ui # noqa: E402 (templates only)
CSRF_TOKEN = secrets.token_urlsafe(24)
# key, label, kind, needs restart, help text
SETTING_FIELDS = [
("UI_LANG", "Sprache der Weboberfläche", "select:en,de", False, "en = English, de = Deutsch."),
("FRIENDLY_NAME", "Name im Netzwerk", "text", True,
"So erscheint die Brücke in Symfonium. Leer = Playername + „(BluOS Bridge)“."),
("BLUOS_HOST", "BluOS-Player (IP oder Hostname)", "text", True, ""),
@@ -1232,6 +1381,10 @@ SETTING_FIELDS = [
("GAPLESS", "Lückenlose Wiedergabe (experimentell)", "bool", False,
"Fügt MP3- bzw. FLAC-Titel zu einem durchgehenden Stream zusammen – für Live-Alben, Mixe und Hörspiele. "
"Spulen startet den Stream neu, das Cover in der BluOS-App bleibt beim ersten Titel."),
("FADE_MS", "Ein-/Ausblenden (Millisekunden)", "number", False,
"Sanftes Ein- und Ausblenden bei Pause, Stopp und Titelwechsel. 0 = aus."),
("OPENHOME", "OpenHome anbieten", "bool", True,
"Die App übergibt die ganze Warteschlange an die Brücke – die Musik läuft weiter, auch wenn das Handy schläft."),
("LOG_LEVEL", "Log-Level", "select:DEBUG,INFO,WARNING", False, ""),
]
RUNNING = {k: str(globals()[k]) for k, *_ in SETTING_FIELDS} # values active since start
@@ -1250,11 +1403,14 @@ def current_settings():
def render_settings(notice=""):
values = current_settings()
parts = []
t = web_ui.texts(UI_LANG)
for key, label, kind, restart, help_text in SETTING_FIELDS:
if UI_LANG != "de" and key in web_ui.FIELDS_EN:
label, help_text = web_ui.FIELDS_EN[key]
value = values.get(key, "")
badge = ' <span class="restart">Neustart</span>' if restart else ""
badge = f' <span class="restart">{t["restart"]}</span>' if restart else ""
if restart and value != RUNNING[key]:
badge = ' <span class="restart">Neustart ausstehend</span>'
badge = f' <span class="restart">{t["restart_pending"]}</span>'
help_html = f"<small>{html.escape(help_text)}</small>" if help_text else ""
if kind == "bool":
checked = " checked" if as_bool(value) else ""
@@ -1270,9 +1426,10 @@ def render_settings(notice=""):
parts.append(f'<label>{html.escape(label)}{badge}<input type="{kind}" name="{key}" '
f'value="{shown}"{placeholder} autocomplete="off">{help_html}</label>')
if not os.access(DATA_DIR, os.W_OK):
notice += (f'<div class="notice err">Achtung: <span class="mono">{html.escape(DATA_DIR)}</span> ist nicht '
"beschreibbar, Einstellungen können nicht gespeichert werden.</div>")
return (web_ui.SETTINGS_HTML.replace("__FIELDS__", "".join(parts)).replace("__NOTICE__", notice)
notice += ('<div class="notice err">' + html.escape(t["not_writable"]).replace(
"{dir}", f'<span class="mono">{html.escape(DATA_DIR)}</span>') + "</div>")
page = web_ui.localize(web_ui.SETTINGS_HTML, UI_LANG)
return (page.replace("__FIELDS__", "".join(parts)).replace("__NOTICE__", notice)
.replace("__CSRF__", CSRF_TOKEN).replace("__FILE__", html.escape(SETTINGS_FILE)))
@@ -1288,16 +1445,19 @@ def save_settings(form, renderer):
value = old.get(key, "")
elif kind == "number":
if not value.isdigit():
return False, f"{label}: bitte eine Zahl eingeben."
return False, f"{label}: " + L("bitte eine Zahl eingeben.", "please enter a number.")
elif kind.startswith("select:") and value not in kind[7:].split(","):
return False, f"{label}: ungültiger Wert."
return False, f"{label}: " + L("ungültiger Wert.", "invalid value.")
new[key] = value
if not new["BLUOS_HOST"] or not new["ADVERTISE_IP"]:
return False, "Player-Adresse und IP der Brücke dürfen nicht leer sein."
return False, L("Player-Adresse und IP der Brücke dürfen nicht leer sein.",
"Player address and bridge IP must not be empty.")
if not 1 <= int(new["BLUOS_PORT"]) <= 65535 or not 10 <= int(new["SSDP_INTERVAL"]) <= 3600:
return False, "Port oder SSDP-Intervall außerhalb des gültigen Bereichs."
return False, L("Port oder SSDP-Intervall außerhalb des gültigen Bereichs.",
"Port or SSDP interval out of range.")
if new["SUBSONIC_URL"] and not re.match(r"^https?://", new["SUBSONIC_URL"]):
return False, "Subsonic-Server muss mit http:// oder https:// beginnen."
return False, L("Musikserver muss mit http:// oder https:// beginnen.",
"Music server must start with http:// or https://.")
new["SUBSONIC_URL"] = new["SUBSONIC_URL"].rstrip("/")
os.makedirs(DATA_DIR, exist_ok=True)
@@ -1312,19 +1472,22 @@ def save_settings(form, renderer):
OVERRIDES.update(new)
# apply what can change at runtime
global REPEAT_OFF, PROXY_STREAMS, GAPLESS
global REPEAT_OFF, PROXY_STREAMS, GAPLESS, FADE_MS, UI_LANG
REPEAT_OFF = as_bool(new["REPEAT_OFF"])
PROXY_STREAMS = as_bool(new["PROXY_STREAMS"])
GAPLESS = as_bool(new["GAPLESS"])
FADE_MS = min(int(new["FADE_MS"] or 0), 5000)
UI_LANG = new["UI_LANG"].lower()
logging.getLogger().setLevel(new["LOG_LEVEL"])
with renderer.lock:
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]]
msg = "Gespeichert."
msg = L("Gespeichert.", "Saved.")
if restart:
msg += " Für " + ", ".join(restart) + " ist ein Neustart der Brücke nötig."
msg += L(" Für " + ", ".join(restart) + " ist ein Neustart der Brücke nötig.",
" Restart the bridge to apply: " + ", ".join(restart) + ".")
return True, msg
@@ -1337,7 +1500,7 @@ def run_action(action, renderer):
if action == "test_subsonic":
sub = renderer.subsonic
if not sub.enabled:
return False, "Subsonic ist nicht konfiguriert."
return False, L("Musikserver ist nicht konfiguriert.", "Music server is not configured.")
q = urllib.parse.urlencode(sub._auth())
with urllib.request.urlopen(f"{sub.base}/rest/ping.view?{q}", timeout=10) as r:
root = ET.fromstring(r.read())
@@ -1346,21 +1509,22 @@ def run_action(action, renderer):
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}"
hint = L(" Hinweis: Der Server kann OpenSubsonic – Servertyp „opensubsonic“ wählen.",
" Note: the server supports OpenSubsonic – choose server type \"opensubsonic\".")
return True, L(f"Anmeldung mit {sub.auth_label} ok", f"Login as {sub.auth_label} ok") + f" (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'}"
return False, L("Server meldet: ", "Server says: ") + (err.get("message") if err is not None else "?")
if action == "pause":
renderer.pause()
return True, "Pausiert."
return True, L("Pausiert.", "Paused.")
if action == "play":
renderer.play()
return True, "Wiedergabe fortgesetzt."
return True, L("Wiedergabe fortgesetzt.", "Playback resumed.")
if action == "restart":
log.info("restart requested via web UI")
threading.Timer(1.0, lambda: os._exit(0)).start() # restart policy brings the container back
return True, "Brücke startet neu …"
return False, "Unbekannte Aktion."
return True, L("Brücke startet neu …", "Bridge is restarting …")
return False, L("Unbekannte Aktion.", "Unknown action.")
class CoverCache:
@@ -1391,6 +1555,88 @@ class CoverCache:
COVERS = CoverCache()
class Monitor:
"""Health data for Uptime Kuma, PRTG and Prometheus. The server check is cached."""
def __init__(self, renderer, ttl=30):
self.renderer = renderer
self.ttl = ttl
self.lock = threading.Lock()
self.server = {"checked": 0.0, "ok": None, "latency_ms": None, "error": ""}
def _check_server(self):
sub = self.renderer.subsonic
if not sub.enabled:
return {"checked": time.time(), "ok": None, "latency_ms": None, "error": "not configured"}
start = time.time()
try:
q = urllib.parse.urlencode(sub._auth())
with urllib.request.urlopen(f"{sub.base}/rest/ping.view?{q}", timeout=8) as r:
ok = ET.fromstring(r.read()).get("status") == "ok"
return {"checked": time.time(), "ok": ok, "latency_ms": int((time.time() - start) * 1000),
"error": "" if ok else "ping failed"}
except Exception as exc:
return {"checked": time.time(), "ok": False, "latency_ms": None, "error": str(exc)}
def health(self):
with self.lock:
if time.time() - self.server["checked"] > self.ttl:
self.server = self._check_server()
server = dict(self.server)
r = self.renderer
player_age = time.time() - r.player_ok if r.player_ok else None
player_ok = player_age is not None and player_age < 90 and not r.player_error
problems = []
if not player_ok:
problems.append("player unreachable")
if server["ok"] is False:
problems.append("music server unreachable")
return {
"status": "ok" if not problems else "degraded",
"problems": problems,
"version": VERSION,
"uptime": int(time.time() - STARTED),
"player": {"ok": player_ok, "last_seen_s": None if player_age is None else int(player_age),
"error": r.player_error},
"server": {"ok": server["ok"], "latency_ms": server["latency_ms"], "error": server["error"]},
"playback": {"state": r.transport_state, "owned": r.owned, "gapless": bool(r.session)},
}
def prtg(self):
h = self.health()
channels = [
{"channel": "Brücke", "value": 1, "valuelookup": "prtg.standardlookups.offon.stateonok"},
{"channel": "Player erreichbar", "value": int(h["player"]["ok"]),
"valuelookup": "prtg.standardlookups.offon.stateonok"},
{"channel": "Musikserver erreichbar", "value": int(bool(h["server"]["ok"])),
"valuelookup": "prtg.standardlookups.offon.stateonok"},
{"channel": "Musikserver Antwortzeit", "value": h["server"]["latency_ms"] or 0, "unit": "TimeResponse"},
{"channel": "Spielt", "value": int(h["playback"]["state"] == "PLAYING" and h["playback"]["owned"])},
{"channel": "Laufzeit", "value": h["uptime"], "unit": "TimeSeconds"},
]
text = f"bluos-upnp-bridge {VERSION}: " + (", ".join(h["problems"]) or "OK")
return {"prtg": {"result": channels, "text": text}}
def prometheus(self):
h = self.health()
lines = [
"# HELP bluos_bridge_up Bridge is running.", "# TYPE bluos_bridge_up gauge", "bluos_bridge_up 1",
"# HELP bluos_bridge_player_up BluOS player answered within the last 90 s.",
"# TYPE bluos_bridge_player_up gauge", f"bluos_bridge_player_up {int(h['player']['ok'])}",
"# HELP bluos_bridge_server_up Music server answers ping.", "# TYPE bluos_bridge_server_up gauge",
f"bluos_bridge_server_up {int(bool(h['server']['ok']))}",
"# HELP bluos_bridge_server_latency_seconds Music server ping latency.",
"# TYPE bluos_bridge_server_latency_seconds gauge",
f"bluos_bridge_server_latency_seconds {(h['server']['latency_ms'] or 0) / 1000:.3f}",
"# HELP bluos_bridge_playing Bridge is playing on the player.", "# TYPE bluos_bridge_playing gauge",
f"bluos_bridge_playing {int(h['playback']['state'] == 'PLAYING' and h['playback']['owned'])}",
"# HELP bluos_bridge_uptime_seconds Bridge uptime.", "# TYPE bluos_bridge_uptime_seconds counter",
f"bluos_bridge_uptime_seconds {h['uptime']}",
f'bluos_bridge_info{{version="{VERSION}"}} 1',
]
return "\n".join(lines) + "\n"
class StreamProxy:
"""Short-lived, unguessable stream URLs for the player.
@@ -1429,6 +1675,7 @@ class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
renderer = None
events = None
monitor = None
description = ""
def log_message(self, fmt, *args):
@@ -1483,6 +1730,7 @@ class Handler(BaseHTTPRequestHandler):
"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),
"openhome": bool(OH), "gapless": GAPLESS,
})
return data
@@ -1496,8 +1744,15 @@ class Handler(BaseHTTPRequestHandler):
return self._send(200, build_scpd(*SCPD_SPECS[name]))
if path == "/health":
return self._send(200, f"ok {self.renderer.transport_state}\n", "text/plain")
if path == "/api/health":
h = self.monitor.health()
return self._json(h, 200 if h["status"] == "ok" else 503)
if path == "/api/prtg":
return self._json(self.monitor.prtg())
if path == "/metrics":
return self._send(200, self.monitor.prometheus(), "text/plain; version=0.0.4; charset=utf-8")
if path in ("/", "/index.html"):
return self._html(web_ui.STATUS_HTML)
return self._html(web_ui.localize(web_ui.STATUS_HTML, UI_LANG))
if path == "/api/status":
return self._json(self._status_json())
if path == "/api/log":
@@ -1505,8 +1760,8 @@ class Handler(BaseHTTPRequestHandler):
return self._json([line for line in list(RING.lines) if line["id"] > after])
if path.startswith("/stream/"):
return self._stream(path[len("/stream/"):])
if path == "/cover":
song_id = (query.get("id") or [""])[0]
if path == "/cover" or (path.startswith("/cover/") and path.endswith(".jpg")):
song_id = (query.get("id") or [""])[0] if path == "/cover" else path[len("/cover/"):-len(".jpg")]
if not song_id or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", song_id):
return self._send(404, "", "text/plain")
try:
@@ -1519,7 +1774,7 @@ class Handler(BaseHTTPRequestHandler):
if self._authorized():
notice = ""
if "saved" in query:
notice = f'<div class="notice ok">{html.escape(query.get("msg", ["Gespeichert."])[0])}</div>'
notice = f'<div class="notice ok">{html.escape(query.get("msg", [L("Gespeichert.", "Saved.")])[0])}</div>'
self._html(render_settings(notice))
return
self._send(404, "not found", "text/plain")
@@ -1585,6 +1840,9 @@ class Handler(BaseHTTPRequestHandler):
# BluOS reconnects to finished / drained "radio" streams - don't replay the session from the start
log.info("gapless stream %r already finished, refusing reconnect", label)
return self._send(410, "", "text/plain")
if session.served:
# reconnect (e.g. resume after a long pause): continue where playback stopped
self.renderer.rebase_session(session)
session.served += 1
# content type from getSong: a probe request would show up as a second playback on the server
ctype = session.segments[0].info.get("content_type") or "audio/mpeg"
@@ -1614,26 +1872,28 @@ class Handler(BaseHTTPRequestHandler):
if path == "/settings":
form = urllib.parse.parse_qs(raw, keep_blank_values=True)
if not hmac.compare_digest((form.get("csrf") or [""])[0], CSRF_TOKEN):
return self._html(render_settings('<div class="notice err">Sitzung abgelaufen, bitte erneut speichern.</div>'), 403)
return self._html(render_settings(
f'<div class="notice err">{web_ui.texts(UI_LANG)["session_expired"]}</div>'), 403)
try:
ok, msg = save_settings(form, self.renderer)
except Exception as exc:
log.exception("saving settings failed")
ok, msg = False, f"Speichern fehlgeschlagen: {exc}"
ok, msg = False, L("Speichern fehlgeschlagen: ", "Saving failed: ") + str(exc)
if ok:
loc = "/settings?saved=1&msg=" + urllib.parse.quote(msg)
return self._send(303, "", "text/plain", {"Location": loc})
return self._html(render_settings(f'<div class="notice err">{html.escape(msg)}</div>'), 400)
# /api/action
if not hmac.compare_digest(self.headers.get("X-CSRF-Token", ""), CSRF_TOKEN):
return self._json({"ok": False, "message": "Sitzung abgelaufen, bitte Seite neu laden."}, 403)
return self._json({"ok": False, "message": L("Sitzung abgelaufen, bitte Seite neu laden.",
"Session expired, please reload the page.")}, 403)
try:
action = json.loads(raw or "{}").get("action", "")
ok, msg = run_action(action, self.renderer)
except UPnPError as exc:
ok, msg = False, exc.desc
except Exception as exc:
ok, msg = False, f"Fehler: {exc}"
ok, msg = False, L("Fehler: ", "Error: ") + str(exc)
self._json({"ok": ok, "message": msg})
def do_POST(self):
@@ -1783,10 +2043,19 @@ def main():
events = EventManager(renderer)
renderer.events = events
global OH
if OPENHOME:
SERVICES.update(openhome.SERVICES)
SCPD_SPECS.update(openhome.SPECS)
OH = openhome.OpenHome(renderer, name, player_name or name, SINK_PROTOCOLS, VERSION)
OH.notify = events._notify
renderer.on_change = OH.on_renderer_change
Handler.renderer = renderer
Handler.events = events
Handler.description = build_description(name)
Handler.name = name
Handler.monitor = Monitor(renderer)
httpd = ThreadingHTTPServer(("0.0.0.0", HTTP_PORT), Handler)
httpd.daemon_threads = True
+3 -2
View File
@@ -1,7 +1,8 @@
services:
bluos-upnp-bridge:
build: .
image: bluos-upnp-bridge:local
image: git.techniverse.net/scriptos/bluos-upnp-bridge:latest
# to build it yourself instead: comment out "image" above and use
# build: .
container_name: bluos-upnp-bridge
restart: unless-stopped
# SSDP discovery needs multicast on the LAN interface -> host network is required
+116 -15
View File
@@ -14,6 +14,8 @@ Tracks that can't be stitched (different codec or FLAC parameters) end the strea
falls back to a normal track change.
"""
import logging
import shutil
import subprocess
import threading
import time
import urllib.request
@@ -99,10 +101,57 @@ def flac_params(header):
return bytes(si[10:13]) + bytes([si[13] & 0xF0])
def flac_format(params):
"""(sample rate, channels, bits per sample) from flac_params()."""
b = params
rate = (b[0] << 12) | (b[1] << 4) | (b[2] >> 4)
channels = ((b[2] >> 1) & 0x07) + 1
bits = (((b[2] & 0x01) << 4) | (b[3] >> 4)) + 1
return rate, channels, bits
def mp3_rate(buf, pos):
version = (buf[pos + 1] >> 3) & 0x03
return _MP3_RATES[version][(buf[pos + 2] >> 2) & 0x03]
FFMPEG = shutil.which("ffmpeg")
class Transcoder:
"""ffmpeg converting a follow-up track into the session's codec (file-like: read/close)."""
def __init__(self, url, kind, rate, channels=2, bits=16):
cmd = [FFMPEG, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", url, "-map", "0:a:0",
"-ar", str(rate), "-ac", str(channels)]
if kind == "flac":
fmt = {16: "s16", 24: "s32", 32: "s32"}.get(bits, "s16")
cmd += ["-c:a", "flac", "-sample_fmt", fmt]
if bits == 24:
cmd += ["-bits_per_raw_sample", "24"]
cmd += ["-f", "flac", "-"]
else:
cmd += ["-c:a", "libmp3lame", "-b:a", "320k", "-write_xing", "0", "-id3v2_version", "0",
"-f", "mp3", "-"]
self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
def read(self, n=CHUNK):
return self.proc.stdout.read(n)
def close(self):
if self.proc.poll() is None:
self.proc.kill()
self.proc.stdout.close()
self.proc.wait()
def flac_open_ended(header):
"""STREAMINFO with total samples = 0 (unknown) and MD5 = 0, so decoders don't stop early."""
h = bytearray(header)
si = 8 # 'fLaC' + block header
# block/frame sizes "any": follow-up tracks (or ffmpeg conversions) may use other block sizes
h[si:si + 4] = (16).to_bytes(2, "big") + (65535).to_bytes(2, "big")
h[si + 4:si + 10] = b"\x00" * 6
h[si + 13] &= 0xF0
h[si + 14:si + 18] = b"\x00\x00\x00\x00"
h[si + 18:si + 34] = b"\x00" * 16
@@ -125,8 +174,9 @@ def flac_sync(buf, start=0):
# ---------------------------------------------------------------------------- session
class Segment:
def __init__(self, upstream, uri, meta, info, label):
def __init__(self, upstream, uri, meta, info, label, cover=""):
self.upstream = upstream
self.cover = cover
self.uri = uri
self.meta = meta
self.info = info
@@ -149,6 +199,8 @@ class Session:
self.closed = False
self.ended = False # proxy finished writing (no more segments)
self.served = 0 # number of player connections served
self.generation = 0 # bumped by rebase(); writers of older generations stop
self.mp3_rate = 44100 # sample rate of the first MP3 track (target for conversions)
self.playback_secs = 0 # updated by the renderer from BluOS' counter
def starts(self):
@@ -167,14 +219,14 @@ class Session:
sent = [s for i, s in enumerate(self.segments) if s.streamed or i == 0]
return sum(s.duration for s in sent) - self.start_secs
def hold_until_played(self, margin=1):
def hold_until_played(self, generation, margin=1):
"""Keep the connection open until playback reaches the end of the sent audio.
BluOS treats an EOF like a dropped radio stream and reconnects at once, so ending the
stream early would make it replay the session from the start.
"""
with self.cond:
while not self.closed and self.playback_secs < self.end_of_streamed() - margin:
while not self.closed and self.generation == generation and self.playback_secs < self.end_of_streamed() - margin:
self.cond.wait(1)
def _queued(self):
@@ -230,6 +282,22 @@ class Session:
self.cond.wait(1)
return None
def rebase(self, index, position):
"""Continue the session from segment `index` at `position` seconds (player reconnected).
BluOS drops paused "radio" streams after about two minutes and reconnects on resume with
its counter reset to 0 - without this it would hear the session from the very start.
"""
with self.cond:
self.segments = self.segments[index:]
for seg in self.segments:
seg.streamed = False
self.start_secs = max(0, int(position))
self.playback_secs = 0
self.ended = False
self.generation += 1
self.cond.notify_all()
def close(self):
with self.cond:
self.closed = True
@@ -244,6 +312,7 @@ class IcyWriter:
self.metaint = metaint
self.count = 0
self.title = ""
self.cover = "" # sent as StreamUrl - some players show it as artwork
self.sent_title = None
def write(self, data):
@@ -257,11 +326,14 @@ class IcyWriter:
self.count += len(part)
if self.count == self.metaint:
self.count = 0
if self.title != self.sent_title:
text = f"StreamTitle='{self.title.replace(chr(39), '`')}';".encode("utf-8")[:4000]
if (self.title, self.cover) != self.sent_title:
text = f"StreamTitle='{self.title.replace(chr(39), '`')}';"
if self.cover:
text += f"StreamUrl='{self.cover}';"
text = text.encode("utf-8")[:4000]
blocks = (len(text) + 15) // 16
self.out.write(bytes([blocks]) + text.ljust(blocks * 16, b"\x00"))
self.sent_title = self.title
self.sent_title = (self.title, self.cover)
else:
self.out.write(b"\x00")
@@ -292,9 +364,10 @@ def stream_session(session, writer, kind_hint=None):
params = None
sent = 0
i = 0
gen = session.generation
while True:
seg = session.wait_segment(i)
if seg is None:
if seg is None or session.generation != gen:
break
start_secs = session.start_secs if i == 0 else 0
try:
@@ -304,12 +377,29 @@ def stream_session(session, writer, kind_hint=None):
break
ctype = resp.headers.get("Content-Type", "")
seg_kind = "flac" if "flac" in ctype else "mp3" if ("mpeg" in ctype or "mp3" in ctype) else None
if seg_kind is None or (kind and seg_kind != kind):
log.info("segment %r (%s) can't be stitched to %s, ending gapless stream", seg.label, ctype, kind)
resp.close()
break
total = _total_size(resp)
buf = _read_at_least(resp, b"", 4096)
convert = False
if i > 0 and seg_kind == "flac" and kind == "flac":
while flac_header_len(buf) == -1:
more = resp.read(CHUNK)
if not more:
break
buf += more
hl = flac_header_len(buf)
convert = hl > 0 and flac_params(buf[:hl]) != params # different sample rate / bit depth
if i > 0 and (seg_kind != kind or convert):
if not FFMPEG:
log.info("segment %r (%s) can't be stitched to %s (no ffmpeg), ending gapless stream",
seg.label, ctype, kind)
resp.close()
break
resp.close()
rate, channels, bits = flac_format(params) if kind == "flac" else (session.mp3_rate, 2, 16)
log.info("gapless: converting %r (%s) to %s %s Hz", seg.label, ctype or "?", kind, rate)
resp = Transcoder(seg.upstream, kind, rate, channels, bits)
seg_kind, total = kind, 0
buf = _read_at_least(resp, b"", 4096)
if seg_kind == "mp3":
head = id3v2_size(buf)
@@ -319,6 +409,8 @@ def stream_session(session, writer, kind_hint=None):
log.warning("segment %r: no MP3 frames found", seg.label)
resp.close()
break
if i == 0:
session.mp3_rate = mp3_rate(buf, audio)
if i > 0 or start_secs:
audio = mp3_skip_info_frame(buf, audio)
if start_secs and seg.duration and total:
@@ -364,23 +456,31 @@ def stream_session(session, writer, kind_hint=None):
kind = kind or seg_kind
with session.cond:
if session.generation != gen:
resp.close() # player reconnected, a newer writer takes over
return sent
if session.closed or i >= len(session.segments) or session.segments[i] is not seg:
resp.close() # queue changed while the upstream was opening
continue
seg.streamed = True
writer.title = seg.label
writer.cover = seg.cover
log.info("gapless: streaming segment %d %r (%s)", i, seg.label, seg_kind)
# hold back the last 128 bytes so a trailing ID3v1 tag can be dropped
tail = b""
data = buf
while True:
if session.closed:
if session.closed or session.generation != gen:
resp.close()
return sent
data = tail + data
tail = data[-128:]
if len(data) > 128:
writer.write(data[:-128])
try:
writer.write(data[:-128])
except Exception:
resp.close() # player went away - don't leave the server stream hanging
raise
more = resp.read(CHUNK)
if not more:
break
@@ -391,6 +491,7 @@ def stream_session(session, writer, kind_hint=None):
sent += 1
i += 1
if sent:
session.hold_until_played()
session.ended = True
session.hold_until_played(gen)
if session.generation == gen:
session.ended = True
return sent
+473
View File
@@ -0,0 +1,473 @@
"""OpenHome renderer services for bluos-upnp-bridge.
With OpenHome the control point (e.g. Symfonium in OpenHome mode) hands the *whole* play queue to
the bridge (Playlist service). The bridge then plays it on its own - the phone may sleep or leave -
and keeps the BluOS player fed with the current and the following track (gapless mode included).
Services: Product, Playlist, Time, Info, Volume (av-openhome-org, version 1).
The playback itself is done by the Renderer; this module only manages the playlist and maps it.
"""
import base64
import html
import logging
import xml.etree.ElementTree as ET
log = logging.getLogger("openhome")
SERVICES = {
"Product": "urn:av-openhome-org:service:Product:1",
"Playlist": "urn:av-openhome-org:service:Playlist:1",
"Time": "urn:av-openhome-org:service:Time:1",
"Info": "urn:av-openhome-org:service:Info:1",
"Volume": "urn:av-openhome-org:service:Volume:1",
}
SERVICE_ID_PREFIX = "urn:av-openhome-org:serviceId:"
TRACKS_MAX = 1000
def _a(name, direction, var):
return name, direction, var
def _vars(evented, args):
"""State variable table: evented vars + A_ARG helper vars (not evented)."""
table = {name: (dtype, "yes", allowed) for name, dtype, allowed in evented}
table.update({name: (dtype, "no", None) for name, dtype in args})
return table
ARG_VARS = [("A_ARG_String", "string"), ("A_ARG_Bool", "boolean"), ("A_ARG_Ui4", "ui4"), ("A_ARG_I4", "i4"),
("A_ARG_Base64", "bin.base64")]
S, B, U, I = "A_ARG_String", "A_ARG_Bool", "A_ARG_Ui4", "A_ARG_I4"
SPECS = {
"Product": ({
"Manufacturer": [_a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S), _a("ImageUri", "out", S)],
"Model": [_a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S), _a("ImageUri", "out", S)],
"Product": [_a("Room", "out", S), _a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S),
_a("ImageUri", "out", S)],
"Standby": [_a("Value", "out", B)],
"SetStandby": [_a("Value", "in", B)],
"SourceCount": [_a("Value", "out", U)],
"SourceXml": [_a("Value", "out", S)],
"SourceIndex": [_a("Value", "out", U)],
"SetSourceIndex": [_a("Value", "in", U)],
"SetSourceIndexByName": [_a("Value", "in", S)],
"Source": [_a("Index", "in", U), _a("SystemName", "out", S), _a("Type", "out", S), _a("Name", "out", S),
_a("Visible", "out", B)],
"Attributes": [_a("Value", "out", S)],
"SourceXmlChangeCount": [_a("Value", "out", U)],
}, _vars([(n, "string", None) for n in (
"ManufacturerName", "ManufacturerInfo", "ManufacturerUrl", "ManufacturerImageUri", "ModelName", "ModelInfo",
"ModelUrl", "ModelImageUri", "ProductRoom", "ProductName", "ProductInfo", "ProductUrl", "ProductImageUri",
"SourceXml", "Attributes")] + [("Standby", "boolean", None), ("SourceCount", "ui4", None),
("SourceIndex", "ui4", None)], ARG_VARS)),
"Playlist": ({
"Play": [], "Pause": [], "Stop": [], "Next": [], "Previous": [],
"SetRepeat": [_a("Value", "in", B)], "Repeat": [_a("Value", "out", B)],
"SetShuffle": [_a("Value", "in", B)], "Shuffle": [_a("Value", "out", B)],
"SeekSecondAbsolute": [_a("Value", "in", U)], "SeekSecondRelative": [_a("Value", "in", I)],
"SeekId": [_a("Value", "in", U)], "SeekIndex": [_a("Value", "in", U)],
"TransportState": [_a("Value", "out", S)], "Id": [_a("Value", "out", U)],
"Read": [_a("Id", "in", U), _a("Uri", "out", S), _a("Metadata", "out", S)],
"ReadList": [_a("IdList", "in", S), _a("TrackList", "out", S)],
"Insert": [_a("AfterId", "in", U), _a("Uri", "in", S), _a("Metadata", "in", S), _a("NewId", "out", U)],
"DeleteId": [_a("Value", "in", U)], "DeleteAll": [],
"TracksMax": [_a("Value", "out", U)],
"IdArray": [_a("Token", "out", U), _a("Array", "out", "A_ARG_Base64")],
"IdArrayChanged": [_a("Token", "in", U), _a("Value", "out", B)],
"ProtocolInfo": [_a("Value", "out", S)],
}, _vars([("TransportState", "string", ["Playing", "Paused", "Stopped", "Buffering"]),
("Repeat", "boolean", None), ("Shuffle", "boolean", None), ("Id", "ui4", None),
("IdArray", "bin.base64", None), ("TracksMax", "ui4", None), ("ProtocolInfo", "string", None)],
ARG_VARS)),
"Time": ({
"Time": [_a("TrackCount", "out", U), _a("Duration", "out", U), _a("Seconds", "out", U)],
}, _vars([("TrackCount", "ui4", None), ("Duration", "ui4", None), ("Seconds", "ui4", None)], ARG_VARS)),
"Info": ({
"Counters": [_a("TrackCount", "out", U), _a("DetailsCount", "out", U), _a("MetatextCount", "out", U)],
"Track": [_a("Uri", "out", S), _a("Metadata", "out", S)],
"Details": [_a("Duration", "out", U), _a("BitRate", "out", U), _a("BitDepth", "out", U),
_a("SampleRate", "out", U), _a("Lossless", "out", B), _a("CodecName", "out", S)],
"Metatext": [_a("Value", "out", S)],
}, _vars([("TrackCount", "ui4", None), ("DetailsCount", "ui4", None), ("MetatextCount", "ui4", None),
("Uri", "string", None), ("Metadata", "string", None), ("Duration", "ui4", None),
("BitRate", "ui4", None), ("BitDepth", "ui4", None), ("SampleRate", "ui4", None),
("Lossless", "boolean", None), ("CodecName", "string", None), ("Metatext", "string", None)],
ARG_VARS)),
"Volume": ({
"Characteristics": [_a("VolumeMax", "out", U), _a("VolumeUnity", "out", U), _a("VolumeSteps", "out", U),
_a("VolumeMilliDbPerStep", "out", U), _a("BalanceMax", "out", U),
_a("FadeMax", "out", U)],
"SetVolume": [_a("Value", "in", U)], "VolumeInc": [], "VolumeDec": [], "Volume": [_a("Value", "out", U)],
"SetBalance": [_a("Value", "in", I)], "BalanceInc": [], "BalanceDec": [], "Balance": [_a("Value", "out", I)],
"SetFade": [_a("Value", "in", I)], "FadeInc": [], "FadeDec": [], "Fade": [_a("Value", "out", I)],
"SetMute": [_a("Value", "in", B)], "Mute": [_a("Value", "out", B)],
"VolumeLimit": [_a("Value", "out", U)],
}, _vars([("Volume", "ui4", None), ("Mute", "boolean", None), ("Balance", "i4", None), ("Fade", "i4", None),
("VolumeLimit", "ui4", None), ("VolumeMax", "ui4", None), ("VolumeUnity", "ui4", None),
("VolumeSteps", "ui4", None), ("VolumeMilliDbPerStep", "ui4", None), ("BalanceMax", "ui4", None),
("FadeMax", "ui4", None)], ARG_VARS)),
}
TRANSPORT = {"PLAYING": "Playing", "PAUSED_PLAYBACK": "Paused", "TRANSITIONING": "Buffering"}
def _bool(value):
return "1" if value else "0"
def _parse_bool(value):
return str(value).strip().lower() in ("1", "true", "yes")
class OHError(Exception):
def __init__(self, code, desc):
super().__init__(desc)
self.code, self.desc = code, desc
class OpenHome:
"""Playlist state + action handlers. Uses the renderer's lock for everything."""
def __init__(self, renderer, product_name, room, sink_protocols, version):
self.r = renderer
self.lock = renderer.lock
self.product_name = product_name
self.room = room
self.sink_protocols = sink_protocols
self.version = version
self.tracks = [] # [(id, uri, metadata)]
self.next_id = 1
self.current_id = 0
self.repeat = False
self.shuffle = False
self.token = 0
self.track_count = 0
self.active = False # OpenHome is driving the renderer
self.notify = None # callback(service) set by the bridge
self._last = {}
self._busy = False
# ---- helpers
def _index(self, track_id):
for i, (tid, _, _) in enumerate(self.tracks):
if tid == track_id:
return i
return -1
def _following(self, track_id):
i = self._index(track_id)
if i < 0:
return None
if i + 1 < len(self.tracks):
return self.tracks[i + 1]
return self.tracks[0] if self.repeat and self.tracks else None
def _preceding(self, track_id):
i = self._index(track_id)
if i > 0:
return self.tracks[i - 1]
return self.tracks[-1] if self.repeat and self.tracks and i == 0 else None
def id_array(self):
return base64.b64encode(b"".join(t[0].to_bytes(4, "big") for t in self.tracks)).decode()
def _changed_array(self):
self.token += 1
self._sync_next()
self._emit("Playlist")
def _emit(self, service):
if self.notify:
self.notify(service)
def _sync_next(self):
"""Make the renderer's "next" track match the playlist."""
if not self.active or not self.current_id:
return
nxt = self._following(self.current_id)
uri, meta = (nxt[1], nxt[2]) if nxt else ("", "")
if (uri, meta) != (self.r.next_uri, self.r.next_meta):
self.r.set_next_uri(uri, meta)
def _play_id(self, track_id, start=True):
i = self._index(track_id)
if i < 0:
raise OHError(800, "Id not found")
tid, uri, meta = self.tracks[i]
self.active = True
self.current_id = tid
self.track_count += 1
log.info("OpenHome: play track %d of %d", i + 1, len(self.tracks))
# set the follow-up first, so a gapless session starts with the right queue
nxt = self._following(tid)
self.r.next_uri, self.r.next_meta = (nxt[1], nxt[2]) if nxt else ("", "")
self.r.set_uri(uri, meta)
self._sync_next()
if start and self.r.transport_state != "PLAYING":
self.r.play()
self._emit("Playlist")
self._emit("Info")
# ---- called by the renderer whenever its state changes
def on_renderer_change(self):
with self.lock:
if not self.active or self._busy:
return
self._busy = True
try:
self._on_change()
finally:
self._busy = False
def _on_change(self):
if True:
if self.r.current_uri and self.current_id:
cur = self.tracks[self._index(self.current_id)] if self._index(self.current_id) >= 0 else None
if not cur or cur[1] != self.r.current_uri:
# the renderer moved on (track ended, gapless crossing): find the new current track
nxt = self._following(self.current_id)
match = nxt if nxt and nxt[1] == self.r.current_uri else next(
(t for t in self.tracks if t[1] == self.r.current_uri), None)
if match:
self.current_id = match[0]
self.track_count += 1
self._sync_next()
self._emit("Info")
state = (self.current_id, self.transport_state(), self.r.position, self.r.volume, self.r.mute)
if state != self._last.get("state"):
prev = self._last.get("state") or (None,) * 5
self._last["state"] = state
if state[:2] != prev[:2]:
self._emit("Playlist")
if state[2] != prev[2] or state[0] != prev[0]:
self._emit("Time")
if state[3:] != prev[3:]:
self._emit("Volume")
def transport_state(self):
if not self.active or not self.r.owned:
return "Stopped"
return TRANSPORT.get(self.r.transport_state, "Stopped")
# ---- evented state per service
def state(self, service):
r = self.r
cur = self.tracks[self._index(self.current_id)] if self._index(self.current_id) >= 0 else (0, "", "")
if service == "Product":
return {
"ManufacturerName": "bluos-upnp-bridge", "ManufacturerInfo": "",
"ManufacturerUrl": "https://git.techniverse.net/scriptos/bluos-upnp-bridge",
"ManufacturerImageUri": "", "ModelName": "BluOS UPnP Bridge", "ModelInfo": self.version,
"ModelUrl": "", "ModelImageUri": "", "ProductRoom": self.room, "ProductName": self.product_name,
"ProductInfo": "", "ProductUrl": "", "ProductImageUri": "", "Standby": "0", "SourceCount": "1",
"SourceXml": self.source_xml(), "SourceIndex": "0", "Attributes": "Info Time Volume",
}
if service == "Playlist":
return {"TransportState": self.transport_state(), "Repeat": _bool(self.repeat),
"Shuffle": _bool(self.shuffle), "Id": str(self.current_id), "IdArray": self.id_array(),
"TracksMax": str(TRACKS_MAX), "ProtocolInfo": self.sink_protocols}
if service == "Time":
return {"TrackCount": str(self.track_count), "Duration": str(int(r.duration() or 0)),
"Seconds": str(int(r.position or 0))}
if service == "Info":
ctype = (r.current_info or {}).get("content_type", "")
return {"TrackCount": str(self.track_count), "DetailsCount": str(self.track_count),
"MetatextCount": "0", "Uri": cur[1], "Metadata": cur[2],
"Duration": str(int(r.duration() or 0)), "BitRate": "0", "BitDepth": "0", "SampleRate": "0",
"Lossless": _bool("flac" in ctype), "CodecName": ctype.split("/")[-1] if ctype else "",
"Metatext": ""}
if service == "Volume":
return {"Volume": str(r.volume), "Mute": _bool(r.mute), "Balance": "0", "Fade": "0",
"VolumeLimit": "100", "VolumeMax": "100", "VolumeUnity": "100", "VolumeSteps": "100",
"VolumeMilliDbPerStep": "1000", "BalanceMax": "0", "FadeMax": "0"}
return {}
def source_xml(self):
return ("<SourceList><Source><Name>Playlist</Name><Type>Playlist</Type><Visible>true</Visible>"
"</Source></SourceList>")
# ---- actions
def handle(self, service, action, args):
with self.lock:
return getattr(self, f"_{service.lower()}")(action, args)
def _product(self, action, args):
st = self.state("Product")
simple = {
"Manufacturer": {"Name": st["ManufacturerName"], "Info": "", "Url": st["ManufacturerUrl"], "ImageUri": ""},
"Model": {"Name": st["ModelName"], "Info": st["ModelInfo"], "Url": "", "ImageUri": ""},
"Product": {"Room": self.room, "Name": self.product_name, "Info": "", "Url": "", "ImageUri": ""},
"Standby": {"Value": "0"}, "SetStandby": {}, "SourceCount": {"Value": "1"},
"SourceXml": {"Value": st["SourceXml"]}, "SourceIndex": {"Value": "0"}, "SetSourceIndex": {},
"SetSourceIndexByName": {}, "Attributes": {"Value": st["Attributes"]},
"SourceXmlChangeCount": {"Value": "0"},
}
if action == "Source":
if args.get("Index", "0") != "0":
raise OHError(800, "Index out of range")
return {"SystemName": "Playlist", "Type": "Playlist", "Name": "Playlist", "Visible": "1"}
if action in simple:
return simple[action]
raise OHError(401, "Invalid Action")
def _playlist(self, action, args):
r = self.r
if action == "Play":
if not self.tracks:
return {}
if self.active and self.current_id and r.owned and r.transport_state == "PAUSED_PLAYBACK":
r.play()
elif not (self.active and r.transport_state == "PLAYING" and r.owned):
self._play_id(self.current_id if self._index(self.current_id) >= 0 else self.tracks[0][0])
return {}
if action == "Pause":
r.pause()
return {}
if action == "Stop":
r.stop()
return {}
if action in ("Next", "Previous"):
target = (self._following if action == "Next" else self._preceding)(self.current_id)
if target:
self._play_id(target[0])
elif action == "Previous" and self.current_id:
self._play_id(self.current_id)
return {}
if action == "SetRepeat":
self.repeat = _parse_bool(args.get("Value"))
self._sync_next()
self._emit("Playlist")
return {}
if action == "SetShuffle":
# the control point shuffles the queue itself; the flag is only reported back
self.shuffle = _parse_bool(args.get("Value"))
self._emit("Playlist")
return {}
if action == "Repeat":
return {"Value": _bool(self.repeat)}
if action == "Shuffle":
return {"Value": _bool(self.shuffle)}
if action == "SeekSecondAbsolute":
r.seek("REL_TIME", _hms(int(args.get("Value", "0"))))
return {}
if action == "SeekSecondRelative":
r.seek("REL_TIME", _hms(max(0, int(r.position) + int(args.get("Value", "0")))))
return {}
if action == "SeekId":
self._play_id(int(args.get("Value", "0")))
return {}
if action == "SeekIndex":
idx = int(args.get("Value", "0"))
if not 0 <= idx < len(self.tracks):
raise OHError(800, "Index out of range")
self._play_id(self.tracks[idx][0])
return {}
if action == "TransportState":
return {"Value": self.transport_state()}
if action == "Id":
return {"Value": str(self.current_id)}
if action == "Read":
i = self._index(int(args.get("Id", "0")))
if i < 0:
raise OHError(800, "Id not found")
return {"Uri": self.tracks[i][1], "Metadata": self.tracks[i][2]}
if action == "ReadList":
entries = []
for part in (args.get("IdList") or "").split():
i = self._index(int(part)) if part.isdigit() else -1
if i >= 0:
tid, uri, meta = self.tracks[i]
entries.append(f"<Entry><Id>{tid}</Id><Uri>{html.escape(uri)}</Uri>"
f"<Metadata>{html.escape(meta)}</Metadata></Entry>")
return {"TrackList": "<TrackList>" + "".join(entries) + "</TrackList>"}
if action == "Insert":
if len(self.tracks) >= TRACKS_MAX:
raise OHError(801, "Playlist full")
after = int(args.get("AfterId", "0"))
pos = 0 if after == 0 else self._index(after) + 1
if after and pos == 0:
raise OHError(800, "Id not found")
new_id = self.next_id
self.next_id += 1
self.tracks.insert(pos, (new_id, args.get("Uri", ""), args.get("Metadata", "")))
self._changed_array()
return {"NewId": str(new_id)}
if action == "DeleteId":
tid = int(args.get("Value", "0"))
i = self._index(tid)
if i < 0:
return {}
playing_this = tid == self.current_id
nxt = self._following(tid) if playing_this else None
del self.tracks[i]
if playing_this:
if nxt and nxt[0] != tid and r.transport_state == "PLAYING":
self._play_id(nxt[0])
else:
self.current_id = nxt[0] if nxt and nxt[0] != tid else 0
if not self.current_id:
r.stop()
self._changed_array()
return {}
if action == "DeleteAll":
self.tracks.clear()
self.current_id = 0
if self.active:
r.stop()
self.active = False
self._changed_array()
return {}
if action == "TracksMax":
return {"Value": str(TRACKS_MAX)}
if action == "IdArray":
return {"Token": str(self.token), "Array": self.id_array()}
if action == "IdArrayChanged":
return {"Value": _bool(str(self.token) != args.get("Token", ""))}
if action == "ProtocolInfo":
return {"Value": self.sink_protocols}
raise OHError(401, "Invalid Action")
def _time(self, action, args):
if action == "Time":
return self.state("Time")
raise OHError(401, "Invalid Action")
def _info(self, action, args):
st = self.state("Info")
if action == "Counters":
return {k: st[k] for k in ("TrackCount", "DetailsCount", "MetatextCount")}
if action == "Track":
return {"Uri": st["Uri"], "Metadata": st["Metadata"]}
if action == "Details":
return {k: st[k] for k in ("Duration", "BitRate", "BitDepth", "SampleRate", "Lossless", "CodecName")}
if action == "Metatext":
return {"Value": ""}
raise OHError(401, "Invalid Action")
def _volume(self, action, args):
r = self.r
st = self.state("Volume")
if action == "Characteristics":
return {k: st[k] for k in ("VolumeMax", "VolumeUnity", "VolumeSteps", "VolumeMilliDbPerStep",
"BalanceMax", "FadeMax")}
if action == "SetVolume":
r.set_volume(max(0, min(100, int(args.get("Value", "0")))))
elif action in ("VolumeInc", "VolumeDec"):
r.set_volume(max(0, min(100, r.volume + (2 if action == "VolumeInc" else -2))))
elif action == "SetMute":
r.set_mute(_parse_bool(args.get("Value")))
elif action in ("Volume", "Mute", "Balance", "Fade", "VolumeLimit"):
return {"Value": st[action]}
elif action in ("SetBalance", "BalanceInc", "BalanceDec", "SetFade", "FadeInc", "FadeDec"):
pass # BluOS has no balance/fade per player
else:
raise OHError(401, "Invalid Action")
self._emit("Volume")
return {}
def _hms(secs):
secs = max(0, int(secs))
return f"{secs // 3600}:{secs % 3600 // 60:02d}:{secs % 60:02d}"
+140 -38
View File
@@ -1,4 +1,5 @@
"""HTML templates for the bluos-upnp-bridge web UI (German UI texts)."""
"""HTML templates and texts for the bluos-upnp-bridge web UI (English and German)."""
import json
BASE_CSS = """
:root{--bg:#f4f5f7;--card:#fff;--text:#1d2129;--muted:#6b7280;--line:#e5e7eb;--accent:#2563eb;
@@ -23,8 +24,92 @@ border-radius:8px;padding:7px 14px;cursor:pointer}button.primary{background:var(
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>
TEXT = {
"en": {
"locale": "en-GB", "bridge": "BluOS Bridge", "settings": "Settings", "to_status": "Back to status page",
"now_playing": "Now playing", "nothing": "Nothing selected", "unknown_title": "Unknown title",
"player": "Bluesound player", "events": "Events", "up_next": "Up next",
"st_playing": "Playing", "st_paused": "Paused", "st_stopped": "Stopped", "st_ready": "Ready",
"st_loading": "Loading …", "elsewhere": " (player busy with another source)", "unreachable": "Bridge unreachable",
"never": "never", "just_now": "just now", "ago_s": "{n} s ago", "ago_min": "{n} min ago",
"rep_queue": "Queue", "rep_one": "One track", "rep_off": "Off",
"name": "Name", "address": "Address", "connection": "Connection", "error": "Error", "state": "State",
"source": "Source", "display": "Display", "format": "Format", "volume": "Volume", "muted": " (muted)",
"repeat": "Repeat", "version": "Version", "uptime": "Uptime", "announced": "Announced as",
"music_server": "Music server", "active_as": "active as", "off": "off", "repeat_off": "Repeat off",
"yes": "yes", "no": "no", "controllers": "Controlling devices", "subscriptions": "Subscriptions (events)",
"openhome": "OpenHome", "gapless": "Gapless",
"sec_bridge": "Bridge & music server", "save": "Save",
"saved_in": "Settings are stored in", "restart_hint1": "Fields marked",
"restart_hint2": "take effect after restarting the bridge.", "restart": "Restart", "restart_pending":
"Restart pending", "tools": "Tools", "test_player": "Test player connection",
"test_server": "Test music server login", "pause": "Pause", "resume": "Resume",
"restart_bridge": "Restart bridge", "confirm_restart":
"Really restart the bridge? Playback continues, but your app has to reconnect.",
"not_writable": "Warning: {dir} is not writable, settings can't be saved.",
"session_expired": "Session expired, please save again.",
},
"de": {
"locale": "de-DE", "bridge": "BluOS-Brücke", "settings": "Einstellungen", "to_status": "Zur Statusseite",
"now_playing": "Läuft gerade", "nothing": "Nichts ausgewählt", "unknown_title": "Unbekannter Titel",
"player": "Bluesound-Player", "events": "Ereignisse", "up_next": "Als Nächstes",
"st_playing": "Spielt", "st_paused": "Pausiert", "st_stopped": "Gestoppt", "st_ready": "Bereit",
"st_loading": "Lädt …", "elsewhere": " (Player woanders aktiv)", "unreachable": "Brücke nicht erreichbar",
"never": "nie", "just_now": "gerade eben", "ago_s": "vor {n} s", "ago_min": "vor {n} min",
"rep_queue": "Warteschlange", "rep_one": "Ein Titel", "rep_off": "Aus",
"name": "Name", "address": "Adresse", "connection": "Verbindung", "error": "Fehler", "state": "Status",
"source": "Quelle", "display": "Anzeige", "format": "Format", "volume": "Lautstärke", "muted": " (stumm)",
"repeat": "Wiederholen", "version": "Version", "uptime": "Läuft seit", "announced": "Angekündigt als",
"music_server": "Musikserver", "active_as": "aktiv als", "off": "aus", "repeat_off": "Wiederholung aus",
"yes": "ja", "no": "nein", "controllers": "Steuernde Geräte", "subscriptions": "Abos (Events)",
"openhome": "OpenHome", "gapless": "Lückenlos",
"sec_bridge": "Brücke & Musikserver", "save": "Speichern",
"saved_in": "Gespeichert wird in", "restart_hint1": "Felder mit",
"restart_hint2": "werden erst nach einem Neustart der Brücke aktiv.", "restart": "Neustart",
"restart_pending": "Neustart ausstehend", "tools": "Werkzeuge", "test_player": "Player-Verbindung testen",
"test_server": "Musikserver-Anmeldung testen", "pause": "Pause", "resume": "Fortsetzen",
"restart_bridge": "Brücke neu starten", "confirm_restart":
"Brücke wirklich neu starten? Die laufende Wiedergabe bleibt, die App muss sich aber neu verbinden.",
"not_writable": "Achtung: {dir} ist nicht beschreibbar, Einstellungen können nicht gespeichert werden.",
"session_expired": "Sitzung abgelaufen, bitte erneut speichern.",
},
}
# English labels/help for the settings page (German ones live in bridge.SETTING_FIELDS)
FIELDS_EN = {
"UI_LANG": ("Language of the web UI", "en = English, de = Deutsch."),
"FRIENDLY_NAME": ("Name on the network", "How the bridge shows up in your app. Empty = player name + "
"“(BluOS Bridge)”."),
"BLUOS_HOST": ("BluOS player (IP or hostname)", ""),
"BLUOS_PORT": ("BluOS API port", "Default: 11000"),
"ADVERTISE_IP": ("IP address of the bridge", "The bridge announces itself on this address via SSDP."),
"SSDP_INTERVAL": ("SSDP announcement every … seconds", ""),
"SERVER_TYPE": ("Server type", "subsonic = classic Subsonic (token auth). opensubsonic = Airsonic Pulse, "
"Navidrome & co. (API key, without key password auth – then “serve "
"streams through the bridge” must be on)."),
"SUBSONIC_URL": ("Music server (as seen from the bridge)", "e.g. http://192.168.1.20:4040 or "
"https://music.example.com"),
"SUBSONIC_ALIASES": ("Further hostnames of the server", "Comma-separated. Hostnames your app uses for the same "
"server, e.g. its public domain."),
"CLIENT_NAME": ("Client name towards the music server", "Shown by the server, e.g. “user@BluOS Player”. "
"{player} = name of the BluOS player."),
"SUBSONIC_USER": ("Service user", "Local user (no LDAP) that may only stream."),
"SUBSONIC_PASSWORD": ("Password of the service user", "Leave empty to keep it."),
"SUBSONIC_API_KEY": ("API key of the service user (opensubsonic only)", "Used instead of user/password. "
"Leave empty to keep it."),
"REPEAT_OFF": ("Switch BluOS repeat off on playback", "Recommended: your app handles repeat and shuffle itself."),
"PROXY_STREAMS": ("Serve music server streams through the bridge", "Recommended: enables seeking and a clean "
"track end. The player must reach the bridge."),
"GAPLESS": ("Gapless playback (experimental)", "Joins tracks into one continuous stream – for live albums, "
"mixes and audio plays. Other formats are converted with ffmpeg."),
"FADE_MS": ("Fade in/out (milliseconds)", "Soft fades on pause, stop and track change. 0 = off."),
"OPENHOME": ("Offer OpenHome", "Your app hands the whole queue to the bridge – music keeps playing while "
"the phone sleeps."),
"LOG_LEVEL": ("Log level", ""),
}
STATUS_HTML = """<!doctype html><html lang="__LANG__"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{bridge}}</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}
@@ -35,26 +120,28 @@ background:var(--line);flex:none}.title{font-size:20px;font-weight:650;margin:0}
.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>
<header><h1 id="name">{{bridge}}</h1><nav><span class="muted mono" id="ver"></span><a href="/settings">{{settings}}</a></nav></header>
<main>
<section class="card wide"><h2>Läuft gerade</h2>
<section class="card wide"><h2>{{now_playing}}</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>
<p class="title" id="title">{{nothing}}</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>
<section class="card"><h2>{{player}}</h2><dl id="player"></dl></section>
<section class="card"><h2>{{bridge}}</h2><dl id="bridge"></dl></section>
<section class="card wide"><h2>{{events}}</h2><div class="log" id="log"></div></section>
</main>
<script>
const T=__T__;
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 STATES={PLAYING:[T.st_playing,"ok"],PAUSED_PLAYBACK:[T.st_paused,"warn"],STOPPED:[T.st_stopped,""],
NO_MEDIA_PRESENT:[T.st_ready,""],TRANSITIONING:[T.st_loading,"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 ago=t=>{if(!t)return T.never;const d=Math.round(Date.now()/1000-t);
return d<5?T.just_now:d<120?T.ago_s.replace("{n}",d):T.ago_min.replace("{n}",Math.round(d/60))};
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;
@@ -62,31 +149,32 @@ 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";
$("state").textContent=label+(s.owned||!s.current?"":T.elsewhere);$("state").className="chip "+cls;
const c=s.current;$("title").textContent=c?(c.title||T.unknown_title):T.nothing;
$("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]]);
$("next").textContent=s.next?`${T.up_next}: ${s.next.title}${s.next.artist?" – "+s.next.artist:""}`:"";
const p=s.player||{},rep={"0":T.rep_queue,"1":T.rep_one,"2":T.rep_off}[p.repeat]||"–";
dl($("player"),[[T.name,esc(p.name||s.bluos_host)],[T.address,`<span class="mono">${esc(s.bluos_host)}:${s.bluos_port}</span>`],
[T.connection,s.player_error?`<span class="err">${T.error}: ${esc(s.player_error)}</span>`:`<span class="ok">ok</span> <span class="muted">(${ago(s.player_ok)})</span>`],
[T.state,esc(p.state||"–")],[T.source,esc(p.service||"–")],[T.display,esc([p.title1,p.title2].filter(Boolean).join(" – ")||"–")],
[T.format,esc(p.streamFormat||"–")],[T.volume,`${s.volume}${s.mute?T.muted:""}`],[T.repeat,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";}
dl($("bridge"),[[T.version,esc(s.version)],[T.uptime,fmt(s.uptime)],[T.announced,`<span class="mono">${esc(s.location)}</span>`],
[T.music_server,s.subsonic.enabled?`<span class="ok">${T.active_as}</span> <span class="mono">${esc(s.subsonic.user)}</span>`:`<span class="warn">${T.off}</span>`],
[T.openhome,s.openhome?T.yes:T.no],[T.gapless,s.gapless?T.yes:T.no],
[T.repeat_off,s.repeat_off?T.yes:T.no],[T.controllers,cps],[T.subscriptions,s.subscriptions]]);
}catch(e){$("state").textContent=T.unreachable;$("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);}
d.innerHTML=`<span class="t">${new Date(l.t*1000).toLocaleTimeString(T.locale)}</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){}
}
@@ -94,8 +182,8 @@ 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>
SETTINGS_HTML = """<!doctype html><html lang="__LANG__"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{settings}} · {{bridge}}</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)}
@@ -106,27 +194,28 @@ border:1px solid var(--line);background:var(--bg);color:var(--text);width:100%}
.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>
<header><h1>{{settings}}</h1><nav><a href="/">{{to_status}}</a></nav></header>
<main>
__NOTICE__
<section class="card"><h2>Brücke &amp; Subsonic</h2>
<section class="card"><h2>{{sec_bridge}}</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>
<div class="actions"><button class="primary" type="submit">{{save}}</button></div>
<p class="muted" style="margin:0">{{saved_in}} <span class="mono">__FILE__</span>. {{restart_hint1}}
<span class="restart">{{restart}}</span> {{restart_hint2}}</p>
</form></section>
<section class="card"><h2>Werkzeuge</h2>
<section class="card"><h2>{{tools}}</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>
<button data-a="test_player">{{test_player}}</button>
<button data-a="test_subsonic">{{test_server}}</button>
<button data-a="pause">{{pause}}</button><button data-a="play">{{resume}}</button>
<button class="danger" data-a="restart">{{restart_bridge}}</button></div>
<div id="out" class="mono muted"></div></section>
</main>
<script>
const T=__T__;
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 a=b.dataset.a;if(a==="restart"&&!confirm(T.confirm_restart))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");
@@ -135,3 +224,16 @@ document.querySelectorAll("button[data-a]").forEach(b=>b.addEventListener("click
}));
</script></body></html>
""".replace("__CSS__", BASE_CSS)
def texts(lang):
return TEXT.get(lang, TEXT["en"])
def localize(template, lang):
"""Fill {{key}} placeholders and the JS text table for the given language."""
t = texts(lang)
out = template.replace("__LANG__", lang if lang in TEXT else "en").replace("__T__", json.dumps(t))
for key, value in t.items():
out = out.replace("{{" + key + "}}", value)
return out