- Python 97.8%
- Dockerfile 2.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| app | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| .python-version | ||
| AGENTS.md | ||
| Dockerfile | ||
| pipeline.yaml | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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
- Visit
https://<your-gateway>/login. - Click Connect Apple Music and sign in.
- The callback stores the
media-user-tokenand 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_TOKENand the.p8key 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-tokenarrives in the/callbackquery 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.