Apple Music download queue server powered by gamdl and BullMQ.
Accepts download requests via REST API, queues them in Redis, and processes them with a background worker. Decryption goes through a wrapper-v2 sidecar, and completed targets are recorded in PostgreSQL so duplicate requests are skipped.
graph LR
Client -->|POST /api/queues| API[Flask API]
API -->|dedup check / skip| PG[(PostgreSQL)]
API -->|enqueue| Redis[(Redis)]
Redis -->|dequeue| Worker[BullMQ Worker]
Worker -->|exec| gamdl
gamdl -->|auth + FairPlay decrypt| Wrapper[wrapper-v2]
Worker -->|mark downloaded| PG
The included compose.yaml wires Kanade together with Redis, PostgreSQL, Bull Board, and a
wrapper-v2 sidecar — no host-side uv / Python / native deps required.
- Docker 24+ with the
composeplugin - An active Apple Music subscription
- A wrapper-v2 image whose Apple-library
rootfsyou have already populated. wrapper-v2 does not ship the Apple Music Android.sofiles; follow the upstream README to extract them from an.apk/.apkmyou provide, then point therootfsvolume incompose.yamlat the resulting tree.
Both config.ini and .env are gitignored so your local edits stay out of version control.
cp config.example.ini config.ini # gamdl + wrapper options
cp .env.example .env # USERNAME / PASSWORD for the wrapper sidecarEdit .env and set USERNAME / PASSWORD to an Apple ID + app-specific password
(account.apple.com → Sign-In and Security → App-Specific
Passwords). Compose forwards them into the wrapper container as WRAPPER_USERNAME /
WRAPPER_PASSWORD; the sidecar signs in at startup and keeps a session cache in its
volume.
When use_wrapper = true (the default), gamdl skips cookies entirely and goes through the
wrapper for everything. Cookies are only required if you want to disable the wrapper or
hit certain web-codec paths. To export them, use the
Get cookies.txt LOCALLY
Chrome extension on a logged-in music.apple.com tab and drop the file at ./cookies.txt.
docker compose up -d # starts kanade, redis, postgres, wrapper, dashboard
docker compose logs -f kanade # tail the API + worker| URL | Service |
|---|---|
| http://localhost:15100 | Kanade API |
| http://localhost:15100/docs | Scalar API docs |
| http://localhost:13100 | Bull Board (queue dashboard) |
curl -X POST http://localhost:15100/api/queues \
-H "Content-Type: application/json" \
-d '{"album_id": 1869843536}'See the API section for the full request / response shape.
config.example.ini documents every supported key inline. The Kanade-specific knobs worth
calling out:
use_wrapper = true/wrapper_url = http://wrapper:80— route account, playback, and FairPlay decryption through the wrapper-v2 sidecar. Required for non-web codecs (ALAC, Atmos, AAC, AC-3, …) — wrapper-v2 reuses the Apple Music Android runtime to do the decryption that a.wvdWidevine device file can't.artist_auto_select = all-albums— required so artist URLs resolve non-interactively in the worker. Without it gamdl raises an interactive prompt that hangs the queue. Valid values:main-albums,compilation-albums,live-albums,singles-eps,all-albums,top-songs,music-videos.song_codec_piority = alac— note the upstream typo (piority, notpriority). Common values:alac,aac,aac-he,aac-web,aac-he-web,atmos,ac3,ask. Web codecs work without the wrapper; everything else needsuse_wrapper = true.download_mode = nm3u8dlre—nm3u8dlre(default, fastest) orytdlp. The Kanade Docker image shipsN_m3u8DL-REon PATH so the default works out of the box.
See the gamdl documentation for the full option reference.
| Variable | Default | Description |
|---|---|---|
REDIS_HOST |
redis |
Redis hostname |
REDIS_PORT |
6379 |
Redis port |
DATABASE_URL |
— | PostgreSQL DSN, e.g. postgresql://kanade:kanade@postgres:5432/kanade (required) |
ENV |
— | Set to production to use gunicorn |
USERNAME |
— | Apple ID forwarded to wrapper as WRAPPER_USERNAME (compose only; read from .env) |
PASSWORD |
— | App-specific password forwarded to wrapper as WRAPPER_PASSWORD (compose only; read from .env) |
Interactive API documentation is available at /docs (Scalar UI).
Queue an album or an artist for download. Provide exactly one of album_id or
artist_id.
# album
curl -X POST http://localhost:15100/api/queues \
-H "Content-Type: application/json" \
-d '{"album_id": 1869843536}'
# artist (downloads all albums, per artist_auto_select)
curl -X POST http://localhost:15100/api/queues \
-H "Content-Type: application/json" \
-d '{"artist_id": 909253}'Request body:
| Field | Type | Required | Description |
|---|---|---|---|
album_id |
integer |
one of | Apple Music album ID (mutually exclusive with artist_id) |
artist_id |
integer |
one of | Apple Music artist ID (mutually exclusive with album_id) |
options.overwrite |
boolean |
— | Overwrite existing files and bypass the duplicate-skip check (default: false) |
Response (queued):
{
"id": "1",
"name": "process",
"data": {
"url": "https://music.apple.com/jp/album/1869843536",
"media_type": "album",
"media_id": 1869843536,
"overwrite": false
},
"timestamp": 1741430400000
}Response (already downloaded — skipped, not enqueued):
{
"status": "skipped",
"reason": "already downloaded",
"media_type": "album",
"media_id": 1869843536,
"url": "https://music.apple.com/jp/album/1869843536"
}A target is remembered by (media_type, media_id) after the worker finishes it. Send
{"options": {"overwrite": true}} to re-download a target that was already recorded.
Health check endpoint. Returns {"status": "ok"}.
Scalar API documentation UI.
OpenAPI 3.1 specification.
| Service | Host port → container | Description |
|---|---|---|
kanade |
15100 → 5000 | Kanade API + worker |
dashboard |
13100 → 3000 | Bull Board (queue dashboard) |
redis |
— (internal) | BullMQ queue backend |
postgres |
— (internal) | Duplicate-skip store (kanade / kanade / kanade) |
wrapper |
— (internal, :80) |
wrapper-v2 (Apple Music auth + FairPlay decryption) |
The wrapper service mounts a Docker volume named rootfs at /app/rootfs. Populate it
out-of-band with the Apple-library tree wrapper-v2 expects — see the
wrapper-v2 README for the extract-libs.sh /
stage-system.sh workflow.
If you'd rather run Kanade against your own Redis / PostgreSQL / wrapper-v2, the published
image at ghcr.io/qtmleap/kanade (tagged on each vX.Y.Z release) handles the gamdl
runtime — N_m3u8DL-RE, mp4decrypt, MP4Box, amdecrypt, and ffmpeg are all baked
in. You provide:
- A reachable wrapper-v2 instance whose URL goes into
wrapper_url. - Redis (
REDIS_HOST/REDIS_PORT). - PostgreSQL (
DATABASE_URL).
docker run --rm -it \
-e REDIS_HOST=redis \
-e DATABASE_URL=postgresql://kanade:kanade@postgres:5432/kanade \
-v ./cookies.txt:/app/cookies.txt:ro \
-v ./config.ini:/app/config.ini:ro \
-v ./content:/app/content \
-p 5000:5000 \
ghcr.io/qtmleap/kanade:latest serveOr run from source (requires Python 3.12+, plus the gamdl native deps on PATH):
uv sync
uv run python main.py serveThe API server starts on http://localhost:5000. Both the API and worker ensure the
PostgreSQL downloads table exists on startup, so a reachable DATABASE_URL is required.
The dev container includes all native dependencies pre-built. Open the project in VS Code with the Dev Containers extension.
| Task | Description |
|---|---|
docker: build |
Build multi-arch Docker image |
docker: push |
Push image to registry |
version: bump |
Bump version in pyproject.toml |
release: deploy |
Bump → build → push |