Fix account swaping
Deploy HEOS panel / deploy (push) Successful in 26s

This commit is contained in:
2026-09-18 02:05:48 +02:00
parent 053be20b9c
commit 5ca504c680
10 changed files with 885 additions and 21 deletions
+199 -3
View File
@@ -15,15 +15,18 @@ friends) are still here so existing Shortcuts and scripts keep working.
import argparse
import os
import time
from functools import wraps
from flask import Flask, jsonify, render_template, request, url_for
from werkzeug.middleware.proxy_fix import ProxyFix
import config
import spotify_zc
from controller import Controller, TargetError
from heos import HeosError
from spotify import SpotifyClient, SpotifyError
from spotify import SpotifyClient, SpotifyDeviceUnavailable, SpotifyError
from spotify_zc import ZeroconfError
from zidoo import ZidooError
app = Flask(__name__)
@@ -81,7 +84,7 @@ def handle_errors(view):
return view(*args, **kwargs)
except (TargetError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
except (HeosError, SpotifyError, ZidooError) as exc:
except (HeosError, SpotifyError, ZeroconfError, ZidooError) as exc:
return jsonify({"error": str(exc)}), 502
return wrapped
@@ -140,6 +143,165 @@ def _spotify_playing_on() -> dict:
return playing_on
def _spotify_account_seeing(device_name: str, besides: str) -> str:
"""Which OTHER configured account can currently see that Connect
receiver, if any. A speaker is logged into one Spotify account at a
time and Spotify shows it to that account alone, so when a room goes
missing this -- not the room's power or its spotify_name -- is very
often the whole answer."""
for key, client in spotify.items():
if key == besides:
continue
try:
if any(d.get("name") == device_name for d in client.devices()):
return key
except (SpotifyError, OSError):
continue # an account we cannot ask is not an account holding it
return None
def _spotify_pause_on(key: str, account: str) -> bool:
"""Pause an account, but only if this room is where it is playing -- an
account that has already moved on is playing somewhere else now, and
stopping it there is nobody's intention. Best effort: an account
Spotify will not answer for is no reason to leave the room alone."""
client = spotify.get(account)
if client is None:
return False
try:
player = client.playback()
if (player.get("device") or {}).get("name") == _spotify_name(key) and player.get("is_playing"):
client.pause()
return True
except (SpotifyError, OSError):
pass
return False
# How long to let a speaker finish changing source before stopping it.
RELEASE_TRIES = 6
RELEASE_WAIT = 0.5
def _is_demo() -> bool:
"""Demo mode's rooms are invented, but the real speakers are very
likely on the same LAN -- and signing one in or out would physically
change it. So the zeroconf side sits out a demo."""
return bool(getattr(controller, "demo", False))
def _find_speaker(device_name: str):
"""A room's own LAN endpoint, or None when it cannot be reached."""
try:
return spotify_zc.find(device_name)
except (ZeroconfError, OSError):
return None
def _spotify_release(key: str, account: str = None) -> dict:
"""Take a room off Spotify: pause the account playing there, then sign
the speaker out over the LAN. Reports what it managed: {"paused",
"signed_out"}.
Signing out is the whole disconnect -- the speaker stops within a
second and drops out of the account's device list -- and it leaves the
room belonging to nobody, which costs nothing now that either button
can claim it back with a sign-in (see _spotify_take_over). Pausing
first is manners: it leaves that account where it was rather than cut
off mid-song.
The HEOS fallback below is for a speaker that does not answer on the
LAN. It can end the stream but not the sign-in, and it cannot do it the
obvious way either: player/set_play_state with stop is accepted for a
Connect stream and then quietly leaves the player paused, session
intact. Putting the speaker on one of its own inputs does end the
stream, and stopping *that* is honoured, so the room is left idle
rather than sitting on a live input.
"""
released = {"paused": _spotify_pause_on(key, account), "signed_out": False}
speaker = None if _is_demo() else _find_speaker(_spotify_name(key))
if speaker is not None:
speaker.reset_users()
released["signed_out"] = True
return released
_release_over_heos(key)
return released
def _release_over_heos(key: str):
"""Stop a room's Spotify stream without the speaker's help -- see
_spotify_release for why it goes the long way round."""
inputs = controller.heos_inputs(key)
if not inputs:
raise ValueError(
f"'{config.TARGETS[key]['label']}' did not answer on the network, and HEOS lists "
"no input on it to fall back to -- so there is no way from here to take this room "
"off Spotify.")
# An AUX jack with nothing in it is the quietest thing a speaker can be
# switched to, and the stop below cuts even that short.
quiet = next((i for i in inputs if "aux" in i["input_id"]), inputs[0])
controller.play_heos_input(key, quiet["input_id"])
# A speaker takes a moment to change source, and a stop that arrives
# while it is still on the Spotify stream is exactly the one HEOS turns
# into a pause -- so wait for the input to be what is playing.
for _ in range(RELEASE_TRIES):
time.sleep(RELEASE_WAIT)
if not controller.on_spotify(key):
break
controller.toggle_play(key, "stop")
# Signing a speaker in is a LAN round trip, but Spotify's cloud hearing
# about it is not: the room only turns up in the account's device list a
# moment later, and a resume before then looks exactly like a failure.
HANDOVER_TRIES = 8
HANDOVER_WAIT = 1.2
def _spotify_take_over(device_name: str, client: SpotifyClient) -> str:
"""Sign a room's speaker in to this account over the LAN, the way the
Spotify app does -- see spotify_zc. Returns None when it worked, or
what went wrong, since that is what the caller has to tell the user.
This is the only thing that moves a speaker between accounts: Spotify's
cloud will not, and until the speaker itself is signed in, the Web API
does not admit the room exists."""
try:
# Checked before knocking, not after: a speaker takes the sign-in,
# drops whoever was on it, and only then finds out Spotify will not
# have the token -- which leaves the room signed in to nobody. A
# login too old to carry the scope must not cost you the room.
if not client.has_scope("streaming"):
return ("that account's Spotify login has no 'streaming' scope, which is the one a "
"speaker asks for -- re-run tools/spotify_auth.py for it (see the README's "
"Spotify section), put the new refresh token in .env and restart the panel")
if _is_demo():
return "the panel is in demo mode, which leaves real speakers alone"
speaker = _find_speaker(device_name)
if speaker is None:
return f"no speaker calling itself '{device_name}' answered on the network"
speaker.add_user(client.me()["id"], client.access_token())
except (ZeroconfError, SpotifyError, OSError) as exc:
return str(exc)
return None
def _resume_once_signed_in(client: SpotifyClient, device_name: str) -> dict:
"""Resume as soon as the room turns up in this account's device list,
rather than once, at the one moment it is least likely to be there."""
for attempt in range(HANDOVER_TRIES):
if attempt:
time.sleep(HANDOVER_WAIT)
try:
return client.resume(device_name)
except SpotifyDeviceUnavailable:
continue
raise SpotifyError(
f"'{device_name}' took the sign-in, but Spotify never offered the room to this account "
"-- which means the speaker could not log in with the token it was given, and is now "
"signed in to nobody. Pick the room once in that account's Spotify app to put it back.")
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
@@ -357,10 +519,44 @@ def api_spotify_resume():
data = _payload()
client = _spotify_from(data)
key = _target_from(data)
device = client.resume(_spotify_name(key))
try:
device = client.resume(_spotify_name(key))
except SpotifyDeviceUnavailable as exc:
# A room this account cannot see is nearly always one another
# account is signed in to -- which is worth naming if the hand-over
# below cannot go through.
holder = _spotify_account_seeing(exc.device_name, besides=data["account"])
# Leave whoever is there paused where they were, rather than cut
# off: signing the speaker in to someone else ends their stream.
if holder:
_spotify_pause_on(key, holder)
trouble = _spotify_take_over(exc.device_name, client)
if trouble:
whose = (f" It is signed in to {config.SPOTIFY_ACCOUNTS[holder]['label']}'s Spotify,"
" and Spotify's own API will not move a speaker between accounts." if holder else "")
raise SpotifyError(
f"Could not sign '{exc.device_name}' in to "
f"{config.SPOTIFY_ACCOUNTS[data['account']]['label']}'s Spotify: {trouble}.{whose} "
"Picking the room once in that account's Spotify app does the same thing by "
"hand.") from exc
device = _resume_once_signed_in(client, exc.device_name)
return jsonify({"target": key, "account": data["account"], "device": device.get("name")})
@app.post("/api/spotify/disconnect")
@handle_errors
def api_spotify_disconnect():
"""Let go of a room: pause what the account is playing there, then sign
the speaker out of it, which stops the music and leaves the room free
for either account's button to claim -- see _spotify_release."""
data = _payload()
_spotify_from(data) # the account has to be one of ours to let go of
key = _target_from(data)
released = _spotify_release(key, data["account"])
return jsonify({"target": key, "account": data["account"],
"device": _spotify_name(key), **released})
# --- The original bridge's API, unchanged -----------------------------
@app.get("/targets")
@handle_errors