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