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