144 lines
5.7 KiB
Python
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 1
|
|
|
|
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="which SPOTIFY_ACCOUNTS slot this login is for, e.g. 1 or 2")
|
|
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_ACCOUNT{args.account}_REFRESH_TOKEN={tokens['refresh_token']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|