Turn text and images into a playlist on Tidal or Spotify.
Prerequisites: Node.js 20+, pnpm
pnpm install
pnpm setup # creates .env with DST_SESSION_PASSWORDEdit .env and add:
DST_TIDAL_CLIENT_IDand/orDST_SPOTIFY_CLIENT_ID(see OAuth apps below)DST_OPENAI_API_KEYorDST_GEMINI_API_KEY
pnpm devOpen http://localhost:3000. Connect a streaming platform, choose an LLM provider, paste text or upload images.
See docs/configuration.md for production deployment, Spotify dev-mode allowlist, and troubleshooting.
| Role | What they need |
|---|---|
| You (operator) | OAuth developer apps, LLM API key, DST_SESSION_PASSWORD in .env |
| End user (clicks Connect) | Their own Tidal or Spotify account — no developer app, no API keys |
Distill is bring-your-own-keys: each deployment uses your developer apps and your LLM billing.
All Distill-owned variables use the DST_ prefix. Generic names like OPENAI_API_KEY are ignored (so shell exports do not leak into the app).
| Variable | Required for app? | Where to get it |
|---|---|---|
DST_SESSION_PASSWORD |
Yes (auto-generated by pnpm setup) |
openssl rand -base64 32 |
DST_TIDAL_CLIENT_ID |
If using Tidal | Tidal Developer Dashboard |
DST_SPOTIFY_CLIENT_ID |
If using Spotify | Spotify Developer Dashboard |
DST_OPENAI_API_KEY or DST_GEMINI_API_KEY |
Yes (one) | OpenAI / Google AI Studio |
DST_TIDAL_CLIENT_SECRET |
CLI eval only | Same Tidal app (client credentials) |
DST_TIDAL_REFRESH_TOKEN |
export-fixtures only |
Dev endpoint while logged in |
DST_LLM_PROVIDER |
Optional | Locks UI provider choice (openai or gemini) |
DST_LLM_EXTRACT_SYSTEM_PROMPT |
Optional | Override song-extraction system prompt (see below) |
DST_LLM_PLAYLIST_METADATA_SYSTEM_PROMPT |
Optional | Override playlist name/description prompt (see below) |
Redirect URIs are not env vars — register these in each provider dashboard:
| Provider | Local redirect URI |
|---|---|
| Tidal | http://localhost:3000/api/oauth/callback/tidal |
| Spotify | https://127.0.0.1:3000/api/oauth/callback/spotify (HTTPS required; use pnpm dev:https) |
Production: https://<your-domain>/api/oauth/callback/{tidal|spotify}
Distill builds redirect URIs from the request origin (X-Forwarded-Host / X-Forwarded-Proto behind a reverse proxy). Set DST_PUBLIC_URL when the public URL differs (e.g. a tunnel). Spotify maps localhost to 127.0.0.1 automatically.
Tidal: public client, PKCE. Scopes: playlists.read playlists.write search.read.
Spotify: public client, PKCE. Scopes: playlist-modify-public playlist-modify-private. Playlists are created private.
Spotify dev-mode gotcha: new apps start in Development mode. Only Spotify accounts on the app's User Management allowlist can sign in. Add your own email before testing Connect locally. End users never register their own app, but in dev mode you must add each user's email. For public hosting, request Extended Quota Mode / complete Spotify app review.
Parsing uses a pluggable adapter layer in server/utils/llm/.
In the app: choose OpenAI or Gemini on the connect screen and enter an API key (unless the operator configured keys in server .env or locked the provider via DST_LLM_PROVIDER).
| Provider | Text | Images | API key |
|---|---|---|---|
| OpenAI | Yes | Yes | Server .env or browser entry on connect |
| Gemini | Yes | Yes | Server .env or browser entry on connect |
| Anthropic | — | — | Coming soon |
Browser BYOK: when the operator has not set DST_OPENAI_API_KEY / DST_GEMINI_API_KEY in .env, end users enter their own key on the connect screen. The key is stored in browser localStorage, verified with a quick test, and sent only when parsing — never written to server .env or a database.
Operator server keys: set DST_OPENAI_API_KEY and/or DST_GEMINI_API_KEY in .env to skip browser key entry for all users on that instance.
Optional env:
| Variable | Default | Purpose |
|---|---|---|
DST_LLM_PROVIDER |
(unset) | Force openai or gemini; overrides UI |
DST_OPENAI_MODEL |
auto |
OpenAI model (gpt-4o-mini for images when auto) |
DST_GEMINI_MODEL |
gemini-2.5-flash |
Gemini model id |
CLI scripts (debug:parse, eval:image) require DST_LLM_PROVIDER when not passed another way.
Two system prompts control LLM behavior. Override via env (use \n for line breaks in a single-line value, or a multiline quoted string in .env):
| Variable | Used for |
|---|---|
DST_LLM_EXTRACT_SYSTEM_PROMPT |
Parsing text/images into { songs: [...] } JSON |
DST_LLM_PLAYLIST_METADATA_SYSTEM_PROMPT |
Suggesting playlist name and description after extraction |
Default DST_LLM_EXTRACT_SYSTEM_PROMPT:
You extract song titles and artists from user-provided text and images.
Return ONLY valid JSON: { "songs": [{ "title", "artist", "album?", "confidence": "high"|"medium"|"low", "source": "text"|"image" }] }
Rules:
- Handle numbered lists, "Artist — Title", setlists, screenshots, social posts
- Deduplicate by artist+title (case insensitive)
- Omit non-song content (headers, dates, venue names without songs)
- If unsure, use lower confidence
- album is optional
Default DST_LLM_PLAYLIST_METADATA_SYSTEM_PROMPT:
You create concise playlist metadata from a track list.
Return ONLY valid JSON: { "name": string, "description": string }
Rules:
- name should be short, natural, and playlist-like
- description should be one sentence, no more than 140 characters
- Do not mention that you are an AI
- Do not include markdown
Encrypts OAuth tokens in cookies. Must stay stable across restarts in production — changing it invalidates all sessions.
In development, if unset, Distill generates an ephemeral key (sessions won't survive restart). pnpm setup generates one for you.
If you changed DST_SESSION_PASSWORD after logging in, log out and reconnect — old cookies won't decrypt.
- Connect Tidal or Spotify (allow popups)
- Choose an LLM provider and enter an API key (or use operator-configured server keys)
- Paste text or upload images (max 5 per request)
- Review matched tracks (edit, reorder, add more)
- Create or update playlist
Toggle Dry run in the dev UI to explore the flow with fixtures — no API keys or OAuth required.
| Symptom | Fix |
|---|---|
| Tidal error 11102 | Redirect URI mismatch — check dashboard vs actual URL (scheme, host, port, path) |
| 401 / "Connect first" | Session expired or DST_SESSION_PASSWORD changed — reconnect |
| Spotify OAuth fails for some users | App in Development mode — add their Spotify email under User Management |
| Gemini rate limit | Check billing; try DST_GEMINI_MODEL=gemini-2.5-flash |
| Tidal rate limit | Tune DST_TIDAL_MAX_* or wait |
| Popup doesn't open | Allow popups for localhost |
426 Upgrade Required on localhost |
Custom dev port: set NUXT_HOST=:: in .env (dual-stack) — see docs/configuration.md |
See docs/testing.md. Requires DST_TIDAL_CLIENT_ID + DST_TIDAL_CLIENT_SECRET (client credentials, separate from user OAuth).
pnpm export-fixtures --playlist https://tidal.com/browse/playlist/{uuid} --parsed fixtures/parsed.json
pnpm eval:matchesDST_TIDAL_REFRESH_TOKEN: with pnpm dev running and Tidal connected, open http://localhost:3000/api/dev/tidal-refresh-token.
pnpm debug:parse --image fixtures/images/setlist.jpgWith dev server: add "debug": true to /api/parse body. Press ** in the UI for the dev HUD (non-production only).
pnpm test:unit
pnpm test:llm-parseSee CONTRIBUTING.md.
MIT — see LICENSE.