Skip to content

About

Browser-backed resolver from a music-entity URL to normalized metadata. No API key required.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

sleevenote

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.

What it does

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.

Running it

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 -d

The 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.

Checking it came up

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.

Configuration

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

Items that cannot be resolved

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.

Design notes

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.

Releasing

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.

Testing

npx vitest run

Runs 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.

Live smoke test

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.ts

CI runs the offline suite only; a schedule runs this one separately.

Why

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.

Licence

MIT.

About

Browser-backed resolver from a music-entity URL to normalized metadata. No API key required.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages