6 Commits
Author SHA1 Message Date
scriptosandClaude Opus 5.5 3745ce5381 README: Symfonium does not support OpenHome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:31:04 +01:00
scriptosandClaude Opus 5.5 67a687f4e6 Release 0.7.1: gapless conversion only without quality loss
Build & Push Docker Image / Build & push image (push) Successful in 6m49s
LOSSLESS_ONLY (default true): follow-up tracks are only converted when
nothing is lost (MP3 into a FLAC stream). FLAC after MP3 or higher
resolution FLAC plays in original quality with a normal track change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:18:08 +01:00
scriptosandClaude Opus 5.5 04cd7c44b8 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>
2026-10-26 12:53:46 +01:00
scriptosandClaude Opus 5.5 23314ebf33 Release 0.6.1: reliable end detection for gapless streams
BluOS freezes its counter once the sent audio runs out instead of
stopping; detect that, keep the stream open until played and refuse
reconnects to drained streams (no replay of the first track).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:47:58 +01:00
scriptosandClaude Opus 5.5 2bdec88b11 Release 0.6.0: queue-aware gapless playback, clean playlist end
- Follow-up tracks are sent to the player only 20 s before they start, so
  queue changes in the app still take effect; emptied queue removes them
- Playlist end: pause before closing the stream and refuse reconnects to
  finished sessions (first track briefly replayed before)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:44:51 +01:00
