Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3745ce5381 | ||
|
|
67a687f4e6 |
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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` |
|
||||
@@ -154,7 +155,7 @@ Basic auth runs over plain HTTP – put a reverse proxy with TLS in front if you
|
||||
|
||||
## OpenHome
|
||||
|
||||
Apps with OpenHome support (e.g. Symfonium, BubbleUPnP, Kazoo) can send the **complete play queue** to the
|
||||
Apps with OpenHome support (e.g. BubbleUPnP, Linn Kazoo) can send the **complete play queue** to the
|
||||
bridge instead of one track at a time. The bridge then plays the queue by itself, keeps the BluOS player fed
|
||||
with the current and the following track (gapless mode included) and reports position, track and volume
|
||||
back to every connected app. Your phone can go to sleep – the music keeps playing.
|
||||
@@ -162,6 +163,9 @@ back to every connected app. Your phone can go to sleep – the music keeps play
|
||||
Supported services: Product (single "Playlist" source), Playlist (incl. repeat), Time, Info, Volume.
|
||||
Shuffle is reported but left to the app (it sends the queue in shuffled order).
|
||||
|
||||
Note: **Symfonium does not support OpenHome** – it always uses the UPnP/DLNA path (current + next track),
|
||||
which works fine, but the app stays in charge of the queue.
|
||||
|
||||
## Gapless playback
|
||||
|
||||
BluOS restarts its audio pipeline for every URL, so normally there is a short gap between tracks. With
|
||||
@@ -176,9 +180,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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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."),
|
||||
|
||||
Reference in New Issue
Block a user