Compare commits

...

2 Commits

2 changed files with 327 additions and 30 deletions

253
api.py
View File

@@ -20,11 +20,10 @@ import os
import sqlite3 import sqlite3
from base64 import b64encode from base64 import b64encode
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
from urllib.request import Request, urlopen
import json import json
from urllib.parse import urlencode
from flask import Flask, request, session, jsonify, make_response from flask import Flask, request, session, jsonify, make_response, redirect, url_for
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from authomatic import Authomatic from authomatic import Authomatic
from authomatic.adapters import WerkzeugAdapter from authomatic.adapters import WerkzeugAdapter
@@ -95,13 +94,22 @@ PROVIDER_ID_MAP = list(AUTHOMATIC_PROVIDER_ID_MAP) + [Spotify]
SPOTIFY_CLIENT_ID = os.environ["SPOTIFY_CLIENT_ID"] SPOTIFY_CLIENT_ID = os.environ["SPOTIFY_CLIENT_ID"]
SPOTIFY_CLIENT_SECRET = os.environ["SPOTIFY_CLIENT_SECRET"] SPOTIFY_CLIENT_SECRET = os.environ["SPOTIFY_CLIENT_SECRET"]
# Must exactly match a Redirect URI configured in your Spotify app settings. # OAuth redirect registered in the Spotify Developer Dashboard (Authorization Code
# flow). Spotify sends the browser here with ?code=&state= — not to the Android app scheme.
REDIRECT_URI = os.environ.get( REDIRECT_URI = os.environ.get(
"SPOTIFY_REDIRECT_URI", "SPOTIFY_REDIRECT_URI",
#"https://api.lockstep.at/spotify/callback" #"https://api.lockstep.at/spotify/callback"
"https://api.lockstep.at/login/spotify/" "https://api.lockstep.at/login/spotify/"
) )
# After server-side token exchange, the browser may be redirected to this URL with
# tokens in the query string (mobile / Custom Tab). This is NOT registered with Spotify;
# only REDIRECT_URI above goes in the dashboard for this flow.
ALLOWED_SPOTIFY_APP_POST_LOGIN_REDIRECT = os.environ.get(
"SPOTIFY_APP_POST_LOGIN_REDIRECT_URI",
"at.lockstep.player://spotify/callback",
)
DB_PATH = os.environ.get("TOKEN_DB_PATH", "spotify_tokens.db") DB_PATH = os.environ.get("TOKEN_DB_PATH", "spotify_tokens.db")
app = Flask(__name__) app = Flask(__name__)
@@ -214,25 +222,41 @@ def spotify_basic_auth_header() -> str:
def refresh_spotify_token(refresh_token: str) -> dict: def refresh_spotify_token(refresh_token: str) -> dict:
body = urlencode({ r = requests.post(
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}).encode("utf-8")
req = Request(
"https://accounts.spotify.com/api/token", "https://accounts.spotify.com/api/token",
data=body, data={
method="POST", "grant_type": "refresh_token",
"refresh_token": refresh_token,
},
headers={ headers={
"Authorization": spotify_basic_auth_header(), "Authorization": spotify_basic_auth_header(),
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
) )
with urlopen(req) as resp: if not r.ok:
payload = json.loads(resp.read().decode("utf-8")) # The token endpoint returns errors as flat
# {"error": "<code>", "error_description": "<msg>"}, which differs from
# the Web API's {"error": {"status": ..., "message": ...}} shape. Rewrite
# the body so our error handler surfaces a consistent envelope to
# clients regardless of which Spotify endpoint failed.
try:
body = r.json()
except ValueError:
body = {}
message = (
body.get("error_description")
or body.get("error")
or r.reason
or "Token refresh failed"
)
r._content = json.dumps({
"error": {"status": r.status_code, "message": message}
}).encode("utf-8")
r.headers["Content-Type"] = "application/json"
return payload r.raise_for_status()
return r.json()
def get_valid_access_token(spotify_user_id: str) -> str: def get_valid_access_token(spotify_user_id: str) -> str:
@@ -266,17 +290,100 @@ def get_valid_access_token(spotify_user_id: str) -> str:
# ---------------------------- # ----------------------------
# Simple Spotify API call # Spotify Web API helpers
# ---------------------------- # ----------------------------
def spotify_get(url: str, access_token: str, params: dict | None = None) -> dict:
"""GET a Spotify Web API endpoint and return the parsed JSON body."""
headers = {"Authorization": f"Bearer {access_token}"}
r = requests.get(url, headers=headers, params=params)
r.raise_for_status()
return r.json()
def spotify_get_paginated(
url: str,
access_token: str,
limit: int = 50,
max_items: int | None = None,
) -> list:
"""
Fetch all items from a paginated Spotify Web API endpoint.
Spotify paging objects return up to `limit` items per page (50 max for most
endpoints) and a `next` URL. We follow `next` until it is null, collecting
items along the way.
"""
items: list = []
params: dict | None = {"limit": limit, "offset": 0}
next_url: str | None = url
while next_url is not None:
page = spotify_get(next_url, access_token, params=params)
items.extend(page.get("items", []))
if max_items is not None and len(items) >= max_items:
return items[:max_items]
# `next` is a fully-qualified URL with limit/offset already encoded,
# so we must not pass `params` again after the first request.
next_url = page.get("next")
params = None
return items
def spotify_get_me(access_token: str) -> dict: def spotify_get_me(access_token: str) -> dict:
req = Request( return spotify_get("https://api.spotify.com/v1/me", access_token)
"https://api.spotify.com/v1/me",
headers={"Authorization": f"Bearer {access_token}"},
method="GET", # ----------------------------
) # Error handling
with urlopen(req) as resp: # ----------------------------
return json.loads(resp.read().decode("utf-8"))
@app.errorhandler(requests.HTTPError)
def handle_spotify_http_error(e: requests.HTTPError):
"""
Translate a non-2xx upstream response from Spotify into a JSON envelope,
passing through the upstream HTTP status code.
Spotify error bodies look like {"error": {"status": ..., "message": ...}}
when JSON is returned; we surface that message when available.
"""
resp = e.response
status = resp.status_code if resp is not None else 502
spotify_error = None
error_message = str(e)
if resp is not None:
try:
spotify_error = resp.json()
except ValueError:
spotify_error = None
if (
isinstance(spotify_error, dict)
and isinstance(spotify_error.get("error"), dict)
):
error_message = (
spotify_error["error"].get("message") or error_message
)
return jsonify({
"ok": False,
"error": error_message,
"spotify": spotify_error,
}), status
@app.errorhandler(requests.RequestException)
def handle_spotify_request_error(e: requests.RequestException):
"""Network-level failures (DNS, connection, timeout) have no upstream status."""
return jsonify({
"ok": False,
"error": f"Upstream request failed: {e}",
}), 502
# ---------------------------- # ----------------------------
@@ -294,6 +401,28 @@ def index():
@app.route("/login/<provider_name>/", methods=["GET", "POST"]) @app.route("/login/<provider_name>/", methods=["GET", "POST"])
def login(provider_name): def login(provider_name):
# Authomatic 1.3.0 (oauth2.login) only runs "phase 1" — redirect to Spotify —
# when there are no query parameters, or only ``user_state``:
# elif (not self.params or (len(self.params) == 1 and 'user_state' in self.params))
# A mobile client opening ``/login/spotify/?redirect_uri=...`` therefore matches
# no branch; login() returns without calling redirect() → empty body and HTTP 200
# (white page). Stash the app callback in the session and reload without query args.
if (
provider_name == "spotify"
and request.args.get("redirect_uri")
and "code" not in request.args
and "error" not in request.args
):
requested = request.args.get("redirect_uri", "")
if requested != ALLOWED_SPOTIFY_APP_POST_LOGIN_REDIRECT:
return jsonify({
"ok": False,
"error": "redirect_uri not allowed for this client",
}), 400
session["spotify_oauth_app_redirect_uri"] = requested
session.modified = True
return redirect(url_for("login", provider_name=provider_name), code=302)
response = make_response() response = make_response()
# Let Authomatic handle the OAuth2 handshake. # Let Authomatic handle the OAuth2 handshake.
@@ -301,7 +430,7 @@ def login(provider_name):
WerkzeugAdapter(request, response), WerkzeugAdapter(request, response),
provider_name, provider_name,
session=session, session=session,
session_saver=lambda: session.modified session_saver=lambda: setattr(session, "modified", True),
) )
# If result is None, Authomatic is still redirecting/processing. # If result is None, Authomatic is still redirecting/processing.
@@ -372,6 +501,27 @@ def login(provider_name):
# keep the Spotify user id in session # keep the Spotify user id in session
session["spotify_user_id"] = result.user.id session["spotify_user_id"] = result.user.id
app_redirect = session.pop("spotify_oauth_app_redirect_uri", None)
if app_redirect:
if app_redirect != ALLOWED_SPOTIFY_APP_POST_LOGIN_REDIRECT:
return jsonify({
"ok": False,
"error": "Stored app redirect_uri does not match allowlist",
}), 400
sep = "&" if ("?" in app_redirect) else "?"
target = (
f"{app_redirect}{sep}"
+ urlencode(
{
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in": str(expires_in),
"token_type": token_type or "",
},
)
)
return redirect(target, code=302)
return jsonify({ return jsonify({
"ok": True, "ok": True,
"spotify_user_id": result.user.id, "spotify_user_id": result.user.id,
@@ -474,7 +624,10 @@ def me():
@app.route("/playlists") @app.route("/playlists")
def playlists(): def playlists():
spotify_user_id = "cidermole" spotify_user_id = session.get("spotify_user_id")
if not spotify_user_id:
return jsonify({"ok": False, "error": "Not logged in"}), 401
access_token = get_valid_access_token(spotify_user_id) access_token = get_valid_access_token(spotify_user_id)
""" """
@@ -482,14 +635,54 @@ def playlists():
url = f"https://api.spotify.com/v1/users/{user_id}/playlists" url = f"https://api.spotify.com/v1/users/{user_id}/playlists"
# -> 403 Forbidden # -> 403 Forbidden
""" """
url = f"https://api.spotify.com/v1/me/playlists" items = spotify_get_paginated(
headers={"Authorization": f"Bearer {access_token}"} "https://api.spotify.com/v1/me/playlists",
r = requests.get(url, headers=headers) access_token,
# TODO: pagination (limit,offset) )
return jsonify({ return jsonify({
"ok": True, "ok": True,
"response": r.json() "total": len(items),
"items": items,
})
@app.route("/playlists/<playlist_id>")
def playlist(playlist_id):
spotify_user_id = session.get("spotify_user_id")
if not spotify_user_id:
return jsonify({"ok": False, "error": "Not logged in"}), 401
access_token = get_valid_access_token(spotify_user_id)
playlist_data = spotify_get(
f"https://api.spotify.com/v1/playlists/{playlist_id}",
access_token,
)
# The playlist response embeds a `tracks` paging object whose first page
# contains up to 100 items. For playlists larger than that, follow the
# dedicated tracks endpoint until exhausted and splice the full list back
# into the response.
tracks_obj = playlist_data.get("tracks") or {}
if tracks_obj.get("next"):
all_tracks = spotify_get_paginated(
f"https://api.spotify.com/v1/playlists/{playlist_id}/tracks",
access_token,
limit=100,
)
playlist_data["tracks"] = {
**tracks_obj,
"items": all_tracks,
"offset": 0,
"limit": len(all_tracks),
"next": None,
"previous": None,
}
return jsonify({
"ok": True,
"playlist": playlist_data,
}) })

104
docs/playlists.md Normal file
View File

@@ -0,0 +1,104 @@
# Playlist endpoints
All routes require an authenticated session (`spotify_user_id` after Spotify login). Responses are JSON (`application/json`).
---
## `GET /playlists`
Returns every playlist for the current user by following Spotifys paginated [`GET /v1/me/playlists`](https://developer.spotify.com/documentation/web-api/reference/get-a-list-of-current-users-playlists) until all pages are loaded.
### Success (200)
| Field | Type | Description |
| --- | --- | --- |
| `ok` | `boolean` | Always `true` on success. |
| `total` | `number` | Count of playlists in `items`. |
| `items` | `array` | Each element is a **simplified playlist object** from Spotify |
**Typical fields on each element of `items`** (Spotify `SimplifiedPlaylistObject`):
| Field | Type |
| --- | --- |
| `description` | `string` \| `null` |
| `id` | `string` |
| `images` | `array` of `{ "url": string, "height": number \| null, "width": number \| null }` |
| `name` | `string` |
| `primary_color` | `string` \| `null` |
| `snapshot_id` | `string` |
| `tracks` | `object` — e.g. `{ "href": string, "total": number }` (track list stub, not full tracks) |
### Errors
`{ "ok": false, "error": string, ... }`
---
## `GET /playlists/<playlist_id>`
`<playlist_id>` is the Spotify playlist ID (the same id as in playlist URLs / `items[].id`).
Fetches [`GET /v1/playlists/{playlist_id}`](https://developer.spotify.com/documentation/web-api/reference/get-playlist) following pagination.
### Success (200)
| Field | Type | Description |
| --- | --- | --- |
| `ok` | `boolean` | Always `true` on success. |
| `playlist` | `object` | **Full playlist object** from Spotify, with `tracks` possibly expanded to every track as described above. |
**Typical fields on `playlist`** (Spotify `PlaylistObject`):
| Field | Type |
| --- | --- |
| `description` | `string` \| `null` |
| `id` | `string` |
| `images` | `array` (image objects, as above) |
| `name` | `string` |
| `primary_color` | `string` \| `null` |
| `snapshot_id` | `string` |
| `tracks` | `{"items": [Track, ...], ...}` |
**Typical fields on each element of `playlist.tracks.items`** (Spotify playlist track wrapper):
| Field | Type |
| --- | --- |
| `track` | `object` \| `null` — full or linked track; `null` if removed |
Nested objects use Spotifys **Track**, **Artist** (simplified), and **Album** (simplified) shapes below (field availability can vary by market or API version; see Spotifys reference).
#### `track` — Spotify `TrackObject`
Returned as the non-`null` value of `playlist.tracks.items[].track` (playlist context usually includes a **full** track with **simplified** `album` and `artists` entries).
| Field | Type |
| --- | --- |
| `album` | `object`**SimplifiedAlbumObject** (see below) |
| `artists` | `array` of **SimplifiedArtistObject** (see below) |
| `duration_ms` | `number` |
| `id` | `string` |
| `name` | `string` |
#### `track.artists[]` — Spotify **SimplifiedArtistObject**
| Field | Type |
| --- | --- |
| `id` | `string` |
| `name` | `string` |
#### `track.album` — Spotify **SimplifiedAlbumObject**
| Field | Type |
| --- | --- |
| `artists` | `array` of **SimplifiedArtistObject** (album-level credits) |
| `id` | `string` |
| `images` | `array` of `{ "url": string, "height": number \| null, "width": number \| null }` |
| `name` | `string` |
### Errors
Same as `/playlists`: **401** when not logged in; otherwise Spotify errors and network errors per the global error handlers.
---
For authoritative field lists and edge cases, see [Spotify Web API reference](https://developer.spotify.com/documentation/web-api).