Release 0.5.2: no duplicate playback entries in gapless mode

The content type of the first track is taken from getSong instead of a
short probe stream request, which the music server counted as a second
playback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 15:31:40 +01:00
co-authored by Claude Opus 5.5
parent e813e3ed56
commit 5f232a2093
2 changed files with 22 additions and 16 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.5.2 – 2026-10-25
- Fix: gapless mode showed every track twice as playing on the server – the content type is now taken
from `getSong` instead of a probe stream request
## 0.5.1 – 2026-10-25
- `CLIENT_NAME`: configurable client name towards the music server (`{player}` = BluOS player name)
+17 -16
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.5.1"
VERSION = "0.5.2"
STARTED = time.time()
# --------------------------------------------------------------------------- config
@@ -277,18 +277,24 @@ 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)."""
def song_info(self, song_id):
"""Length and content type from getSong - unlike a stream request, this doesn't count as playback."""
info = {"duration": 0, "content_type": ""}
if not (self.enabled and song_id):
return 0
return info
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
if song is not None:
info = {"duration": int(song.get("duration", "0") or 0), "content_type": song.get("contentType", "")}
except Exception as exc:
log.debug("getSong %s failed: %s", song_id, exc)
return 0
return info
def duration(self, song_id):
"""Track length in seconds from getSong (0 if unknown)."""
return self.song_info(song_id)["duration"]
def cover_url(self, song_id, size=600):
if not (self.enabled and song_id):
@@ -459,8 +465,9 @@ class Renderer:
def _segment(self, uri, meta):
info = parse_didl(meta)
if not info["duration"]:
info["duration"] = self.subsonic.duration(self.subsonic.song_id(uri))
song = self.subsonic.song_info(self.subsonic.song_id(uri))
info["duration"] = info["duration"] or song["duration"]
info["content_type"] = song["content_type"]
return gapless.Segment(self.subsonic.rewrite(uri), uri, meta, info, self._title(info))
def _close_session(self):
@@ -1558,14 +1565,8 @@ class Handler(BaseHTTPRequestHandler):
"""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")
# 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"
log.info("gapless stream %r -> player (%s%s)", label, ctype, ", ICY titles" if icy else "")
self.close_connection = True