scriptosandClaude Opus 5.5 5f232a2093 Release 0.5.2: no duplicate playback entries in gapless mode
The content type of the first track is taken from getSong instead of a
short probe stream request, which the music server counted as a second
playback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:31:40 +01:00
10 changed files with 1362 additions and 150 deletions
+8
View File
@@ -30,6 +30,14 @@ REPEAT_OFF=true
PROXY_STREAMS=true
# experimental: stitch MP3/FLAC tracks into one continuous stream
GAPLESS=false
# gapless: convert follow-up tracks only if no quality is lost (MP3 -> FLAC)
LOSSLESS_ONLY=true
# 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
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## 0.7.1 – 2026-10-26
- Gapless: `LOSSLESS_ONLY` (default `true`) – follow-up tracks are only converted if no quality is lost
(MP3 into a FLAC stream). A FLAC after an MP3 or a higher-resolution FLAC is played in original quality
with a normal track change instead of being converted to MP3 / resampled
## 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"
(playlist end, follow-up track that can't be stitched) and react immediately
- Gapless: keep the stream open until the sent audio has been played; refuse reconnects to drained streams
## 0.6.0 – 2026-10-26
- Gapless: follow-up tracks are sent to the player only 20 s before they start, so queue changes in the
app (reordering, removing, adding tracks) take effect until then
- Gapless: an emptied queue removes the queued follow-up track
- Fix: at the end of a playlist the first track of the gapless stream briefly played again – BluOS
reconnected to the finished stream; the bridge now pauses before closing it and refuses reconnects
## 0.5.2 – 2026-10-25
- Fix: gapless mode showed every track twice as playing on the server – the content type is now taken
from `getSong` instead of a probe stream request
## 0.5.1 – 2026-10-25
- `CLIENT_NAME`: configurable client name towards the music server (`{player}` = BluOS player name)
+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
+57 -12
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,10 @@ 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) |
| `LOSSLESS_ONLY` | `true` | Gapless: convert follow-up tracks only if no quality is lost |
| `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,16 +143,35 @@ 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. BubbleUPnP, Linn 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).
Note: **Symfonium does not support OpenHome** – it always uses the UPnP/DLNA path (current + next track),
which works fine, but the app stays in charge of the queue.
## Gapless playback
BluOS restarts its audio pipeline for every URL, so normally there is a short gap between tracks. With
`GAPLESS=true` (or the switch on the settings page) the bridge serves **one continuous stream** instead and
appends the next track as soon as your app announces it (`SetNextAVTransportURI`):
appends the next track your app announces (`SetNextAVTransportURI`) about 20 seconds before it is due – queue
changes in the app until then are respected:
| Format | How it's stitched |
| :--- | :--- |
@@ -148,9 +179,21 @@ appends the next track as soon as your app announces it (`SetNextAVTransportURI`
| 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). The stream takes the format of its first track. Follow-up tracks in
another format are converted with **ffmpeg** – by default only if no quality is lost (`LOSSLESS_ONLY=true`):
| Stream | Follow-up track | Result |
| :--- | :--- | :--- |
| FLAC | MP3 | converted to FLAC (just re-packed, no further loss) – gapless |
| FLAC | FLAC with higher sample rate / bit depth | not converted – normal track change, original quality |
| MP3 | FLAC | not converted – normal track change, FLAC in original quality |
With `LOSSLESS_ONLY=false` every follow-up track is converted (FLAC → MP3 with 320 kbit/s, resampling).
Albums ripped in one format are never converted.
BluOS drops paused "radio" streams after about two minutes and reconnects on resume – the bridge then
continues exactly where playback was paused.
## Networking
@@ -169,6 +212,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.
@@ -179,6 +223,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.
+362 -65
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.5.1"
VERSION = "0.7.1"
STARTED = time.time()
# --------------------------------------------------------------------------- config
@@ -84,6 +84,15 @@ 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()
LOSSLESS_ONLY = as_bool(cfg("LOSSLESS_ONLY", "true"))
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", "")
@@ -277,18 +286,24 @@ class Subsonic:
endpoint = re.sub(r"/rest/[^/]+$", f"/rest/{path}.view", parts.path)
return urllib.parse.urlunsplit((base.scheme, base.netloc, endpoint, urllib.parse.urlencode(q), ""))
def duration(self, song_id):
"""Track length in seconds from getSong (0 if unknown)."""
def song_info(self, song_id):
"""Length and content type from getSong - unlike a stream request, this doesn't count as playback."""
info = {"duration": 0, "content_type": ""}
if not (self.enabled and song_id):
return 0
return info
try:
q = urllib.parse.urlencode({**self._auth(), "id": song_id})
with urllib.request.urlopen(f"{self.base}/rest/getSong.view?{q}", timeout=10) as r:
song = ET.fromstring(r.read()).find(".//{http://subsonic.org/restapi}song")
return int(song.get("duration", "0")) if song is not None else 0
if song is not None:
info = {"duration": int(song.get("duration", "0") or 0), "content_type": song.get("contentType", "")}
except Exception as exc:
log.debug("getSong %s failed: %s", song_id, exc)
return 0
return info
def duration(self, song_id):
"""Track length in seconds from getSong (0 if unknown)."""
return self.song_info(song_id)["duration"]
def cover_url(self, song_id, size=600):
if not (self.enabled and song_id):
@@ -381,8 +396,13 @@ 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
self._last_secs_at = 0.0
self.player_ok = 0.0
self.player_error = ""
self.control_points = {} # ip -> last seen (web UI)
@@ -421,7 +441,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
@@ -443,7 +464,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
@@ -452,6 +478,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
@@ -459,9 +487,10 @@ class Renderer:
def _segment(self, uri, meta):
info = parse_didl(meta)
if not info["duration"]:
info["duration"] = self.subsonic.duration(self.subsonic.song_id(uri))
return gapless.Segment(self.subsonic.rewrite(uri), uri, meta, info, self._title(info))
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), self._art(uri, info))
def _close_session(self):
if self.session:
@@ -473,7 +502,7 @@ class Renderer:
self._close_session()
self.session = gapless.Session(self._segment(self.current_uri, self.current_meta), start_secs=seek)
if self.next_uri and self._proxied(self.next_uri):
self.session.offer_next(self._segment(self.next_uri, self.next_meta))
self.session.offer_next(self._segment(self.next_uri, self.next_meta), 0)
self.seg_index = 0
self.player_url = STREAMS.add_session(self.session, self._title(info))
log.info("play %r at %s (gapless via %s)", self._title(info) or "?", hms(seek), self.player_url)
@@ -554,7 +583,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()
@@ -562,32 +591,118 @@ class Renderer:
def set_next_uri(self, uri, meta):
with self.lock:
self.next_uri, self.next_meta = uri, meta
log.info("SetNextAVTransportURI %r", self._title(parse_didl(meta)))
if self.session and self.owned and not self.session.closed and uri and self._proxied(uri):
self.session.offer_next(self._segment(uri, meta))
log.info("SetNextAVTransportURI %r", self._title(parse_didl(meta)) if uri else "(none)")
if self.session and self.owned and not self.session.closed:
if uri and self._proxied(uri):
ok = self.session.offer_next(self._segment(uri, meta), self.seg_index)
else:
ok = self.session.clear_next(self.seg_index)
if not ok:
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")
@@ -619,7 +734,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
@@ -656,7 +780,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()
@@ -698,19 +822,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]
@@ -727,16 +865,26 @@ class Renderer:
seg = sess.segments[self.seg_index]
self.position = secs - starts[self.seg_index] + (sess.start_secs if self.seg_index == 0 else 0)
self.player_duration = 0
# BluOS freezes its counter (or shows "connecting") once the sent audio has run out
now = time.time()
if secs != self._last_secs:
self._last_secs, self._last_secs_at = secs, now
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"):
(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
log.info("gapless stream finished at %r", seg.label)
self._close_session()
old = self.session
# pause (or start the unstitched follow-up) before closing the stream - otherwise BluOS
# reconnects to the ended stream and briefly plays its beginning again
self._track_ended()
if self.session is old:
self._close_session()
def _track_ended(self):
if self.next_uri:
@@ -768,6 +916,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)
@@ -904,13 +1054,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">',
@@ -937,7 +1090,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()
@@ -996,6 +1150,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())
@@ -1081,8 +1240,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", ""))
@@ -1174,12 +1346,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, ""),
@@ -1208,6 +1382,13 @@ 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."),
("LOSSLESS_ONLY", "Lückenlos nur ohne Qualitätsverlust", "bool", False,
"Wandelt Folgetitel nur um, wenn nichts verloren geht (MP3 → FLAC). Ein FLAC nach einem MP3 wird nicht zu "
"MP3, sondern im Original mit normalem Titelwechsel gespielt."),
("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
@@ -1226,11 +1407,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 ""
@@ -1246,9 +1430,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)))
@@ -1264,16 +1449,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)
@@ -1288,19 +1476,23 @@ 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, LOSSLESS_ONLY
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()
LOSSLESS_ONLY = gapless.LOSSLESS_ONLY = as_bool(new["LOSSLESS_ONLY"])
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
@@ -1313,7 +1505,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())
@@ -1322,21 +1514,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:
@@ -1367,6 +1560,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.
@@ -1405,6 +1680,7 @@ class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
renderer = None
events = None
monitor = None
description = ""
def log_message(self, fmt, *args):
@@ -1459,6 +1735,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
@@ -1472,8 +1749,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":
@@ -1481,8 +1765,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:
@@ -1495,7 +1779,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")
@@ -1556,16 +1840,17 @@ class Handler(BaseHTTPRequestHandler):
def _stream_session(self, session, label):
"""Serve a gapless session as one endless stream (optionally with ICY titles)."""
if session.closed:
if session.closed or session.ended or (
session.served and session.playback_secs >= session.end_of_streamed() - 3):
# 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")
first = session.segments[0]
try:
with urllib.request.urlopen(urllib.request.Request(
first.upstream, headers={"Range": "bytes=0-1"}), timeout=10) as probe:
ctype = probe.headers.get("Content-Type", "audio/mpeg")
except Exception as exc:
log.warning("gapless %r: upstream failed: %s", label, exc)
return self._send(502, "", "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"
icy = self.headers.get("Icy-MetaData") == "1"
log.info("gapless stream %r -> player (%s%s)", label, ctype, ", ICY titles" if icy else "")
self.close_connection = True
@@ -1592,26 +1877,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):
@@ -1761,10 +2048,20 @@ def main():
events = EventManager(renderer)
renderer.events = events
gapless.LOSSLESS_ONLY = LOSSLESS_ONLY
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
+203 -31
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,73 @@ 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")
LOSSLESS_ONLY = True # only convert follow-up tracks if no quality is lost (set by the bridge)
def _lossless_conversion(src_kind, dst_kind, src_format, dst_params):
"""True if converting src into the stream format keeps the full quality.
MP3 -> FLAC only re-packs the decoded audio. FLAC -> FLAC is fine if sample rate and bit depth don't
go down (and the channels match). FLAC -> MP3 or downsampling would lose quality.
"""
if src_kind == "mp3" and dst_kind == "flac":
return True
if src_kind == "flac" and dst_kind == "flac" and src_format and dst_params:
rate, channels, bits = src_format
d_rate, d_channels, d_bits = flac_format(dst_params)
return channels == d_channels and d_rate >= rate and d_bits >= bits
return False
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 +190,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
@@ -141,12 +207,16 @@ class Segment:
class Session:
"""One continuous stream made of several tracks."""
def __init__(self, first, start_secs=0):
def __init__(self, first, start_secs=0, lead_secs=20):
self.segments = [first]
self.start_secs = start_secs # stream begins this far into the first track
self.lead_secs = lead_secs # follow-up tracks are appended this long before they're due
self.cond = threading.Condition()
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):
@@ -160,33 +230,89 @@ class Session:
def end_of_last(self):
return sum(s.duration for s in self.segments) - self.start_secs
def offer_next(self, seg):
"""Append or replace the queued follow-up track. Returns False if it's too late."""
def end_of_streamed(self):
"""Playback time at which the audio sent so far runs out."""
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, 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:
last = self.segments[-1]
if last.uri == seg.uri:
return True
if len(self.segments) >= 2 and not last.streamed and self._is_queued(last):
self.segments[-1] = seg
else:
while not self.closed and self.generation == generation and self.playback_secs < self.end_of_streamed() - margin:
self.cond.wait(1)
def _queued(self):
"""The follow-up segment that hasn't been sent to the player yet (or None)."""
if len(self.segments) >= 2 and not self.segments[-1].streamed:
return self.segments[-1]
return None
def offer_next(self, seg, current_index):
"""Queue seg after the playing track, replacing a queued one that wasn't sent yet.
Returns False if a different track was already sent to the player (too late to change).
"""
with self.cond:
queued = self._queued()
if queued is not None and len(self.segments) - 1 > current_index:
if queued.uri != seg.uri:
self.segments[-1] = seg
log.info("gapless: queued track changed to %r", seg.label)
elif len(self.segments) - 1 == current_index:
self.segments.append(seg)
elif self.segments[-1].uri != seg.uri:
return False
self.cond.notify_all()
return True
def _is_queued(self, seg):
# a segment is "queued" (replaceable) if playback hasn't reached it yet
idx = self.segments.index(seg)
return self.playback_secs < self.starts()[idx]
def clear_next(self, current_index):
"""Drop the queued follow-up track (queue emptied in the app). False if already sent."""
with self.cond:
if len(self.segments) - 1 <= current_index:
return True
if self._queued() is not None:
self.segments.pop()
log.info("gapless: queued track removed")
self.cond.notify_all()
return True
return False
def wait_segment(self, i, timeout_after_end=8):
"""Block until segment i exists. None if the session ends."""
"""Block until segment i exists and is due. None if the session ends.
Follow-up tracks are only sent lead_secs before they start, so queue changes in the app
until then still take effect.
"""
with self.cond:
while not self.closed and len(self.segments) <= i:
# nothing queued: give up shortly after playback reaches the end of the stream
if self.playback_secs >= self.end_of_last() + timeout_after_end:
while not self.closed:
if len(self.segments) > i:
if i == 0 or self.playback_secs >= self.starts()[i] - self.lead_secs:
return self.segments[i]
elif self.playback_secs >= self.end_of_last() + timeout_after_end:
# nothing queued: give up shortly after playback reaches the end of the stream
return None
self.cond.wait(1)
return None if self.closed else self.segments[i]
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:
@@ -202,6 +328,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):
@@ -215,11 +342,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")
@@ -250,9 +380,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:
@@ -262,12 +393,36 @@ 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
src_format = None
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
src_format = flac_format(flac_params(buf[:hl])) if hl > 0 else None
if i > 0 and (seg_kind != kind or convert) and LOSSLESS_ONLY and not _lossless_conversion(seg_kind, kind, src_format, params):
log.info("segment %r (%s) would lose quality as %s, ending gapless stream (normal track change)",
seg.label, ctype or "?", kind)
resp.close()
break
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)
@@ -277,6 +432,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:
@@ -321,20 +478,32 @@ def stream_session(session, writer, kind_hint=None):
buf = buf[hlen:]
kind = kind or seg_kind
seg.streamed = True
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
@@ -344,5 +513,8 @@ def stream_session(session, writer, kind_hint=None):
writer.write(tail)
sent += 1
i += 1
session.ended = True
if sent:
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}"
+144 -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,96 @@ 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."),
"LOSSLESS_ONLY": ("Gapless only without quality loss", "Converts follow-up tracks only if nothing is lost "
"(MP3 → FLAC). A FLAC after an MP3 is not turned "
"into MP3 but played in original quality with a normal "
"track change."),
"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 +124,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 +153,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 +186,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 +198,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 +228,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