Release 0.4.0: stream proxy with seeking, gapless MP3/FLAC playback

- Stream proxy: Subsonic streams served via the bridge with Content-Length and
  byte ranges, so BluOS seeks natively and ends tracks cleanly (PROXY_STREAMS)
- Gapless playback (experimental, GAPLESS): MP3/FLAC tracks stitched into one
  continuous stream, track changes mapped from the playback position, ICY titles
- Fix: tracks restarted at the end (BluOS reconnecting length-less streams)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 16:40:33 +02:00
co-authored by Claude Opus 5.5
parent 393639543f
commit 5c4341dd9b
7 changed files with 688 additions and 45 deletions
+4
View File
@@ -17,6 +17,10 @@ SUBSONIC_PASSWORD=change-me
# --- Behaviour ----------------------------------------------------------------
REPEAT_OFF=true
# serve Subsonic streams through the bridge (seeking, clean track end)
PROXY_STREAMS=true
# experimental: stitch MP3/FLAC tracks into one continuous stream
GAPLESS=false
LOG_LEVEL=INFO
# --- Web UI (settings page, basic auth) --------------------------------------
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## 0.4.0 – 2026-10-24
- **Gapless playback** (experimental, `GAPLESS=true`): the bridge serves one continuous stream and appends
the next track's audio directly – perfect for live albums, DJ mixes and audio plays
- MP3: ID3 tags and Xing/Info frames of follow-up tracks are dropped, frames are concatenated
- FLAC: STREAMINFO of the first track is marked open-ended, follow-up tracks are appended without
metadata blocks (same sample rate / channels / bit depth required)
- Tracks that can't be stitched fall back to a normal track change
- The bridge maps the playback position to the audible track and reports track changes to the app
- ICY titles: the BluOS app shows the current track in its second line
- Track length from Subsonic `getSong` if the app doesn't send it
## 0.3.0 – 2026-10-24
- **Stream proxy**: Subsonic streams are served through the bridge (`/stream/<random token>`) with
`Content-Length` and byte-range support. BluOS now treats them as files: **seeking works natively**
and the title stays visible after seeking. The service account token never reaches the player.
- New setting `PROXY_STREAMS` (default `true`)
- Track end detection by known track length – BluOS reconnects length-less HTTP streams like internet radio
## 0.2.1 – 2026-10-24
- Fix: songs restarted at the end instead of advancing (BluOS reconnecting the stream)
## 0.2.0 – 2026-10-24
- Web UI: live status page (now playing, player and bridge status, event log) and settings page with basic auth
+1 -1
View File
@@ -6,7 +6,7 @@ LABEL org.opencontainers.image.title="bluos-upnp-bridge" \
RUN useradd --system --uid 10001 --no-create-home bridge \
&& mkdir -p /data && chown bridge /data
WORKDIR /app
COPY bridge.py web_ui.py /app/
COPY bridge.py web_ui.py gapless.py /app/
USER bridge
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data
+29 -29
View File
@@ -1,29 +1,29 @@
MIT License
---
Copyright © Patrick Asmus (scriptos)
Website: https://www.patrick-asmus.de
Blog: https://www.cleveradmin.de
Email: support@techniverse.net
---
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MIT License
---
Copyright © Patrick Asmus (scriptos)
Website: https://www.patrick-asmus.de
Blog: https://www.cleveradmin.de
Email: support@techniverse.net
---
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25 -4
View File
@@ -47,6 +47,9 @@ no extra app.
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
- 🔁 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`)
@@ -98,6 +101,8 @@ All settings come from environment variables (`.env`). Settings saved in the web
| `SUBSONIC_URL` | – | e.g. `https://music.example.com` – enables credential rewriting |
| `SUBSONIC_USER` | – | Stream-only service account |
| `SUBSONIC_PASSWORD` | – | Its password |
| `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) |
| `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 |
@@ -116,21 +121,37 @@ The status page never shows credentials or player URLs; the cover is proxied thr
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.
## Gapless playback
BluOS restarts its audio pipeline for every URL, so normally there is a short gap between tracks. With
`GAPLESS=true` (or the switch on the settings page) the bridge serves **one continuous stream** instead and
appends the next track as soon as your app announces it (`SetNextAVTransportURI`):
| Format | How it's stitched |
| :--- | :--- |
| MP3 | ID3v2/ID3v1 tags and the Xing/Info frame of follow-up tracks are dropped, MPEG frames are concatenated. A few milliseconds of encoder padding remain. |
| 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.
## Networking
- **Host network is required** (`network_mode: host`): SSDP discovery needs multicast on the LAN interface.
- **VLANs**: if your phone and the bridge live in different networks, relay SSDP (UDP 1900, `239.255.255.250`)
between them. On OPNsense/pfSense the `udpbroadcastrelay` plugin does the job. mDNS repeaters are *not* enough.
- **Firewall**: bridge → player TCP 11000, player → your music server (HTTP/HTTPS), control point → bridge TCP 49494.
- **Firewall**: bridge → player TCP 11000, control point → bridge TCP 49494, **player → bridge TCP 49494**
(stream proxy), bridge → your music server (HTTP/HTTPS).
- ⚠️ **Docker subnets**: if a Docker network on the host overlaps the player's subnet (e.g. a default
`172.20.0.0/16` bridge network and the player in `172.20.x.x`), the bridge gets `No route to host`.
Check with `ip route get <player-ip>`.
## Known limitations
- **Seeking**: BluOS' own seek on plain HTTP streams restarts the file while the position counter jumps ahead.
The bridge restarts the stream with Subsonic's `timeOffset` instead, which only works if your server transcodes.
Proper byte-range seeking through the bridge is planned.
- **Track transitions**: without `GAPLESS` there is a short gap between tracks.
- **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.
- **One player per instance**: run one container per player (different `HTTP_PORT`). Groups created in the
+257 -11
View File
@@ -33,7 +33,7 @@ import xml.etree.ElementTree as ET
from email.utils import formatdate
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "0.2.0"
VERSION = "0.4.0"
STARTED = time.time()
# --------------------------------------------------------------------------- config
@@ -77,6 +77,8 @@ SUBSONIC_PASSWORD = cfg("SUBSONIC_PASSWORD")
SSDP_INTERVAL = int(cfg("SSDP_INTERVAL", "60"))
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"))
WEB_USER = os.environ.get("WEB_USER", "admin")
WEB_PASSWORD = os.environ.get("WEB_PASSWORD", "")
@@ -240,6 +242,19 @@ class Subsonic:
endpoint = re.sub(r"/rest/[^/]+$", f"/rest/{path}.view", parts.path)
return urllib.parse.urlunsplit((base.scheme, base.netloc, endpoint, urllib.parse.urlencode(q), ""))
def duration(self, song_id):
"""Track length in seconds from getSong (0 if unknown)."""
if not (self.enabled and song_id):
return 0
try:
q = urllib.parse.urlencode({**self._auth(), "id": song_id})
with urllib.request.urlopen(f"{self.base}/rest/getSong.view?{q}", timeout=10) as r:
song = ET.fromstring(r.read()).find(".//{http://subsonic.org/restapi}song")
return int(song.get("duration", "0")) if song is not None else 0
except Exception as exc:
log.debug("getSong %s failed: %s", song_id, exc)
return 0
def cover_url(self, song_id, size=600):
if not (self.enabled and song_id):
return ""
@@ -331,6 +346,8 @@ class Renderer:
self.mute = False
self.player = {} # last BluOS /Status values (web UI)
self.player_name = ""
self.session = None # gapless.Session while gapless playback runs
self.seg_index = 0
self.player_ok = 0.0
self.player_error = ""
self.control_points = {} # ip -> last seen (web UI)
@@ -399,23 +416,66 @@ class Renderer:
if self.events:
self.events.notify_avt()
def _proxied(self, uri=None):
uri = self.current_uri if uri is None else uri
return PROXY_STREAMS and self.subsonic.enabled and self.subsonic.is_subsonic(uri)
def _segment(self, uri, meta):
info = parse_didl(meta)
if not info["duration"]:
info["duration"] = self.subsonic.duration(self.subsonic.song_id(uri))
return gapless.Segment(self.subsonic.rewrite(uri), uri, meta, info, self._title(info))
def _close_session(self):
if self.session:
self.session.close()
self.session = None
def _start_gapless(self, seek):
info = self.current_info
self._close_session()
self.session = gapless.Session(self._segment(self.current_uri, self.current_meta), start_secs=seek)
if self.next_uri and self._proxied(self.next_uri):
self.session.offer_next(self._segment(self.next_uri, self.next_meta))
self.seg_index = 0
self.player_url = STREAMS.add_session(self.session, self._title(info))
log.info("play %r at %s (gapless via %s)", self._title(info) or "?", hms(seek), self.player_url)
self.expect_stop = False
self.started_at = time.time()
self.seek_offset = 0
self.position = seek
if REPEAT_OFF:
try:
self.bluos.repeat_off()
except Exception as exc:
log.warning("could not switch repeat off: %s", exc)
self.bluos.play_url(self.player_url, self._title(info), self._art(self.current_uri, info))
self.owned = True
self._set_state("PLAYING")
def _start(self, seek=None):
"""Start playback of current_uri on the player.
BluOS' own seek on plain HTTP streams restarts the file while the counter
jumps ahead, so seeking always restarts the stream at an offset instead.
Subsonic streams are served through the bridge's own stream proxy, which adds
Content-Length and byte-range support. Without it BluOS treats the file like
internet radio: it can't seek and reconnects at EOF.
"""
info = self.current_info
seek = int(seek or 0)
offset_supported = self.subsonic.is_subsonic(self.current_uri) and self.subsonic.enabled
player_url = self.subsonic.rewrite(self.current_uri, offset=seek if offset_supported else 0)
if player_url == self.current_uri and self.subsonic.has_credentials(player_url):
proxied = self._proxied()
if proxied and GAPLESS:
return self._start_gapless(seek)
self._close_session()
offset_supported = not proxied and self.subsonic.is_subsonic(self.current_uri) and self.subsonic.enabled
upstream = self.subsonic.rewrite(self.current_uri, offset=seek if offset_supported else 0)
if upstream == self.current_uri and self.subsonic.has_credentials(upstream):
# never hand a control point's credentials to the player (it shows and stores the URL)
log.error("refusing to play URL with foreign credentials: %s", mask_url(player_url))
log.error("refusing to play URL with foreign credentials: %s", mask_url(upstream))
self._set_state("STOPPED")
raise UPnPError(716, "Resource not found (credentials could not be replaced)")
self.player_url = player_url
log.info("play %r at %s (%s)", self._title(info) or "?", hms(seek), mask_url(self.player_url))
self.player_url = STREAMS.add(upstream, self._title(info)) if proxied else upstream
log.info("play %r at %s (%s)", self._title(info) or "?", hms(seek),
f"via proxy {self.player_url}" if proxied else mask_url(self.player_url))
self.expect_stop = False
self.started_at = time.time()
self.seek_offset = seek if offset_supported else 0
@@ -427,10 +487,21 @@ class Renderer:
except Exception as exc:
log.warning("could not switch repeat off: %s", exc)
self.bluos.play_url(self.player_url, self._title(info), self._art(self.current_uri, info),
None if offset_supported else seek)
None if (offset_supported or proxied) else seek)
if proxied and seek:
# /Play with url+seek is unreliable; seek once the stream is running
threading.Timer(1.5, self._late_seek, args=(self.player_url, seek)).start()
self.owned = True
self._set_state("PLAYING")
def _late_seek(self, url, secs):
with self.lock:
if self.player_url == url and self.owned:
try:
self.bluos.seek(secs)
except Exception as exc:
log.warning("seek after start failed: %s", exc)
# ---- AVTransport
def set_uri(self, uri, meta):
with self.lock:
@@ -450,6 +521,8 @@ class Renderer:
with self.lock:
self.next_uri, self.next_meta = uri, meta
log.info("SetNextAVTransportURI %r", self._title(parse_didl(meta)))
if self.session and self.owned and not self.session.closed and uri and self._proxied(uri):
self.session.offer_next(self._segment(uri, meta))
self._notify_avt()
def play(self):
@@ -473,6 +546,7 @@ class Renderer:
self.expect_stop = True
if self.owned:
self.bluos.pause() # /Stop would clear the queue display; pause is enough
self._close_session()
self.position = 0
self._set_state("STOPPED")
@@ -485,6 +559,14 @@ class Renderer:
if not self.current_uri:
raise UPnPError(701, "Transition not available")
paused = self.transport_state == "PAUSED_PLAYBACK"
if self._proxied() and self.owned and not self.session and \
self.transport_state in ("PLAYING", "PAUSED_PLAYBACK"):
# the proxy supports byte ranges, so BluOS can seek natively
self.bluos.seek(secs)
self.position = secs
self.started_at = time.time()
self._notify_avt()
return
self._start(seek=secs)
if paused:
self.bluos.pause()
@@ -540,6 +622,9 @@ class Renderer:
return
settling = time.time() - self.started_at < 4
ours = self._is_ours(stream_url, st)
if self.session and ours and not settling:
self._gapless_status(st, state)
return
if ours and not settling:
new_pos = self.seek_offset + int(float(st.findtext("secs", "0") or 0))
duration = self.duration()
@@ -548,6 +633,13 @@ class Renderer:
log.info("player restarted the same track (repeat?), treating as track end")
self._track_ended()
return
# Without Content-Length BluOS treats the stream like internet radio: at EOF it keeps
# reporting "stream"/"connecting" and the counter runs on. Use the known duration.
known = self.current_info["duration"]
if known and new_pos >= known + 1 and not self.expect_stop and state in ("stream", "play", "connecting"):
log.info("position %s passed track length %s, treating as track end", hms(new_pos), hms(known))
self._track_ended()
return
self.position = new_pos
totlen = int(float(st.findtext("totlen", "0") or 0))
self.player_duration = self.seek_offset + totlen if totlen else 0
@@ -573,6 +665,37 @@ class Renderer:
else:
self._track_ended()
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))
sess.playback_secs = secs
starts = sess.starts()
streamed = [i for i, s in enumerate(sess.segments) if s.streamed or i == 0]
last = max(streamed)
idx = max(i for i in streamed if secs >= starts[i])
while self.seg_index < idx:
self.seg_index += 1
seg = sess.segments[self.seg_index]
self.current_uri, self.current_meta, self.current_info = seg.uri, seg.meta, seg.info
nxt = sess.segments[self.seg_index + 1] if self.seg_index + 1 < len(sess.segments) else None
self.next_uri, self.next_meta = (nxt.uri, nxt.meta) if nxt else ("", "")
log.info("gapless: now playing %r", seg.label)
self._notify_avt()
seg = sess.segments[self.seg_index]
self.position = secs - starts[self.seg_index] + (sess.start_secs if self.seg_index == 0 else 0)
self.player_duration = 0
if state in ("play", "stream"):
self._set_state("PLAYING")
elif state == "pause":
self._set_state("PAUSED_PLAYBACK")
if self.seg_index == last and not self.expect_stop and (
(seg.duration and self.position >= seg.duration + 1) or state == "stop"):
# end of the stitched stream; a follow-up that couldn't be stitched starts normally
log.info("gapless stream finished at %r", seg.label)
self._close_session()
self._track_ended()
def _track_ended(self):
if self.next_uri:
log.info("track ended, advancing to next")
@@ -1008,6 +1131,7 @@ def soap_fault(code, desc):
# --------------------------------------------------------------------------- web UI
import gapless # noqa: E402
import web_ui # noqa: E402 (templates only)
CSRF_TOKEN = secrets.token_urlsafe(24)
@@ -1027,6 +1151,11 @@ SETTING_FIELDS = [
("SUBSONIC_PASSWORD", "Subsonic-Passwort", "password", False, "Leer lassen = unverändert."),
("REPEAT_OFF", "BluOS-Wiederholung beim Abspielen ausschalten", "bool", False,
"Empfohlen: Symfonium kümmert sich selbst um Wiederholen und Zufallswiedergabe."),
("PROXY_STREAMS", "Subsonic-Streams über die Brücke ausliefern", "bool", False,
"Empfohlen: ermöglicht Spulen und ein sauberes Songende. Der Player muss die Brücke erreichen können."),
("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."),
("LOG_LEVEL", "Log-Level", "select:DEBUG,INFO,WARNING", False, ""),
]
RUNNING = {k: str(globals()[k]) for k, *_ in SETTING_FIELDS} # values active since start
@@ -1101,8 +1230,10 @@ def save_settings(form, renderer):
OVERRIDES.update(new)
# apply what can change at runtime
global REPEAT_OFF
global REPEAT_OFF, PROXY_STREAMS, GAPLESS
REPEAT_OFF = as_bool(new["REPEAT_OFF"])
PROXY_STREAMS = as_bool(new["PROXY_STREAMS"])
GAPLESS = as_bool(new["GAPLESS"])
logging.getLogger().setLevel(new["LOG_LEVEL"])
with renderer.lock:
renderer.subsonic = Subsonic(new["SUBSONIC_URL"], new["SUBSONIC_USER"], new["SUBSONIC_PASSWORD"])
@@ -1166,6 +1297,37 @@ class CoverCache:
COVERS = CoverCache()
class StreamProxy:
"""Short-lived, unguessable stream URLs for the player.
The player only ever sees http://<bridge>/stream/<token>; the upstream URL with the
service account token stays inside the bridge.
"""
def __init__(self, keep=30):
self.items = collections.OrderedDict()
self.keep = keep
self.lock = threading.Lock()
def add(self, upstream, label=""):
token = secrets.token_urlsafe(18)
with self.lock:
self.items[token] = (upstream, label)
while len(self.items) > self.keep:
self.items.popitem(last=False)
return f"http://{ADVERTISE_IP}:{HTTP_PORT}/stream/{token}"
def add_session(self, session, label=""):
return self.add(session, label)
def get(self, token):
with self.lock:
return self.items.get(token)
STREAMS = StreamProxy()
# --------------------------------------------------------------------------- HTTP server
class Handler(BaseHTTPRequestHandler):
@@ -1246,6 +1408,8 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/log":
after = int((query.get("after") or ["0"])[0] or 0)
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]
snap = self.renderer.snapshot()["current"]
@@ -1268,6 +1432,88 @@ class Handler(BaseHTTPRequestHandler):
do_HEAD = do_GET
def _stream(self, token):
"""Proxy a Subsonic stream with Content-Length and byte-range support."""
item = STREAMS.get(token)
if not item:
return self._send(404, "unknown stream\n", "text/plain")
upstream, label = item
if isinstance(upstream, gapless.Session):
return self._stream_session(upstream, label)
client_range = self.headers.get("Range")
try:
up = urllib.request.urlopen(urllib.request.Request(
upstream, headers={"Range": client_range or "bytes=0-"}), timeout=20)
except urllib.error.HTTPError as exc:
log.warning("stream %r: upstream answered %s", label, exc.code)
return self._send(502 if exc.code != 416 else 416, "", "text/plain")
except Exception as exc:
log.warning("stream %r: upstream failed: %s", label, exc)
return self._send(502, "", "text/plain")
headers = {"Content-Type": up.headers.get("Content-Type", "audio/mpeg"), "Accept-Ranges": "bytes",
"Cache-Control": "no-store", "Connection": "close"}
content_range = up.headers.get("Content-Range")
length = up.headers.get("Content-Length")
code = 200
if up.status == 206 and content_range:
if client_range:
code = 206
headers["Content-Range"] = content_range
elif not length:
length = content_range.rsplit("/", 1)[-1]
if length and length.isdigit():
headers["Content-Length"] = length
log.info("stream %r -> player (%s%s)", label, f"range {client_range}" if client_range else "full",
f", {int(length) // 1024} KiB" if length and length.isdigit() else "")
self.close_connection = True
self.send_response(code)
for k, v in headers.items():
self.send_header(k, v)
self.end_headers()
if self.command == "HEAD":
up.close()
return
try:
while True:
chunk = up.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
pass # player closed the connection (seek, skip, stop)
finally:
up.close()
def _stream_session(self, session, label):
"""Serve a gapless session as one endless stream (optionally with ICY titles)."""
if session.closed:
return self._send(410, "", "text/plain")
first = session.segments[0]
try:
with urllib.request.urlopen(urllib.request.Request(
first.upstream, headers={"Range": "bytes=0-1"}), timeout=10) as probe:
ctype = probe.headers.get("Content-Type", "audio/mpeg")
except Exception as exc:
log.warning("gapless %r: upstream failed: %s", label, exc)
return self._send(502, "", "text/plain")
icy = self.headers.get("Icy-MetaData") == "1"
log.info("gapless stream %r -> player (%s%s)", label, ctype, ", ICY titles" if icy else "")
self.close_connection = True
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
if icy:
self.send_header("icy-metaint", "16000")
self.send_header("icy-name", self.name)
self.end_headers()
if self.command == "HEAD":
return
try:
gapless.stream_session(session, gapless.IcyWriter(self.wfile, 16000 if icy else 0))
except (BrokenPipeError, ConnectionResetError):
log.info("gapless stream %r: player closed the connection", label)
def _web_post(self, path):
if not self._authorized():
return
+348
View File
@@ -0,0 +1,348 @@
"""Gapless playback for bluos-upnp-bridge.
BluOS restarts its pipeline for every URL, which leaves a 1-2 s gap between tracks. In gapless
mode the bridge serves one continuous stream and appends the next track's audio data directly
after the current one:
- MP3: ID3v2/ID3v1 tags and the Xing/Info frame of follow-up tracks are dropped, the MPEG frames
are simply concatenated (a few ms of encoder padding remain).
- FLAC: the first track's STREAMINFO gets "total samples unknown" and a zero MD5 so decoders keep
going; follow-up tracks are appended without their metadata blocks. Tracks must have the same
sample rate, channel count and bit depth.
Tracks that can't be stitched (different codec or FLAC parameters) end the stream; the bridge then
falls back to a normal track change.
"""
import logging
import threading
import time
import urllib.request
log = logging.getLogger("gapless")
CHUNK = 65536
# ---------------------------------------------------------------------------- MP3
_MP3_BITRATES = {
# (version_is_mpeg1, layer3) -> kbit/s table (index 0..15)
True: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
False: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
}
_MP3_RATES = {3: [44100, 48000, 32000], 2: [22050, 24000, 16000], 0: [11025, 12000, 8000]}
def id3v2_size(buf):
"""Length of a leading ID3v2 tag (0 if none)."""
if len(buf) >= 10 and buf[:3] == b"ID3":
size = (buf[6] & 0x7F) << 21 | (buf[7] & 0x7F) << 14 | (buf[8] & 0x7F) << 7 | (buf[9] & 0x7F)
return 10 + size + (10 if buf[5] & 0x10 else 0)
return 0
def mp3_frame_len(buf, pos):
"""Length of the MPEG layer III frame at pos, or 0 if no valid header."""
if pos + 4 > len(buf) or buf[pos] != 0xFF or (buf[pos + 1] & 0xE0) != 0xE0:
return 0
version = (buf[pos + 1] >> 3) & 0x03 # 3 = MPEG1, 2 = MPEG2, 0 = MPEG2.5
layer = (buf[pos + 1] >> 1) & 0x03 # 1 = layer III
br_idx = buf[pos + 2] >> 4
sr_idx = (buf[pos + 2] >> 2) & 0x03
padding = (buf[pos + 2] >> 1) & 0x01
if version == 1 or layer != 1 or br_idx in (0, 15) or sr_idx == 3:
return 0
bitrate = _MP3_BITRATES[version == 3][br_idx] * 1000
rate = _MP3_RATES[version][sr_idx]
factor = 144 if version == 3 else 72
return factor * bitrate // rate + padding
def mp3_sync(buf, start=0):
"""Offset of the first frame at/after start that is followed by another valid frame."""
pos = start
while pos < len(buf) - 4:
n = mp3_frame_len(buf, pos)
if n and (pos + n + 4 > len(buf) or mp3_frame_len(buf, pos + n)):
return pos
pos += 1
return -1
def mp3_skip_info_frame(buf, pos):
"""If the frame at pos is a Xing/Info/VBRI header frame, return the offset after it."""
n = mp3_frame_len(buf, pos)
if n and (b"Xing" in buf[pos:pos + 64] or b"Info" in buf[pos:pos + 64] or b"VBRI" in buf[pos:pos + 64]):
return pos + n
return pos
# ---------------------------------------------------------------------------- FLAC
def flac_header_len(buf):
"""Length of 'fLaC' + all metadata blocks, or -1 if buf is too short, or 0 if not FLAC."""
if buf[:4] != b"fLaC":
return 0
pos = 4
while True:
if pos + 4 > len(buf):
return -1
last = buf[pos] & 0x80
length = int.from_bytes(buf[pos + 1:pos + 4], "big")
pos += 4 + length
if last:
return pos if pos <= len(buf) else -1
def flac_params(header):
"""Sample rate / channels / bits per sample bytes of STREAMINFO for compatibility checks."""
si = header[8:8 + 34]
return bytes(si[10:13]) + bytes([si[13] & 0xF0])
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
h[si + 13] &= 0xF0
h[si + 14:si + 18] = b"\x00\x00\x00\x00"
h[si + 18:si + 34] = b"\x00" * 16
return bytes(h)
def flac_sync(buf, start=0):
"""Offset of the next plausible FLAC frame header (sync 0xFFF8/0xFFF9)."""
pos = start
while pos < len(buf) - 4:
if buf[pos] == 0xFF and buf[pos + 1] in (0xF8, 0xF9):
bs, sr = buf[pos + 2] >> 4, buf[pos + 2] & 0x0F
ch, ss = buf[pos + 3] >> 4, (buf[pos + 3] >> 1) & 0x07
if bs != 0 and sr != 15 and ch < 11 and ss not in (3, 7):
return pos
pos += 1
return -1
# ---------------------------------------------------------------------------- session
class Segment:
def __init__(self, upstream, uri, meta, info, label):
self.upstream = upstream
self.uri = uri
self.meta = meta
self.info = info
self.label = label
self.streamed = False # proxy started sending its audio
@property
def duration(self):
return self.info.get("duration") or 0
class Session:
"""One continuous stream made of several tracks."""
def __init__(self, first, start_secs=0):
self.segments = [first]
self.start_secs = start_secs # stream begins this far into the first track
self.cond = threading.Condition()
self.closed = False
self.ended = False # proxy finished writing (no more segments)
self.playback_secs = 0 # updated by the renderer from BluOS' counter
def starts(self):
"""Playback time (stream seconds) at which each segment starts."""
out, t = [], -self.start_secs
for seg in self.segments:
out.append(max(0, t))
t += seg.duration
return out
def end_of_last(self):
return sum(s.duration for s in self.segments) - self.start_secs
def offer_next(self, seg):
"""Append or replace the queued follow-up track. Returns False if it's too late."""
with self.cond:
last = self.segments[-1]
if last.uri == seg.uri:
return True
if len(self.segments) >= 2 and not last.streamed and self._is_queued(last):
self.segments[-1] = seg
else:
self.segments.append(seg)
self.cond.notify_all()
return True
def _is_queued(self, seg):
# a segment is "queued" (replaceable) if playback hasn't reached it yet
idx = self.segments.index(seg)
return self.playback_secs < self.starts()[idx]
def wait_segment(self, i, timeout_after_end=8):
"""Block until segment i exists. None if the session ends."""
with self.cond:
while not self.closed and len(self.segments) <= i:
# nothing queued: give up shortly after playback reaches the end of the stream
if self.playback_secs >= self.end_of_last() + timeout_after_end:
return None
self.cond.wait(1)
return None if self.closed else self.segments[i]
def close(self):
with self.cond:
self.closed = True
self.cond.notify_all()
class IcyWriter:
"""Writes audio, inserting ICY metadata (StreamTitle) every metaint bytes if requested."""
def __init__(self, out, metaint=0):
self.out = out
self.metaint = metaint
self.count = 0
self.title = ""
self.sent_title = None
def write(self, data):
if not self.metaint:
self.out.write(data)
return
while data:
room = self.metaint - self.count
part, data = data[:room], data[room:]
self.out.write(part)
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]
blocks = (len(text) + 15) // 16
self.out.write(bytes([blocks]) + text.ljust(blocks * 16, b"\x00"))
self.sent_title = self.title
else:
self.out.write(b"\x00")
def _open(url, start=0):
req = urllib.request.Request(url, headers={"Range": f"bytes={start}-"})
return urllib.request.urlopen(req, timeout=20)
def _total_size(resp):
cr = resp.headers.get("Content-Range", "")
tail = cr.rsplit("/", 1)[-1] if cr else resp.headers.get("Content-Length", "")
return int(tail) if tail.isdigit() else 0
def _read_at_least(resp, buf, n):
while len(buf) < n:
chunk = resp.read(CHUNK)
if not chunk:
break
buf += chunk
return buf
def stream_session(session, writer, kind_hint=None):
"""Write all segments of the session into writer. Returns the number of segments sent."""
kind = None
params = None
sent = 0
i = 0
while True:
seg = session.wait_segment(i)
if seg is None:
break
start_secs = session.start_secs if i == 0 else 0
try:
resp = _open(seg.upstream)
except Exception as exc:
log.warning("segment %r: upstream failed: %s", seg.label, exc)
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)
if seg_kind == "mp3":
head = id3v2_size(buf)
buf = _read_at_least(resp, buf, head + 4096)
audio = mp3_sync(buf, head)
if audio < 0:
log.warning("segment %r: no MP3 frames found", seg.label)
resp.close()
break
if i > 0 or start_secs:
audio = mp3_skip_info_frame(buf, audio)
if start_secs and seg.duration and total:
# jump into the file: approximate byte position, then resync to a frame
target = audio + int((total - audio) * start_secs / seg.duration)
resp.close()
resp = _open(seg.upstream, target)
buf = _read_at_least(resp, b"", 8192)
pos = mp3_sync(buf, 0)
buf = buf[pos:] if pos >= 0 else b""
else:
buf = buf[audio:] if i > 0 else buf # first track: keep its tags for the decoder
else: # flac
hlen = flac_header_len(buf)
while hlen == -1:
more = resp.read(CHUNK)
if not more:
break
buf += more
hlen = flac_header_len(buf)
if hlen <= 0:
log.warning("segment %r: invalid FLAC header", seg.label)
resp.close()
break
header = buf[:hlen]
p = flac_params(header)
if params and p != params:
log.info("segment %r: different FLAC format, ending gapless stream", seg.label)
resp.close()
break
params = params or p
if i == 0:
writer.write(flac_open_ended(header))
if start_secs and seg.duration and total:
target = hlen + int((total - hlen) * start_secs / seg.duration)
resp.close()
resp = _open(seg.upstream, target)
buf = _read_at_least(resp, b"", 16384)
pos = flac_sync(buf, 0)
buf = buf[pos:] if pos >= 0 else b""
else:
buf = buf[hlen:]
kind = kind or seg_kind
seg.streamed = True
writer.title = seg.label
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:
resp.close()
return sent
data = tail + data
tail = data[-128:]
if len(data) > 128:
writer.write(data[:-128])
more = resp.read(CHUNK)
if not more:
break
data = more
resp.close()
if not (seg_kind == "mp3" and tail[:3] == b"TAG" and len(tail) == 128):
writer.write(tail)
sent += 1
i += 1
session.ended = True
return sent