Compare commits
2 Commits
4627786dc4
...
71f55ab20d
| Author | SHA1 | Date | |
|---|---|---|---|
| 71f55ab20d | |||
| 378009f8b0 |
249
api.py
249
api.py
@@ -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(
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
data={
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
}).encode("utf-8")
|
},
|
||||||
|
|
||||||
req = Request(
|
|
||||||
"https://accounts.spotify.com/api/token",
|
|
||||||
data=body,
|
|
||||||
method="POST",
|
|
||||||
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
|
||||||
|
# ----------------------------
|
||||||
|
|
||||||
|
@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
|
||||||
)
|
)
|
||||||
with urlopen(req) as resp:
|
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
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
104
docs/playlists.md
Normal 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 Spotify’s 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 Spotify’s **Track**, **Artist** (simplified), and **Album** (simplified) shapes below (field availability can vary by market or API version; see Spotify’s 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).
|
||||||
Reference in New Issue
Block a user