@@ -33,7 +33,7 @@ import xml.etree.ElementTree as ET
from email . utils import formatdate
from http . server import BaseHTTPRequestHandler , ThreadingHTTPServer
VERSION = " 0.4.0 "
VERSION = " 0.5.1 "
STARTED = time . time ( )
# --------------------------------------------------------------------------- config
@@ -74,6 +74,11 @@ FRIENDLY_NAME = cfg("FRIENDLY_NAME")
SUBSONIC_URL = cfg ( " SUBSONIC_URL " ) . rstrip ( " / " )
SUBSONIC_USER = cfg ( " SUBSONIC_USER " )
SUBSONIC_PASSWORD = cfg ( " SUBSONIC_PASSWORD " )
SERVER_TYPE = cfg ( " SERVER_TYPE " , " subsonic " ) . lower ( )
SUBSONIC_API_KEY = cfg ( " SUBSONIC_API_KEY " )
SUBSONIC_ALIASES = cfg ( " SUBSONIC_ALIASES " )
CLIENT_NAME = cfg ( " CLIENT_NAME " , " bluos-upnp-bridge " )
PLAYER_NAME = " " # BluOS player name, filled in at start for {player} in CLIENT_NAME
SSDP_INTERVAL = int ( cfg ( " SSDP_INTERVAL " , " 60 " ) )
LOG_LEVEL = cfg ( " LOG_LEVEL " , " INFO " ) . upper ( )
REPEAT_OFF = as_bool ( cfg ( " REPEAT_OFF " , " true " ) )
@@ -181,19 +186,44 @@ def parse_didl(meta):
# --------------------------------------------------------------------------- subsonic
class Subsonic :
""" Rewrites Subsonic URLs to the bridge ' s own service account (token auth). """
""" Rewrites Subsonic URLs to the bridge ' s own service account.
server_type " subsonic " : classic token auth (u + t + s).
server_type " opensubsonic " : OpenSubsonic servers (Airsonic Pulse, Navidrome, ...) - uses the
apiKey extension if an API key is set, otherwise the (hex encoded) password. Airsonic Pulse
stores passwords hashed, so token auth can ' t work there. The password never reaches the
player as long as streams are proxied.
aliases: further hostnames under which the control point reaches the same server
(e.g. a public domain while the bridge talks to the internal address).
"""
AUTH_PARAMS = { " u " , " p " , " t " , " s " , " apiKey " , " c " , " v " , " f " }
def __init__ ( self , base , user , password ) :
def __init__ ( self , base , user , password , server_type = " subsonic " , api_key = " " , aliases = " " ,
client = " bluos-upnp-bridge " ) :
self . client = client or " bluos-upnp-bridge "
self . base = base
self . user = user
self . password = password
self . server_type = server_type if server_type in ( " subsonic " , " opensubsonic " ) else " subsonic "
self . api_key = api_key if self . server_type == " opensubsonic " else " "
self . host = ( urllib . parse . urlsplit ( base ) . hostname or " " ) . lower ( ) if base else " "
self . hosts = { self . host } | { h . strip ( ) . lower ( ) for h in ( aliases or " " ) . replace ( " ; " , " , " ) . split ( " , " ) if h . strip ( ) }
self . hosts . discard ( " " )
@property
def enabled ( self ) :
return bool ( self . base and self . user and self . password )
return bool ( self . base and ( self . api_key or ( self . user and self . password ) ) )
@property
def password_auth ( self ) :
return self . server_type == " opensubsonic " and not self . api_key
@property
def auth_label ( self ) :
if self . api_key :
return " API-Key "
return f " { self . user } ( { ' Passwort ' if self . password_auth else ' Token ' } ) "
@staticmethod
def has_credentials ( url ) :
@@ -201,15 +231,20 @@ class Subsonic:
return any ( k in q for k in ( " p " , " t " , " apiKey " ) )
def _auth ( self ) :
if self . api_key :
return { " apiKey " : self . api_key , " v " : " 1.16.1 " , " c " : self . client }
if self . password_auth :
return { " u " : self . user , " p " : " enc: " + self . password . encode ( " utf-8 " ) . hex ( ) , " v " : " 1.16.1 " ,
" c " : self . client }
salt = secrets . token_hex ( 6 )
token = hashlib . md5 ( ( self . password + salt ) . encode ( " utf-8 " ) ) . hexdigest ( )
return { " u " : self . user , " t " : token , " s " : salt , " v " : " 1.13.0 " , " c " : " bluos-upnp-bridge " }
return { " u " : self . user , " t " : token , " s " : salt , " v " : " 1.13.0 " , " c " : self . client }
def is_subsonic ( self , url ) :
parts = urllib . parse . urlsplit ( url or " " )
if " /rest/ " not in parts . path :
return False
return not self . host or ( parts . hostname or " " ) . lower ( ) == self . host
return not self . hosts or ( parts . hostname or " " ) . lower ( ) in self . hosts
def song_id ( self , url ) :
q = urllib . parse . parse_qs ( urllib . parse . urlsplit ( url ) . query )
@@ -383,11 +418,13 @@ class Renderer:
def _art ( self , uri , info ) :
if self . subsonic . is_subsonic ( uri ) :
cover = self . subsonic . cover_url ( self . subsonic . song_id ( uri ) )
if cover :
return cover
song_id = self . subsonic . song_id ( uri )
if song_id and self . subsonic . enabled :
# served by the bridge, so no server credentials end up on the player
return f " http:// { ADVERTISE_IP } : { HTTP_PORT } /cover?id= { urllib . parse . quote ( song_id ) } "
return " "
art = info . get ( " art " , " " )
return self . subsonic . rewrite ( art , path = " getCoverArt " ) if art else " "
return " " if self . subsonic . has_credentials ( art ) else art
def _is_ours ( self , stream_url , st ) :
""" BluOS may re-encode the URL, so compare the significant query params only. """
@@ -466,6 +503,11 @@ class Renderer:
if proxied and GAPLESS :
return self . _start_gapless ( seek )
self . _close_session ( )
if not proxied and self . subsonic . password_auth and self . subsonic . is_subsonic ( self . current_uri ) :
# without the proxy the player would see (and store) the service account password
log . error ( " password auth needs PROXY_STREAMS=true or an API key - refusing to play " )
self . _set_state ( " STOPPED " )
raise UPnPError ( 716 , " Resource not found (enable stream proxy or set an API key) " )
offset_supported = not proxied and self . subsonic . is_subsonic ( self . current_uri ) and self . subsonic . enabled
upstream = self . subsonic . rewrite ( self . current_uri , offset = seek if offset_supported else 0 )
if upstream == self . current_uri and self . subsonic . has_credentials ( upstream ) :
@@ -1145,10 +1187,20 @@ SETTING_FIELDS = [
( " ADVERTISE_IP " , " IP-Adresse der Brücke " , " text " , True ,
" Unter dieser Adresse kündigt sich die Brücke per SSDP im Netz an. " ) ,
( " SSDP_INTERVAL " , " SSDP-Ankündigung alle … Sekunden " , " number " , True , " " ) ,
( " SUBSONIC_URL " , " Subsonic-Server " , " text " , False , " z. B. https://music.example.com " ) ,
( " SUBSONIC_USER " , " S ubsonic-Benutzer (Service-Account) " , " text " , False ,
" Lokaler Benutzer, der nur streamen darf. Wird in jede Stream-URL für den Player eingesetzt ." ) ,
( " SUBSONIC_PASSWORD " , " Subsonic-Passwort " , " password " , False , " Leer lassen = unverändert. " ) ,
( " SERVER_TYPE " , " Servertyp " , " select:subsonic,opensubsonic " , False ,
" s ubsonic = klassischer Subsonic (Token-Anmeldung). opensubsonic = Airsonic Pulse, Navidrome & Co. "
" (API-Key, ohne Key Passwort-Anmeldung – dann muss „Streams über die Brücke ausliefern“ an sein) ." ) ,
( " SUBSONIC_URL " , " Musikserver (von der Brücke aus) " , " text " , False ,
" z. B. http://192.168.1.20:4040 oder https://music.example.com " ) ,
( " SUBSONIC_ALIASES " , " Weitere Hostnamen des Servers " , " text " , False ,
" Kommagetrennt. Hostnamen, unter denen Symfonium denselben Server anspricht, z. B. die öffentliche Domain. " ) ,
( " CLIENT_NAME " , " Client-Name am Musikserver " , " text " , False ,
" So erscheint die Brücke beim Server, z. B. „benutzer@BluOS Player“. {player} = Name des BluOS-Players. " ) ,
( " SUBSONIC_USER " , " Service-Benutzer " , " text " , False ,
" Lokaler Benutzer (kein LDAP), der nur streamen darf. " ) ,
( " SUBSONIC_PASSWORD " , " Passwort des Service-Benutzers " , " password " , False , " Leer lassen = unverändert. " ) ,
( " SUBSONIC_API_KEY " , " API-Key des Service-Benutzers (nur opensubsonic) " , " password " , False ,
" Wird statt Benutzer/Passwort genutzt. Leer lassen = unverändert. " ) ,
( " REPEAT_OFF " , " BluOS-Wiederholung beim Abspielen ausschalten " , " bool " , False ,
" Empfohlen: Symfonium kümmert sich selbst um Wiederholen und Zufallswiedergabe. " ) ,
( " PROXY_STREAMS " , " Subsonic-Streams über die Brücke ausliefern " , " bool " , False ,
@@ -1161,6 +1213,12 @@ SETTING_FIELDS = [
RUNNING = { k : str ( globals ( ) [ k ] ) for k , * _ in SETTING_FIELDS } # values active since start
def make_subsonic ( values ) :
return Subsonic ( values [ " SUBSONIC_URL " ] . rstrip ( " / " ) , values [ " SUBSONIC_USER " ] , values [ " SUBSONIC_PASSWORD " ] ,
values [ " SERVER_TYPE " ] . lower ( ) , values [ " SUBSONIC_API_KEY " ] , values [ " SUBSONIC_ALIASES " ] ,
values [ " CLIENT_NAME " ] . replace ( " {player} " , PLAYER_NAME or " BluOS " ) . strip ( ) )
def current_settings ( ) :
return { k : cfg ( k , RUNNING [ k ] ) for k , * _ in SETTING_FIELDS }
@@ -1179,7 +1237,7 @@ def render_settings(notice=""):
parts . append ( f ' <label class= " check " ><input type= " checkbox " name= " { key } " value= " true " { checked } > '
f ' <span> { html . escape ( label ) } { badge } <br> { help_html } </span></label> ' )
elif kind . startswith ( " select: " ) :
opts = " " . join ( f ' <option { " selected " if o == value . upper ( ) else " " } > { o } </option> '
opts = " " . join ( f ' <option { " selected " if o . upper ( ) == value . upper ( ) else " " } > { o } </option> '
for o in kind [ 7 : ] . split ( " , " ) )
parts . append ( f ' <label> { html . escape ( label ) } { badge } <select name= " { key } " > { opts } </select> { help_html } </label> ' )
else :
@@ -1236,7 +1294,7 @@ def save_settings(form, renderer):
GAPLESS = as_bool ( new [ " GAPLESS " ] )
logging . getLogger ( ) . setLevel ( new [ " LOG_LEVEL " ] )
with renderer . lock :
renderer . subsonic = Subsonic ( new [ " SUBSONIC_URL " ] , new [ " SUBSONIC_USER " ] , new [ " SUBSONIC_PASSWORD " ] )
renderer . subsonic = make_subsonic ( new )
changed = [ k for k in new if new [ k ] != old . get ( k ) ]
log . info ( " settings saved via web UI (changed: %s ) " , " , " . join ( changed ) or " nothing " )
restart = [ label for key , label , _ , needs , _ in SETTING_FIELDS if needs and new [ key ] != RUNNING [ key ] ]
@@ -1260,7 +1318,12 @@ def run_action(action, renderer):
with urllib . request . urlopen ( f " { sub . base } /rest/ping.view? { q } " , timeout = 10 ) as r :
root = ET . fromstring ( r . read ( ) )
if root . get ( " status " ) == " ok " :
return True , f " Anmeldung als { sub . user } ok (API { root . get ( ' version ' ) } ). "
server = root . get ( " type " ) or " subsonic "
extra = f " , { server } { root . get ( ' serverVersion ' ) } " if root . get ( " serverVersion " ) else " "
hint = " "
if root . get ( " openSubsonic " ) == " true " and sub . server_type == " subsonic " :
hint = " Hinweis: Der Server kann OpenSubsonic – Servertyp „opensubsonic“ wählen. "
return True , f " Anmeldung mit { sub . auth_label } ok (API { root . get ( ' version ' ) } { extra } ). { hint } "
err = root . find ( " { http://subsonic.org/restapi}error " )
return False , f " Subsonic meldet: { err . get ( ' message ' ) if err is not None else ' unbekannter Fehler ' } "
if action == " pause " :
@@ -1277,21 +1340,28 @@ def run_action(action, renderer):
class CoverCache :
""" Serves the current cover without exposing the Subsonic token to the browser . """
""" Serves covers to the browser and the player without exposing server credentials . """
def __init__ ( self ) :
self . key = None
self . data = ( b " " , " " )
def __init__ ( self , size = 20 ) :
self . items = collections . OrderedDict ( )
self . size = size
self . lock = threading . Lock ( )
def get ( self , subsonic , song_id ) :
with self . lock :
if self . key != song_id :
url = subsonic . cover_url ( song_id , 300 )
with urllib . request . urlopen ( url , timeout = 10 ) as r :
self . data = ( r . read ( ) , r . headers . get ( " Content-Type " , " image/jpeg " ) )
self . key = song_id
return self . data
if song_id in self . items :
self . items . move_to_end ( song_id )
return self . items [ song_id ]
with urllib . request . urlopen ( subsonic . cover_url ( song_id , 600 ) , timeout = 10 ) as r :
ctype = r . headers . get ( " Content-Type " , " image/jpeg " )
if not ctype . startswith ( " image/ " ) :
raise ValueError ( f " no image ( { ctype } ) " )
data = ( r . read ( ) , ctype )
with self . lock :
self . items [ song_id ] = data
while len ( self . items ) > self . size :
self . items . popitem ( last = False )
return data
COVERS = CoverCache ( )
@@ -1386,7 +1456,8 @@ class Handler(BaseHTTPRequestHandler):
" version " : VERSION , " uptime " : int ( time . time ( ) - STARTED ) , " name " : self . name ,
" bluos_host " : BLUOS_HOST , " bluos_port " : BLUOS_PORT ,
" location " : f " http:// { ADVERTISE_IP } : { HTTP_PORT } /description.xml " ,
" subsonic " : { " enabled " : r . subsonic . enabled , " user " : r . subsonic . user , " url " : r . subsonic . base } ,
" subsonic " : { " enabled " : r . subsonic . enabled , " user " : r . subsonic . auth_label , " url " : r . subsonic . base ,
" type " : r . subsonic . server_type } ,
" repeat_off " : REPEAT_OFF , " subscriptions " : len ( self . events . subs ) ,
} )
return data
@@ -1412,8 +1483,7 @@ class Handler(BaseHTTPRequestHandler):
return self . _stream ( path [ len ( " /stream/ " ) : ] )
if path == " /cover " :
song_id = ( query . get ( " id " ) or [ " " ] ) [ 0 ]
snap = self . renderer . snapshot ( ) [ " current " ]
if not snap or snap [ " song_id " ] != song_id or not song_id :
if not song_id or not re . fullmatch ( r " [A-Za-z0-9_.:-] { 1,128} " , song_id ) :
return self . _send ( 404 , " " , " text/plain " )
try :
data , ctype = COVERS . get ( self . renderer . subsonic , song_id )
@@ -1680,8 +1750,10 @@ class SSDP:
def main ( ) :
bluos = BluOS ( BLUOS_HOST , BLUOS_PORT )
subsonic = Subsonic ( SUBSONIC_URL , SUBSONIC_USER , SUBSONIC_PASSWORD )
subsonic = make_subsonic ( current_settings ( ) )
global PLAYER_NAME
player_name = bluos . name ( )
PLAYER_NAME = player_name
name = FRIENDLY_NAME or ( player_name or BLUOS_HOST ) + " (BluOS Bridge) "
renderer = Renderer ( bluos , subsonic )