Add spotify integration
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-15 22:07:43 +02:00
parent 6a0f1fa5e8
commit 7c19ff9096
17 changed files with 810 additions and 44 deletions
+81 -4
View File
@@ -23,6 +23,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
import config
from controller import Controller, TargetError
from heos import HeosError
from spotify import SpotifyClient, SpotifyError
app = Flask(__name__)
@@ -33,6 +34,18 @@ app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
controller: Controller = None
spotify: dict = {} # account key -> SpotifyClient, configured accounts only
def _build_spotify() -> dict:
if not (config.SPOTIFY_CLIENT_ID and config.SPOTIFY_CLIENT_SECRET):
return {}
return {
key: SpotifyClient(config.SPOTIFY_CLIENT_ID, config.SPOTIFY_CLIENT_SECRET, account["refresh_token"])
for key, account in config.SPOTIFY_ACCOUNTS.items()
if account.get("refresh_token")
}
if __name__ != "__main__":
# Imported by a WSGI server rather than run as a script, so main()'s
@@ -43,6 +56,7 @@ if __name__ != "__main__":
controller = DemoController(config)
else:
controller = Controller(config)
spotify = _build_spotify()
def handle_errors(view):
@@ -53,7 +67,7 @@ def handle_errors(view):
return view(*args, **kwargs)
except (TargetError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
except HeosError as exc:
except (HeosError, SpotifyError) as exc:
return jsonify({"error": str(exc)}), 502
return wrapped
@@ -79,15 +93,54 @@ def _target_from(data: dict, field: str = "target") -> str:
return key
def _spotify_from(data: dict) -> SpotifyClient:
if not spotify:
raise ValueError("Spotify isn't configured -- see the README's Spotify section")
key = data.get("account")
if not key:
raise ValueError(f"Missing 'account'. Spotify accounts: {', '.join(spotify)}")
if key not in spotify:
raise ValueError(f"Unknown Spotify account '{key}'. Spotify accounts: {', '.join(spotify)}")
return spotify[key]
def _spotify_name(key: str) -> str:
target = config.TARGETS[key]
return target.get("spotify_name", target["heos_name"])
def _mark_spotify_accounts(rooms: list):
"""Tag each room HEOS says is on Spotify with the account playing it, so
its card can pick out that account's button. Spotify is only asked when
some room is on Spotify at all, and an account it refuses (a revoked
token, say) just matches nothing instead of failing the whole poll."""
on_spotify = [room for room in rooms if room.get("spotify")]
if not (spotify and on_spotify):
return
playing_on = {} # device name -> account key
for account, client in spotify.items():
try:
player = client.playback()
except (SpotifyError, OSError):
continue
device = (player.get("device") or {}).get("name")
# Should two accounts both claim a device, the one actually playing wins.
if device and (device not in playing_on or player.get("is_playing")):
playing_on[device] = account
for room in on_spotify:
room["spotify_account"] = playing_on.get(_spotify_name(room["key"]))
# --- The UI -----------------------------------------------------------
@app.route("/")
def index():
return render_template(
"index.html",
app_name=config.APP_NAME,
host=config.TARGETS[config.HOST_KEY],
host={"key": config.HOST_KEY, **config.TARGETS[config.HOST_KEY]},
rooms=[{"key": key, **config.TARGETS[key]} for key in config.ROOM_KEYS],
step=config.VOLUME_STEP,
spotify_accounts=[{"key": key, "label": config.SPOTIFY_ACCOUNTS[key]["label"]} for key in spotify],
)
@@ -104,7 +157,9 @@ def manifest():
@app.get("/api/state")
@handle_errors
def api_state():
return jsonify(controller.state())
data = controller.state()
_mark_spotify_accounts(data["rooms"])
return jsonify(data)
@app.get("/api/targets")
@@ -213,6 +268,27 @@ def api_avr_set_input():
return jsonify(controller.avr_select_input(code))
@app.get("/api/spotify/devices")
@handle_errors
def api_spotify_devices():
"""Diagnostic: every Spotify Connect receiver one account currently sees
(?account=fifou) -- use this to fill in a target's spotify_name if it
differs from heos_name."""
return jsonify(_spotify_from(request.args).devices())
@app.post("/api/spotify/resume")
@handle_errors
def api_spotify_resume():
"""Ask a room's own Spotify Connect receiver to resume one account's
playback -- the reverse of connecting to it from the Spotify app."""
data = _payload()
client = _spotify_from(data)
key = _target_from(data)
device = client.resume(_spotify_name(key))
return jsonify({"target": key, "account": data["account"], "device": device.get("name")})
# --- The original bridge's API, unchanged -----------------------------
@app.get("/targets")
@handle_errors
@@ -355,7 +431,7 @@ def legacy_avr_input():
def main():
global controller
global controller, spotify
parser = argparse.ArgumentParser(description="HEOS panel")
parser.add_argument("--port", type=int, default=config.WEB_PORT)
parser.add_argument("--host", default="0.0.0.0",
@@ -370,6 +446,7 @@ def main():
controller = DemoController(config)
else:
controller = Controller(config)
spotify = _build_spotify()
app.run(host=args.host, port=args.port, threaded=True)