diff --git a/.env.example b/.env.example index 02a10f4..e1878b9 100644 --- a/.env.example +++ b/.env.example @@ -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) -------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index ad49e81..d065dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`) 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 diff --git a/Dockerfile b/Dockerfile index be43d24..e9a8b07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/LICENSE b/LICENSE index 070c511..58ed85b 100644 --- a/LICENSE +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 7b9d0a3..4210d92 100644 --- a/README.md +++ b/README.md @@ -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 `. ## 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 diff --git a/bridge.py b/bridge.py index 4ebd98a..1d805c8 100644 --- a/bridge.py +++ b/bridge.py @@ -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:///stream/; 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 diff --git a/gapless.py b/gapless.py new file mode 100644 index 0000000..b270da0 --- /dev/null +++ b/gapless.py @@ -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