Release 0.7.1: gapless conversion only without quality loss
Build & Push Docker Image / Build & push image (push) Successful in 6m49s

LOSSLESS_ONLY (default true): follow-up tracks are only converted when
nothing is lost (MP3 into a FLAC stream). FLAC after MP3 or higher
resolution FLAC plays in original quality with a normal track change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 13:18:08 +01:00
co-authored by Claude Opus 5.5
parent 04cd7c44b8
commit 67a687f4e6
6 changed files with 55 additions and 5 deletions
+2
View File
@@ -30,6 +30,8 @@ REPEAT_OFF=true
PROXY_STREAMS=true
# experimental: stitch MP3/FLAC tracks into one continuous stream
GAPLESS=false
# gapless: convert follow-up tracks only if no quality is lost (MP3 -> FLAC)
LOSSLESS_ONLY=true
# fade in/out on pause, stop and track change (ms), 0 = off
FADE_MS=500
# offer OpenHome (app hands the whole queue to the bridge)
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.7.1 – 2026-10-26
- Gapless: `LOSSLESS_ONLY` (default `true`) – follow-up tracks are only converted if no quality is lost
(MP3 into a FLAC stream). A FLAC after an MP3 or a higher-resolution FLAC is played in original quality
with a normal track change instead of being converted to MP3 / resampled
## 0.7.0 – 2026-10-26
- **OpenHome renderer** (Product, Playlist, Time, Info, Volume): the app hands the whole queue to the bridge,
+12 -3
View File
@@ -126,6 +126,7 @@ All settings come from environment variables (`.env`). Settings saved in the web
| `CLIENT_NAME` | `bluos-upnp-bridge` | Client name shown by the server (e.g. `user@BluOS Player`); `{player}` = BluOS player name |
| `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) |
| `LOSSLESS_ONLY` | `true` | Gapless: convert follow-up tracks only if no quality is lost |
| `FADE_MS` | `500` | Fade in/out on pause, stop and track change in milliseconds, `0` = off |
| `OPENHOME` | `true` | Offer the OpenHome services (restart required) |
| `UI_LANG` | `en` | Language of the web UI: `en` or `de` |
@@ -176,9 +177,17 @@ changes in the app until then are respected:
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 and its cover (ICY metadata). Seeking restarts the
stream at the new position (short pause). Tracks in another format (MP3 ↔ FLAC, other sample rate) are
converted with **ffmpeg** to the stream's format (FLAC → MP3 with 320 kbit/s); without ffmpeg the bridge falls
back to a normal track change.
stream at the new position (short pause). The stream takes the format of its first track. Follow-up tracks in
another format are converted with **ffmpeg** – by default only if no quality is lost (`LOSSLESS_ONLY=true`):
| Stream | Follow-up track | Result |
| :--- | :--- | :--- |
| FLAC | MP3 | converted to FLAC (just re-packed, no further loss) – gapless |
| FLAC | FLAC with higher sample rate / bit depth | not converted – normal track change, original quality |
| MP3 | FLAC | not converted – normal track change, FLAC in original quality |
With `LOSSLESS_ONLY=false` every follow-up track is converted (FLAC → MP3 with 320 kbit/s, resampling).
Albums ripped in one format are never converted.
BluOS drops paused "radio" streams after about two minutes and reconnects on resume – the bridge then
continues exactly where playback was paused.
+8 -2
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.7.0"
VERSION = "0.7.1"
STARTED = time.time()
# --------------------------------------------------------------------------- config
@@ -87,6 +87,7 @@ GAPLESS = as_bool(cfg("GAPLESS", "false"))
FADE_MS = int(cfg("FADE_MS", "500") or 0)
OPENHOME = as_bool(cfg("OPENHOME", "true"))
UI_LANG = cfg("UI_LANG", "en").lower()
LOSSLESS_ONLY = as_bool(cfg("LOSSLESS_ONLY", "true"))
def L(de, en):
@@ -1381,6 +1382,9 @@ SETTING_FIELDS = [
("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."),
("LOSSLESS_ONLY", "Lückenlos nur ohne Qualitätsverlust", "bool", False,
"Wandelt Folgetitel nur um, wenn nichts verloren geht (MP3 → FLAC). Ein FLAC nach einem MP3 wird nicht zu "
"MP3, sondern im Original mit normalem Titelwechsel gespielt."),
("FADE_MS", "Ein-/Ausblenden (Millisekunden)", "number", False,
"Sanftes Ein- und Ausblenden bei Pause, Stopp und Titelwechsel. 0 = aus."),
("OPENHOME", "OpenHome anbieten", "bool", True,
@@ -1472,12 +1476,13 @@ def save_settings(form, renderer):
OVERRIDES.update(new)
# apply what can change at runtime
global REPEAT_OFF, PROXY_STREAMS, GAPLESS, FADE_MS, UI_LANG
global REPEAT_OFF, PROXY_STREAMS, GAPLESS, FADE_MS, UI_LANG, LOSSLESS_ONLY
REPEAT_OFF = as_bool(new["REPEAT_OFF"])
PROXY_STREAMS = as_bool(new["PROXY_STREAMS"])
GAPLESS = as_bool(new["GAPLESS"])
FADE_MS = min(int(new["FADE_MS"] or 0), 5000)
UI_LANG = new["UI_LANG"].lower()
LOSSLESS_ONLY = gapless.LOSSLESS_ONLY = as_bool(new["LOSSLESS_ONLY"])
logging.getLogger().setLevel(new["LOG_LEVEL"])
with renderer.lock:
renderer.subsonic = make_subsonic(new)
@@ -2043,6 +2048,7 @@ def main():
events = EventManager(renderer)
renderer.events = events
gapless.LOSSLESS_ONLY = LOSSLESS_ONLY
global OH
if OPENHOME:
SERVICES.update(openhome.SERVICES)
+23
View File
@@ -116,6 +116,22 @@ def mp3_rate(buf, pos):
FFMPEG = shutil.which("ffmpeg")
LOSSLESS_ONLY = True # only convert follow-up tracks if no quality is lost (set by the bridge)
def _lossless_conversion(src_kind, dst_kind, src_format, dst_params):
"""True if converting src into the stream format keeps the full quality.
MP3 -> FLAC only re-packs the decoded audio. FLAC -> FLAC is fine if sample rate and bit depth don't
go down (and the channels match). FLAC -> MP3 or downsampling would lose quality.
"""
if src_kind == "mp3" and dst_kind == "flac":
return True
if src_kind == "flac" and dst_kind == "flac" and src_format and dst_params:
rate, channels, bits = src_format
d_rate, d_channels, d_bits = flac_format(dst_params)
return channels == d_channels and d_rate >= rate and d_bits >= bits
return False
class Transcoder:
@@ -380,6 +396,7 @@ def stream_session(session, writer, kind_hint=None):
total = _total_size(resp)
buf = _read_at_least(resp, b"", 4096)
convert = False
src_format = None
if i > 0 and seg_kind == "flac" and kind == "flac":
while flac_header_len(buf) == -1:
more = resp.read(CHUNK)
@@ -388,6 +405,12 @@ def stream_session(session, writer, kind_hint=None):
buf += more
hl = flac_header_len(buf)
convert = hl > 0 and flac_params(buf[:hl]) != params # different sample rate / bit depth
src_format = flac_format(flac_params(buf[:hl])) if hl > 0 else None
if i > 0 and (seg_kind != kind or convert) and LOSSLESS_ONLY and not _lossless_conversion(seg_kind, kind, src_format, params):
log.info("segment %r (%s) would lose quality as %s, ending gapless stream (normal track change)",
seg.label, ctype or "?", kind)
resp.close()
break
if i > 0 and (seg_kind != kind or convert):
if not FFMPEG:
log.info("segment %r (%s) can't be stitched to %s (no ffmpeg), ending gapless stream",
+4
View File
@@ -102,6 +102,10 @@ FIELDS_EN = {
"track end. The player must reach the bridge."),
"GAPLESS": ("Gapless playback (experimental)", "Joins tracks into one continuous stream – for live albums, "
"mixes and audio plays. Other formats are converted with ffmpeg."),
"LOSSLESS_ONLY": ("Gapless only without quality loss", "Converts follow-up tracks only if nothing is lost "
"(MP3 → FLAC). A FLAC after an MP3 is not turned "
"into MP3 but played in original quality with a normal "
"track change."),
"FADE_MS": ("Fade in/out (milliseconds)", "Soft fades on pause, stop and track change. 0 = off."),
"OPENHOME": ("Offer OpenHome", "Your app hands the whole queue to the bridge – music keeps playing while "
"the phone sleeps."),