Files
scriptosandClaude Opus 5.5 04cd7c44b8
Build & Push Docker Image / Build & push image (push) Successful in 8m57s
Release 0.7.0: OpenHome, fades, gapless covers/conversion, monitoring
- OpenHome renderer (Product, Playlist, Time, Info, Volume): the app hands
  the whole queue to the bridge, which plays it on its own
- Soft fades on pause, stop and user track changes (FADE_MS)
- Gapless: cover per track, ffmpeg conversion of mixed formats, resume at
  the paused position after BluOS reconnects
- Monitoring: /api/health, /api/prtg, /metrics
- English web UI (UI_LANG=en|de)
- Multi-arch image build via Gitea Actions, image includes ffmpeg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:53:46 +01:00

474 lines
22 KiB
Python

"""OpenHome renderer services for bluos-upnp-bridge.
With OpenHome the control point (e.g. Symfonium in OpenHome mode) hands the *whole* play queue to
the bridge (Playlist service). The bridge then plays it on its own - the phone may sleep or leave -
and keeps the BluOS player fed with the current and the following track (gapless mode included).
Services: Product, Playlist, Time, Info, Volume (av-openhome-org, version 1).
The playback itself is done by the Renderer; this module only manages the playlist and maps it.
"""
import base64
import html
import logging
import xml.etree.ElementTree as ET
log = logging.getLogger("openhome")
SERVICES = {
"Product": "urn:av-openhome-org:service:Product:1",
"Playlist": "urn:av-openhome-org:service:Playlist:1",
"Time": "urn:av-openhome-org:service:Time:1",
"Info": "urn:av-openhome-org:service:Info:1",
"Volume": "urn:av-openhome-org:service:Volume:1",
}
SERVICE_ID_PREFIX = "urn:av-openhome-org:serviceId:"
TRACKS_MAX = 1000
def _a(name, direction, var):
return name, direction, var
def _vars(evented, args):
"""State variable table: evented vars + A_ARG helper vars (not evented)."""
table = {name: (dtype, "yes", allowed) for name, dtype, allowed in evented}
table.update({name: (dtype, "no", None) for name, dtype in args})
return table
ARG_VARS = [("A_ARG_String", "string"), ("A_ARG_Bool", "boolean"), ("A_ARG_Ui4", "ui4"), ("A_ARG_I4", "i4"),
("A_ARG_Base64", "bin.base64")]
S, B, U, I = "A_ARG_String", "A_ARG_Bool", "A_ARG_Ui4", "A_ARG_I4"
SPECS = {
"Product": ({
"Manufacturer": [_a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S), _a("ImageUri", "out", S)],
"Model": [_a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S), _a("ImageUri", "out", S)],
"Product": [_a("Room", "out", S), _a("Name", "out", S), _a("Info", "out", S), _a("Url", "out", S),
_a("ImageUri", "out", S)],
"Standby": [_a("Value", "out", B)],
"SetStandby": [_a("Value", "in", B)],
"SourceCount": [_a("Value", "out", U)],
"SourceXml": [_a("Value", "out", S)],
"SourceIndex": [_a("Value", "out", U)],
"SetSourceIndex": [_a("Value", "in", U)],
"SetSourceIndexByName": [_a("Value", "in", S)],
"Source": [_a("Index", "in", U), _a("SystemName", "out", S), _a("Type", "out", S), _a("Name", "out", S),
_a("Visible", "out", B)],
"Attributes": [_a("Value", "out", S)],
"SourceXmlChangeCount": [_a("Value", "out", U)],
}, _vars([(n, "string", None) for n in (
"ManufacturerName", "ManufacturerInfo", "ManufacturerUrl", "ManufacturerImageUri", "ModelName", "ModelInfo",
"ModelUrl", "ModelImageUri", "ProductRoom", "ProductName", "ProductInfo", "ProductUrl", "ProductImageUri",
"SourceXml", "Attributes")] + [("Standby", "boolean", None), ("SourceCount", "ui4", None),
("SourceIndex", "ui4", None)], ARG_VARS)),
"Playlist": ({
"Play": [], "Pause": [], "Stop": [], "Next": [], "Previous": [],
"SetRepeat": [_a("Value", "in", B)], "Repeat": [_a("Value", "out", B)],
"SetShuffle": [_a("Value", "in", B)], "Shuffle": [_a("Value", "out", B)],
"SeekSecondAbsolute": [_a("Value", "in", U)], "SeekSecondRelative": [_a("Value", "in", I)],
"SeekId": [_a("Value", "in", U)], "SeekIndex": [_a("Value", "in", U)],
"TransportState": [_a("Value", "out", S)], "Id": [_a("Value", "out", U)],
"Read": [_a("Id", "in", U), _a("Uri", "out", S), _a("Metadata", "out", S)],
"ReadList": [_a("IdList", "in", S), _a("TrackList", "out", S)],
"Insert": [_a("AfterId", "in", U), _a("Uri", "in", S), _a("Metadata", "in", S), _a("NewId", "out", U)],
"DeleteId": [_a("Value", "in", U)], "DeleteAll": [],
"TracksMax": [_a("Value", "out", U)],
"IdArray": [_a("Token", "out", U), _a("Array", "out", "A_ARG_Base64")],
"IdArrayChanged": [_a("Token", "in", U), _a("Value", "out", B)],
"ProtocolInfo": [_a("Value", "out", S)],
}, _vars([("TransportState", "string", ["Playing", "Paused", "Stopped", "Buffering"]),
("Repeat", "boolean", None), ("Shuffle", "boolean", None), ("Id", "ui4", None),
("IdArray", "bin.base64", None), ("TracksMax", "ui4", None), ("ProtocolInfo", "string", None)],
ARG_VARS)),
"Time": ({
"Time": [_a("TrackCount", "out", U), _a("Duration", "out", U), _a("Seconds", "out", U)],
}, _vars([("TrackCount", "ui4", None), ("Duration", "ui4", None), ("Seconds", "ui4", None)], ARG_VARS)),
"Info": ({
"Counters": [_a("TrackCount", "out", U), _a("DetailsCount", "out", U), _a("MetatextCount", "out", U)],
"Track": [_a("Uri", "out", S), _a("Metadata", "out", S)],
"Details": [_a("Duration", "out", U), _a("BitRate", "out", U), _a("BitDepth", "out", U),
_a("SampleRate", "out", U), _a("Lossless", "out", B), _a("CodecName", "out", S)],
"Metatext": [_a("Value", "out", S)],
}, _vars([("TrackCount", "ui4", None), ("DetailsCount", "ui4", None), ("MetatextCount", "ui4", None),
("Uri", "string", None), ("Metadata", "string", None), ("Duration", "ui4", None),
("BitRate", "ui4", None), ("BitDepth", "ui4", None), ("SampleRate", "ui4", None),
("Lossless", "boolean", None), ("CodecName", "string", None), ("Metatext", "string", None)],
ARG_VARS)),
"Volume": ({
"Characteristics": [_a("VolumeMax", "out", U), _a("VolumeUnity", "out", U), _a("VolumeSteps", "out", U),
_a("VolumeMilliDbPerStep", "out", U), _a("BalanceMax", "out", U),
_a("FadeMax", "out", U)],
"SetVolume": [_a("Value", "in", U)], "VolumeInc": [], "VolumeDec": [], "Volume": [_a("Value", "out", U)],
"SetBalance": [_a("Value", "in", I)], "BalanceInc": [], "BalanceDec": [], "Balance": [_a("Value", "out", I)],
"SetFade": [_a("Value", "in", I)], "FadeInc": [], "FadeDec": [], "Fade": [_a("Value", "out", I)],
"SetMute": [_a("Value", "in", B)], "Mute": [_a("Value", "out", B)],
"VolumeLimit": [_a("Value", "out", U)],
}, _vars([("Volume", "ui4", None), ("Mute", "boolean", None), ("Balance", "i4", None), ("Fade", "i4", None),
("VolumeLimit", "ui4", None), ("VolumeMax", "ui4", None), ("VolumeUnity", "ui4", None),
("VolumeSteps", "ui4", None), ("VolumeMilliDbPerStep", "ui4", None), ("BalanceMax", "ui4", None),
("FadeMax", "ui4", None)], ARG_VARS)),
}
TRANSPORT = {"PLAYING": "Playing", "PAUSED_PLAYBACK": "Paused", "TRANSITIONING": "Buffering"}
def _bool(value):
return "1" if value else "0"
def _parse_bool(value):
return str(value).strip().lower() in ("1", "true", "yes")
class OHError(Exception):
def __init__(self, code, desc):
super().__init__(desc)
self.code, self.desc = code, desc
class OpenHome:
"""Playlist state + action handlers. Uses the renderer's lock for everything."""
def __init__(self, renderer, product_name, room, sink_protocols, version):
self.r = renderer
self.lock = renderer.lock
self.product_name = product_name
self.room = room
self.sink_protocols = sink_protocols
self.version = version
self.tracks = [] # [(id, uri, metadata)]
self.next_id = 1
self.current_id = 0
self.repeat = False
self.shuffle = False
self.token = 0
self.track_count = 0
self.active = False # OpenHome is driving the renderer
self.notify = None # callback(service) set by the bridge
self._last = {}
self._busy = False
# ---- helpers
def _index(self, track_id):
for i, (tid, _, _) in enumerate(self.tracks):
if tid == track_id:
return i
return -1
def _following(self, track_id):
i = self._index(track_id)
if i < 0:
return None
if i + 1 < len(self.tracks):
return self.tracks[i + 1]
return self.tracks[0] if self.repeat and self.tracks else None
def _preceding(self, track_id):
i = self._index(track_id)
if i > 0:
return self.tracks[i - 1]
return self.tracks[-1] if self.repeat and self.tracks and i == 0 else None
def id_array(self):
return base64.b64encode(b"".join(t[0].to_bytes(4, "big") for t in self.tracks)).decode()
def _changed_array(self):
self.token += 1
self._sync_next()
self._emit("Playlist")
def _emit(self, service):
if self.notify:
self.notify(service)
def _sync_next(self):
"""Make the renderer's "next" track match the playlist."""
if not self.active or not self.current_id:
return
nxt = self._following(self.current_id)
uri, meta = (nxt[1], nxt[2]) if nxt else ("", "")
if (uri, meta) != (self.r.next_uri, self.r.next_meta):
self.r.set_next_uri(uri, meta)
def _play_id(self, track_id, start=True):
i = self._index(track_id)
if i < 0:
raise OHError(800, "Id not found")
tid, uri, meta = self.tracks[i]
self.active = True
self.current_id = tid
self.track_count += 1
log.info("OpenHome: play track %d of %d", i + 1, len(self.tracks))
# set the follow-up first, so a gapless session starts with the right queue
nxt = self._following(tid)
self.r.next_uri, self.r.next_meta = (nxt[1], nxt[2]) if nxt else ("", "")
self.r.set_uri(uri, meta)
self._sync_next()
if start and self.r.transport_state != "PLAYING":
self.r.play()
self._emit("Playlist")
self._emit("Info")
# ---- called by the renderer whenever its state changes
def on_renderer_change(self):
with self.lock:
if not self.active or self._busy:
return
self._busy = True
try:
self._on_change()
finally:
self._busy = False
def _on_change(self):
if True:
if self.r.current_uri and self.current_id:
cur = self.tracks[self._index(self.current_id)] if self._index(self.current_id) >= 0 else None
if not cur or cur[1] != self.r.current_uri:
# the renderer moved on (track ended, gapless crossing): find the new current track
nxt = self._following(self.current_id)
match = nxt if nxt and nxt[1] == self.r.current_uri else next(
(t for t in self.tracks if t[1] == self.r.current_uri), None)
if match:
self.current_id = match[0]
self.track_count += 1
self._sync_next()
self._emit("Info")
state = (self.current_id, self.transport_state(), self.r.position, self.r.volume, self.r.mute)
if state != self._last.get("state"):
prev = self._last.get("state") or (None,) * 5
self._last["state"] = state
if state[:2] != prev[:2]:
self._emit("Playlist")
if state[2] != prev[2] or state[0] != prev[0]:
self._emit("Time")
if state[3:] != prev[3:]:
self._emit("Volume")
def transport_state(self):
if not self.active or not self.r.owned:
return "Stopped"
return TRANSPORT.get(self.r.transport_state, "Stopped")
# ---- evented state per service
def state(self, service):
r = self.r
cur = self.tracks[self._index(self.current_id)] if self._index(self.current_id) >= 0 else (0, "", "")
if service == "Product":
return {
"ManufacturerName": "bluos-upnp-bridge", "ManufacturerInfo": "",
"ManufacturerUrl": "https://git.techniverse.net/scriptos/bluos-upnp-bridge",
"ManufacturerImageUri": "", "ModelName": "BluOS UPnP Bridge", "ModelInfo": self.version,
"ModelUrl": "", "ModelImageUri": "", "ProductRoom": self.room, "ProductName": self.product_name,
"ProductInfo": "", "ProductUrl": "", "ProductImageUri": "", "Standby": "0", "SourceCount": "1",
"SourceXml": self.source_xml(), "SourceIndex": "0", "Attributes": "Info Time Volume",
}
if service == "Playlist":
return {"TransportState": self.transport_state(), "Repeat": _bool(self.repeat),
"Shuffle": _bool(self.shuffle), "Id": str(self.current_id), "IdArray": self.id_array(),
"TracksMax": str(TRACKS_MAX), "ProtocolInfo": self.sink_protocols}
if service == "Time":
return {"TrackCount": str(self.track_count), "Duration": str(int(r.duration() or 0)),
"Seconds": str(int(r.position or 0))}
if service == "Info":
ctype = (r.current_info or {}).get("content_type", "")
return {"TrackCount": str(self.track_count), "DetailsCount": str(self.track_count),
"MetatextCount": "0", "Uri": cur[1], "Metadata": cur[2],
"Duration": str(int(r.duration() or 0)), "BitRate": "0", "BitDepth": "0", "SampleRate": "0",
"Lossless": _bool("flac" in ctype), "CodecName": ctype.split("/")[-1] if ctype else "",
"Metatext": ""}
if service == "Volume":
return {"Volume": str(r.volume), "Mute": _bool(r.mute), "Balance": "0", "Fade": "0",
"VolumeLimit": "100", "VolumeMax": "100", "VolumeUnity": "100", "VolumeSteps": "100",
"VolumeMilliDbPerStep": "1000", "BalanceMax": "0", "FadeMax": "0"}
return {}
def source_xml(self):
return ("<SourceList><Source><Name>Playlist</Name><Type>Playlist</Type><Visible>true</Visible>"
"</Source></SourceList>")
# ---- actions
def handle(self, service, action, args):
with self.lock:
return getattr(self, f"_{service.lower()}")(action, args)
def _product(self, action, args):
st = self.state("Product")
simple = {
"Manufacturer": {"Name": st["ManufacturerName"], "Info": "", "Url": st["ManufacturerUrl"], "ImageUri": ""},
"Model": {"Name": st["ModelName"], "Info": st["ModelInfo"], "Url": "", "ImageUri": ""},
"Product": {"Room": self.room, "Name": self.product_name, "Info": "", "Url": "", "ImageUri": ""},
"Standby": {"Value": "0"}, "SetStandby": {}, "SourceCount": {"Value": "1"},
"SourceXml": {"Value": st["SourceXml"]}, "SourceIndex": {"Value": "0"}, "SetSourceIndex": {},
"SetSourceIndexByName": {}, "Attributes": {"Value": st["Attributes"]},
"SourceXmlChangeCount": {"Value": "0"},
}
if action == "Source":
if args.get("Index", "0") != "0":
raise OHError(800, "Index out of range")
return {"SystemName": "Playlist", "Type": "Playlist", "Name": "Playlist", "Visible": "1"}
if action in simple:
return simple[action]
raise OHError(401, "Invalid Action")
def _playlist(self, action, args):
r = self.r
if action == "Play":
if not self.tracks:
return {}
if self.active and self.current_id and r.owned and r.transport_state == "PAUSED_PLAYBACK":
r.play()
elif not (self.active and r.transport_state == "PLAYING" and r.owned):
self._play_id(self.current_id if self._index(self.current_id) >= 0 else self.tracks[0][0])
return {}
if action == "Pause":
r.pause()
return {}
if action == "Stop":
r.stop()
return {}
if action in ("Next", "Previous"):
target = (self._following if action == "Next" else self._preceding)(self.current_id)
if target:
self._play_id(target[0])
elif action == "Previous" and self.current_id:
self._play_id(self.current_id)
return {}
if action == "SetRepeat":
self.repeat = _parse_bool(args.get("Value"))
self._sync_next()
self._emit("Playlist")
return {}
if action == "SetShuffle":
# the control point shuffles the queue itself; the flag is only reported back
self.shuffle = _parse_bool(args.get("Value"))
self._emit("Playlist")
return {}
if action == "Repeat":
return {"Value": _bool(self.repeat)}
if action == "Shuffle":
return {"Value": _bool(self.shuffle)}
if action == "SeekSecondAbsolute":
r.seek("REL_TIME", _hms(int(args.get("Value", "0"))))
return {}
if action == "SeekSecondRelative":
r.seek("REL_TIME", _hms(max(0, int(r.position) + int(args.get("Value", "0")))))
return {}
if action == "SeekId":
self._play_id(int(args.get("Value", "0")))
return {}
if action == "SeekIndex":
idx = int(args.get("Value", "0"))
if not 0 <= idx < len(self.tracks):
raise OHError(800, "Index out of range")
self._play_id(self.tracks[idx][0])
return {}
if action == "TransportState":
return {"Value": self.transport_state()}
if action == "Id":
return {"Value": str(self.current_id)}
if action == "Read":
i = self._index(int(args.get("Id", "0")))
if i < 0:
raise OHError(800, "Id not found")
return {"Uri": self.tracks[i][1], "Metadata": self.tracks[i][2]}
if action == "ReadList":
entries = []
for part in (args.get("IdList") or "").split():
i = self._index(int(part)) if part.isdigit() else -1
if i >= 0:
tid, uri, meta = self.tracks[i]
entries.append(f"<Entry><Id>{tid}</Id><Uri>{html.escape(uri)}</Uri>"
f"<Metadata>{html.escape(meta)}</Metadata></Entry>")
return {"TrackList": "<TrackList>" + "".join(entries) + "</TrackList>"}
if action == "Insert":
if len(self.tracks) >= TRACKS_MAX:
raise OHError(801, "Playlist full")
after = int(args.get("AfterId", "0"))
pos = 0 if after == 0 else self._index(after) + 1
if after and pos == 0:
raise OHError(800, "Id not found")
new_id = self.next_id
self.next_id += 1
self.tracks.insert(pos, (new_id, args.get("Uri", ""), args.get("Metadata", "")))
self._changed_array()
return {"NewId": str(new_id)}
if action == "DeleteId":
tid = int(args.get("Value", "0"))
i = self._index(tid)
if i < 0:
return {}
playing_this = tid == self.current_id
nxt = self._following(tid) if playing_this else None
del self.tracks[i]
if playing_this:
if nxt and nxt[0] != tid and r.transport_state == "PLAYING":
self._play_id(nxt[0])
else:
self.current_id = nxt[0] if nxt and nxt[0] != tid else 0
if not self.current_id:
r.stop()
self._changed_array()
return {}
if action == "DeleteAll":
self.tracks.clear()
self.current_id = 0
if self.active:
r.stop()
self.active = False
self._changed_array()
return {}
if action == "TracksMax":
return {"Value": str(TRACKS_MAX)}
if action == "IdArray":
return {"Token": str(self.token), "Array": self.id_array()}
if action == "IdArrayChanged":
return {"Value": _bool(str(self.token) != args.get("Token", ""))}
if action == "ProtocolInfo":
return {"Value": self.sink_protocols}
raise OHError(401, "Invalid Action")
def _time(self, action, args):
if action == "Time":
return self.state("Time")
raise OHError(401, "Invalid Action")
def _info(self, action, args):
st = self.state("Info")
if action == "Counters":
return {k: st[k] for k in ("TrackCount", "DetailsCount", "MetatextCount")}
if action == "Track":
return {"Uri": st["Uri"], "Metadata": st["Metadata"]}
if action == "Details":
return {k: st[k] for k in ("Duration", "BitRate", "BitDepth", "SampleRate", "Lossless", "CodecName")}
if action == "Metatext":
return {"Value": ""}
raise OHError(401, "Invalid Action")
def _volume(self, action, args):
r = self.r
st = self.state("Volume")
if action == "Characteristics":
return {k: st[k] for k in ("VolumeMax", "VolumeUnity", "VolumeSteps", "VolumeMilliDbPerStep",
"BalanceMax", "FadeMax")}
if action == "SetVolume":
r.set_volume(max(0, min(100, int(args.get("Value", "0")))))
elif action in ("VolumeInc", "VolumeDec"):
r.set_volume(max(0, min(100, r.volume + (2 if action == "VolumeInc" else -2))))
elif action == "SetMute":
r.set_mute(_parse_bool(args.get("Value")))
elif action in ("Volume", "Mute", "Balance", "Fade", "VolumeLimit"):
return {"Value": st[action]}
elif action in ("SetBalance", "BalanceInc", "BalanceDec", "SetFade", "FadeInc", "FadeDec"):
pass # BluOS has no balance/fade per player
else:
raise OHError(401, "Invalid Action")
self._emit("Volume")
return {}
def _hms(secs):
secs = max(0, int(secs))
return f"{secs // 3600}:{secs % 3600 // 60:02d}:{secs % 60:02d}"