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