Files
heos/tools/spotify_auth.py
T
franzz 7c19ff9096
Deploy HEOS panel / deploy (push) Successful in 25s
Add spotify integration
2026-09-15 22:07:43 +02:00

144 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""One-time Spotify login per account, to get the refresh token config.py
needs for that account's Spotify button on the panel.
python3 tools/spotify_auth.py --client-id ... --client-secret ... --account fifou
Run this somewhere you can actually reach a browser to approve the
login -- your laptop, or a WSL shell if Windows can reach it (WSL2
forwards localhost both ways, so a browser on the Windows side works
fine too). It listens on 127.0.0.1 for the one redirect Spotify sends
back, so nothing here ever sees your Spotify password, only the
short-lived code Spotify hands back afterwards.
In a plain shell with no desktop session wired up, this can't open a
browser for you automatically -- it tries, and that attempt can print
its own "Operation not supported" message when it fails. That's the
browser launcher failing, not this script; the URL it prints above that
still works, copied into any browser by hand.
Before running it:
1. Create an app at https://developer.spotify.com/dashboard (any name).
2. In its settings, add this exact Redirect URI:
http://127.0.0.1:8899/callback
Spotify allows plain http for a 127.0.0.1 redirect specifically --
nowhere else -- which is why this doesn't need HTTPS to work.
3. Copy its Client ID and Client Secret and pass them here.
It prints SPOTIFY_<ACCOUNT>_REFRESH_TOKEN for the account you logged in
as -- put that, plus the client id and secret, in .env (the README's
Spotify section has the details). Run it again with the other --account,
logged in as that account, for its own token. None of these belong in
config.py itself or in git.
"""
import argparse
import base64
import json
import secrets
import sys
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
REDIRECT_PORT = 8899
REDIRECT_URI = f"http://127.0.0.1:{REDIRECT_PORT}/callback"
SCOPES = "user-read-playback-state user-modify-playback-state"
def get_code(client_id: str, state: str) -> str:
"""Open Spotify's login page and block until its redirect lands."""
result = {}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass # the printed instructions are enough noise already
def do_GET(self):
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
result["code"] = query.get("code", [None])[0]
result["state"] = query.get("state", [None])[0]
result["error"] = query.get("error", [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
body = "You can close this tab and go back to the terminal." \
if result["code"] else f"Spotify said: {result['error']}"
self.wfile.write(body.encode())
server = HTTPServer(("127.0.0.1", REDIRECT_PORT), Handler)
authorize_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode({
"client_id": client_id,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"scope": SCOPES,
"state": state,
})
print(f"Open this URL and log in to Spotify:\n\n{authorize_url}\n")
try:
# Best-effort only: in a plain WSL shell (no desktop session wired
# up) this can fail with its own "Operation not supported" message
# printed straight to the terminal -- that's the browser launcher
# complaining, not this script; ignore it and open the URL above
# by hand (from Windows too -- WSL2 forwards localhost both ways,
# so the redirect below still reaches this script).
webbrowser.open(authorize_url)
except Exception:
pass
print("Waiting for Spotify to redirect back here once you approve it...")
server.handle_request() # one request is all this ever needs
server.server_close()
if result.get("error"):
sys.exit(f"Spotify refused: {result['error']}")
if result.get("state") != state:
sys.exit("state mismatch -- got a callback that wasn't for this run, aborting")
return result["code"]
def exchange(client_id: str, client_secret: str, code: str) -> dict:
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
body = urllib.parse.urlencode({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
}).encode()
request = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=body,
headers={
"Authorization": f"Basic {credentials}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read())
except urllib.error.HTTPError as exc:
sys.exit(f"Spotify rejected the code exchange: {exc.read().decode()}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--client-id", required=True)
parser.add_argument("--client-secret", required=True)
parser.add_argument("--account", required=True,
help="the SPOTIFY_ACCOUNTS key this login is for, e.g. fifou or clarita")
args = parser.parse_args()
state = secrets.token_urlsafe(16)
code = get_code(args.client_id, state)
tokens = exchange(args.client_id, args.client_secret, code)
print("\nPut these in .env -- the client id and secret are the same for every account:\n")
print(f"SPOTIFY_CLIENT_ID={args.client_id}")
print(f"SPOTIFY_CLIENT_SECRET={args.client_secret}")
print(f"SPOTIFY_{args.account.upper()}_REFRESH_TOKEN={tokens['refresh_token']}")
if __name__ == "__main__":
main()