Release 0.6.1: reliable end detection for gapless streams

BluOS freezes its counter once the sent audio runs out instead of
stopping; detect that, keep the stream open until played and refuse
reconnects to drained streams (no replay of the first track).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 10:47:58 +01:00
co-authored by Claude Opus 5.5
parent 2bdec88b11
commit 23314ebf33
3 changed files with 38 additions and 4 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.6.1 – 2026-10-26
- Gapless: detect the end of the sent audio when BluOS freezes its counter or reports "connecting"
(playlist end, follow-up track that can't be stitched) and react immediately
- Gapless: keep the stream open until the sent audio has been played; refuse reconnects to drained streams
## 0.6.0 – 2026-10-26
- Gapless: follow-up tracks are sent to the player only 20 s before they start, so queue changes in the
+14 -4
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.6.0"
VERSION = "0.6.1"
STARTED = time.time()
# --------------------------------------------------------------------------- config
@@ -389,6 +389,8 @@ class Renderer:
self.player_name = ""
self.session = None # gapless.Session while gapless playback runs
self.seg_index = 0
self._last_secs = -1 # stall detection for gapless streams
self._last_secs_at = 0.0
self.player_ok = 0.0
self.player_error = ""
self.control_points = {} # ip -> last seen (web UI)
@@ -739,12 +741,18 @@ class Renderer:
seg = sess.segments[self.seg_index]
self.position = secs - starts[self.seg_index] + (sess.start_secs if self.seg_index == 0 else 0)
self.player_duration = 0
# BluOS freezes its counter (or shows "connecting") once the sent audio has run out
now = time.time()
if secs != self._last_secs:
self._last_secs, self._last_secs_at = secs, now
stalled = state == "connecting" or (state in ("play", "stream") and now - self._last_secs_at >= 2.5)
drained = seg.duration and self.position >= seg.duration - 2 and stalled
if state in ("play", "stream"):
self._set_state("PLAYING")
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"):
(seg.duration and self.position >= seg.duration + 1) or state == "stop" or drained):
# end of the stitched stream; a follow-up that couldn't be stitched starts normally
log.info("gapless stream finished at %r", seg.label)
old = self.session
@@ -1572,10 +1580,12 @@ class Handler(BaseHTTPRequestHandler):
def _stream_session(self, session, label):
"""Serve a gapless session as one endless stream (optionally with ICY titles)."""
if session.closed or session.ended:
# BluOS reconnects to finished "radio" streams - don't replay the session from the start
if session.closed or session.ended or (
session.served and session.playback_secs >= session.end_of_streamed() - 3):
# BluOS reconnects to finished / drained "radio" streams - don't replay the session from the start
log.info("gapless stream %r already finished, refusing reconnect", label)
return self._send(410, "", "text/plain")
session.served += 1
# content type from getSong: a probe request would show up as a second playback on the server
ctype = session.segments[0].info.get("content_type") or "audio/mpeg"
icy = self.headers.get("Icy-MetaData") == "1"
+18
View File
@@ -148,6 +148,7 @@ class Session:
self.cond = threading.Condition()
self.closed = False
self.ended = False # proxy finished writing (no more segments)
self.served = 0 # number of player connections served
self.playback_secs = 0 # updated by the renderer from BluOS' counter
def starts(self):
@@ -161,6 +162,21 @@ class Session:
def end_of_last(self):
return sum(s.duration for s in self.segments) - self.start_secs
def end_of_streamed(self):
"""Playback time at which the audio sent so far runs out."""
sent = [s for i, s in enumerate(self.segments) if s.streamed or i == 0]
return sum(s.duration for s in sent) - self.start_secs
def hold_until_played(self, margin=1):
"""Keep the connection open until playback reaches the end of the sent audio.
BluOS treats an EOF like a dropped radio stream and reconnects at once, so ending the
stream early would make it replay the session from the start.
"""
with self.cond:
while not self.closed and self.playback_secs < self.end_of_streamed() - margin:
self.cond.wait(1)
def _queued(self):
"""The follow-up segment that hasn't been sent to the player yet (or None)."""
if len(self.segments) >= 2 and not self.segments[-1].streamed:
@@ -374,5 +390,7 @@ def stream_session(session, writer, kind_hint=None):
writer.write(tail)
sent += 1
i += 1
if sent:
session.hold_until_played()
session.ended = True
return sent