Apple Music gateway: MusicKit auth + playlist API proxy for beets-appleplaylists
  • Python 97.8%
  • Dockerfile 2.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-19 23:23:43 +01:00
app Gateway: classify upstream failures and retry transient calls (#21) 2026-08-19 23:23:43 +01:00
tests Gateway: classify upstream failures and retry transient calls (#21) 2026-08-19 23:23:43 +01:00
.dockerignore Gateway: Dockerfile for the gateway image (#9) 2026-08-10 13:14:47 +00:00
.gitignore Scaffold apple-music-gateway: MusicKit auth + playlist API proxy 2026-08-10 12:33:12 +00:00
.python-version Scaffold apple-music-gateway: MusicKit auth + playlist API proxy 2026-08-10 12:33:12 +00:00
AGENTS.md Gateway: SQLite credential store + MusicKit login page and callback 2026-08-10 12:43:16 +00:00
Dockerfile Gateway: Dockerfile for the gateway image (#9) 2026-08-10 13:14:47 +00:00
pipeline.yaml Gateway: Concourse pipeline: build + push image on main (#10) 2026-08-10 13:14:47 +00:00
pyproject.toml Scaffold apple-music-gateway: MusicKit auth + playlist API proxy 2026-08-10 12:33:12 +00:00
README.md Gateway: classify upstream failures and retry transient calls (#21) 2026-08-19 23:23:43 +01:00
uv.lock Scaffold apple-music-gateway: MusicKit auth + playlist API proxy 2026-08-10 12:33:12 +00:00

apple-music-gateway

MusicKit auth + playlist API proxy for coop/beets-appleplaylists.

The gateway owns the Apple Music session for the beets plugin: it signs the MusicKit developer token, runs the one-time browser login, stores the Apple user token in SQLite, and proxies playlist data over a tiny authenticated API. The beets host needs only the gateway URL + a bearer token — never the .p8 key.

Why a gateway?

Apple does not allow plain-HTTP redirect URIs for MusicKit, even for localhost. A deployed HTTPS service is the only clean way to complete the login flow. A side benefit: the private key and OAuth machinery leave the beets host entirely, and any number of machines can sync by knowing only the gateway URL + a bearer token.

Configuration

All configuration comes from environment variables (see app/config.py). Missing required variables abort startup with a message naming them.

Variable Required Purpose
APPLE_TEAM_ID yes Apple Developer team ID; the iss claim of the MusicKit developer token
APPLE_KEY_ID yes MusicKit key ID; the kid header of the developer token
APPLE_KEY_PATH yes Path to the AuthKey_*.p8 MusicKit private key inside the container
APPLE_SERVICES_ID yes The MusicKit Services ID (the redirect URI is registered on it)
SERVICE_TOKEN yes Shared bearer token protecting /api/v1/*; the beets plugin uses the same value
GATEWAY_BASE_URL yes External https URL of the deployment; the login page redirects to {GATEWAY_BASE_URL}/callback
GATEWAY_DB_PATH no SQLite file holding the Apple user token; default ./gateway.db — mount a volume here

Deployment

The image is built by the Concourse pipeline (coop/apple-music-gateway, see pipeline.yaml) on every push to main and pushed to the Forgejo package registry as git.sams.wtf/coop/apple-music-gateway:<version> (datetime tag, e.g. v2026.8.5.143055) with latest as an alias.

The MusicKit Services ID must have the deployed https URL of /callback registered as its redirect URI. GATEWAY_BASE_URL must be that exact external URL — the login page redirects there after authorization.

docker run -d --name apple-music-gateway \
  -p 127.0.0.1:8000:8000 \
  -e APPLE_TEAM_ID=ABCDE12345 \
  -e APPLE_KEY_ID=XXXXXXXXXX \
  -e APPLE_KEY_PATH=/secrets/AuthKey_XXXXXXXXXX.p8 \
  -e APPLE_SERVICES_ID=com.example.musickit \
  -e SERVICE_TOKEN='a-long-random-bearer-token' \
  -e GATEWAY_BASE_URL=https://music.example.com \
  -e GATEWAY_DB_PATH=/data/gateway.db \
  -v apple-music-gateway-data:/data \
  -v /etc/apple/AuthKey_XXXXXXXXXX.p8:/secrets/AuthKey_XXXXXXXXXX.p8:ro \
  git.sams.wtf/coop/apple-music-gateway:latest

The container runs as an unprivileged user and listens on port 8000. The SQLite database file is created owner-only on first login; keep it on a volume so it survives container restarts (the apple-music-gateway-data volume above). The image's healthcheck probes GET /healthz.

Reverse proxy (required for HTTPS)

Put the gateway behind a TLS-terminating proxy — the registered redirect URI must be https. The container only listens on localhost in the example above, so the proxy is also what exposes it. Caddy example:

music.example.com {
    reverse_proxy 127.0.0.1:8000
}

One-time login

  1. Visit https://<your-gateway>/login.
  2. Click Connect Apple Music and sign in.
  3. The callback stores the media-user-token and shows "Linked. You can close this tab."

The user token does not expire on its own but can be revoked (in which case the API returns 502 with a re-login hint). DELETE /api/v1/login clears it from the gateway; it does not revoke anything at Apple.

API

All /api/v1/* routes except status require Authorization: Bearer <SERVICE_TOKEN> (compared with hmac.compare_digest). Without a valid token they return 401 with a hint pointing at /login. status accepts an optional bearer token: without one it returns only the gateway login_url; with a valid token it also verifies the Apple session and returns logged_in, valid, and login_url.

Method Path Description
GET / Redirect to /login for browser convenience
GET /healthz Liveness probe; {"status": "ok"}
GET /login MusicKit JS login page (embeds a fresh developer token)
GET /callback?media-user-token=... OAuth callback; stores the user token
GET /api/v1/status Optional token; anonymous {"login_url"}, authenticated {"logged_in", "valid", "login_url"}
GET /api/v1/playlists [{"id", "name"}] — every library playlist (no tracks)
GET /api/v1/playlists/{id}/tracks [{"position", "library_track_id", "title", "artist", "album", "duration_ms", "isrc", "catalog_id"}], nested playlists expanded, music videos skipped
DELETE /api/v1/login Clear the stored user token; {"cleared": bool}

Apple-side failures are typed and mapped to distinguishable responses: a 401/403 from Apple (revoked or expired user token) returns 502 with a message pointing at /login; a missing playlist returns 404; a timeout returns 504; connection failures and a persistent Apple rate limit return 503; malformed Apple data and other upstream failures return 502. A missing stored user token returns 502 before any Apple call is made. Transient connection failures, timeouts, 429, and Apple 5xx responses are retried up to three times with bounded exponential backoff (0.5, 1, and 2 seconds, capped at 4 seconds). Authentication, missing-resource, and malformed responses are not retried.

Security

  • SERVICE_TOKEN and the .p8 key are secrets. Never log or commit them; the token is excluded from config reprs and the signer never logs tokens.
  • The Apple user token is stored in SQLite created owner-only (0o600); existing files are never chmodded automatically, but a warning is issued if group/other permission bits are set.
  • The media-user-token arrives in the /callback query string, which is never logged: application logging omits it and the uvicorn access log scrubs query strings entirely.
  • The service runs as an unprivileged user in the container.

Development

uv sync --extra dev
uv run pytest
uv run uvicorn --factory app:create_app --host 0.0.0.0 --port 8000

Tests never touch the network: the Apple client is exercised through FakeSession/FakeResponse and the routes through FastAPI's TestClient.

Intended client

coop/beets-appleplaylists — the beets plugin that syncs Apple Music playlists into a library via beets-playlistmanager. It configures service_url/service_token and uses beet apple login / status / logout / sync against this gateway.