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:
@@ -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) --------------------------------------
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user