Alt-tango DJ app for macOS: Spotify-driven playing console + Claude-assisted library builder. Ships the dj-incarnadine.org site as a celilo module (website/celilo-module/).
  • TypeScript 79.7%
  • CSS 13.8%
  • Astro 4.2%
  • JavaScript 1.1%
  • Go Template 0.5%
  • Other 0.7%
Find a file
Peter Banka 07cd2a13e2
Some checks failed
release / version (push) Successful in 0s
release / publish (push) Failing after 17s
fix(module): give on_install a budget the deploy path can meet
120000ms covers a standalone `celilo module run-hook` (~71s measured) but not
the DEPLOY path, which also publishes the static site and issues
dns_registrar.registerHost twice before it reaches
public_web.registerReverseProxy.

Measured 2026-09-29 (celilo#1463): the deploy of 0.6.0+1 died with "Hook total
timeout exceeded (120s), waiting on public_web.registerReverseProxy (44s)". The
module was left DEPLOYED with its /live reverse proxy never registered, so
/live/trpc fell through to the apex static site. A hand-run `run-hook` finished
in 71s and registered it.

No changeset: this leaves the manifest's declared version alone so the release
publishes directly (the detect step sets should_publish=true only when no
changesets are pending), and the registry's +N build counter still advances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 12:53:36 -07:00
.beads Track the direnv and Gas City identity files; ignore beads state 2026-09-28 09:19:47 -07:00
.claude Rename the house category to electronic 2026-08-18 12:19:48 -07:00
.cursor/rules Foundation: scaffold, Drizzle schema on bun:sqlite, db module + tests 2026-08-02 09:39:32 -07:00
.forgejo/workflows Add the e2e harness: fake Spotify, Playwright, PR validation 2026-09-27 23:45:14 -07:00
app Go online at dj-incarnadine.org, not dj.incarnadine.org 2026-09-28 17:11:13 -07:00
docs Go online at dj-incarnadine.org, not dj.incarnadine.org 2026-09-28 17:11:13 -07:00
e2e E2e: a guest's request reaches the panel, plays, and is credited 2026-09-27 23:53:29 -07:00
electron Refuse to start when something already answers on the port 2026-08-19 17:55:21 -07:00
openspec chore(openspec): adopt slice-driven planning schema 2026-08-31 11:24:41 -07:00
projects Go online at dj-incarnadine.org, not dj.incarnadine.org 2026-09-28 17:11:13 -07:00
scripts Run a set from start to finish, then look back on it 2026-09-26 18:09:40 -07:00
src Go online at dj-incarnadine.org, not dj.incarnadine.org 2026-09-28 17:11:13 -07:00
website fix(module): give on_install a budget the deploy path can meet 2026-09-29 12:53:36 -07:00
.envrc Track the direnv and Gas City identity files; ignore beads state 2026-09-28 09:19:47 -07:00
.gitignore Ignore SQLite -shm/-wal side files too 2026-09-28 17:11:21 -07:00
AGENTS.md Go online at dj-incarnadine.org, not dj.incarnadine.org 2026-09-28 17:11:13 -07:00
bun.lock Add the e2e harness: fake Spotify, Playwright, PR validation 2026-09-27 23:45:14 -07:00
CLAUDE.md Plan the DJ online companion; make AGENTS.md canonical 2026-09-27 23:20:03 -07:00
CONTEXT.md Plan the DJ online companion; make AGENTS.md canonical 2026-09-27 23:20:03 -07:00
GEMINI.md Plan the DJ online companion; make AGENTS.md canonical 2026-09-27 23:20:03 -07:00
globals.d.ts Server + DJ console: tRPC router, lasertag-styled React console 2026-08-02 09:57:10 -07:00
lasertag.d.ts Server + DJ console: tRPC router, lasertag-styled React console 2026-08-02 09:57:10 -07:00
mise.toml Wire website release CI/CD (changesets -> celilo-registry) 2026-08-05 11:47:45 -07:00
package.json Add bun run live:dev; log Spotify's reason on token failures 2026-09-28 16:45:51 -07:00
playwright.config.ts Add the e2e harness: fake Spotify, Playwright, PR validation 2026-09-27 23:45:14 -07:00
README.md Add bun run live:dev; log Spotify's reason on token failures 2026-09-28 16:45:51 -07:00
tsconfig.json Root tsconfig: stop sweeping website/ 2026-08-17 14:32:16 -07:00

incarnadine

An alt-tango DJ app for macOS. Two modes:

  • Building mode — a Claude skill (.claude/skills/tango-library/) imports your Spotify playlists into a local SQLite library and enriches each track with tango metadata (traditionality, energy, romance, juiciness, BPM) for your review.
  • Playing mode — a local web console that drives the Spotify desktop app via AppleScript: pick from scored candidate cards, manage the queue, auto-advance with a hard gap (never a crossfade), capture notes, watch set stats.

Requires: macOS, Spotify Premium, the Spotify desktop app, Bun.

Setup

bun install

1. Spotify app credentials (building mode only)

Playing mode needs no Spotify API auth. To import playlists you need a Spotify app:

  1. Create an app at https://developer.spotify.com/dashboard
  2. Add http://127.0.0.1:8988/callback as a Redirect URI
  3. Export the client id: export SPOTIFY_CLIENT_ID=... (or put it in .env)

The first bun run library import ... opens a browser for a one-time OAuth (PKCE) grant; the token is cached in .spotify-token.json.

2. Spotify desktop app settings

In Spotify → Settings:

  • Disable Autoplay ("Autoplay similar songs when your music ends") — otherwise Spotify may start an unrelated track in the gap before this app advances.
  • Disable Crossfade — hard gaps between songs are the point. Each song plays to its end, then the app holds a few seconds of silence (POST_TRACK_GAP_S in src/playback/engine.ts) before firing the next one.

3. First-run AppleScript permission

The first time the server sends a command to Spotify, macOS prompts to allow Terminal (or whatever runs Bun) to control Spotify. Do this before an event:

osascript -e 'tell application "Spotify" to player state'

Approve the automation prompt (System Settings → Privacy & Security → Automation if you need to fix it later).

4. Run a set

bun start        # runs under caffeinate so the Mac won't sleep mid-set

Open http://localhost:3000, start the Spotify desktop app, and DJ. (Port taken? PORT=3210 bun start. Different library file? TANGO_DB=path.)

Spotify grabs focus when a new track starts; the app gives focus back automatically. To keep Spotify off-screen entirely, hide it with ⌘H (not minimize) — the app re-hides it after every track change.

Building the library

Ask Claude to use the tango-library skill, or run the helper directly:

bun run library import <playlist-url-or-id>   # import a playlist
bun run library sync                          # re-walk everything imported
bun run library list-playlists                # completeness report
bun run library export                        # mirror library → one playlist
bun run library list --needs-review           # tracks awaiting review
bun run library set <uri> energy 7            # set a value (Claude enrichment)
bun run library confirm <uri> energy 4        # DJ correction — never overwritten
bun run library note <uri> claude "text"      # attach a note

Before an event: offline readiness

A venue with bad internet can kill a set. Spotify Premium plays downloaded tracks offline, but only per playlist — so mirror the whole library into one playlist and download that:

bun run library sync      # re-walk every playlist; recovers anything a flaky
                          # connection left half-imported
bun run library export    # mirror every non-excluded track into one playlist

export creates (or updates) a private playlist called Incarnadine DJ Library and prints its link. In the Spotify app, open it and switch Download on, then wait for the green arrow — Spotify exposes no API or AppleScript for download state, so that check is yours to make.

Re-run export after importing or excluding tracks; it re-diffs every time, so the playlist always matches the library. That also means hand edits made in Spotify get reverted on the next run.

To see whether anything is missing:

bun run library list-playlists

Every playlist you've imported is listed with a status: ok, partial (a fetch died partway — sync will finish it), error, or forbidden (Spotify refuses other owners' playlists to new apps; import-embed gets ~100 tracks of those, and sync skips them unless you pass --force).

Online companion (local)

Guests follow a set and send requests at /live, served by the dj-live app server (app/server). To run the whole thing on one machine:

# App server in test mode (self-signed DJ tokens). Real Spotify search when
# SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET are in .env, else a fake catalog
bun run live:dev

# Console pointed at it (SPOTIFY=fake plays tracks without the Spotify app)
LIVE_URL=http://localhost:3100/live SPOTIFY=fake bun run dev

The guest site is at http://localhost:3100/live. "Go online" in the console signs in through the IdP; in test mode, store a self-signed DJ token in the console's settings instead (live.token and live.tokenExpiresAt), as e2e/seed.ts does.

Tests

bun run test   # unit tests: src, app, scripts
bun run check  # tsc + lasertag
bunx playwright install chromium   # once
bun run e2e    # Playwright: app server + console with fake Spotify, phone + desktop

bun run e2e starts its own app server (port 3400) and console (port 3300) with a seeded library, so it runs alongside a dev console.