Files
scriptosandClaude Opus 5.5 67a687f4e6
Build & Push Docker Image / Build & push image (push) Successful in 6m49s
Release 0.7.1: gapless conversion only without quality loss
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>
2026-10-26 13:18:08 +01:00

244 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTML templates and texts for the bluos-upnp-bridge web UI (English and German)."""
import json
BASE_CSS = """
:root{--bg:#f4f5f7;--card:#fff;--text:#1d2129;--muted:#6b7280;--line:#e5e7eb;--accent:#2563eb;
--ok:#16a34a;--warn:#d97706;--err:#dc2626;--chip:#eef2ff;--mono:ui-monospace,SFMono-Regular,Consolas,monospace}
@media (prefers-color-scheme:dark){:root{--bg:#0f1115;--card:#181b22;--text:#e6e8ec;--muted:#9aa3b2;
--line:#2a2f3a;--accent:#60a5fa;--chip:#1e293b}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;
border-bottom:1px solid var(--line);background:var(--card)}
header h1{font-size:18px;margin:0}header nav a{color:var(--accent);text-decoration:none;margin-left:14px}
main{max-width:1100px;margin:0 auto;padding:20px 16px;display:grid;gap:16px;
grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px}
.card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 12px}
.wide{grid-column:1/-1}.muted{color:var(--muted)}.mono{font-family:var(--mono);font-size:13px}
.chip{display:inline-block;padding:2px 10px;border-radius:999px;background:var(--chip);font-size:12px;font-weight:600}
.ok{color:var(--ok)}.warn{color:var(--warn)}.err{color:var(--err)}
dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin:0}dt{color:var(--muted)}dd{margin:0;word-break:break-word}
button,.btn{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--text);
border-radius:8px;padding:7px 14px;cursor:pointer}button.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
button.danger{color:var(--err)}button:disabled{opacity:.5;cursor:default}
"""
TEXT = {
"en": {
"locale": "en-GB", "bridge": "BluOS Bridge", "settings": "Settings", "to_status": "Back to status page",
"now_playing": "Now playing", "nothing": "Nothing selected", "unknown_title": "Unknown title",
"player": "Bluesound player", "events": "Events", "up_next": "Up next",
"st_playing": "Playing", "st_paused": "Paused", "st_stopped": "Stopped", "st_ready": "Ready",
"st_loading": "Loading …", "elsewhere": " (player busy with another source)", "unreachable": "Bridge unreachable",
"never": "never", "just_now": "just now", "ago_s": "{n} s ago", "ago_min": "{n} min ago",
"rep_queue": "Queue", "rep_one": "One track", "rep_off": "Off",
"name": "Name", "address": "Address", "connection": "Connection", "error": "Error", "state": "State",
"source": "Source", "display": "Display", "format": "Format", "volume": "Volume", "muted": " (muted)",
"repeat": "Repeat", "version": "Version", "uptime": "Uptime", "announced": "Announced as",
"music_server": "Music server", "active_as": "active as", "off": "off", "repeat_off": "Repeat off",
"yes": "yes", "no": "no", "controllers": "Controlling devices", "subscriptions": "Subscriptions (events)",
"openhome": "OpenHome", "gapless": "Gapless",
"sec_bridge": "Bridge & music server", "save": "Save",
"saved_in": "Settings are stored in", "restart_hint1": "Fields marked",
"restart_hint2": "take effect after restarting the bridge.", "restart": "Restart", "restart_pending":
"Restart pending", "tools": "Tools", "test_player": "Test player connection",
"test_server": "Test music server login", "pause": "Pause", "resume": "Resume",
"restart_bridge": "Restart bridge", "confirm_restart":
"Really restart the bridge? Playback continues, but your app has to reconnect.",
"not_writable": "Warning: {dir} is not writable, settings can't be saved.",
"session_expired": "Session expired, please save again.",
},
"de": {
"locale": "de-DE", "bridge": "BluOS-Brücke", "settings": "Einstellungen", "to_status": "Zur Statusseite",
"now_playing": "Läuft gerade", "nothing": "Nichts ausgewählt", "unknown_title": "Unbekannter Titel",
"player": "Bluesound-Player", "events": "Ereignisse", "up_next": "Als Nächstes",
"st_playing": "Spielt", "st_paused": "Pausiert", "st_stopped": "Gestoppt", "st_ready": "Bereit",
"st_loading": "Lädt …", "elsewhere": " (Player woanders aktiv)", "unreachable": "Brücke nicht erreichbar",
"never": "nie", "just_now": "gerade eben", "ago_s": "vor {n} s", "ago_min": "vor {n} min",
"rep_queue": "Warteschlange", "rep_one": "Ein Titel", "rep_off": "Aus",
"name": "Name", "address": "Adresse", "connection": "Verbindung", "error": "Fehler", "state": "Status",
"source": "Quelle", "display": "Anzeige", "format": "Format", "volume": "Lautstärke", "muted": " (stumm)",
"repeat": "Wiederholen", "version": "Version", "uptime": "Läuft seit", "announced": "Angekündigt als",
"music_server": "Musikserver", "active_as": "aktiv als", "off": "aus", "repeat_off": "Wiederholung aus",
"yes": "ja", "no": "nein", "controllers": "Steuernde Geräte", "subscriptions": "Abos (Events)",
"openhome": "OpenHome", "gapless": "Lückenlos",
"sec_bridge": "Brücke & Musikserver", "save": "Speichern",
"saved_in": "Gespeichert wird in", "restart_hint1": "Felder mit",
"restart_hint2": "werden erst nach einem Neustart der Brücke aktiv.", "restart": "Neustart",
"restart_pending": "Neustart ausstehend", "tools": "Werkzeuge", "test_player": "Player-Verbindung testen",
"test_server": "Musikserver-Anmeldung testen", "pause": "Pause", "resume": "Fortsetzen",
"restart_bridge": "Brücke neu starten", "confirm_restart":
"Brücke wirklich neu starten? Die laufende Wiedergabe bleibt, die App muss sich aber neu verbinden.",
"not_writable": "Achtung: {dir} ist nicht beschreibbar, Einstellungen können nicht gespeichert werden.",
"session_expired": "Sitzung abgelaufen, bitte erneut speichern.",
},
}
# English labels/help for the settings page (German ones live in bridge.SETTING_FIELDS)
FIELDS_EN = {
"UI_LANG": ("Language of the web UI", "en = English, de = Deutsch."),
"FRIENDLY_NAME": ("Name on the network", "How the bridge shows up in your app. Empty = player name + "
"“(BluOS Bridge)”."),
"BLUOS_HOST": ("BluOS player (IP or hostname)", ""),
"BLUOS_PORT": ("BluOS API port", "Default: 11000"),
"ADVERTISE_IP": ("IP address of the bridge", "The bridge announces itself on this address via SSDP."),
"SSDP_INTERVAL": ("SSDP announcement every … seconds", ""),
"SERVER_TYPE": ("Server type", "subsonic = classic Subsonic (token auth). opensubsonic = Airsonic Pulse, "
"Navidrome & co. (API key, without key password auth – then “serve "
"streams through the bridge” must be on)."),
"SUBSONIC_URL": ("Music server (as seen from the bridge)", "e.g. http://192.168.1.20:4040 or "
"https://music.example.com"),
"SUBSONIC_ALIASES": ("Further hostnames of the server", "Comma-separated. Hostnames your app uses for the same "
"server, e.g. its public domain."),
"CLIENT_NAME": ("Client name towards the music server", "Shown by the server, e.g. “user@BluOS Player”. "
"{player} = name of the BluOS player."),
"SUBSONIC_USER": ("Service user", "Local user (no LDAP) that may only stream."),
"SUBSONIC_PASSWORD": ("Password of the service user", "Leave empty to keep it."),
"SUBSONIC_API_KEY": ("API key of the service user (opensubsonic only)", "Used instead of user/password. "
"Leave empty to keep it."),
"REPEAT_OFF": ("Switch BluOS repeat off on playback", "Recommended: your app handles repeat and shuffle itself."),
"PROXY_STREAMS": ("Serve music server streams through the bridge", "Recommended: enables seeking and a clean "
"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."),
"LOG_LEVEL": ("Log level", ""),
}
STATUS_HTML = """<!doctype html><html lang="__LANG__"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{bridge}}</title>
<style>__CSS__
.now{display:flex;gap:16px;align-items:center}.cover{width:120px;height:120px;border-radius:10px;object-fit:cover;
background:var(--line);flex:none}.title{font-size:20px;font-weight:650;margin:0}.artist{margin:2px 0}
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:12px}
.bar>div{height:100%;background:var(--accent);width:0;transition:width .9s linear}
.times{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-top:4px}
.log{max-height:380px;overflow:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55}
.log div{padding:1px 0;border-bottom:1px dashed var(--line);white-space:pre-wrap;word-break:break-word}
.log .t{color:var(--muted);margin-right:8px}.log .WARNING{color:var(--warn)}.log .ERROR{color:var(--err)}
</style></head><body>
<header><h1 id="name">{{bridge}}</h1><nav><span class="muted mono" id="ver"></span><a href="/settings">{{settings}}</a></nav></header>
<main>
<section class="card wide"><h2>{{now_playing}}</h2>
<div class="now"><img class="cover" id="cover" alt="">
<div style="flex:1;min-width:0"><span class="chip" id="state">–</span>
<p class="title" id="title">{{nothing}}</p><p class="artist" id="artist"></p><p class="muted" id="album"></p></div></div>
<div class="bar"><div id="bar"></div></div><div class="times"><span id="pos">0:00</span><span id="dur">0:00</span></div>
<p class="muted" id="next" style="margin:10px 0 0"></p>
</section>
<section class="card"><h2>{{player}}</h2><dl id="player"></dl></section>
<section class="card"><h2>{{bridge}}</h2><dl id="bridge"></dl></section>
<section class="card wide"><h2>{{events}}</h2><div class="log" id="log"></div></section>
</main>
<script>
const T=__T__;
const $=id=>document.getElementById(id);
const STATES={PLAYING:[T.st_playing,"ok"],PAUSED_PLAYBACK:[T.st_paused,"warn"],STOPPED:[T.st_stopped,""],
NO_MEDIA_PRESENT:[T.st_ready,""],TRANSITIONING:[T.st_loading,"warn"]};
const fmt=s=>{s=Math.max(0,Math.floor(s||0));const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=String(s%60).padStart(2,"0");
return h?`${h}:${String(m).padStart(2,"0")}:${x}`:`${m}:${x}`};
const ago=t=>{if(!t)return T.never;const d=Math.round(Date.now()/1000-t);
return d<5?T.just_now:d<120?T.ago_s.replace("{n}",d):T.ago_min.replace("{n}",Math.round(d/60))};
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const dl=(el,rows)=>{el.innerHTML=rows.map(([k,v])=>`<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")};
let coverKey=null;
async function status(){
try{const s=await (await fetch("/api/status",{cache:"no-store"})).json();
$("name").textContent=s.name;document.title=s.name;$("ver").textContent="v"+s.version;
const [label,cls]=STATES[s.transport_state]||[s.transport_state,""];
$("state").textContent=label+(s.owned||!s.current?"":T.elsewhere);$("state").className="chip "+cls;
const c=s.current;$("title").textContent=c?(c.title||T.unknown_title):T.nothing;
$("artist").textContent=c?c.artist:"";$("album").textContent=c?c.album:"";
const key=c&&c.song_id?c.song_id:null;if(key!==coverKey){coverKey=key;$("cover").src=key?"/cover?id="+encodeURIComponent(key):"";}
$("cover").style.visibility=key?"visible":"hidden";
const d=s.duration||0;$("pos").textContent=fmt(s.position);$("dur").textContent=d?fmt(d):"–";
$("bar").style.width=d?Math.min(100,s.position/d*100)+"%":"0";
$("next").textContent=s.next?`${T.up_next}: ${s.next.title}${s.next.artist?" – "+s.next.artist:""}`:"";
const p=s.player||{},rep={"0":T.rep_queue,"1":T.rep_one,"2":T.rep_off}[p.repeat]||"–";
dl($("player"),[[T.name,esc(p.name||s.bluos_host)],[T.address,`<span class="mono">${esc(s.bluos_host)}:${s.bluos_port}</span>`],
[T.connection,s.player_error?`<span class="err">${T.error}: ${esc(s.player_error)}</span>`:`<span class="ok">ok</span> <span class="muted">(${ago(s.player_ok)})</span>`],
[T.state,esc(p.state||"–")],[T.source,esc(p.service||"–")],[T.display,esc([p.title1,p.title2].filter(Boolean).join(" – ")||"–")],
[T.format,esc(p.streamFormat||"–")],[T.volume,`${s.volume}${s.mute?T.muted:""}`],[T.repeat,rep]]);
const cps=Object.entries(s.control_points||{}).sort((a,b)=>b[1]-a[1]).map(([ip,t])=>`<span class="mono">${esc(ip)}</span> <span class="muted">${ago(t)}</span>`).join("<br>")||"–";
dl($("bridge"),[[T.version,esc(s.version)],[T.uptime,fmt(s.uptime)],[T.announced,`<span class="mono">${esc(s.location)}</span>`],
[T.music_server,s.subsonic.enabled?`<span class="ok">${T.active_as}</span> <span class="mono">${esc(s.subsonic.user)}</span>`:`<span class="warn">${T.off}</span>`],
[T.openhome,s.openhome?T.yes:T.no],[T.gapless,s.gapless?T.yes:T.no],
[T.repeat_off,s.repeat_off?T.yes:T.no],[T.controllers,cps],[T.subscriptions,s.subscriptions]]);
}catch(e){$("state").textContent=T.unreachable;$("state").className="chip err";}
}
let lastId=0;
async function logs(){
try{const r=await (await fetch("/api/log?after="+lastId,{cache:"no-store"})).json();const box=$("log");
const atBottom=box.scrollHeight-box.scrollTop-box.clientHeight<30;
for(const l of r){lastId=l.id;const d=document.createElement("div");
d.innerHTML=`<span class="t">${new Date(l.t*1000).toLocaleTimeString(T.locale)}</span><span class="${l.level}">${esc(l.msg)}</span>`;box.appendChild(d);}
while(box.children.length>300)box.removeChild(box.firstChild);if(atBottom)box.scrollTop=box.scrollHeight;
}catch(e){}
}
status();logs();setInterval(status,1000);setInterval(logs,2000);
</script></body></html>
""".replace("__CSS__", BASE_CSS)
SETTINGS_HTML = """<!doctype html><html lang="__LANG__"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{settings}} · {{bridge}}</title>
<style>__CSS__
main{grid-template-columns:1fr}form{display:grid;gap:14px}
label{display:grid;gap:4px;font-weight:600}label small{font-weight:400;color:var(--muted)}
input[type=text],input[type=number],input[type=password],select{font:inherit;padding:8px 10px;border-radius:8px;
border:1px solid var(--line);background:var(--bg);color:var(--text);width:100%}
.check{display:flex;gap:10px;align-items:flex-start;font-weight:600}.check input{margin-top:5px}
.restart{color:var(--warn);font-weight:400;font-size:12px}
.notice{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}.notice.ok{border-color:var(--ok)}.notice.err{border-color:var(--err)}
.actions{display:flex;flex-wrap:wrap;gap:10px}#out{white-space:pre-wrap;margin-top:12px}
</style></head><body>
<header><h1>{{settings}}</h1><nav><a href="/">{{to_status}}</a></nav></header>
<main>
__NOTICE__
<section class="card"><h2>{{sec_bridge}}</h2>
<form method="post" action="/settings"><input type="hidden" name="csrf" value="__CSRF__">
__FIELDS__
<div class="actions"><button class="primary" type="submit">{{save}}</button></div>
<p class="muted" style="margin:0">{{saved_in}} <span class="mono">__FILE__</span>. {{restart_hint1}}
<span class="restart">{{restart}}</span> {{restart_hint2}}</p>
</form></section>
<section class="card"><h2>{{tools}}</h2>
<div class="actions">
<button data-a="test_player">{{test_player}}</button>
<button data-a="test_subsonic">{{test_server}}</button>
<button data-a="pause">{{pause}}</button><button data-a="play">{{resume}}</button>
<button class="danger" data-a="restart">{{restart_bridge}}</button></div>
<div id="out" class="mono muted"></div></section>
</main>
<script>
const T=__T__;
document.querySelectorAll("button[data-a]").forEach(b=>b.addEventListener("click",async()=>{
const a=b.dataset.a;if(a==="restart"&&!confirm(T.confirm_restart))return;
const out=document.getElementById("out");out.textContent="…";b.disabled=true;
try{const r=await fetch("/api/action",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":"__CSRF__"},body:JSON.stringify({action:a})});
const j=await r.json();out.textContent=(j.ok?"✔ ":"✖ ")+j.message;out.className="mono "+(j.ok?"ok":"err");
if(a==="restart"&&j.ok)setTimeout(()=>location.reload(),6000);
}catch(e){out.textContent="✖ "+e;out.className="mono err";}finally{b.disabled=false;}
}));
</script></body></html>
""".replace("__CSS__", BASE_CSS)
def texts(lang):
return TEXT.get(lang, TEXT["en"])
def localize(template, lang):
"""Fill {{key}} placeholders and the JS text table for the given language."""
t = texts(lang)
out = template.replace("__LANG__", lang if lang in TEXT else "en").replace("__T__", json.dumps(t))
for key, value in t.items():
out = out.replace("{{" + key + "}}", value)
return out