Resolve a music-entity URL to normalized metadata. No provider API key.
Sleeve notes are the printed metadata on an album sleeve — artist, title, track listing. That is what this returns.
A stateless TypeScript service that drives headless Chromium via Playwright, reads the JSON the page itself fetches, normalizes it, and caches aggressively in Redis. Auth and rate limiting are deliberately absent — they belong to a load balancer in front, which keeps private and public deployments the same artifact.
GET /v1/track/:id -> Track
GET /v1/album/:id -> Album (includes tracks)
GET /v1/playlist/:id -> Playlist (includes tracks)
GET /health -> "sleevenote ok"
GET /metrics -> Prometheus exposition
:id is the raw Spotify entity id (the last path segment of
open.spotify.com/{track,album,playlist}/{id}), not a full URL.
An Album or Playlist response carries declaredItems (Spotify's own
total, or null when it declared none) and complete (whether every
declared item came back). By default a short listing still answers 502
extraction_incomplete, exactly as before -- add ?partial=allow to
/v1/album/:id or /v1/playlist/:id to get it anyway, as a 200 with
complete: false. The flag is accepted and ignored on /v1/track/:id, which
has no listing to be partial. See
docs/design-notes.md
for why the default stays strict and why the flag is per-request rather than
a server setting.
A listing that Spotify serves with nothing in it answers 502
listing_empty, which is a fact about that id rather than a fault: Spotify
declared no items, so there were none to extract. Personalised listings read
this way to a signed-out viewer -- a "daylist" is the common case, and its
shareable link (the one with ?si=) resolves normally. This is deliberately
NOT extraction_empty, which now means only that Spotify declared items we
recognised none of -- a genuine sign that extraction has stopped matching
Spotify's item shape, and the one that trips the redesign canary metric.
When sleevenote cannot take a lookup on, it answers 503 with
Retry-After: 5 rather than making the caller wait: overloaded when every
browser context stayed busy for POOL_WAIT_CAP_MS, and browser_unavailable
when the browser died mid-lookup or is being relaunched. Both are transient.
Neither says anything about Spotify's page, which is why they are not 502s.
A key with a stale cache entry serves that entry instead.
Real request/response examples, captured from a running deployment, live in docs/examples -- a committed contract an out-of-repo client can build and test against.
cp .env.example .env
# Edit .env: at minimum, set BIND_ADDR to this host's own address on the
# private network you want to serve on -- compose refuses to start without it.
docker compose build
docker compose up -dThe image is built FROM Playwright's own published image
(mcr.microsoft.com/playwright:v1.62.1-noble), tag-matched to the playwright
version pinned in package-lock.json. This is deliberate: a bare Node image
with an apt-get'd Chromium is exactly the browser/driver mismatch that rotted
the prior art this project replaces. If you bump the playwright dependency,
bump the FROM tag in the Dockerfile in the same change.
docker-compose.yml publishes on ${BIND_ADDR}:3000 and has no default,
deliberately. BIND_ADDR must be this host's own address on the network
you intend to serve — not 0.0.0.0, which silently starts publishing on any
interface the host later gains, and not a bare network address like
10.0.0.0, which is not assignable to a host and cannot be bound at all.
The service has no authentication of its own. It is designed to sit behind something that provides it — a load balancer, a reverse proxy, a firewalled private network. Publishing it anywhere that is not already true gives anonymous callers a browser that will fetch URLs on their behalf. Binding to a private address is a convenience, not the control; the network is.
docker compose builds and runs the stack wherever the active Docker
context points, which is not necessarily localhost — e.g. a remote context
over SSH. Don't assume 127.0.0.1; resolve the host you're actually talking
to:
HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}' \
| sed -E 's#^(tcp|ssh)://([^@/]+@)?##; s#:.*$##; s#^unix://.*#127.0.0.1#')
echo "docker context: $(docker context show) -> $HOST"
curl -fsS "http://$HOST:3000/health"Expected: sleevenote ok, printed alongside the host it hit. Then
docker compose down.
Every key loadConfig reads, with its default — see .env.example for the
copy-pasteable version with fuller comments:
| Key | Default | Meaning |
|---|---|---|
PORT |
3000 |
HTTP port the service listens on |
LOG_LEVEL |
info |
pino level. silent turns logging off; anything below warn still emits one line per request |
REDIS_URL |
redis://127.0.0.1:6379 |
the only state this stateless service depends on |
POOL_SIZE |
2 |
browser contexts kept warm; excess requests queue, they don't fail |
CONTEXT_MAX_USES |
50 |
extractions served before a context is recycled |
CONTEXT_CLOSE_TIMEOUT_MS |
5000 |
how long a context close may take before it is abandoned and the slot refilled. Every close is on a release path, so an unbounded one holds the slot |
BROWSER_MAX_AGE_MS |
21600000 (6h) |
recycle the whole browser once it is this old. Blue/green: the replacement is launched first, and the old one drains |
BROWSER_MAX_LEASES |
1000 |
...or once it has served this many leases |
BROWSER_MAX_MEMORY_MB |
2048 |
...or once its process tree's PSS passes this. 0 turns the memory trigger off; it is off anyway where there is no /proc |
BROWSER_CHECK_INTERVAL_MS |
60000 |
how often the triggers are checked and memory is sampled |
BROWSER_CLOSE_TIMEOUT_MS |
10000 |
how long a browser may take to close before it is SIGKILLed |
BROWSER_MAX_RELAUNCH_FAILURES |
5 |
consecutive browser failures -- a relaunch that fails, or a browser that dies within 60s of launch -- before the process exits, so the container restart policy takes over |
POOL_WAIT_CAP_MS |
min(20000, PRODUCE_BUDGET_MS / 2), floored, at least 1 |
how long a caller may wait for a context before a 503 overloaded. An explicit value must be below PRODUCE_BUDGET_MS |
NAV_TIMEOUT_MS |
45000 |
cap on a single page navigation |
ENTITY_DATA_TIMEOUT_MS |
15000 |
how long to wait for Spotify's entity query after the page looks settled. Deliberately far below NAV_TIMEOUT_MS: it covers the gap between "went idle" and "data arrived", so reusing the nav timeout would make every genuinely silent extraction cost 45s before it could say so. Also bounds waiting for response bodies to finish arriving, and each pagination window |
PRODUCE_BUDGET_MS |
150000 |
cap on one whole extraction, including the wait for a context; also sets the cache's single-flight lock TTL (see src/config.ts). Enforced by the pool: a lease still held when it runs out is revoked and its context replaced |
TTL_TRACK |
2592000 (30d) |
track cache TTL, in seconds |
TTL_ALBUM |
2592000 (30d) |
album cache TTL, in seconds |
TTL_PLAYLIST |
14400 (4h) |
playlist cache TTL — playlists genuinely change |
TTL_NEGATIVE |
600 |
how long a confirmed-absent id is negative-cached |
FAILURE_RELAY_TTL |
5 |
how long a failed extraction stays visible to callers already waiting on the same id. Seconds, deliberately: this is a handoff to the cohort already blocked, not a negative cache for errors. Raising it throttles a permanently-broken entity, at the price of a fixed-and-redeployed scraper still serving the old failure until it expires |
A playlist may hold podcast episodes and local files. Episodes are returned as
tracks, attributed to their show. Local files cannot be -- their Spotify uri
carries no id -- so Playlist and Album both report unresolvedItems:
tracks.length + unresolvedItems always equals what Spotify listed.
Why the code is shaped the way it is -- the pagination techniques that failed
before the current one worked, why four extraction errors stay distinct, why
the failure-relay TTL is seconds -- lives in
docs/design-notes.md. The comments in src/ are kept
short and point there.
Pushing a signed vX.Y.Z tag builds and publishes
ghcr.io/cycle-five/sleevenote and creates the GitHub release. The version
bump belongs in the PR — the release workflow refuses a tag that disagrees
with package.json. Full process, and the reasoning behind the live-Spotify
release gate, in docs/releasing.md.
A running instance reports its own build as
sleevenote_build_info{version="..."} on GET /metrics.
The browser pool's state is sleevenote_pool_contexts{state="free"|"leased"}
and sleevenote_pool_waiting, read at scrape time. Every context leased with
callers waiting is a starved pool.
The browser itself is sleevenote_browser_generations{state="serving"|"draining"},
sleevenote_browser_age_seconds (absent while nothing is serving) and
sleevenote_browser_memory_bytes (absent while nothing is serving, or while
memory cannot be measured), with sleevenote_browser_launches_total{reason}
and sleevenote_browser_launch_failures_total counting what the manager did.
npx vitest runRuns the offline suite: no network required, though tests/store.redis.test.ts
exercises a real Redis if one is reachable at REDIS_URL and otherwise skips
with a stated reason.
tests/live.smoke.test.ts is the only test that touches the real internet —
it drives an actual browser against open.spotify.com. It SKIPs with a stated
reason unless explicitly opted in, so a red run always means something is
broken, never "no network":
SLEEVENOTE_LIVE=1 npx vitest run tests/live.smoke.test.tsCI runs the offline suite only; a schedule runs this one separately.
The provider API this replaces stopped being obtainable. Spotify deprecated a set of Web API endpoints in November 2024 and, separately, has blocked the creation of new Web API applications since roughly December 2025. An operator without pre-existing credentials cannot get them. The design document covers what was tried before resorting to this, and what it costs.
MIT.