This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user