{
 "openapi": "3.0.3",
 "info": {
  "title": "Music Metadata API",
  "description": "\n## The Spotify `audio-features` replacement\n\nSpotify deprecated their `audio-features` endpoint on **27 November 2024**.\nThis API fills that gap — upload any audio file, get BPM, key, and energy back in seconds.\n\n---\n\n### Authentication\n\nPass your API key in the `X-Api-Key` header:\n\n```\nX-Api-Key: fb_live_your_key_here\n```\n\nNo key configured on the server = open access (development mode).\n\n**This is a server-side API.** Your key is a secret credential (it counts against your\nquota and, on paid plans, your bill), so keep it server-side and never embed it in\nfront-end code. Cross-origin browser requests are intentionally not allowed — a\npreflight `OPTIONS` from a non-allowlisted origin is rejected — so a key can't be\nscraped from client-side JavaScript. Call the API from your backend (`curl`, an SDK,\nany server-to-server request; none use CORS), or proxy browser requests through your\nown server.\n\n---\n\n### Spotify Migration Guide\n\n| Spotify field | This API | Notes |\n|---|---|---|\n| `tempo` | `bpm` | Same unit (BPM). Add `bpm_confidence` to filter weak beats. |\n| `energy` | `energy` | Both normalised [0-1]. Different algorithm. |\n| `key` + `mode` | `key`, `mode`, `camelot`, `open_key` | Human-readable key + numeric mode + Camelot + Open Key notation. |\n| `loudness` | `loudness_db` | Both in dBFS. Direct equivalent. |\n| `time_signature` | `time_signature` | Identical field. Most tracks return 4. |\n| `duration_ms` | `duration_ms` | Full track length from iTunes (catalog tracks only). |\n| `danceability` | `danceability` | Signal-analysis approximation [0–1]. |\n| `valence` | `valence` | Signal-analysis approximation [0–1]. |\n| `acousticness` | `acousticness` | Spectral-flatness (tonality) based [0–1], scaled to Spotify's distribution. Rank correlation ~0.34 — a sorting signal, not a verdict. |\n| `instrumentalness` | `instrumentalness` | MFCC vocal-formant based [0–1]. |\n| `liveness` | `liveness` | Noise floor ratio proxy [0–1]. |\n| `speechiness` | `speechiness` | Zero-crossing rate based [0–1]. |\n\n### Extra fields (not in Spotify)\n\n| Field | Notes |\n|---|---|\n| `bpm_confidence` | Beat-strength score — filter low-rhythm/ambient tracks. |\n| `key_confidence` | Key detection reliability [0–1]. Below 0.4 = atonal. |\n| `camelot` | Camelot wheel notation for DJs (e.g. `8A`, `3B`). |\n| `open_key` | Open Key notation for Serato / Mixed In Key users (e.g. `1m`, `4d`). |\n| `mood` | MIREX-style mood category derived from valence + energy. Values: happy, calm, sad, tense, energetic, melancholic, neutral. |\n| `mood_vector` | Dimensional mood scores [0–1] across 5 axes (`happy`, `sad`, `aggressive`, `relaxed`, `party`). Sourced from public music classifiers. |\n| `representative_segment_start` | Start time (seconds) of the highest-energy 30-second window — the chorus or drop. Useful for sync licensing and preview generation. `/analyze` only. |\n| `genre` | Broad genre classification (e.g. electronic, rock, pop, hip-hop). Sourced from public music databases. |\n| `album_name` | Album or single title from catalog data. `/lookup` only. |\n| `release_date` | ISO 8601 release date (YYYY-MM-DD) from catalog data. `/lookup` only. |\n| `duration_ms` | Full track duration in milliseconds from catalog data. `/lookup` only. |\n| `explicit` | Boolean explicit flag from catalog data. `/lookup` only. |\n| `mbid` | MusicBrainz Recording ID (UUID). `/lookup` only — cross-references the open music encyclopedia. |\n| `isrc` | International Standard Recording Code. `/lookup` only — uniquely identifies the master recording. |\n| `itunes_track_id` | iTunes track ID (or `mb:`, `fma:`, `msd:` prefix for fallback sources). `/lookup` only. |\n\n### New endpoints (not in Spotify)\n\n| Endpoint | Description |\n|---|---|\n| `GET /bpm?bpm=128&tolerance=2` | Find catalog tracks by BPM — no audio upload needed. |\n| `POST /bulk` | Look up up to 50 tracks in one request. |\n\n**Old Spotify call** → `GET https://api.spotify.com/v1/audio-features/{id}` *(broken)*\n\n**This API** → `POST https://api.freqblog.com/analyze` with the audio file\n\n---\n\n### Privacy\n\nAudio is processed entirely in volatile memory and never written to disk.\nNo audio data is retained after the HTTP response is sent.\nMetadata results may be cached server-side by file hash for performance.\n\nOperated from the United Kingdom. GDPR-compliant data handling.\n",
  "contact": {
   "name": "FreqBlog",
   "url": "https://freqblog.com/",
   "email": "hello@freqblog.com"
  },
  "license": {
   "name": "Commercial — see freqblog.com/terms.html",
   "url": "https://freqblog.com/terms.html"
  },
  "version": "1.5.0"
 },
 "servers": [
  {
   "url": "https://api.freqblog.com",
   "description": "Production"
  }
 ],
 "paths": {
  "/me/portal": {
   "get": {
    "summary": "Customer Portal",
    "description": "Redirect the authenticated customer to their Stripe Customer Portal — where\nthey can update payment method, switch plans, view invoices, or cancel.\n\nAuth: `X-Api-Key` header (preferred) or `?key=` query param — the query form\nlets this be a plain clickable link in the welcome email / dashboard, since\na browser can't set a header.\n\nLooks up the Stripe customer by email (= the API-key label). Returns 404 if\nno Stripe customer matches; this is expected for free-tier keys that never\nwent through checkout.",
    "operationId": "customer_portal_me_portal_get",
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    }
   }
  },
  "/request-free-key": {
   "post": {
    "summary": "Request Free Key",
    "description": "Request a free API key (1,000 req/mo). One key per email address.\nIncludes disposable email blocking, IP rate limiting, and optional Turnstile verification.",
    "operationId": "request_free_key_request_free_key_post",
    "requestBody": {
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/FreeKeyRequest"
       }
      }
     },
     "required": true
    },
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/analyze": {
   "post": {
    "tags": [
     "Audio"
    ],
    "summary": "Extract audio features",
    "description": "Upload an audio file (MP3, WAV, AIFF, OGG, FLAC, AAC/M4A, max 15 MB). Returns BPM, musical key, and RMS energy.\n\n**Representative-window analysis**: we scan the whole file and analyse its most representative 30 seconds — the highest-energy window — rather than a fixed position, so intros and quiet openings don't skew the result. The start of the analysed window is returned as `representative_segment_start`. Files shorter than 30 s are analysed in full.\n\n**Send the whole track, compressed** — not a clip. The window is chosen by scanning what you upload, so an excerpt can yield different values than the full recording. Audio is analysed as mono at 44.1 kHz, so a lossless master is downsampled before anything is computed and gains you no accuracy over a 320 kbps MP3 or 256 kbps M4A — which also keeps you under the 15 MB cap that a full-length WAV (~40 MB) or FLAC (~20–28 MB) would exceed.\n\n**Caching**: identical file bytes return a cached result in < 100 ms. Check the `cached` field in the response.",
    "operationId": "analyze_endpoint_analyze_post",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Body_analyze_endpoint_analyze_post"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Audio features extracted successfully",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/AudioAnalysisResponse"
        },
        "example": {
         "bpm": 128.0,
         "bpm_confidence": 4.12,
         "key": "A-Minor",
         "key_confidence": 0.83,
         "mode": 0,
         "camelot": "8A",
         "open_key": "1m",
         "acousticness": 0.21,
         "danceability": 0.78,
         "instrumentalness": 0.04,
         "liveness": 0.09,
         "speechiness": 0.06,
         "valence": 0.54,
         "mood": "happy",
         "representative_segment_start": 62.0,
         "energy": 0.74,
         "loudness_db": -7.4,
         "time_signature": 4,
         "cached": false
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "413": {
      "description": "File exceeds the 15 MB upload limit (`error_code` `UPLOAD_TOO_LARGE`). Re-encode smaller rather than trimming — the analysed window is chosen by scanning the file",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "415": {
      "description": "Upload is not decodable audio (`error_code` `UPLOAD_NOT_AUDIO`), or is too small to be audio at all — a truncated or empty file (`UPLOAD_TOO_SMALL`)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Audio analysis failed",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Per-minute rate limit or monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "504": {
      "description": "Analysis timed out (> 10s)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     }
    }
   }
  },
  "/identify": {
   "post": {
    "tags": [
     "Audio"
    ],
    "summary": "Identify a recording by audio fingerprint",
    "description": "Upload an audio file and get back the recording it represents — title, artist, MusicBrainz ID, ISRC, plus full FreqBlog audio features when the recording is already in the catalog.\n\nUses **Chromaprint** for fingerprinting and **AcoustID** for matching against an open dataset of 60+ million recordings. Audio is held briefly in `/dev/shm` (RAM-backed tmpfs) for fingerprinting and unlinked the moment fpcalc returns; no audio is written to durable storage.\n\n**You always get features.** When the fingerprint resolves to a recording we already hold, `audio_features` carries the full catalog response. When it doesn't — an unknown fingerprint, or a known one we don't stock — the uploaded audio is analysed directly and the result comes back in `analysis`. `features_from` tells you which: `catalog` values come from our 30-second-preview pipeline, `upload` values from your full file, so they are not interchangeable. Pass `analyze=false` for identification only.\n\n**Quota cost**: 2 requests for identification, 3 when the upload had to be analysed (vs 1 for a bare `/analyze`). Nothing is billed if fingerprinting or analysis fails.",
    "operationId": "identify_endpoint_identify_post",
    "parameters": [
     {
      "name": "analyze",
      "in": "query",
      "required": false,
      "schema": {
       "type": "boolean",
       "description": "When the fingerprint doesn't resolve to a catalog recording, analyse the uploaded audio directly and return its features in `analysis`. Set false for identification only (and to be billed 2 instead of 3).",
       "default": true,
       "title": "Analyze"
      },
      "description": "When the fingerprint doesn't resolve to a catalog recording, analyse the uploaded audio directly and return its features in `analysis`. Set false for identification only (and to be billed 2 instead of 3)."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Body_identify_endpoint_identify_post"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Fingerprint matched (or no match found).",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/IdentifyResponse"
        }
       }
      }
     },
     "400": {
      "description": "Empty file.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "413": {
      "description": "File exceeds the 15 MB upload limit (`error_code` `UPLOAD_TOO_LARGE`). Re-encode smaller rather than trimming — the analysed window is chosen by scanning the file",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "415": {
      "description": "Upload is not decodable audio (`error_code` `UPLOAD_NOT_AUDIO`), or is too small to be audio at all — a truncated or empty file (`UPLOAD_TOO_SMALL`)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Could not generate audio fingerprint",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Per-minute rate limit or monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "502": {
      "description": "Upstream AcoustID error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "503": {
      "description": "Identify service not configured (AcoustID API key missing)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "504": {
      "description": "Fingerprint generation timed out",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     }
    }
   }
  },
  "/lookup": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Look up a track by name, ISRC, MusicBrainz ID, or Spotify ID",
    "description": "Search the pre-analyzed track catalog by track name and optional artist. Returns audio features instantly (< 10 ms) without uploading any audio.\n\n**Multi-tier catalog**: 270,000+ pre-analyzed tracks (refreshed daily from Apple Music charts — 45 country + 16 genre, iTunes Search categories, and Deezer charts; live count at `/cache/stats`) are returned instantly with the full feature set including `album_name`, `release_date`, `duration_ms`, `explicit`, `source`, and `first_ingested_at` alongside all audio features. If a track is not in the live catalog, a 4-layer fallback chain is attempted automatically — covering 8.5M+ additional tracks: MusicBrainz → AcousticBrainz (7.5M tracks, mood/genre/mood_vector) → FMA Echo Nest (13k tracks, valence) → Million Song Dataset (1M tracks, classic catalog). AcousticBrainz-sourced results include `bpm`, `key`, `danceability`, `acousticness`, `instrumentalness`, `mood`, `mood_vector`, `genre`, `mbid`, and `isrc`. `mood_vector` is also backfilled for catalog tracks when an AcousticBrainz match is found; `null` when no match exists. Fields that require audio (`energy`, `loudness_db`, `liveness`, `speechiness`, `valence`, `bpm_confidence`) are null for fallback tracks. The result is cached server-side so subsequent lookups are instant.\n\n**Track-name miss → 202 Accepted (on-demand ingest)**: if every fallback layer comes up empty for a `?track=&artist=` lookup, we return HTTP 202 + queue an on-demand ingest — typically ready in 30 s–2 min. Retry the same call shortly or watch progress in the dashboard. `?isrc=` / `?spotify_id=` paths return 404 on miss instead (no name+artist to drive the ingest).\n\n**This is the direct replacement for Spotify's deprecated `/audio-features/{id}` endpoint.** Look up by track name (`?track=...&artist=...`), by ISRC (`?isrc=USUM71900001` — the catalog is checked first, then MusicBrainz and Deezer resolve the recording on a miss), or by Spotify track ID (`?spotify_id=...` — resolved from our Spotify-ID map or, on a miss, by matching the track's title against the catalog; a title several artists share is ambiguous and 404s rather than guessing, so for those look up by `?track=&artist=`, or use `?isrc=` if your own Spotify integration already gives you the ISRC via `external_ids.isrc`), or by MusicBrainz recording ID (`?mbid=...` — features come straight from AcousticBrainz for that exact recording; the precise key when ISRC is absent or name matching is ambiguous; returns 404 when AcousticBrainz has no analysis for it). Supply at least one of `track`, `isrc`, `mbid`, or `spotify_id`. Sending several is accepted — the call resolves by precedence (`track` › `isrc` › `mbid` › `spotify_id`) and the rest are ignored, so a client that holds more than one identifier can send them all without having to choose. The order is widest-coverage first: `track` and `isrc` queue an on-demand ingest on a miss, whereas `mbid` and `spotify_id` can 404, so an extra identifier never makes the lookup less likely to resolve.",
    "operationId": "lookup_endpoint_lookup_get",
    "parameters": [
     {
      "name": "track",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "minLength": 1,
       "maxLength": 200,
       "description": "Track name (partial match supported). Supply this OR `isrc` OR `mbid` OR `spotify_id` — if you send several, `track` is the one used (it has the widest coverage and queues an on-demand ingest on a miss rather than 404ing). For short titles (≤2 chars, e.g. Aphex Twin '4'), supply `artist` to anchor the search.",
       "title": "Track",
       "nullable": true
      },
      "description": "Track name (partial match supported). Supply this OR `isrc` OR `mbid` OR `spotify_id` — if you send several, `track` is the one used (it has the widest coverage and queues an on-demand ingest on a miss rather than 404ing). For short titles (≤2 chars, e.g. Aphex Twin '4'), supply `artist` to anchor the search."
     },
     {
      "name": "title",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "minLength": 1,
       "maxLength": 200,
       "description": "Alias for `track` — accepted for parity with `/bulk` items (Chartmetric / Spotify-style integrations field the name as `title`). If both are sent, `track` wins.",
       "title": "Title",
       "nullable": true
      },
      "description": "Alias for `track` — accepted for parity with `/bulk` items (Chartmetric / Spotify-style integrations field the name as `title`). If both are sent, `track` wins."
     },
     {
      "name": "artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 200,
       "description": "Artist name (optional, narrows results — only used alongside `track`). Required when `track` is ≤2 characters.",
       "title": "Artist",
       "nullable": true
      },
      "description": "Artist name (optional, narrows results — only used alongside `track`). Required when `track` is ≤2 characters."
     },
     {
      "name": "isrc",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 15,
       "description": "ISRC, e.g. `USUM71900001`. Catalog is checked first, then MusicBrainz and Deezer resolve the recording on a miss — an unknown but resolvable ISRC is queued for on-demand ingest (HTTP 202). Supply this OR `track` OR `mbid` OR `spotify_id` — sending it alongside `track` is accepted, but `track` takes precedence and the ISRC is ignored.",
       "title": "Isrc",
       "nullable": true
      },
      "description": "ISRC, e.g. `USUM71900001`. Catalog is checked first, then MusicBrainz and Deezer resolve the recording on a miss — an unknown but resolvable ISRC is queued for on-demand ingest (HTTP 202). Supply this OR `track` OR `mbid` OR `spotify_id` — sending it alongside `track` is accepted, but `track` takes precedence and the ISRC is ignored."
     },
     {
      "name": "spotify_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 80,
       "description": "Spotify track ID (also accepts a `spotify:track:...` URI or open.spotify.com URL). Resolved from our Spotify-ID map, or — on a miss — by looking the track's title up in the catalog. A title several catalog artists share cannot be told apart and 404s rather than guessing, so this is still not a universal reverse lookup. For full coverage prefer `track` (+`artist`) or `isrc`. Supply this OR `track` OR `isrc` OR `mbid` — it has the LOWEST precedence of the four, so any of the others sent alongside it wins.",
       "title": "Spotify Id",
       "nullable": true
      },
      "description": "Spotify track ID (also accepts a `spotify:track:...` URI or open.spotify.com URL). Resolved from our Spotify-ID map, or — on a miss — by looking the track's title up in the catalog. A title several catalog artists share cannot be told apart and 404s rather than guessing, so this is still not a universal reverse lookup. For full coverage prefer `track` (+`artist`) or `isrc`. Supply this OR `track` OR `isrc` OR `mbid` — it has the LOWEST precedence of the four, so any of the others sent alongside it wins."
     },
     {
      "name": "mbid",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 40,
       "description": "MusicBrainz recording ID (UUID, e.g. `5b11f4ce-a62d-471e-81fc-a69a8278c7da`). Features are read straight from AcousticBrainz for that exact recording — the authoritative MBID→features path — so it's the precise key for cases where ISRC is absent (e.g. pre-1986 vinyl) or name matching is ambiguous. Returns 404 when AcousticBrainz has no analysis for the recording. Supply this OR `track` OR `isrc` OR `spotify_id` — `track` and `isrc` both take precedence over it, since this mode can hard-404 where they would queue an ingest.",
       "title": "Mbid",
       "nullable": true
      },
      "description": "MusicBrainz recording ID (UUID, e.g. `5b11f4ce-a62d-471e-81fc-a69a8278c7da`). Features are read straight from AcousticBrainz for that exact recording — the authoritative MBID→features path — so it's the precise key for cases where ISRC is absent (e.g. pre-1986 vinyl) or name matching is ambiguous. Returns 404 when AcousticBrainz has no analysis for the recording. Supply this OR `track` OR `isrc` OR `spotify_id` — `track` and `isrc` both take precedence over it, since this mode can hard-404 where they would queue an ingest."
     },
     {
      "name": "wait",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 25,
       "minimum": 0,
       "description": "Bounded synchronous wait in seconds (0–25). On a **`track`** miss, hold the request up to this long for the on-demand ingest to finish and return the analysed track **inline (HTTP 200)** instead of a 202 + re-poll — handy now that ingest is typically ~15s. Falls back to the normal 202 if it isn't ready in time (then re-poll as usual). If the ingest instead reaches a **terminal** verdict while you wait — the track isn't on any streaming source we can analyse — you get that **404** (`terminal: true` + `Retry-After`) immediately rather than a 202 that would never resolve. If omitted, a small tier-dependent default applies (paid keys wait briefly so the track usually comes back in the same call; free keys default to an immediate 202); pass an explicit `?wait=0` to force the async 202, or a higher value to wait longer. **Importing a catalogue? Use `?wait=0`.** Holding thousands of requests open is what trips the per-key concurrency limit; `?wait=0` returns each miss instantly so your throughput is bound by your own loop rather than by ingest latency — then re-poll `poll_url`, or send up to 50 tracks per call to `POST /bulk`. Only applies to name lookups.",
       "title": "Wait",
       "nullable": true
      },
      "description": "Bounded synchronous wait in seconds (0–25). On a **`track`** miss, hold the request up to this long for the on-demand ingest to finish and return the analysed track **inline (HTTP 200)** instead of a 202 + re-poll — handy now that ingest is typically ~15s. Falls back to the normal 202 if it isn't ready in time (then re-poll as usual). If the ingest instead reaches a **terminal** verdict while you wait — the track isn't on any streaming source we can analyse — you get that **404** (`terminal: true` + `Retry-After`) immediately rather than a 202 that would never resolve. If omitted, a small tier-dependent default applies (paid keys wait briefly so the track usually comes back in the same call; free keys default to an immediate 202); pass an explicit `?wait=0` to force the async 202, or a higher value to wait longer. **Importing a catalogue? Use `?wait=0`.** Holding thousands of requests open is what trips the per-key concurrency limit; `?wait=0` returns each miss instantly so your throughput is bound by your own loop rather than by ingest latency — then re-poll `poll_url`, or send up to 50 tracks per call to `POST /bulk`. Only applies to name lookups."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Track found in catalog",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TrackLookupResponse"
        },
        "example": {
         "track_name": "Blinding Lights",
         "artist_name": "The Weeknd",
         "album_name": "After Hours",
         "itunes_track_id": "1488408568",
         "isrc": "CAUM71900002",
         "mbid": "5b11f4ce-a62d-471e-81fc-a69a8278c7da",
         "release_date": "2019-11-29",
         "duration_ms": 200040,
         "explicit": false,
         "source": "apple_chart",
         "first_ingested_at": "2025-11-14T01:23:45",
         "bpm": 171.0,
         "bpm_confidence": 3.8,
         "key": "C#-Minor",
         "key_confidence": 0.76,
         "mode": 0,
         "camelot": "12A",
         "open_key": "5m",
         "energy": 0.91,
         "loudness_db": -7.1,
         "danceability": 0.76,
         "valence": 0.51,
         "mood": "energetic",
         "mood_vector": {
          "happy": 0.82,
          "sad": 0.04,
          "aggressive": 0.12,
          "relaxed": 0.31,
          "party": 0.77
         },
         "time_signature": 4,
         "acousticness": 0.04,
         "instrumentalness": 0.02,
         "liveness": 0.09,
         "speechiness": 0.06,
         "representative_segment_start": 62.0,
         "genre": "pop"
        }
       }
      }
     },
     "202": {
      "description": "Track not found via `track` lookup — on-demand ingest queued; you will be notified when ready (per-track email under the daily cap, otherwise rolled into a daily digest). **Billed once for the track, not twice**: this 202 is charged (it queues real work), and the re-poll that collects the finished track comes back **free** — so polling `poll_url` costs you nothing beyond the queue you already paid for. A track that is never collected stays charged."
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "No analysable track. Four cases: (1) `isrc` / `spotify_id` resolved to nothing in the catalog, MusicBrainz, or Deezer; (2) a `track` lookup the pipeline has **already** terminally failed to ingest — the track isn't on any streaming source we can analyse (Apple Music / Deezer): common for CD-only, unreleased, off-streaming or very-regional releases, since features are computed from streaming preview audio; (3) an `mbid` whose recording has no AcousticBrainz analysis (the body carries the recording's identity so you can retry via `?isrc=` / `?track=`). (4) a `track` that is a **double A-side chart title** — one Billboard row naming two songs, like `Boombastic/In The Summertime` — which no streaming service carries as a single recording. Case (4) returns `error_code: \"COMPOUND_TITLE\"`, `outcome: \"compound_title\"`, `reason: \"double_a_side\"`, the `sides` we split it into, and a `candidates` array giving the `itunes_track_id` / `isrc` of every side we hold analysed. The two sides are **different recordings with different bpm and key**, so we name them and let you pick rather than guessing one — re-request the side you want by its id or ISRC. This costs no quota and queues no ingest. Case (2) adds a `Retry-After` header (seconds) plus `error_code: \"TRACK_UNAVAILABLE\"`, `terminal: true`, `outcome: \"unavailable\"`, `reason` (`not_on_streaming` or `no_preview_audio`) and `retry_after_days` in the body so batch clients can stop polling — `TRACK_UNAVAILABLE` is deliberately distinct from a generic `NOT_FOUND`, which means only that nothing has been established about the track yet. Case (2) also carries a `fallback` object pointing at `POST /analyze` — if you hold the audio file yourself, upload it and we compute the features from your file (omitted for keys scoped away from `/analyze`). A *first-time* `track` miss returns 202 + queues an ingest instead — unless you passed `?wait=` and that ingest reaches the case-(2) verdict while you are waiting, in which case you get this terminal 404 on the FIRST call rather than a 202 you would have re-polled for nothing.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "No identifier supplied: pass at least one of `track`, `isrc`, `mbid`, or `spotify_id` (sending several is fine — the call resolves by precedence). Also returned when the `isrc` or `mbid` is malformed.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded, or too many concurrent /lookup requests in flight for this key (carries `Retry-After`; space requests out or use `/bulk` for batches)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     }
    }
   }
  },
  "/v1/audio-features/{identifier}": {
   "get": {
    "tags": [
     "Spotify-compat"
    ],
    "summary": "Get audio features — Spotify-shape compatible",
    "description": "**Spotify-shape compatible replacement for the deprecated `GET /v1/audio-features/{id}`.** Change the host (`api.spotify.com` → `api.freqblog.com`) and the path + response field names match byte-for-byte: `acousticness`, `danceability`, `energy`, `instrumentalness`, `key` (0–11, `-1` if undetected), `liveness`, `loudness` (dB), `mode` (0/1), `speechiness`, `tempo` (BPM), `time_signature`, `valence`, plus `id` / `uri` / `track_href` / `analysis_url` / `duration_ms` / `type`.\n\n`{identifier}` is auto-detected: a 22-character base-62 string → Spotify track ID (also accepts `spotify:track:…` URIs and `open.spotify.com/track/…` URLs); a 12-character ISRC (e.g. `USUM71900001`, hyphens optional) → ISRC lookup (catalog first, then MusicBrainz resolves the recording on a miss). The Spotify-ID drop-in checks our Spotify-ID map and, on a miss, resolves the track's **title** and matches it against the catalog. A title that several catalog artists share is **ambiguous and returns 404 rather than a guess** — the wrong recording would mean the wrong bpm and key — so this is still not a universal Spotify-ID reverse lookup. For reliable coverage, identify tracks by **name or ISRC**: `GET /lookup?track=<title>&artist=<artist>` searches the full catalog and queues an on-demand fetch + analysis on a miss; if your own Spotify integration already gives you the ISRC (`external_ids.isrc`), call this same route as `GET /v1/audio-features/<ISRC>` or `GET /lookup?isrc=<ISRC>`.\n\nNotes — the *values* are FreqBlog's own analysis (signal-analysis approximations: directionally compatible with Spotify's ML numbers, not numerically identical — see the [Field-by-Field Threshold Guide](https://freqblog.com/blog/spotify-audio-features-migration-thresholds/) for catalog distribution data and recommended threshold adjustments per field).\n\n**`?calibrate=spotify`** (opt-in) applies a per-field regression fitted on a 5,724-track overlap between our catalog and the public maharshipandya/spotify-tracks-dataset. v1 calibrates `loudness` only (R²=0.43, RMSE 3.0 dB vs raw 8.5 dB — 64% improvement). Other fields fall through unchanged because their heuristic-vs-ML methodology gap is too wide to ship a regression honestly. Default (omit `?calibrate=`) returns raw values.\n\n`analysis_url` is always `null` (no per-segment-analysis analog). For tracks resolved by ISRC, `uri` / `track_href` are `null` and `id` carries the ISRC, since we have no Spotify ID for the recording. Error bodies use FreqBlog's `{\"detail\": …}` shape, not Spotify's `{\"error\": {…}}`. Counts as one quota request. For batches, use the Spotify-shaped sibling: `GET /v1/audio-features?ids=…` (comma-separated, up to 100 IDs).",
    "operationId": "spotify_audio_features_endpoint_v1_audio_features__identifier__get",
    "parameters": [
     {
      "name": "identifier",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "minLength": 1,
       "maxLength": 128,
       "description": "A Spotify track ID (22-char base-62, or a `spotify:track:…` URI / open.spotify.com URL) — or an ISRC (12-char, e.g. `USUM71900001`; hyphens optional).",
       "title": "Identifier"
      },
      "description": "A Spotify track ID (22-char base-62, or a `spotify:track:…` URI / open.spotify.com URL) — or an ISRC (12-char, e.g. `USUM71900001`; hyphens optional)."
     },
     {
      "name": "calibrate",
      "in": "query",
      "required": false,
      "schema": {
       "enum": [
        "spotify"
       ],
       "type": "string",
       "description": "Set to `spotify` (lowercase, exact) to apply Spotify-scale calibration to numeric values. v1 calibrates loudness only (R²=0.43, RMSE 3.0 dB vs raw 8.5 dB — 64% improvement) using a linear regression fitted on a 5,724-track overlap with maharshipandya/spotify-tracks-dataset. Other fields fall through unchanged because their heuristic-vs-ML methodology gap is too wide to calibrate honestly; see the threshold guide for per-field details. Default (omit) returns raw FreqBlog values. Typos / case variants like `Spotify`, `SPOTIFY`, `true`, `1` will 422 — explicit rather than silently returning raw values.",
       "title": "Calibrate",
       "nullable": true
      },
      "description": "Set to `spotify` (lowercase, exact) to apply Spotify-scale calibration to numeric values. v1 calibrates loudness only (R²=0.43, RMSE 3.0 dB vs raw 8.5 dB — 64% improvement) using a linear regression fitted on a 5,724-track overlap with maharshipandya/spotify-tracks-dataset. Other fields fall through unchanged because their heuristic-vs-ML methodology gap is too wide to calibrate honestly; see the threshold guide for per-field details. Default (omit) returns raw FreqBlog values. Typos / case variants like `Spotify`, `SPOTIFY`, `true`, `1` will 422 — explicit rather than silently returning raw values."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Audio features in Spotify's `AudioFeaturesObject` shape",
      "content": {
       "application/json": {
        "schema": {},
        "example": {
         "acousticness": 0.04,
         "danceability": 0.76,
         "duration_ms": 200040,
         "energy": 0.91,
         "id": "0VjIjW4GlUZAMYd2vXMi3b",
         "instrumentalness": 0.02,
         "key": 1,
         "liveness": 0.09,
         "loudness": -7.1,
         "mode": 0,
         "speechiness": 0.06,
         "tempo": 171.0,
         "time_signature": 4,
         "track_href": "https://api.spotify.com/v1/tracks/0VjIjW4GlUZAMYd2vXMi3b",
         "type": "audio_features",
         "uri": "spotify:track:0VjIjW4GlUZAMYd2vXMi3b",
         "valence": 0.51
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "No track found for that Spotify track ID / ISRC. **For an ISRC we could identify but don't yet hold, this 404 also queues an on-demand fetch — retry the same call in ~15-30s and it usually resolves.** A Spotify ID is resolved from our ID map, or on a miss by the track's title — so an unmapped ID 404s when nothing in the catalog carries that title, or when several artists share it and the right recording is ambiguous (we return 404 rather than guess). For reliable coverage, identify the track by name (`GET /lookup?track=<title>&artist=<artist>`, which fetches on a miss) or by ISRC (`GET /lookup?isrc=<ISRC>`)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "`identifier` is neither a 22-char Spotify track ID nor a 12-char ISRC",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded, or too many concurrent single-ID lookups for this key (per-key in-flight cap shared with /lookup — free 6, paid 10). Back off per Retry-After, or send a large migration through the batch form `GET /v1/audio-features?ids=…`.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "503": {
      "description": "The resolve exceeded its internal deadline (kept below nginx's 30s) under load — transient. Retry per Retry-After, or use the time-boxed batch form `GET /v1/audio-features?ids=…` / `POST /bulk`, which return partial results instead of timing out.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     }
    }
   }
  },
  "/v1/audio-features": {
   "get": {
    "tags": [
     "Spotify-compat"
    ],
    "summary": "Get audio features for several tracks — Spotify-shape compatible (batch)",
    "description": "**Spotify-shape compatible replacement for the deprecated `GET /v1/audio-features?ids=…`.** Up to **100** comma-separated identifiers per request. Returns `{\"audio_features\": [...]}` — the array length and order match the input; entries are `null` for IDs that don't resolve (matching Spotify's behaviour).\n\nEach `ids` entry is auto-detected exactly like the single-ID route: a 22-char base-62 string → Spotify track ID (also accepts `spotify:track:…` URIs and `open.spotify.com/track/…` URLs); a 12-char ISRC (e.g. `USUM71900001`, hyphens optional) → ISRC lookup. Identifiers that match neither shape fill their slot with `null` rather than failing the whole batch.\n\nAs with the single-ID route, a Spotify ID is resolved from our ID map or, on a miss, by the track's **title**. Slots come back `null` when nothing in the catalog carries that title, or when several artists share it and the right recording is ambiguous — we return `null` rather than guess, since the wrong recording would mean the wrong bpm and key.\n\n**A `null` ISRC slot now queues an on-demand fetch.** When we can identify the recording behind an ISRC but don't hold it yet, the slot comes back `null` (and uncharged) *and* an ingest is queued — so **re-sending the same call in ~15-30s typically returns the data**. Retrying a null-heavy batch once is worthwhile; it wasn't before. Spotify-ID slots can't do this: an unresolvable Spotify ID has no track name to fetch by. For reliable coverage, identify tracks by **name or ISRC**: `GET /lookup?track=<title>&artist=<artist>` searches the full catalog and fetches on a miss, and ISRCs (e.g. from your own Spotify integration's `external_ids.isrc`) resolve via the same batch route or `GET /lookup?isrc=<ISRC>`.\n\n**Quota — one token per ID that returns data.** Each resolved ID counts as one quota request; IDs that come back `null` — an unmapped Spotify ID, an ISRC with no match, or one not resolved before the time-box — are **not** charged, exactly like the single-ID route, which is free on a 404 miss. So a 100-ID batch of raw Spotify IDs where only 5 are mapped bills 5, not 100. Spotify billed a 100-ID batch as 1 call; we bill per *resolved* ID, because each resolved lookup runs its own catalog query (and potentially a MusicBrainz roundtrip on an ISRC). See `/pricing` for higher tiers.\n\nOther notes from the single-ID route apply: the *values* are FreqBlog's analysis (signal-analysis approximations, directionally Spotify-compatible — see the [Field-by-Field Threshold Guide](https://freqblog.com/blog/spotify-audio-features-migration-thresholds/) for catalog distribution data and recommended threshold adjustments per field); `analysis_url` is always `null`; errors use FreqBlog's `{\"detail\": …}` shape, not Spotify's `{\"error\": {…}}`.\n\n**Resolved in parallel server-side, bounded for stability.** A large batch of uncatalogued IDs can take a while (each ISRC miss is a MusicBrainz roundtrip); if it exceeds the server time-box you get **partial results** — IDs resolved in time carry features, the rest come back `null` (and aren't charged) and resolve on a retry. A small number of `/bulk` + `/v1/audio-features` batch requests per key run at once — excess get `429` with `Retry-After`.",
    "operationId": "spotify_audio_features_batch_endpoint_v1_audio_features_get",
    "parameters": [
     {
      "name": "ids",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Comma-separated identifiers (max 100). Each is either a Spotify track ID (22-char base-62; also accepts `spotify:track:…` URIs and `open.spotify.com/track/…` URLs) or an ISRC (12-char, e.g. `USUM71900001`; hyphens optional). Only IDs that resolve to data are charged — unmapped IDs return `null` for free. No raw Spotify IDs? Identify tracks by name via `GET /lookup?track=<title>&artist=<artist>`.",
       "title": "Ids",
       "nullable": true,
       "example": "USUM71900001,003vvx7Niy0yvhvHt4a68B,USUMV1902060"
      },
      "description": "Comma-separated identifiers (max 100). Each is either a Spotify track ID (22-char base-62; also accepts `spotify:track:…` URIs and `open.spotify.com/track/…` URLs) or an ISRC (12-char, e.g. `USUM71900001`; hyphens optional). Only IDs that resolve to data are charged — unmapped IDs return `null` for free. No raw Spotify IDs? Identify tracks by name via `GET /lookup?track=<title>&artist=<artist>`."
     },
     {
      "name": "calibrate",
      "in": "query",
      "required": false,
      "schema": {
       "enum": [
        "spotify"
       ],
       "type": "string",
       "description": "Set to `spotify` (lowercase, exact) to apply Spotify-scale calibration to numeric values across the entire batch. Same calibration as the single-ID route: v1 calibrates loudness only (R²=0.43) from a 5,724-track public-dataset overlap. Typos / case variants 422 explicitly.",
       "title": "Calibrate",
       "nullable": true
      },
      "description": "Set to `spotify` (lowercase, exact) to apply Spotify-scale calibration to numeric values across the entire batch. Same calibration as the single-ID route: v1 calibrates loudness only (R²=0.43) from a 5,724-track public-dataset overlap. Typos / case variants 422 explicitly."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Batch audio features in Spotify's `{audio_features: [...]}` shape; entries are `null` for IDs that don't resolve (Spotify's behaviour)",
      "content": {
       "application/json": {
        "schema": {},
        "example": {
         "audio_features": [
          {
           "acousticness": 0.04,
           "danceability": 0.76,
           "energy": 0.91,
           "id": "0VjIjW4GlUZAMYd2vXMi3b",
           "key": 1,
           "mode": 0,
           "tempo": 171.0,
           "type": "audio_features",
           "uri": "spotify:track:0VjIjW4GlUZAMYd2vXMi3b",
           "...": "(remaining Spotify fields)"
          },
          null,
          {
           "id": "USUM71900001",
           "key": 5,
           "tempo": 85.39,
           "type": "audio_features",
           "track_href": "https://api.freqblog.com/lookup?isrc=USUM71900001",
           "...": "(remaining Spotify fields)"
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "`ids` missing, empty, or longer than 100. Supply `?ids=<comma-separated Spotify track IDs or 12-char ISRCs, up to 100>`. No raw Spotify IDs? Identify tracks by name via `GET /lookup?track=<title>&artist=<artist>`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota insufficient, or too many concurrent batch requests for this key (shared with /bulk)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "503": {
      "description": "Reserved for genuine service unavailability. The server time-box no longer fails a slow batch — IDs unresolved in time come back `null` in the 200 response (and aren't charged); retry to collect them.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/bulk": {
   "post": {
    "tags": [
     "Catalog"
    ],
    "summary": "Bulk track lookup",
    "description": "Look up up to 50 tracks in a single request. Identify each item by **name** (`track`/`artist`), by **ISRC** (`isrc`), or by **both** — every item needs at least one of `isrc` or `track`. (`title` is accepted as an alias for `track`.)\n\n**One bad row never fails the batch.** Items past the 50-per-call cap, or an item carrying neither `isrc` nor `track`, are echoed back in order with `found: false` and a `backfill_status` of `over_limit` / `invalid_no_query` (not processed, not charged) — the response `skipped` count tells you how many. Resubmit the over-limit tail in batches of ≤50. You may send up to 200 items in one request; a larger payload is a `400`.\n\n**ISRC is resolved first, as an exact key** (catalog → MusicBrainz → Deezer), then the name is the fallback. Because ISRC is exact it resolves CJK / K-pop / niche tracks whose romaji/native fuzzy name-match misses — supplying `{isrc, artist, title}` gives you exact-match coverage with a name fallback in one pass. Each item is otherwise resolved through the same 4-layer fallback chain as `/lookup` (Apple Music catalog → MusicBrainz/AcousticBrainz → FMA Echo Nest → Million Song Dataset), covering 8.5M+ tracks in total. Tracks not found in any source return `found: false` with a null `result`; the `isrc` you supplied is echoed back on each result so you can map results onto ISRC-keyed inputs.\n\n**Use this instead of looping `/lookup`** — one HTTP round-trip, with the lookups resolved in parallel server-side (bounded for stability), dramatically faster for playlist processing.\n\n**On-demand ingest in batches (paid plans):** when a track resolves to a known recording (via ISRC or name) that isn't in the catalogue yet, it's queued for on-demand ingest (`backfill_status=\"queued\"`) — so a recommendation/resolution pass warms the catalogue for the next call, instead of needing per-track `/lookup?isrc=` fallbacks. Bounded per request; the rest come back `found=false` and resolve on a retry.\n\n**Quota — charged per item that returns data or queues real work.** An item is billed when it returns audio features OR queues an on-demand ingest; an item that resolves to nothing (an ISRC with no match anywhere, or a name miss with nothing to ingest) is **free**, as is one still processing at the time-box. See `requests_used` for the actual count.\n\n**Large / uncatalogued batches**: most playlists return in well under a second, but a batch of many obscure tracks can take longer to resolve; if it exceeds the server time-box you get **partial results** — resolved tracks are returned, the rest come back `found=false` with `backfill_status=\"processing\"` (retry to collect them). A small number of `/bulk` requests per key may run at once — excess get `429` with `Retry-After`.",
    "operationId": "bulk_lookup_endpoint_bulk_post",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "array",
        "items": {
         "$ref": "#/components/schemas/BulkLookupItem"
        },
        "title": "Body"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Bulk lookup complete (partial results possible)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BulkLookupResponse"
        }
       }
      }
     },
     "400": {
      "description": "Empty list, or more than 200 items. Up to 50 items are processed per call; items 51..200 are echoed as `over_limit` (uncharged) rather than rejecting the whole batch.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "The BODY SHAPE is wrong. Send a bare JSON array of objects — `[{\"isrc\": \"USUM71027402\"}, {\"track\": \"Runaway\", \"artist\": \"Kanye West\"}]` — not an object wrapping one (`{\"tracks\": [...]}`), and not an array of bare strings (`[\"USUM71027402\"]`). Note that a bad *item* does NOT 422: an item carrying neither `isrc` nor `track` is echoed back as `invalid_no_query` inside the 200, uncharged.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded, or too many concurrent /bulk requests for this key",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "503": {
      "description": "Reserved for genuine service unavailability. The server time-box no longer fails a slow batch — tracks unresolved in time come back `found=false` with `backfill_status=\"processing\"` in the 200 response (and aren't charged); retry to collect them.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     }
    }
   }
  },
  "/bpm": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Find tracks by BPM",
    "description": "Return catalog tracks within ±`tolerance` BPM of the target. No audio upload needed — instant lookup from the pre-analyzed catalog.\n\nResults are ordered by proximity to the target BPM, then by an internal catalogue ordering key (not an audience metric). Use `tolerance=0.5` for exact-BPM matching or `tolerance=5` for a wider range.\n\n**Quota**: counts as one request regardless of how many tracks are returned.",
    "operationId": "bpm_endpoint_bpm_get",
    "parameters": [
     {
      "name": "bpm",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 300.0,
       "minimum": 20.0,
       "description": "Target BPM",
       "title": "Bpm",
       "nullable": true
      },
      "description": "Target BPM"
     },
     {
      "name": "tolerance",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 10.0,
       "minimum": 0.5,
       "description": "BPM tolerance window (±BPM)",
       "default": 2.0,
       "title": "Tolerance"
      },
      "description": "BPM tolerance window (±BPM)"
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 50,
       "minimum": 1,
       "description": "Maximum tracks to return",
       "default": 10,
       "title": "Limit"
      },
      "description": "Maximum tracks to return"
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Tracks matching the BPM range",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/BpmSearchResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Missing or out-of-range `bpm`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     }
    }
   }
  },
  "/key": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Find tracks by musical key",
    "description": "Return catalog tracks in the specified musical key. Accepts **Camelot** (e.g. `8A`), **Open Key** (e.g. `1m`), or a **key name** (e.g. `A-Minor`, `F#-Major`).\n\nResults are ordered by an internal catalogue ordering key (not an audience metric). No audio upload needed — instant lookup from the pre-analyzed catalog.\n\n**Quota**: counts as one request regardless of how many tracks are returned.",
    "operationId": "key_endpoint_key_get",
    "parameters": [
     {
      "name": "q",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "minLength": 1,
       "maxLength": 20,
       "description": "Key query: Camelot (8A), Open Key (1m), or key name (A-Minor)",
       "title": "Q"
      },
      "description": "Key query: Camelot (8A), Open Key (1m), or key name (A-Minor)"
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 50,
       "minimum": 1,
       "description": "Maximum tracks to return",
       "default": 10,
       "title": "Limit"
      },
      "description": "Maximum tracks to return"
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Tracks matching the requested key",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/KeySearchResponse"
        }
       }
      }
     },
     "400": {
      "description": "Unrecognised key format",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/search": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Search the catalog by track / artist / album",
    "description": "Full-text search across track name, artist name, and album name. Returns lightweight track stubs (no audio features) ordered by relevance, then by an internal catalogue ordering key (not an audience metric). Use `/lookup` to fetch full audio features once you have a track id.\n\nEach whitespace-separated token is matched as a prefix (so `lov dean` matches *'Lover'* by *'Olivia Dean'*). Punctuation in the query is ignored.\n\n**Quota**: counts as one request regardless of how many results are returned.",
    "operationId": "search_endpoint_search_get",
    "parameters": [
     {
      "name": "q",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "minLength": 1,
       "maxLength": 200,
       "description": "Search query (artist, track, album, or any combination).",
       "title": "Q"
      },
      "description": "Search query (artist, track, album, or any combination)."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 50,
       "minimum": 1,
       "description": "Maximum results to return.",
       "default": 10,
       "title": "Limit"
      },
      "description": "Maximum results to return."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Search results",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SearchResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/artist/tracks": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "List catalog tracks for an artist",
    "description": "Returns lightweight track stubs for an artist (case-insensitive exact match on `artist_name`). Use `/search` first if you don't have an exact spelling, or `/lookup` to retrieve full audio features for any single track.\n\nOrdered by an internal catalogue ordering key (not an audience metric), then track name. Supports pagination via `limit` + `offset`.\n\n**Quota**: counts as one request regardless of how many tracks are returned.",
    "operationId": "artist_tracks_endpoint_artist_tracks_get",
    "parameters": [
     {
      "name": "artist",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "minLength": 1,
       "maxLength": 200,
       "description": "Artist name (case-insensitive exact match).",
       "title": "Artist"
      },
      "description": "Artist name (case-insensitive exact match)."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 100,
       "minimum": 1,
       "description": "Maximum tracks to return.",
       "default": 20,
       "title": "Limit"
      },
      "description": "Maximum tracks to return."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "minimum": 0,
       "description": "Pagination offset.",
       "default": 0,
       "title": "Offset"
      },
      "description": "Pagination offset."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Tracks for the requested artist",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ArtistTracksResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "No tracks for this artist in the catalog",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/genres": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "List all genres in the catalog with track counts",
    "description": "Returns every distinct populated `genre` value in the catalog with the number of tracks tagged. Useful for building filter UIs or discovering coverage gaps.\n\nGenres come from AcousticBrainz (primary) and Last.fm (resolved by the background enrichment pipeline at ingest/backfill time — not on the synchronous lookup path), normalised (e.g. `raphiphop` → `hip-hop`, `folkcountry` → `folk`).\n\n**Quota**: counts as one request.",
    "operationId": "genres_endpoint_genres_get",
    "parameters": [
     {
      "name": "min_count",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 10000,
       "minimum": 1,
       "description": "Minimum track count for a genre to be included.",
       "default": 1,
       "title": "Min Count"
      },
      "description": "Minimum track count for a genre to be included."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 500,
       "minimum": 1,
       "description": "Maximum genres to return.",
       "default": 200,
       "title": "Limit"
      },
      "description": "Maximum genres to return."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "List of genres in the catalog",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/GenresResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/genres/{name}/tracks": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "List catalog tracks tagged with a genre",
    "description": "Returns lightweight track stubs tagged with the given genre (case-insensitive). Use the `/genres` endpoint to discover available genre names.\n\nOrdered by an internal catalogue ordering key (not an audience metric), then track name. Supports pagination via `limit` + `offset`.\n\n**Quota**: counts as one request.",
    "operationId": "genre_tracks_endpoint_genres__name__tracks_get",
    "parameters": [
     {
      "name": "name",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Genre name (case-insensitive).",
       "title": "Name"
      },
      "description": "Genre name (case-insensitive)."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 100,
       "minimum": 1,
       "description": "Maximum tracks to return.",
       "default": 20,
       "title": "Limit"
      },
      "description": "Maximum tracks to return."
     },
     {
      "name": "offset",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "minimum": 0,
       "description": "Pagination offset.",
       "default": 0,
       "title": "Offset"
      },
      "description": "Pagination offset."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Tracks tagged with the requested genre",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/GenreTracksResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "No tracks for this genre in the catalog",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/key/{camelot}/compatible": {
   "get": {
    "tags": [
     "Utility"
    ],
    "summary": "Harmonically compatible keys for a Camelot value",
    "description": "Pure-logic helper — returns keys that mix harmonically with the input under the standard Camelot wheel rules. No catalog lookup, no quota cost.\n\nAlways returns: `same`, `relative` (same number, opposite letter), `adjacent_up` (+1), `adjacent_down` (-1). With `extended=true` also returns `energy_boost` (+7) and `energy_drop` (-7) for DJ key-change moves.\n\nEach compatible key is returned in three notations: Camelot, Open Key, and human-readable.",
    "operationId": "key_compatible_endpoint_key__camelot__compatible_get",
    "parameters": [
     {
      "name": "camelot",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "minLength": 2,
       "maxLength": 3,
       "description": "Camelot notation, e.g. `8A` or `12B`.",
       "title": "Camelot"
      },
      "description": "Camelot notation, e.g. `8A` or `12B`."
     },
     {
      "name": "extended",
      "in": "query",
      "required": false,
      "schema": {
       "type": "boolean",
       "description": "Include `energy_boost` (+7) and `energy_drop` (-7) variants.",
       "default": false,
       "title": "Extended"
      },
      "description": "Include `energy_boost` (+7) and `energy_drop` (-7) variants."
     }
    ],
    "responses": {
     "200": {
      "description": "Harmonically compatible keys",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/CamelotCompatibleResponse"
        }
       }
      }
     },
     "400": {
      "description": "Invalid Camelot value",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/track/{itunes_track_id}/embedding": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Numeric feature vector for a track",
    "description": "Project a track's audio features into a fixed-order numeric vector — convenient for similarity search, clustering, or feeding directly into your own ML model.\n\nThe `fields` array tells you what each position means. The `embedding_mask` array marks which positions came from real analysis (`true`) versus filled with a default (`false`) — use this to ignore filler positions in cosine-similarity calculations.\n\nLook up `itunes_track_id` values via `/search` or from any `/lookup` response.\n\n**Quota**: counts as one request.",
    "operationId": "track_embedding_endpoint_track__itunes_track_id__embedding_get",
    "parameters": [
     {
      "name": "itunes_track_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Catalog track id (or `mb:`/`fma:`/`msd:` synthetic id).",
       "title": "Itunes Track Id"
      },
      "description": "Catalog track id (or `mb:`/`fma:`/`msd:` synthetic id)."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Numeric feature vector for the track",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/EmbeddingResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "Track not in catalog or has no audio analysis",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/similar": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Find acoustically similar tracks",
    "description": "Recommendation engine in one call. Pass a seed track id and get back the most acoustically similar tracks in the catalog, ranked by cosine similarity over an 18-feature audio embedding (BPM, key, energy, loudness, danceability, valence, mood, AcousticBrainz descriptors and more).\n\nPer-feature z-score normalisation runs at index build time using only real (non-default) values, so each feature contributes equally regardless of its raw scale. Filler values from missing features contribute zero to similarity — tracks with little real data don't poison the rankings.\n\nSimilarity is measured over the features the seed actually has analysed, with each candidate compared on that same footing — so a fully-analysed track is never out-ranked by a thinly-analysed one that merely has fewer dimensions to disagree on.\n\nResults are **one row per recording**: the same track ingested under several storefront ids is collapsed to its best-scoring row, so a 15-result list is 15 distinct tracks. By default (`cross_genre=auto`) results are kept in a mixable genre lane — use `cross_genre=strict` for the seed's genre family only, or `allow` for pure acoustic similarity. Each result carries **`genre_relation`** (`same` / `compatible` / `cross` / `unknown`): under `auto` the ordering is cosine PLUS a genre adjustment, so `score` alone does not explain the order, and a thin genre pads out with lower-ranked `cross` picks — filter on `genre_relation` to keep only the in-lane ones.\n\n**Pass `track_id`** from any prior `/lookup`, `/search`, or `/bpm` response. Use `exclude_same_artist=true` to filter out other tracks by the same artist (useful for cross-artist recommendation feeds).\n\n**Quota**: counts as one request regardless of how many tracks are returned.",
    "operationId": "similar_endpoint_similar_get",
    "parameters": [
     {
      "name": "track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed track: a catalog `itunes_track_id` (from any /lookup, /search or /bpm response). Alternatively seed by `isrc`, `spotify_id`, or `track`(+`artist`). Alias: `seed_track_id`.",
       "title": "Track Id",
       "nullable": true
      },
      "description": "Seed track: a catalog `itunes_track_id` (from any /lookup, /search or /bpm response). Alternatively seed by `isrc`, `spotify_id`, or `track`(+`artist`). Alias: `seed_track_id`."
     },
     {
      "name": "seed_track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Alias for `track_id` (the seed param the other set endpoints use).",
       "title": "Seed Track Id",
       "nullable": true
      },
      "description": "Alias for `track_id` (the seed param the other set endpoints use)."
     },
     {
      "name": "itunes_track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Alias for the seed id — the `itunes_track_id` field name a /lookup response returns.",
       "title": "Itunes Track Id",
       "nullable": true
      },
      "description": "Alias for the seed id — the `itunes_track_id` field name a /lookup response returns."
     },
     {
      "name": "isrc",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by ISRC — e.g. straight from a /lookup response — instead of a catalog id.",
       "title": "Isrc",
       "nullable": true
      },
      "description": "Seed by ISRC — e.g. straight from a /lookup response — instead of a catalog id."
     },
     {
      "name": "spotify_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by Spotify track id / URI / URL (resolves only the <1% of tracks mapped to a Spotify id).",
       "title": "Spotify Id",
       "nullable": true
      },
      "description": "Seed by Spotify track id / URI / URL (resolves only the <1% of tracks mapped to a Spotify id)."
     },
     {
      "name": "track",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by track name (optionally with `artist`).",
       "title": "Track",
       "nullable": true
      },
      "description": "Seed by track name (optionally with `artist`)."
     },
     {
      "name": "artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Artist name — used with `track`.",
       "title": "Artist",
       "nullable": true
      },
      "description": "Artist name — used with `track`."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 50,
       "minimum": 1,
       "description": "Maximum results to return.",
       "default": 10,
       "title": "Limit"
      },
      "description": "Maximum results to return."
     },
     {
      "name": "exclude_same_artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "boolean",
       "description": "Drop other tracks by the same artist as the seed.",
       "default": false,
       "title": "Exclude Same Artist"
      },
      "description": "Drop other tracks by the same artist as the seed."
     },
     {
      "name": "cross_genre",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "pattern": "^(auto|allow|strict)$",
       "description": "Genre handling. 'auto' (default) keeps results in a mixable genre lane — a track that is only coincidentally close on audio features sinks to the bottom and appears only if too few in-genre matches exist. 'strict' = same genre-family only. 'allow' = genre-blind (pure acoustic similarity).",
       "default": "auto",
       "title": "Cross Genre"
      },
      "description": "Genre handling. 'auto' (default) keeps results in a mixable genre lane — a track that is only coincidentally close on audio features sinks to the bottom and appears only if too few in-genre matches exist. 'strict' = same genre-family only. 'allow' = genre-blind (pure acoustic similarity)."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Tracks ranked by audio-feature similarity to the seed.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SimilarResponse"
        }
       }
      }
     },
     "400": {
      "description": "Seed track has too few real features to recommend from.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "Seed track not found in catalog (or has no analysis).",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/charts/{country}": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Live national music chart (Apple Music)",
    "description": "Live top-songs chart for any of 45 supported countries, sourced from Apple Music RSS. Each entry is cross-referenced against the FreqBlog catalog — when the track is already analysed, `in_catalog=true` and `catalog_track` is populated with the full TrackStub (including `itunes_track_id` you can pass straight to `/lookup`, `/similar` or `/track/{id}/embedding`).\n\nSnapshots are cached for 12 hours per country/type/limit, so calling this is cheap.\n\n**Quota**: counts as one request.",
    "operationId": "charts_endpoint_charts__country__get",
    "parameters": [
     {
      "name": "country",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "minLength": 2,
       "maxLength": 2,
       "description": "ISO 3166-1 alpha-2 country code, lowercase (e.g. `gb`, `us`, `jp`).",
       "title": "Country"
      },
      "description": "ISO 3166-1 alpha-2 country code, lowercase (e.g. `gb`, `us`, `jp`)."
     },
     {
      "name": "type",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Chart type: `most-played` (default) or `new-music`.",
       "default": "most-played",
       "title": "Type"
      },
      "description": "Chart type: `most-played` (default) or `new-music`."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 100,
       "minimum": 1,
       "description": "Maximum chart entries to return.",
       "default": 50,
       "title": "Limit"
      },
      "description": "Maximum chart entries to return."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Live Apple Music chart for the country",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ChartResponse"
        }
       }
      }
     },
     "400": {
      "description": "Unsupported country code",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "502": {
      "description": "Upstream Apple RSS error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/radio": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Generate a harmonic + BPM-continuity radio playlist",
    "description": "Build a continuous DJ-style playlist starting from a seed track. The walk is greedy: at each step we pick the most similar candidate that also satisfies harmonic mixing rules (Camelot wheel adjacency ≤ `max_key_distance`) and BPM continuity (within ±`bpm_drift` of the previous track).\n\nReturned tracks are in play order — the first entry equals the seed. Use this output directly with the export endpoints (`/export/rekordbox?track_ids=...`) to send a ready-to-mix playlist into Rekordbox or Serato.\n\n**Quota**: counts as one request regardless of `n`.",
    "operationId": "radio_endpoint_radio_get",
    "parameters": [
     {
      "name": "seed_track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed track: a catalog `itunes_track_id`, or seed by `isrc`, `spotify_id`, or `track`(+`artist`). Alias: `track_id`.",
       "title": "Seed Track Id",
       "nullable": true
      },
      "description": "Seed track: a catalog `itunes_track_id`, or seed by `isrc`, `spotify_id`, or `track`(+`artist`). Alias: `track_id`."
     },
     {
      "name": "track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Alias for `seed_track_id`.",
       "title": "Track Id",
       "nullable": true
      },
      "description": "Alias for `seed_track_id`."
     },
     {
      "name": "itunes_track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Alias for the seed id — the `itunes_track_id` field name a /lookup response returns.",
       "title": "Itunes Track Id",
       "nullable": true
      },
      "description": "Alias for the seed id — the `itunes_track_id` field name a /lookup response returns."
     },
     {
      "name": "isrc",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by ISRC (e.g. straight from a /lookup response).",
       "title": "Isrc",
       "nullable": true
      },
      "description": "Seed by ISRC (e.g. straight from a /lookup response)."
     },
     {
      "name": "spotify_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by Spotify track id / URI / URL (catalog-mapped tracks only).",
       "title": "Spotify Id",
       "nullable": true
      },
      "description": "Seed by Spotify track id / URI / URL (catalog-mapped tracks only)."
     },
     {
      "name": "track",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by track name (optionally with `artist`).",
       "title": "Track",
       "nullable": true
      },
      "description": "Seed by track name (optionally with `artist`)."
     },
     {
      "name": "artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Artist name — used with `track`.",
       "title": "Artist",
       "nullable": true
      },
      "description": "Artist name — used with `track`."
     },
     {
      "name": "n",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 50,
       "minimum": 2,
       "description": "Total playlist length including the seed.",
       "default": 20,
       "title": "N"
      },
      "description": "Total playlist length including the seed."
     },
     {
      "name": "max_key_distance",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 12,
       "minimum": 0,
       "description": "Maximum Camelot-wheel hops between consecutive tracks. 1 = strict (same/adjacent/relative); 2 = relaxed; ≥3 = effectively no key constraint.",
       "default": 2,
       "title": "Max Key Distance"
      },
      "description": "Maximum Camelot-wheel hops between consecutive tracks. 1 = strict (same/adjacent/relative); 2 = relaxed; ≥3 = effectively no key constraint."
     },
     {
      "name": "bpm_drift",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 30.0,
       "minimum": 0.5,
       "description": "Max BPM difference between consecutive tracks.",
       "default": 8.0,
       "title": "Bpm Drift"
      },
      "description": "Max BPM difference between consecutive tracks."
     },
     {
      "name": "exclude_same_artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "boolean",
       "description": "Drop tracks by the seed's artist from the entire playlist.",
       "default": false,
       "title": "Exclude Same Artist"
      },
      "description": "Drop tracks by the seed's artist from the entire playlist."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Harmonic playlist starting from the seed",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/RadioResponse"
        }
       }
      }
     },
     "400": {
      "description": "Seed has too little data to build a radio playlist.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "Seed track not in catalog (no audio analysis)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/transition": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Score how well two tracks mix (harmonic + tempo + energy)",
    "description": "Score the transition FROM `from_track_id` INTO `to_track_id` on a 0-100 scale, blending Camelot-wheel key compatibility, octave-aware BPM proximity (half/double time counts), and energy smoothness — with a plain-English `reason`. This is set *flow*, not raw key/BPM. Find `itunes_track_id`s via `/search` or any `/lookup` response; chain `/next-track` → `/transition` → `/export/rekordbox`.\n\n**Quota**: counts as one request.",
    "operationId": "transition_endpoint_transition_get",
    "parameters": [
     {
      "name": "from_track_id",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Track you're mixing OUT of (catalog `itunes_track_id`).",
       "title": "From Track Id"
      },
      "description": "Track you're mixing OUT of (catalog `itunes_track_id`)."
     },
     {
      "name": "to_track_id",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Track you're mixing INTO (catalog `itunes_track_id`).",
       "title": "To Track Id"
      },
      "description": "Track you're mixing INTO (catalog `itunes_track_id`)."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Mix-compatibility score for the from→to transition",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TransitionResponse"
        }
       }
      }
     },
     "400": {
      "description": "from_track_id and to_track_id are the same",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "One of the track ids is not in the catalog",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/next-track": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Ranked next-track suggestions for a seed (harmonic + tempo + energy)",
    "description": "Given a seed track, return the tracks that mix best after it: the seed's closest sonic neighbours, re-ranked by transition score (Camelot key + octave-aware BPM + energy). Each suggestion carries its score, component breakdown, catalogue `genre` and a plain-English `reason`. By default (`cross_genre=auto`) picks are kept in a mixable genre lane, so an off-genre track that only coincidentally shares the key/BPM doesn't top the list — use `cross_genre=strict` for same-genre only, or `allow` to disable. Use `min_score` to keep only clean mixes; `bpm_drift` / `max_key_distance` constrain the candidate pool.\n\n**Quota**: counts as 3 requests (it scores a large candidate pool).",
    "operationId": "next_track_endpoint_next_track_get",
    "parameters": [
     {
      "name": "seed_track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed track: a catalog `itunes_track_id`, or seed by `isrc`, `spotify_id`, or `track`(+`artist`). Alias: `track_id`.",
       "title": "Seed Track Id",
       "nullable": true
      },
      "description": "Seed track: a catalog `itunes_track_id`, or seed by `isrc`, `spotify_id`, or `track`(+`artist`). Alias: `track_id`."
     },
     {
      "name": "track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Alias for `seed_track_id`.",
       "title": "Track Id",
       "nullable": true
      },
      "description": "Alias for `seed_track_id`."
     },
     {
      "name": "itunes_track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Alias for the seed id — the `itunes_track_id` field name a /lookup response returns.",
       "title": "Itunes Track Id",
       "nullable": true
      },
      "description": "Alias for the seed id — the `itunes_track_id` field name a /lookup response returns."
     },
     {
      "name": "isrc",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by ISRC (e.g. straight from a /lookup response).",
       "title": "Isrc",
       "nullable": true
      },
      "description": "Seed by ISRC (e.g. straight from a /lookup response)."
     },
     {
      "name": "spotify_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by Spotify track id / URI / URL (catalog-mapped tracks only).",
       "title": "Spotify Id",
       "nullable": true
      },
      "description": "Seed by Spotify track id / URI / URL (catalog-mapped tracks only)."
     },
     {
      "name": "track",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Seed by track name (optionally with `artist`).",
       "title": "Track",
       "nullable": true
      },
      "description": "Seed by track name (optionally with `artist`)."
     },
     {
      "name": "artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Artist name — used with `track`.",
       "title": "Artist",
       "nullable": true
      },
      "description": "Artist name — used with `track`."
     },
     {
      "name": "n",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 50,
       "minimum": 1,
       "description": "Number of suggestions to return.",
       "default": 10,
       "title": "N"
      },
      "description": "Number of suggestions to return."
     },
     {
      "name": "min_score",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 100,
       "minimum": 0,
       "description": "Drop suggestions scoring below this overall value.",
       "default": 0,
       "title": "Min Score"
      },
      "description": "Drop suggestions scoring below this overall value."
     },
     {
      "name": "exclude_same_artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "boolean",
       "description": "Skip tracks by the seed's artist.",
       "default": false,
       "title": "Exclude Same Artist"
      },
      "description": "Skip tracks by the seed's artist."
     },
     {
      "name": "bpm_drift",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 60.0,
       "minimum": 0.5,
       "description": "Max BPM difference (octave-aware) for a candidate to be considered.",
       "default": 12.0,
       "title": "Bpm Drift"
      },
      "description": "Max BPM difference (octave-aware) for a candidate to be considered."
     },
     {
      "name": "max_key_distance",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 12,
       "minimum": 0,
       "description": "Max Camelot-wheel hops for a candidate. 12 = no key constraint.",
       "default": 2,
       "title": "Max Key Distance"
      },
      "description": "Max Camelot-wheel hops for a candidate. 12 = no key constraint."
     },
     {
      "name": "cross_genre",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "pattern": "^(auto|allow|strict)$",
       "description": "Genre handling. 'auto' (default) keeps picks in a mixable genre lane — an off-genre track that only coincidentally shares the key + BPM sinks to the bottom and appears only if too few in-genre picks exist. 'strict' = same genre-family only. 'allow' = genre-blind (pure harmonic + tempo + energy).",
       "default": "auto",
       "title": "Cross Genre"
      },
      "description": "Genre handling. 'auto' (default) keeps picks in a mixable genre lane — an off-genre track that only coincidentally shares the key + BPM sinks to the bottom and appears only if too few in-genre picks exist. 'strict' = same genre-family only. 'allow' = genre-blind (pure harmonic + tempo + energy)."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Ranked next-track suggestions",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/NextTrackResponse"
        }
       }
      }
     },
     "400": {
      "description": "Seed has too little data to rank from",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "Seed track not in catalog / similarity index",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "503": {
      "description": "Similarity index not yet built",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/setlist": {
   "post": {
    "tags": [
     "Catalog"
    ],
    "summary": "Order a crate into a beat-matched, energy-arced set",
    "description": "Give it a crate of catalog `itunes_track_id`s and it returns them **ordered** into a continuous set: each consecutive pair is harmonically + tempo compatible, and the set's energy follows the chosen `arc` (`peak_time` builds to a late peak then eases; `warmup` rises; `cooldown` falls; `flat` just optimises for smooth mixing). Greedy placement maximises transition score minus distance from the arc's energy target at each step.\n\nReturns the ordered `tracks`, the per-step `transitions` (with scores + reasons), and an overall `flow_score`. Chain into `/export/rekordbox?track_ids=...` (in the returned order) to drop the set straight into Rekordbox/Serato. Ids not in the catalog are returned in `omitted` and skipped.\n\n**Quota**: counts as 5 requests (it scores every candidate pair).",
    "operationId": "setlist_endpoint_setlist_post",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/SetlistRequest"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Tracks ordered into a beat-matched, energy-arced set",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/SetlistResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "None of the supplied track ids are in the catalog",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Invalid body (need 2-100 track_ids; arc must be a known shape)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     }
    }
   }
  },
  "/recommendations": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Recommended tracks for seed tracks (Spotify /recommendations replacement)",
    "description": "The drop-in for Spotify's removed `GET /v1/recommendations`. Supply up to 5 catalog seed tracks; we blend them into a single point in audio-feature space and return the nearest catalogue tracks. Seeds are FreqBlog `itunes_track_id`s (from `/search` or any `/lookup`), ranked on this pipeline's open-data audio features — no Spotify account, no deprecated endpoint.\n\n**Don't have an id?** Seed by name instead: pass `track=<title>&artist=<artist>` and we resolve it to the best catalogue match and use that as the seed (the resolved track is echoed back as `seed_query` so you can confirm it). `seed_tracks` wins when both are supplied. A name we don't yet have returns 404 — `/lookup?track=…&artist=…` it first (that queues analysis), then seed by the returned id.\n\nBy default (`cross_genre=auto`) picks are re-ranked by genre affinity, so a coincidentally feature-close cross-genre track doesn't outrank your same-genre picks — use `cross_genre=strict` to return same-genre-family tracks only (off-genre matches are dropped server-side), or `allow` to disable the genre ranking entirely.\n\n**Tuning (Spotify-compatible).** Every attribute below takes `min_`, `max_` and `target_`, exactly as Spotify's endpoint did: `acousticness`, `danceability`, `duration_ms`, `energy`, `instrumentalness`, `liveness`, `loudness`, `speechiness`, `tempo`, `valence`. `min_`/`max_` are HARD filters — a track outside the range, or one we hold no analysed value for, is removed before ranking. `target_` is a preference: nearer tracks rank higher and nothing is removed. So `?track=Got to Be Real&min_tempo=100&max_tempo=130&target_energy=0.8` gives you 100-130 BPM only, energetic ones first.\n\n⚠ **`popularity` was retired as a tunable on 2026-08-27** and now returns a 400. It never filtered by audience size — the value records how a track entered our catalogue — and only ~4% of analysed tracks carry a chart placement, so the bound removed almost everything for no gain. The `popularity` FIELD is still returned on every track.\n\nA tunable we don't serve (`key`, `mode`, `time_signature`, `popularity`) is a **400, not a silent drop** — a filter we can't honour must say so. When you supply any tunable the response carries a `filters` block reporting what was applied, how many tracks each bound removed, and whether the bounds ran out of catalogue before `limit` — check it before concluding the catalogue is thin.\n\n**Quota**: counts as 2 requests.",
    "operationId": "recommendations_endpoint_recommendations_get",
    "parameters": [
     {
      "name": "seed_tracks",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "description": "Comma-separated catalog `itunes_track_id`s (1-5) from `/search` or any `/lookup`. Recommendations are based on their blend. Omit and use `track`(+`artist`) to seed by name instead.",
       "title": "Seed Tracks",
       "nullable": true
      },
      "description": "Comma-separated catalog `itunes_track_id`s (1-5) from `/search` or any `/lookup`. Recommendations are based on their blend. Omit and use `track`(+`artist`) to seed by name instead."
     },
     {
      "name": "track",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 200,
       "description": "Seed by track NAME instead of an id — we resolve it to the best catalogue match and use that as the single seed (echoed back as `seed_query`). Pair with `artist` to disambiguate. Ignored when `seed_tracks` is supplied.",
       "title": "Track",
       "nullable": true
      },
      "description": "Seed by track NAME instead of an id — we resolve it to the best catalogue match and use that as the single seed (echoed back as `seed_query`). Pair with `artist` to disambiguate. Ignored when `seed_tracks` is supplied."
     },
     {
      "name": "artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 200,
       "description": "Artist name narrowing the `track` seed (case-insensitive).",
       "title": "Artist",
       "nullable": true
      },
      "description": "Artist name narrowing the `track` seed (case-insensitive)."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 100,
       "minimum": 1,
       "description": "Number of recommendations to return.",
       "default": 20,
       "title": "Limit"
      },
      "description": "Number of recommendations to return."
     },
     {
      "name": "exclude_seed_artists",
      "in": "query",
      "required": false,
      "schema": {
       "type": "boolean",
       "description": "Drop tracks by any of the seed artists.",
       "default": false,
       "title": "Exclude Seed Artists"
      },
      "description": "Drop tracks by any of the seed artists."
     },
     {
      "name": "cross_genre",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "pattern": "^(auto|allow|strict)$",
       "description": "Genre handling (mirrors `/next-track`). 'auto' (default) re-ranks by genre affinity so a feature-close cross-genre track can't outrank your same-genre picks, while cross-genre picks still appear (result never shorter). 'strict' = same genre-family only (off-genre matches dropped server-side). 'allow' = genre-blind (pure audio-feature cosine).",
       "default": "auto",
       "title": "Cross Genre"
      },
      "description": "Genre handling (mirrors `/next-track`). 'auto' (default) re-ranks by genre affinity so a feature-close cross-genre track can't outrank your same-genre picks, while cross-genre picks still appear (result never shorter). 'strict' = same genre-family only (off-genre matches dropped server-side). 'allow' = genre-blind (pure audio-feature cosine)."
     },
     {
      "name": "min_acousticness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `acousticness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Acousticness",
       "nullable": true
      },
      "description": "Lowest acceptable `acousticness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_acousticness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Highest acceptable `acousticness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Acousticness",
       "nullable": true
      },
      "description": "Highest acceptable `acousticness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_acousticness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Preferred `acousticness` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Acousticness",
       "nullable": true
      },
      "description": "Preferred `acousticness` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_danceability",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `danceability` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Danceability",
       "nullable": true
      },
      "description": "Lowest acceptable `danceability` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_danceability",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Highest acceptable `danceability` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Danceability",
       "nullable": true
      },
      "description": "Highest acceptable `danceability` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_danceability",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Preferred `danceability` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Danceability",
       "nullable": true
      },
      "description": "Preferred `danceability` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_duration_ms",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 36000000.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `duration_ms` (milliseconds). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Duration Ms",
       "nullable": true
      },
      "description": "Lowest acceptable `duration_ms` (milliseconds). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_duration_ms",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 36000000.0,
       "minimum": 0.0,
       "description": "Highest acceptable `duration_ms` (milliseconds). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Duration Ms",
       "nullable": true
      },
      "description": "Highest acceptable `duration_ms` (milliseconds). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_duration_ms",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 36000000.0,
       "minimum": 0.0,
       "description": "Preferred `duration_ms` (milliseconds). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Duration Ms",
       "nullable": true
      },
      "description": "Preferred `duration_ms` (milliseconds). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_energy",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `energy` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Energy",
       "nullable": true
      },
      "description": "Lowest acceptable `energy` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_energy",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Highest acceptable `energy` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Energy",
       "nullable": true
      },
      "description": "Highest acceptable `energy` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_energy",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Preferred `energy` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Energy",
       "nullable": true
      },
      "description": "Preferred `energy` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_instrumentalness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `instrumentalness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Instrumentalness",
       "nullable": true
      },
      "description": "Lowest acceptable `instrumentalness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_instrumentalness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Highest acceptable `instrumentalness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Instrumentalness",
       "nullable": true
      },
      "description": "Highest acceptable `instrumentalness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_instrumentalness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Preferred `instrumentalness` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Instrumentalness",
       "nullable": true
      },
      "description": "Preferred `instrumentalness` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_liveness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `liveness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Liveness",
       "nullable": true
      },
      "description": "Lowest acceptable `liveness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_liveness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Highest acceptable `liveness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Liveness",
       "nullable": true
      },
      "description": "Highest acceptable `liveness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_liveness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Preferred `liveness` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Liveness",
       "nullable": true
      },
      "description": "Preferred `liveness` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_loudness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 5.0,
       "minimum": -60.0,
       "description": "Lowest acceptable `loudness` (dBFS, negative). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Loudness",
       "nullable": true
      },
      "description": "Lowest acceptable `loudness` (dBFS, negative). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_loudness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 5.0,
       "minimum": -60.0,
       "description": "Highest acceptable `loudness` (dBFS, negative). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Loudness",
       "nullable": true
      },
      "description": "Highest acceptable `loudness` (dBFS, negative). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_loudness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 5.0,
       "minimum": -60.0,
       "description": "Preferred `loudness` (dBFS, negative). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Loudness",
       "nullable": true
      },
      "description": "Preferred `loudness` (dBFS, negative). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_speechiness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `speechiness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Speechiness",
       "nullable": true
      },
      "description": "Lowest acceptable `speechiness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_speechiness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Highest acceptable `speechiness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Speechiness",
       "nullable": true
      },
      "description": "Highest acceptable `speechiness` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_speechiness",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Preferred `speechiness` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Speechiness",
       "nullable": true
      },
      "description": "Preferred `speechiness` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_tempo",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 300.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `tempo` (BPM). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Tempo",
       "nullable": true
      },
      "description": "Lowest acceptable `tempo` (BPM). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_tempo",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 300.0,
       "minimum": 0.0,
       "description": "Highest acceptable `tempo` (BPM). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Tempo",
       "nullable": true
      },
      "description": "Highest acceptable `tempo` (BPM). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_tempo",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 300.0,
       "minimum": 0.0,
       "description": "Preferred `tempo` (BPM). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Tempo",
       "nullable": true
      },
      "description": "Preferred `tempo` (BPM). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "min_valence",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Lowest acceptable `valence` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Min Valence",
       "nullable": true
      },
      "description": "Lowest acceptable `valence` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "max_valence",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Highest acceptable `valence` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped.",
       "title": "Max Valence",
       "nullable": true
      },
      "description": "Highest acceptable `valence` (0-1). Hard filter — tracks outside the range, and tracks we hold no analysed value for, are dropped."
     },
     {
      "name": "target_valence",
      "in": "query",
      "required": false,
      "schema": {
       "type": "number",
       "maximum": 1.0,
       "minimum": 0.0,
       "description": "Preferred `valence` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate.",
       "title": "Target Valence",
       "nullable": true
      },
      "description": "Preferred `valence` (0-1). Tracks nearer this value rank higher; unlike `min_`/`max_` it never removes a candidate."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Recommended tracks for the seed blend",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/RecommendationsResponse"
        }
       }
      }
     },
     "400": {
      "description": "`error_code` says which: `NO_SEED` (no `seed_tracks` and no `track` name), `TUNABLE_UNSUPPORTED` (a `min_`/`max_`/`target_` attribute we don't serve — rejected rather than silently ignored), or `TUNABLE_RANGE_INVERTED` (`min_X` above `max_X`)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "Seeds don't resolve to a catalogue track (bad ids, or a `track` name that didn't match)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "503": {
      "description": "Similarity index not yet built",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/related-artists": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Artists related to a seed artist (Spotify related-artists replacement)",
    "description": "The drop-in for Spotify's removed `/v1/artists/{id}/related-artists`. Pass an artist name; we build the artist's audio-feature centroid from their catalogue tracks and return the artists whose tracks sit closest to it. Each result carries a sample track id you can feed to `/lookup` or `/next-track`.\n\n**Quota**: counts as 2 requests.",
    "operationId": "related_artists_endpoint_related_artists_get",
    "parameters": [
     {
      "name": "artist",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "minLength": 1,
       "description": "Seed artist name (as it appears in the catalog; case-insensitive).",
       "title": "Artist"
      },
      "description": "Seed artist name (as it appears in the catalog; case-insensitive)."
     },
     {
      "name": "limit",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 50,
       "minimum": 1,
       "description": "Number of related artists to return.",
       "default": 20,
       "title": "Limit"
      },
      "description": "Number of related artists to return."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Artists related to the seed artist",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/RelatedArtistsResponse"
        }
       }
      }
     },
     "400": {
      "description": "No artist supplied",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "Seed artist not found in the catalog",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "503": {
      "description": "Similarity index not yet built",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/tag": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Compact descriptive tags for a track (mood / energy / genre, with provenance)",
    "description": "Returns a compact, **honestly-labelled** tag array for a track — a tag-shaped projection of the same open-data analysis `/lookup` returns, handy for filtering, bucketing and library tagging. Resolve by `track`(+`artist`), `isrc`, `mbid`, `spotify_id`, or catalog `track_id` — supply at least one; sending several is fine and resolves by precedence (`track` › `isrc` › `track_id` › `mbid` › `spotify_id`).\n\nEvery tag carries a `confidence` + `provenance`: `measured` (our Essentia analysis), `derived` (computed from those), `model-estimated` (AcousticBrainz mood probabilities — research-grade, raw value included), `catalog-genre` (broad catalogue tag). Tags are present only where the underlying field is.\n\nFor the full numeric feature set use `/lookup`; for the nearest tracks use `/similar`.\n\n**Quota**: counts as one request.",
    "operationId": "tag_endpoint_tag_get",
    "parameters": [
     {
      "name": "track",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 200,
       "description": "Track name. Supply this OR `isrc`/`mbid`/`spotify_id`/`track_id` — sending several is accepted and resolves by precedence (`track` › `isrc` › `track_id` › `mbid` › `spotify_id`), so a client holding more than one identifier need not choose.",
       "title": "Track",
       "nullable": true
      },
      "description": "Track name. Supply this OR `isrc`/`mbid`/`spotify_id`/`track_id` — sending several is accepted and resolves by precedence (`track` › `isrc` › `track_id` › `mbid` › `spotify_id`), so a client holding more than one identifier need not choose."
     },
     {
      "name": "artist",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 200,
       "description": "Artist name (narrows `track`; required when `track` ≤2 chars).",
       "title": "Artist",
       "nullable": true
      },
      "description": "Artist name (narrows `track`; required when `track` ≤2 chars)."
     },
     {
      "name": "isrc",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 15,
       "description": "ISRC, e.g. `USUM71900001`.",
       "title": "Isrc",
       "nullable": true
      },
      "description": "ISRC, e.g. `USUM71900001`."
     },
     {
      "name": "mbid",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 40,
       "description": "MusicBrainz recording ID (UUID) — reads tags straight from AcousticBrainz for that recording.",
       "title": "Mbid",
       "nullable": true
      },
      "description": "MusicBrainz recording ID (UUID) — reads tags straight from AcousticBrainz for that recording."
     },
     {
      "name": "spotify_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 80,
       "description": "Spotify track ID — resolved from our Spotify-ID map or, on a miss, by matching the track's title; a title several artists share is ambiguous and misses rather than guessing. Prefer `track` (+`artist`) or `isrc` for full coverage.",
       "title": "Spotify Id",
       "nullable": true
      },
      "description": "Spotify track ID — resolved from our Spotify-ID map or, on a miss, by matching the track's title; a title several artists share is ambiguous and misses rather than guessing. Prefer `track` (+`artist`) or `isrc` for full coverage."
     },
     {
      "name": "track_id",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "maxLength": 80,
       "description": "Catalog `itunes_track_id` (from `/search` or any `/lookup`).",
       "title": "Track Id",
       "nullable": true
      },
      "description": "Catalog `itunes_track_id` (from `/search` or any `/lookup`)."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Tags for the resolved track",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/TagResponse"
        }
       }
      }
     },
     "400": {
      "description": "Short track name needs an artist",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "Track not found or not yet analysed",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "No identifier supplied (sending several is fine — the call resolves by precedence)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     }
    }
   }
  },
  "/export/catalog/manifest": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Bulk catalogue export — manifest (Enterprise)",
    "description": "Metadata for the current bulk catalogue export: row count, column list, build time, schema version, and the SHA-256 + byte size of every file.\n\nFetch this before downloading — it tells you whether the export has changed since you last pulled it, and lets you verify the download's integrity.",
    "operationId": "catalog_export_manifest_export_catalog_manifest_get",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/export/catalog/download/{fmt}": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Bulk catalogue export — download (Enterprise)",
    "description": "Download the whole analysed catalogue as one gzipped file.\n\n- `csv` — gzipped CSV with a header row.\n- `jsonl` — gzipped newline-delimited JSON, one object per track.\n- `attribution` — the plain-text source/attribution notice, also inside both files' distribution.\n\nVerify the SHA-256 against `/export/catalog/manifest` before loading. The file is served directly by the edge and supports HTTP **range requests**, so an interrupted transfer can be resumed with `curl -C -`.\n\n`HEAD` is not supported (405) — use `/export/catalog/manifest` instead, which returns the byte size, SHA-256 and build time of every file in one cheap call, and is strictly more useful than a HEAD for deciding whether to re-download.",
    "operationId": "catalog_export_download_export_catalog_download__fmt__get",
    "parameters": [
     {
      "name": "fmt",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "`csv`, `jsonl`, or `attribution`.",
       "title": "Fmt"
      },
      "description": "`csv`, `jsonl`, or `attribution`."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/export/{format}": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Export tracks as a DJ playlist (Rekordbox / Traktor / Serato / Engine / M3U8 / CSV / cuesheet)",
    "description": "Generate a DJ-ready file from a list of catalog `itunes_track_id`s.\n\n**Formats:**\n- `rekordbox` — Pioneer Rekordbox XML 1.0.0 (hot cue at the chorus/drop; structured BPM + key). Also the cleanest structured-import path for **Serato** and **Engine DJ**, so `serato` and `engine` are accepted as aliases for it.\n- `traktor` — Native Traktor NML collection + playlist (structured BPM + key per track).\n- `m3u` (alias `m3u8`) — Extended M3U8 with BPM + Camelot in each EXTINF title. Read by Serato, Traktor, VirtualDJ, Engine DJ.\n- `csv` — Spreadsheet of every track with the **full** audio-feature set (BPM, key, Camelot, energy, danceability, valence, mood, genre, ISRC, …) — the only format that carries all the data. Opens in Excel / Sheets / pandas.\n- `cuesheet` — Plain-text cue sheet. Easy to paste into a notebook or share over chat.\n\nPair with `/radio?seed_track_id=...` to generate a harmonic playlist, then export it to your DJ software in one chained call.\n\n**Note:** playlist file locations are synthetic (`freqblog://<id>` / `FreqBlog` volume); after import, relink each track to your local audio library — DJ software has built-in tools for this. (`csv` has no file paths — it's pure data.)\n\n**Quota**: counts as one request.",
    "operationId": "export_endpoint_export__format__get",
    "parameters": [
     {
      "name": "format",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Output format: `rekordbox`, `traktor`, `m3u` (alias `m3u8`), `csv`, or `cuesheet`. `serato` and `engine` are accepted as aliases for `rekordbox`.",
       "title": "Format"
      },
      "description": "Output format: `rekordbox`, `traktor`, `m3u` (alias `m3u8`), `csv`, or `cuesheet`. `serato` and `engine` are accepted as aliases for `rekordbox`."
     },
     {
      "name": "track_ids",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Comma-separated list of catalog `itunes_track_id`s, in playlist order.",
       "title": "Track Ids"
      },
      "description": "Comma-separated list of catalog `itunes_track_id`s, in playlist order."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "DJ-ready file (Rekordbox XML / Traktor NML / M3U8 / CSV / text).",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "Unknown format or no track ids supplied.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "None of the requested track ids are in the catalog.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/bulk-csv": {
   "post": {
    "tags": [
     "Catalog"
    ],
    "summary": "Bulk-enrich a CSV of (artist, title) pairs",
    "description": "Upload a CSV with `artist,title` columns and receive an enriched CSV with the full audio-features set appended to each row — BPM, key, mood, valence, danceability, energy, ISRC, MBID, and 28 more.\n\nHeader row is required and must contain `artist` and `title` (case-insensitive). Extra columns in the input are preserved in the output untouched.\n\n**Capacity**: up to 500 rows per request. Tracks not in the catalog or fallback chain come back with `found=false` and empty audio columns. Each data row with an artist and title counts as one quota request — headroom is verified before processing (`429` if the file is larger than your remaining quota) and **charged only for the rows that actually enrich**.\n\n**Partial results**: if the file can't finish within the server time-box, you still get a `200` with a *partial* CSV — the rows that resolved are filled in, and any the deadline cut off come back with `found=\"processing\"` and empty columns (and are **not** charged). Re-submit just the `processing` rows to collect them. The response headers `X-Bulk-Complete` (`true`/`false`), `X-Bulk-Rows-Processed` and `X-Bulk-Rows-Pending` summarise the outcome so you can tell a complete file from a partial one without scanning the rows.\n\nForm field name: `file` (multipart/form-data).",
    "operationId": "bulk_csv_endpoint_bulk_csv_post",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "$ref": "#/components/schemas/Body_bulk_csv_endpoint_bulk_csv_post"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Enriched CSV with audio features per row.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "400": {
      "description": "CSV parse error or no data rows.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "413": {
      "description": "More than 500 rows.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota insufficient for the number of rows in the file",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "503": {
      "description": "Reserved for genuine service unavailability. A slow file no longer 503s — rows unresolved within the server time-box come back with found=\"processing\" in the 200 response (and aren't charged); re-submit just those rows to collect them.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/me/webhook": {
   "get": {
    "tags": [
     "System"
    ],
    "summary": "Get the webhook URL configured for your API key",
    "operationId": "get_webhook_me_webhook_get",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WebhookStatus"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "System"
    ],
    "summary": "Set or update the webhook URL for your API key",
    "description": "Configure a webhook URL to be POSTed when your on-demand ingests and field-fill backfills complete — so a pipeline is pushed the result instead of polling or reading the email.\n\n**Events** (`type` / `X-FreqBlog-Event`): `ingest.completed`, `ingest.failed` (on-demand `/lookup` misses), and `backfill.completed` (field-fill). Ingest events carry a `result_url` you can GET for the full feature set.\n\n**Payload example** (JSON):\n```json\n{ \"type\": \"ingest.completed\", \"status\": \"completed\", \"track_name\": \"...\", \"artist_name\": \"...\", \"job_id\": 123, \"timestamp\": \"2026-07-14T19:00:00Z\", \"result_url\": \"https://api.freqblog.com/lookup?track=...\" }\n```\n\n**`ingest.failed` adds a machine-readable why** — so you can retry what is worth retrying and stop re-queueing what is not, instead of applying one flat backoff to everything:\n```json\n{ \"type\": \"ingest.failed\", \"status\": \"failed\", \"track_name\": \"...\", \"artist_name\": \"...\", \"job_id\": 123, \"timestamp\": \"2026-08-02T07:00:00Z\", \"result_url\": \"...\", \"terminal\": true, \"reason\": \"not_on_streaming\", \"retry_after_days\": 7, \"message\": \"We couldn't find a recording matching this title and artist on the sources we analyse (Apple Music / Deezer) — ...\" }\n```\n`terminal: true` means the verdict will not change until `retry_after_days` has elapsed — stop re-polling that track until then. `terminal: false` means the failure was transient and the track is worth another attempt (we also retry it ourselves). `message` is the human sentence, for logs and support; branch on `reason`, never on `message`. `retry_after_days` is present only when `terminal` is true.\n\n| `reason` | terminal | meaning |\n|---|---|---|\n| `not_on_streaming` | yes | No acceptable match on Apple Music / Deezer — CD-only, unreleased, off-streaming or very-regional. Upload the file to `POST /analyze` if you hold it yourself. |\n| `no_preview_audio` | yes | Matched, but the release carries no 30-second preview clip, and features are computed from preview audio. |\n| `throttled` | no | A music source rate-limited **us**, not a problem with the track. |\n| `cdn_forbidden` | no | The preview URL was refused by its CDN and no fallback source had it. |\n| `download_failed` | no | The preview fetch failed before analysis. |\n| `analysis_failed` | no | Audio downloaded, but analysis did not complete. |\n| `internal_error` | no | Our fault. Retry. |\n\n⚠ `not_on_streaming` / `no_preview_audio` are the **same strings** a terminal `/lookup` 404 returns in its own `reason` field, with the same `terminal` and `retry_after_days` semantics — one branch in your client handles both surfaces. Treat the list as open: a future release may add a `reason`, so default unknown values to 'retry later' rather than rejecting the delivery.\n\n**Signature verification**: each POST carries `X-FreqBlog-Timestamp` and `X-FreqBlog-Signature: sha256=<hex>`, where the hex is `HMAC-SHA256(secret, X-FreqBlog-Timestamp + '.' + rawBody)` and `secret = SHA256(key_hash)` where `key_hash = SHA256(your_plaintext_api_key)` (i.e. SHA-256 applied twice, both as lowercase hex). Recompute it, constant-time compare, and reject a timestamp older than ~5 minutes (replay guard).\n\n**URL must be a public HTTPS host** (private/loopback/link-local addresses are refused). Call DELETE to clear.\n\n**Delivery + retries**: up to 3 attempts per event (immediately, then +2s, +6s), 8s timeout, any `2xx` = delivered. A `4xx` other than `429` is treated as permanent and is NOT retried — return `5xx`/`429` for anything you want redelivered. After 15 consecutive failures we pause delivery, then send one probe event per hour; the first probe you answer `2xx` resumes normal delivery (no need to re-register). Treat webhooks as a signal to fetch, not your only copy — every ingest lands in the catalog regardless, so `/lookup` always returns the record.",
    "operationId": "set_webhook_me_webhook_post",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "$ref": "#/components/schemas/WebhookConfig"
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WebhookStatus"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "System"
    ],
    "summary": "Clear the webhook URL for your API key",
    "operationId": "delete_webhook_me_webhook_delete",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/WebhookStatus"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/track/{itunes_track_id}/artwork": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Cover art for a track",
    "description": "302-redirect to a cover-art image for the track. Resolution order: iTunes Lookup API for numeric catalog ids, then Cover Art Archive via the track's MusicBrainz release IDs. The first hit is cached for 30 days (misses are also cached so we don't pummel upstream services).\n\n**Sizes**: 100, 200, 250, 300, 500, 600, 1000, 1200 — the closest supported size is used; iTunes URLs accept any of these, Cover Art Archive supports 250/500/1200.\n\n**Quota**: counts as one request.",
    "operationId": "track_artwork_endpoint_track__itunes_track_id__artwork_get",
    "parameters": [
     {
      "name": "itunes_track_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Catalog track id (numeric iTunes id, or `mb:`/`fma:`/`msd:`/`apple_<hash>` synthetic id).",
       "title": "Itunes Track Id"
      },
      "description": "Catalog track id (numeric iTunes id, or `mb:`/`fma:`/`msd:`/`apple_<hash>` synthetic id)."
     },
     {
      "name": "size",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 1200,
       "minimum": 100,
       "description": "Desired image size in pixels (square).",
       "default": 300,
       "title": "Size"
      },
      "description": "Desired image size in pixels (square)."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "302": {
      "description": "Redirect to the actual cover-art image."
     },
     "400": {
      "description": "Invalid `size` parameter.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "No cover art available for this track.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/track/{itunes_track_id}/lyrics": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Lyrics (plain + synced) for a catalog track",
    "description": "Returns synced + plain lyrics for a track via the open **LRClib** dataset. Lyrics are cached for 30 days per (track, artist) pair; cache misses are also cached so we don't repeatedly hit LRClib for tracks they don't have.\n\nSynced lyrics come back parsed: each entry is `{ms: int, text: str}` so you don't have to do LRC math client-side. Instrumentals return `instrumental: true` with empty lyrics arrays.\n\n**Quota**: counts as one request.",
    "operationId": "track_lyrics_endpoint_track__itunes_track_id__lyrics_get",
    "parameters": [
     {
      "name": "itunes_track_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Catalog `itunes_track_id`.",
       "title": "Itunes Track Id"
      },
      "description": "Catalog `itunes_track_id`."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Lyrics payload (plain + synced) or instrumental flag.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "No lyrics found for this track",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/track/{itunes_track_id}/waveform.svg": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "SVG waveform for a track",
    "description": "Returns an SVG waveform render of the track's 30-second iTunes preview. The bucketed RMS values are cached for 90 days; SVG rendering happens at request time so callers can request any width/height without re-decoding audio.\n\nUse `currentColor` in your CSS to theme the waveform — every bar inherits the parent element's text colour. Defaults: 600×80.\n\nOnly works for catalog tracks with a numeric iTunes track id (the preview URL comes from iTunes Lookup). Synthetic ids (`mb:`/`fma:`/`msd:`/`apple_<hash>`) return 404.\n\n**Quota**: counts as one request.",
    "operationId": "track_waveform_endpoint_track__itunes_track_id__waveform_svg_get",
    "parameters": [
     {
      "name": "itunes_track_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Numeric catalog `itunes_track_id`.",
       "title": "Itunes Track Id"
      },
      "description": "Numeric catalog `itunes_track_id`."
     },
     {
      "name": "w",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 2400,
       "minimum": 120,
       "description": "SVG width in pixels.",
       "default": 600,
       "title": "W"
      },
      "description": "SVG width in pixels."
     },
     {
      "name": "h",
      "in": "query",
      "required": false,
      "schema": {
       "type": "integer",
       "maximum": 400,
       "minimum": 20,
       "description": "SVG height in pixels.",
       "default": 80,
       "title": "H"
      },
      "description": "SVG height in pixels."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "SVG waveform image.",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "401": {
      "description": "Missing, unrecognised, deactivated or superseded API key — `error_code` says which: `KEY_MISSING` / `KEY_DEACTIVATED` / `KEY_SUPERSEDED`",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "404": {
      "description": "No audio preview available for this track",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "429": {
      "description": "Monthly quota exceeded",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/synthetic/{layer}/{synth_id}/{sub_path}": {
   "get": {
    "tags": [
     "Catalog"
    ],
    "summary": "Colon-free alias for /track/{layer}:{id}/...",
    "description": "Some HTTP clients and proxies don't handle the `:` character in URL path segments cleanly. This alias accepts the layer and id as separate path components and 307-redirects to the canonical `/track/{layer}:{id}/...` URL. Use this when raw `/track/mb:abc-123/embedding` requests fail in your stack.\n\nSupported layers: `mb` (MusicBrainz Recording ID), `fma` (FMA Echo Nest), `msd` (Million Song Dataset).",
    "operationId": "synthetic_alias_synthetic__layer___synth_id___sub_path__get",
    "parameters": [
     {
      "name": "layer",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Synthetic layer: `mb`, `fma`, or `msd`.",
       "title": "Layer"
      },
      "description": "Synthetic layer: `mb`, `fma`, or `msd`."
     },
     {
      "name": "synth_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "The id portion (without the `<layer>:` prefix).",
       "title": "Synth Id"
      },
      "description": "The id portion (without the `<layer>:` prefix)."
     },
     {
      "name": "sub_path",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "description": "Sub-path under /track/{id}/, e.g. `embedding`, `artwork`, `lyrics`, `waveform.svg`.",
       "title": "Sub Path"
      },
      "description": "Sub-path under /track/{id}/, e.g. `embedding`, `artwork`, `lyrics`, `waveform.svg`."
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "307": {
      "description": "Redirect to /track/{layer}:{id}/{sub_path}"
     },
     "400": {
      "description": "Unsupported layer.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorResponse"
        }
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/me/stats": {
   "get": {
    "tags": [
     "System"
    ],
    "summary": "Usage statistics for your API key",
    "description": "Return detailed usage statistics for the authenticated API key.",
    "operationId": "me_stats_me_stats_get",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/me/preferences": {
   "get": {
    "tags": [
     "System"
    ],
    "summary": "Notification preferences for your API key",
    "description": "Read the current ingest/backfill email preference.",
    "operationId": "me_preferences_me_preferences_get",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/me/preferences/ingest_emails": {
   "post": {
    "tags": [
     "System"
    ],
    "summary": "Toggle ingest/backfill email notifications",
    "description": "Flip ingest/backfill emails on or off for every active key sharing this\nlabel. The dashboard's Recent Activity panel is the in-app channel when\nemails are off.",
    "operationId": "me_preferences_ingest_emails_me_preferences_ingest_emails_post",
    "parameters": [
     {
      "name": "enabled",
      "in": "query",
      "required": true,
      "schema": {
       "type": "boolean",
       "description": "True to receive emails for on-demand ingest and backfill completions; false to silence them. Welcome, billing, security, and quota emails are unaffected.",
       "title": "Enabled"
      },
      "description": "True to receive emails for on-demand ingest and backfill completions; false to silence them. Welcome, billing, security, and quota emails are unaffected."
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/health": {
   "get": {
    "tags": [
     "System"
    ],
    "summary": "Health check",
    "operationId": "health_health_get",
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    }
   }
  },
  "/health/lookup": {
   "get": {
    "tags": [
     "System"
    ],
    "summary": "Data-path health check (live lookup probe)",
    "description": "Goes one level deeper than `/health` — runs an internal lookup for a known-good catalog track and returns 200 only if features come back. Use this with UptimeRobot (or any external probe) to alert on data-path regressions, not just process-up. No auth required. Counts as zero quota against any key.",
    "operationId": "health_lookup_health_lookup_get",
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    }
   }
  },
  "/health/backfill-worker": {
   "get": {
    "tags": [
     "System"
    ],
    "summary": "Backfill worker health check",
    "description": "Check if the background backfill worker is healthy and queue status.",
    "operationId": "backfill_worker_health_health_backfill_worker_get",
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    }
   }
  },
  "/health/deep": {
   "get": {
    "tags": [
     "System"
    ],
    "summary": "Deep health check — write path + WAL + worker liveness",
    "description": "Integration probe for monitoring (UptimeRobot keyword=\"ok\"). Returns 200 ONLY when all green: SQLite write path responds in <2s + the CATALOGUE READ path answers + all configured backfill workers alive. Returns 503 + status=\"error\" if the write path is wedged (the 2026-05-25 failure mode — uvicorn alive, /lookup writes timing out), the catalogue read path is broken (the 2026-08-27 failure mode — a schema change the running code did not expect, which this endpoint missed for 20 consecutive checks before `catalog_read` was added), or zero workers alive. Returns 200 + status=\"warning\" + non-empty `issues` for soft degradation (some workers down). `checks.wal_size_mb` is reported for information but does NOT affect `status` (2026-07-26 — it is a proxy metric; wal_watchdog owns alerting on it). No auth required. Counts as zero quota.",
    "operationId": "health_deep_health_deep_get",
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    }
   }
  },
  "/cache/stats": {
   "get": {
    "tags": [
     "System"
    ],
    "summary": "Cache statistics",
    "operationId": "cache_stats_cache_stats_get",
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     }
    }
   }
  },
  "/backfill/notifications": {
   "get": {
    "tags": [
     "Backfill"
    ],
    "summary": "Get backfill notifications for the authenticated key",
    "description": "Returns the last 7 days of backfill notifications for the authenticated key (max 100). Pass your API key via the X-Api-Key header.",
    "operationId": "get_backfill_notifications_backfill_notifications_get",
    "parameters": [
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  },
  "/backfill/notifications/{notif_id}/dismiss": {
   "post": {
    "tags": [
     "Backfill"
    ],
    "summary": "Dismiss a backfill notification",
    "description": "Mark a notification as read/dismissed. The notification must belong to the authenticated key.",
    "operationId": "dismiss_backfill_notification_backfill_notifications__notif_id__dismiss_post",
    "parameters": [
     {
      "name": "notif_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "integer",
       "description": "Notification ID",
       "title": "Notif Id"
      },
      "description": "Notification ID"
     },
     {
      "name": "x-api-key",
      "in": "header",
      "required": false,
      "schema": {
       "type": "string",
       "title": "X-Api-Key"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "Successful Response",
      "content": {
       "application/json": {
        "schema": {}
       }
      }
     },
     "422": {
      "description": "Validation Error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/HTTPValidationError"
        }
       }
      }
     }
    }
   }
  }
 },
 "components": {
  "schemas": {
   "ArtistTracksResponse": {
    "properties": {
     "artist": {
      "type": "string",
      "title": "Artist",
      "description": "Artist name as submitted (case preserved)."
     },
     "total": {
      "type": "integer",
      "title": "Total",
      "description": "Total tracks in catalog for this artist (independent of `limit`/`offset`)."
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of tracks in this response page."
     },
     "tracks": {
      "items": {
       "$ref": "#/components/schemas/TrackStub"
      },
      "type": "array",
      "title": "Tracks"
     }
    },
    "type": "object",
    "required": [
     "artist",
     "total",
     "count",
     "tracks"
    ],
    "title": "ArtistTracksResponse"
   },
   "AudioAnalysisResponse": {
    "properties": {
     "bpm": {
      "type": "number",
      "maximum": 300.0,
      "minimum": 20.0,
      "title": "Bpm",
      "description": "Tempo in beats per minute. Equivalent to Spotify's `tempo` field.",
      "example": 128.0
     },
     "bpm_alt": {
      "type": "number",
      "title": "Bpm Alt",
      "description": "Half-time/double-time corrected alternative BPM, when our heuristic suggests the detected `bpm` may be off by a factor of 2. Null when no correction is warranted. Never replaces `bpm`.",
      "nullable": true,
      "example": 256.0
     },
     "bpm_confidence": {
      "type": "number",
      "title": "Bpm Confidence",
      "description": "Beat-strength score from Essentia's RhythmExtractor2013. Higher = stronger rhythmic pulse. Typical range 0–10. Values below 1.0 indicate weak or absent rhythm (ambient, spoken word).",
      "example": 4.12
     },
     "key": {
      "type": "string",
      "title": "Key",
      "description": "Musical key and scale as a human-readable string, e.g. `A-Minor` or `F#-Major`. Combines Spotify's integer `key` + `mode` fields into one readable value.",
      "example": "A-Minor"
     },
     "key_confidence": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Key Confidence",
      "description": "Key detection confidence [0–1]. Below 0.4 indicates atonal, modal, or harmonically ambiguous content. Equivalent to Spotify's implicit key reliability.",
      "example": 0.83
     },
     "mode": {
      "type": "integer",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Mode",
      "description": "Musical mode as a Spotify-compatible integer: `0` = minor, `1` = major. Direct drop-in for Spotify's deprecated `mode` field.",
      "nullable": true,
      "example": 0
     },
     "key_int": {
      "type": "integer",
      "maximum": 11.0,
      "minimum": 0.0,
      "title": "Key Int",
      "description": "Pitch-class integer for the key, Spotify-compatible: `0` = C, `1` = C♯/D♭, `2` = D, … `11` = B. `null` when the key is undetected/atonal (Spotify used `-1`).",
      "nullable": true,
      "example": 1
     },
     "camelot": {
      "type": "string",
      "title": "Camelot",
      "description": "Camelot wheel notation for harmonic mixing, e.g. `8A` (A-Minor) or `5B` (D-Major). Used by DJs to find harmonically compatible tracks. A = minor keys, B = major keys. Numbers 1–12.",
      "nullable": true,
      "example": "8A"
     },
     "open_key": {
      "type": "string",
      "title": "Open Key",
      "description": "Open Key notation for harmonic mixing, e.g. `1m` (A-Minor) or `1d` (C-Major). Alternative to Camelot — used by Serato DJ and Mixed In Key. d = major (dominant), m = minor. Numbers 1–12.",
      "nullable": true,
      "example": "1m"
     },
     "acousticness": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Acousticness",
      "description": "Tonality-based acousticness estimate [0–1]. Derived from spectral flatness — low flatness means more tonal, which correlates with acoustic instrumentation. Scaled to the same distribution as Spotify's `acousticness` so thresholds transfer, but it is a signal measure, not a trained classifier: agreement with Spotify is a rank correlation of ~0.34, and a highly tonal synth pad can read as acoustic. Treat it as a sorting signal rather than a verdict on any single track.",
      "nullable": true,
      "example": 0.21
     },
     "danceability": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Danceability",
      "description": "How suitable the track is for dancing [0–1]. Based on tempo (peaks at 120 BPM), beat strength, and rhythmic regularity. Equivalent to Spotify's `danceability` field.",
      "nullable": true,
      "example": 0.78
     },
     "instrumentalness": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Instrumentalness",
      "description": "Likelihood the track contains no vocals [0–1]. Derived from MFCC variance in vocal-formant frequency ranges. Equivalent to Spotify's `instrumentalness` field.",
      "nullable": true,
      "example": 0.04
     },
     "liveness": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Liveness",
      "description": "Likelihood the track was performed live [0–1]. Derived from the noise floor ratio (audience/room ambience proxy). Equivalent to Spotify's `liveness` field.",
      "nullable": true,
      "example": 0.09
     },
     "speechiness": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Speechiness",
      "description": "Presence of spoken words [0–1]. Derived from zero-crossing rate — high ZCR indicates speech or rap. Equivalent to Spotify's `speechiness` field.",
      "nullable": true,
      "example": 0.06
     },
     "valence": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Valence",
      "description": "Musical positiveness [0–1]. High = happy/euphoric, low = sad/dark. Derived from scale (major/minor), tempo, spectral brightness, and energy. Equivalent to Spotify's `valence` field.",
      "nullable": true,
      "example": 0.54
     },
     "mood": {
      "type": "string",
      "title": "Mood",
      "description": "MIREX-style mood category derived from valence and energy. One of: happy, calm, sad, tense, energetic, melancholic, neutral. Maps the four-quadrant mood model: high valence + high energy = happy; low valence + high energy = tense; etc.",
      "nullable": true,
      "example": "happy"
     },
     "representative_segment_start": {
      "type": "number",
      "title": "Representative Segment Start",
      "description": "Start time in seconds of the highest-energy 30-second window. Identifies the most sonically representative (e.g. chorus or drop) segment. Useful for sync licensing, preview generation, and waveform thumbnails. 0.0 for tracks ≤ 30 s.",
      "nullable": true,
      "example": 62.0
     },
     "rms_energy": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Rms Energy",
      "description": "Normalised RMS loudness [0–1]. Comparable to Spotify's `energy` field — both measure perceptual intensity. Computed over the same 30-second window as BPM and key.",
      "example": 0.74
     },
     "loudness_db": {
      "type": "number",
      "title": "Loudness Db",
      "description": "Integrated loudness in dBFS (decibels relative to full scale). Equivalent to Spotify's `loudness` field. Typical mastered pop/electronic: -10 to -5 dBFS. Quiet/acoustic: -20 to -15.",
      "nullable": true,
      "example": -7.4
     },
     "time_signature": {
      "type": "integer",
      "title": "Time Signature",
      "description": "Estimated time signature (bar grouping). Equivalent to Spotify's `time_signature` field. Most tracks return 4 (4/4). Values: 2, 3, 4, 5, 6, 7.",
      "nullable": true,
      "example": 4
     },
     "mood_vector": {
      "additionalProperties": {
       "type": "number"
      },
      "type": "object",
      "title": "Mood Vector",
      "description": "Dimensional mood scores [0–1] across five axes: `happy`, `sad`, `aggressive`, `relaxed`, `party`. Populated when an AcousticBrainz high-level match is found; `null` otherwise. Use these for fine-grained mood filtering beyond the single-label `mood` field.",
      "nullable": true,
      "example": {
       "aggressive": 0.12,
       "happy": 0.82,
       "party": 0.77,
       "relaxed": 0.31,
       "sad": 0.04
      }
     },
     "cached": {
      "type": "boolean",
      "title": "Cached",
      "description": "True if this result was served from the server-side cache. Cached responses are < 100 ms. The underlying analysis is identical to a fresh result.",
      "default": false
     },
     "bpm_snapped": {
      "type": "integer",
      "title": "Bpm Snapped",
      "description": "`bpm` rounded to a whole number — the grid tempo, in the form every DJ tool reports. Traktor, Serato and Rekordbox return clean integers because they derive a BEAT GRID, and a grid has exactly one tempo by construction; our `bpm` is a *measurement* of the audio, so it carries a decimal. For grid-produced music the true tempo is almost certainly the whole number and the decimal is our error — `bpm_confidence` correlates with how close we land to an integer, which is the evidence for that. **Always populated, never null.** Use `bpm` when you want the measurement (beatmatching by ear, where 127.89 vs 128.00 is the point) and `bpm_confidence` to judge how far to trust either.",
      "readOnly": true,
      "nullable": true,
      "example": 128
     }
    },
    "type": "object",
    "required": [
     "bpm",
     "bpm_confidence",
     "key",
     "key_confidence",
     "rms_energy",
     "bpm_snapped"
    ],
    "title": "AudioAnalysisResponse"
   },
   "Body_analyze_endpoint_analyze_post": {
    "properties": {
     "file": {
      "type": "string",
      "format": "binary",
      "title": "File",
      "description": "Audio file (MP3, WAV, AIFF, OGG, FLAC, AAC/M4A)"
     }
    },
    "type": "object",
    "required": [
     "file"
    ],
    "title": "Body_analyze_endpoint_analyze_post"
   },
   "Body_bulk_csv_endpoint_bulk_csv_post": {
    "properties": {
     "file": {
      "type": "string",
      "format": "binary",
      "title": "File",
      "description": "CSV file with `artist,title` columns."
     }
    },
    "type": "object",
    "required": [
     "file"
    ],
    "title": "Body_bulk_csv_endpoint_bulk_csv_post"
   },
   "Body_identify_endpoint_identify_post": {
    "properties": {
     "file": {
      "type": "string",
      "format": "binary",
      "title": "File",
      "description": "Audio file (any format ffmpeg can decode, max 15 MB)"
     }
    },
    "type": "object",
    "required": [
     "file"
    ],
    "title": "Body_identify_endpoint_identify_post"
   },
   "BpmSearchResponse": {
    "properties": {
     "bpm": {
      "type": "number",
      "title": "Bpm",
      "description": "Target BPM queried."
     },
     "tolerance": {
      "type": "number",
      "title": "Tolerance",
      "description": "Tolerance window applied (±BPM)."
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of tracks returned."
     },
     "tracks": {
      "items": {
       "$ref": "#/components/schemas/TrackLookupResponse"
      },
      "type": "array",
      "title": "Tracks"
     }
    },
    "type": "object",
    "required": [
     "bpm",
     "tolerance",
     "count",
     "tracks"
    ],
    "title": "BpmSearchResponse"
   },
   "BulkLookupItem": {
    "properties": {
     "track": {
      "type": "string",
      "maxLength": 200,
      "title": "Track",
      "description": "Track name (partial match supported). Supply this OR `isrc` (or both — `isrc` is tried first as an exact key, the name is the fallback and the on-demand-ingest seed). Accepts `title` as an alias. For short titles (≤2 chars, e.g. Aphex Twin '4'), supply `artist` to anchor the search.",
      "nullable": true,
      "example": "Blinding Lights"
     },
     "artist": {
      "type": "string",
      "maxLength": 200,
      "title": "Artist",
      "description": "Artist name (optional, narrows the name search). A list-format value — a JSON array `[\"A\",\"B\"]` or a Python-style `['A','B']` string (common from Chartmetric / Spotify export pipelines) — is accepted and collapsed to a clean comma-joined name; multi-artist credits never reject the item.",
      "nullable": true,
      "example": "The Weeknd"
     },
     "isrc": {
      "type": "string",
      "maxLength": 15,
      "title": "Isrc",
      "description": "ISRC (e.g. `USUM71900001`, hyphens optional). Tried first as an exact key — resolves CJK / K-pop / niche tracks whose romaji/native fuzzy name-match misses — then MusicBrainz / Deezer on a catalog miss. Supply this OR `track`.",
      "nullable": true,
      "example": "USUM71900001"
     }
    },
    "type": "object",
    "title": "BulkLookupItem"
   },
   "BulkLookupResponse": {
    "properties": {
     "results": {
      "items": {
       "$ref": "#/components/schemas/BulkTrackResult"
      },
      "type": "array",
      "title": "Results"
     },
     "found": {
      "type": "integer",
      "title": "Found",
      "description": "Number of tracks found in the catalog."
     },
     "not_found": {
      "type": "integer",
      "title": "Not Found",
      "description": "Number of tracks not found (includes skipped items)."
     },
     "requests_used": {
      "type": "integer",
      "title": "Requests Used",
      "description": "Number of quota requests consumed by this call."
     },
     "skipped": {
      "type": "integer",
      "title": "Skipped",
      "description": "Items echoed but NOT processed or charged this call — either past the 50-track per-call cap (`backfill_status=\"over_limit\"`) or missing both `isrc` and `track` (`backfill_status=\"invalid_no_query\"`). Resubmit the over-limit tail in batches of ≤50; fix the malformed rows. 0 on a normal batch.",
      "default": 0
     }
    },
    "type": "object",
    "required": [
     "results",
     "found",
     "not_found",
     "requests_used"
    ],
    "title": "BulkLookupResponse"
   },
   "BulkTrackResult": {
    "properties": {
     "track": {
      "type": "string",
      "title": "Track",
      "description": "Track name as submitted (null for ISRC-only items).",
      "nullable": true
     },
     "artist": {
      "type": "string",
      "title": "Artist",
      "description": "Artist name as submitted in the request.",
      "nullable": true
     },
     "isrc": {
      "type": "string",
      "title": "Isrc",
      "description": "ISRC for this item, when one was supplied — echoed back so you can map results onto ISRC-keyed inputs.",
      "nullable": true
     },
     "found": {
      "type": "boolean",
      "title": "Found",
      "description": "True if the track was found in any source (catalog or fallback chain)."
     },
     "result": {
      "allOf": [
       {
        "$ref": "#/components/schemas/TrackLookupResponse"
       }
      ],
      "description": "Audio features. Null if track not found.",
      "nullable": true
     },
     "backfill_status": {
      "type": "string",
      "title": "Backfill Status",
      "description": "`queued` when a field-backfill or on-demand ingest was started for this item; `processing` when the server time-box was hit before it resolved (retry to collect it); `over_limit` when the item was past this call's 50-track processing cap; `invalid_no_query` when the item carried neither `isrc` nor `track`; null otherwise. `over_limit`/`invalid_no_query` items are echoed (in order) but not processed or charged — resubmit them.",
      "nullable": true
     }
    },
    "type": "object",
    "required": [
     "found"
    ],
    "title": "BulkTrackResult"
   },
   "CamelotCompatibleResponse": {
    "properties": {
     "input_camelot": {
      "type": "string",
      "title": "Input Camelot",
      "description": "Input Camelot notation as parsed.",
      "example": "8A"
     },
     "input_key": {
      "type": "string",
      "title": "Input Key",
      "description": "Input as a human-readable key.",
      "example": "A-Minor"
     },
     "compatible": {
      "items": {
       "$ref": "#/components/schemas/KeyVariant"
      },
      "type": "array",
      "title": "Compatible",
      "description": "Keys that mix harmonically with the input. Always includes `same`, `relative`, `adjacent_up`, `adjacent_down`. With `extended=true` also includes `energy_boost` and `energy_drop`."
     }
    },
    "type": "object",
    "required": [
     "input_camelot",
     "input_key",
     "compatible"
    ],
    "title": "CamelotCompatibleResponse"
   },
   "ChartEntry": {
    "properties": {
     "rank": {
      "type": "integer",
      "title": "Rank",
      "description": "Position on the chart, 1-indexed."
     },
     "itunes_track_id": {
      "type": "string",
      "title": "Itunes Track Id",
      "description": "iTunes track id from the chart feed.",
      "nullable": true
     },
     "track_name": {
      "type": "string",
      "title": "Track Name"
     },
     "artist_name": {
      "type": "string",
      "title": "Artist Name"
     },
     "album_name": {
      "type": "string",
      "title": "Album Name",
      "nullable": true
     },
     "artwork_url": {
      "type": "string",
      "title": "Artwork Url",
      "description": "100×100 artwork from Apple, when available.",
      "nullable": true
     },
     "release_date": {
      "type": "string",
      "title": "Release Date",
      "nullable": true
     },
     "in_catalog": {
      "type": "boolean",
      "title": "In Catalog",
      "description": "True if this track is already analysed in the FreqBlog catalog (call /lookup or /track/{id}/embedding to retrieve features)."
     },
     "catalog_track": {
      "allOf": [
       {
        "$ref": "#/components/schemas/TrackStub"
       }
      ],
      "description": "Catalog row when `in_catalog=true`, including any populated `mbid`, `isrc`, `genre`.",
      "nullable": true
     }
    },
    "type": "object",
    "required": [
     "rank",
     "track_name",
     "artist_name",
     "in_catalog"
    ],
    "title": "ChartEntry"
   },
   "ChartResponse": {
    "properties": {
     "country": {
      "type": "string",
      "title": "Country",
      "description": "ISO 3166-1 alpha-2 country code as queried.",
      "example": "gb"
     },
     "type": {
      "type": "string",
      "title": "Type",
      "description": "Chart type. Currently `most-played` (Apple's daily top songs)."
     },
     "fetched_at": {
      "type": "string",
      "title": "Fetched At",
      "description": "ISO 8601 timestamp this chart snapshot was fetched. Cached for 12 hours."
     },
     "count": {
      "type": "integer",
      "title": "Count"
     },
     "entries": {
      "items": {
       "$ref": "#/components/schemas/ChartEntry"
      },
      "type": "array",
      "title": "Entries"
     }
    },
    "type": "object",
    "required": [
     "country",
     "type",
     "fetched_at",
     "count",
     "entries"
    ],
    "title": "ChartResponse"
   },
   "EmbeddingResponse": {
    "properties": {
     "itunes_track_id": {
      "type": "string",
      "title": "Itunes Track Id",
      "example": "1488408568"
     },
     "track_name": {
      "type": "string",
      "title": "Track Name",
      "example": "Blinding Lights"
     },
     "artist_name": {
      "type": "string",
      "title": "Artist Name",
      "example": "The Weeknd"
     },
     "dim": {
      "type": "integer",
      "title": "Dim",
      "description": "Length of the embedding vector.",
      "example": 18
     },
     "fields": {
      "items": {
       "type": "string"
      },
      "type": "array",
      "title": "Fields",
      "description": "Field name for each position in `embedding`. Always returned in a stable order so callers can rely on positional indexing."
     },
     "embedding": {
      "items": {
       "type": "number"
      },
      "type": "array",
      "title": "Embedding",
      "description": "Numeric feature vector. Missing values are filled with sensible defaults (see `embedding_mask`)."
     },
     "embedding_mask": {
      "items": {
       "type": "boolean"
      },
      "type": "array",
      "title": "Embedding Mask",
      "description": "Per-position truthy when the value came from real analysis, falsy when filled with a default. Use to mask out filler values in similarity calculations."
     }
    },
    "type": "object",
    "required": [
     "itunes_track_id",
     "track_name",
     "artist_name",
     "dim",
     "fields",
     "embedding",
     "embedding_mask"
    ],
    "title": "EmbeddingResponse",
    "description": "Numeric feature vector for a track — convenient for ML / similarity / clustering."
   },
   "ErrorResponse": {
    "properties": {
     "detail": {
      "type": "string",
      "title": "Detail"
     },
     "error_code": {
      "type": "string",
      "title": "Error Code",
      "nullable": true
     }
    },
    "type": "object",
    "required": [
     "detail"
    ],
    "title": "ErrorResponse",
    "description": "A JSON error. `detail` is for humans, `error_code` is for code.\n\n⚠ Branch on `error_code`, never on `detail`. `detail` is prose we deliberately keep\nimproving — five of these messages were reworded on 2026-08-13 alone — so matching on\nit is a breaking change waiting to happen. `error_code` is UPPER_SNAKE, always present\non an error, and its meaning is frozen once shipped. Full registry: app/errors.py."
   },
   "ExtendedAudioFeatures": {
    "properties": {
     "gender": {
      "type": "string",
      "title": "Gender",
      "description": "Vocal gender classification: `male` / `female`. Inferred from MFCC + F0 features. Best for tracks with sustained vocals; less reliable on instrumentals or short clips.",
      "nullable": true,
      "example": "female"
     },
     "timbre": {
      "type": "string",
      "title": "Timbre",
      "description": "Timbral brightness classification: `bright` / `dark`. Bright = strong high-frequency content (cymbals, electric guitar); dark = bass-dominant.",
      "nullable": true,
      "example": "bright"
     },
     "tonal_atonal": {
      "type": "string",
      "title": "Tonal Atonal",
      "description": "Tonality classification: `tonal` / `atonal`. Tonal = clear key centre; atonal = ambiguous or shifting tonality (typical of avant-garde / drone).",
      "nullable": true,
      "example": "tonal"
     }
    },
    "type": "object",
    "title": "ExtendedAudioFeatures",
    "description": "Higher-level categorical labels from AcousticBrainz. Each value is the\nmost-confident class from a binary classifier — useful for filtering /\nbucketing but not for fine-grained analysis."
   },
   "FreeKeyRequest": {
    "properties": {
     "email": {
      "type": "string",
      "title": "Email"
     },
     "name": {
      "type": "string",
      "title": "Name",
      "default": ""
     },
     "turnstile_token": {
      "type": "string",
      "title": "Turnstile Token",
      "default": ""
     },
     "utm_source": {
      "type": "string",
      "title": "Utm Source",
      "default": ""
     },
     "utm_medium": {
      "type": "string",
      "title": "Utm Medium",
      "default": ""
     },
     "utm_campaign": {
      "type": "string",
      "title": "Utm Campaign",
      "default": ""
     }
    },
    "type": "object",
    "required": [
     "email"
    ],
    "title": "FreeKeyRequest"
   },
   "GenreInfo": {
    "properties": {
     "genre": {
      "type": "string",
      "title": "Genre",
      "example": "electronic"
     },
     "track_count": {
      "type": "integer",
      "title": "Track Count",
      "description": "Number of catalog tracks tagged with this genre."
     }
    },
    "type": "object",
    "required": [
     "genre",
     "track_count"
    ],
    "title": "GenreInfo"
   },
   "GenreTracksResponse": {
    "properties": {
     "genre": {
      "type": "string",
      "title": "Genre"
     },
     "total": {
      "type": "integer",
      "title": "Total",
      "description": "Total tracks in catalog for this genre (independent of `limit`/`offset`)."
     },
     "count": {
      "type": "integer",
      "title": "Count"
     },
     "tracks": {
      "items": {
       "$ref": "#/components/schemas/TrackStub"
      },
      "type": "array",
      "title": "Tracks"
     }
    },
    "type": "object",
    "required": [
     "genre",
     "total",
     "count",
     "tracks"
    ],
    "title": "GenreTracksResponse"
   },
   "GenresResponse": {
    "properties": {
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of distinct genres returned."
     },
     "genres": {
      "items": {
       "$ref": "#/components/schemas/GenreInfo"
      },
      "type": "array",
      "title": "Genres",
      "description": "Genres ordered by track_count descending."
     }
    },
    "type": "object",
    "required": [
     "count",
     "genres"
    ],
    "title": "GenresResponse"
   },
   "HTTPValidationError": {
    "properties": {
     "detail": {
      "items": {
       "$ref": "#/components/schemas/ValidationError"
      },
      "type": "array",
      "title": "Detail"
     }
    },
    "type": "object",
    "title": "HTTPValidationError"
   },
   "IdentifyResponse": {
    "properties": {
     "matched": {
      "type": "boolean",
      "title": "Matched",
      "description": "True when the fingerprint matched a known recording in the AcoustID database."
     },
     "score": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Score",
      "description": "AcoustID match confidence, 0–1.",
      "example": 0.94
     },
     "acoustid": {
      "type": "string",
      "title": "Acoustid",
      "description": "AcoustID UUID for the recording group, when known.",
      "nullable": true
     },
     "mbid": {
      "type": "string",
      "title": "Mbid",
      "description": "MusicBrainz Recording ID for the matched recording.",
      "nullable": true
     },
     "isrc": {
      "type": "string",
      "title": "Isrc",
      "description": "ISRC, when resolvable from the matched recording.",
      "nullable": true
     },
     "track_name": {
      "type": "string",
      "title": "Track Name",
      "description": "Track title from the AcoustID/MusicBrainz match.",
      "nullable": true
     },
     "artist_name": {
      "type": "string",
      "title": "Artist Name",
      "description": "Artist name(s) from the AcoustID/MusicBrainz match.",
      "nullable": true
     },
     "in_catalog": {
      "type": "boolean",
      "title": "In Catalog",
      "description": "True if the matched recording is already in the FreqBlog catalog. When true, `audio_features` is populated.",
      "default": false
     },
     "audio_features": {
      "allOf": [
       {
        "$ref": "#/components/schemas/TrackLookupResponse"
       }
      ],
      "description": "Full FreqBlog audio features for the matched recording — populated when the MBID resolves to a catalog row, otherwise null. Call `/lookup` separately if you want to trigger an on-demand analysis for unknown recordings.",
      "nullable": true
     },
     "fingerprint_duration_seconds": {
      "type": "number",
      "title": "Fingerprint Duration Seconds",
      "description": "Duration of the fingerprinted audio (seconds), as reported by Chromaprint.",
      "nullable": true
     },
     "analysis": {
      "allOf": [
       {
        "$ref": "#/components/schemas/AudioAnalysisResponse"
       }
      ],
      "description": "Audio features computed directly from the file you uploaded, populated when the recording could not be resolved to a catalog row (unknown fingerprint, or matched but not in the catalog). Set `analyze=false` to skip this and get identification only.",
      "nullable": true
     },
     "features_from": {
      "type": "string",
      "title": "Features From",
      "description": "Where the returned features came from — `catalog` (a known recording, in `audio_features`) or `upload` (computed from your file, in `analysis`); null when no features were produced. Deliberately explicit: catalog values come from our 30-second-preview pipeline and upload values from your full file, so they are not interchangeable and a caller should never have to guess which it got.",
      "nullable": true,
      "example": "catalog"
     }
    },
    "type": "object",
    "required": [
     "matched",
     "score"
    ],
    "title": "IdentifyResponse"
   },
   "KeySearchResponse": {
    "properties": {
     "key": {
      "type": "string",
      "title": "Key",
      "description": "Resolved key in stored format, e.g. 'A-Minor'."
     },
     "camelot": {
      "type": "string",
      "title": "Camelot",
      "description": "Camelot notation for this key.",
      "nullable": true
     },
     "open_key": {
      "type": "string",
      "title": "Open Key",
      "description": "Open Key notation for this key.",
      "nullable": true
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of tracks returned."
     },
     "tracks": {
      "items": {
       "$ref": "#/components/schemas/TrackLookupResponse"
      },
      "type": "array",
      "title": "Tracks"
     }
    },
    "type": "object",
    "required": [
     "key",
     "count",
     "tracks"
    ],
    "title": "KeySearchResponse"
   },
   "KeyVariant": {
    "properties": {
     "camelot": {
      "type": "string",
      "title": "Camelot",
      "description": "Camelot notation, e.g. `8A`.",
      "example": "8A"
     },
     "open_key": {
      "type": "string",
      "title": "Open Key",
      "description": "Open Key notation, e.g. `1m`.",
      "nullable": true,
      "example": "1m"
     },
     "key": {
      "type": "string",
      "title": "Key",
      "description": "Human-readable key, e.g. `A-Minor`.",
      "example": "A-Minor"
     },
     "relation": {
      "type": "string",
      "title": "Relation",
      "description": "How this key relates to the input. One of: `same` (same key), `relative` (same number, opposite letter — minor↔major), `adjacent_up` (one step clockwise on the wheel), `adjacent_down` (one step counterclockwise), `energy_boost` (+7 same letter — common DJ key change up), `energy_drop` (-7 same letter — key change down).",
      "example": "adjacent_up"
     }
    },
    "type": "object",
    "required": [
     "camelot",
     "key",
     "relation"
    ],
    "title": "KeyVariant",
    "description": "One harmonically compatible key, returned by `/key/{camelot}/compatible`."
   },
   "NextTrackResponse": {
    "properties": {
     "seed": {
      "$ref": "#/components/schemas/TrackStub",
      "description": "The track you're mixing out of."
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of suggestions returned (≤ `n`)."
     },
     "suggestions": {
      "items": {
       "$ref": "#/components/schemas/NextTrackSuggestion"
      },
      "type": "array",
      "title": "Suggestions",
      "description": "Best-first by overall mix-compatibility score."
     }
    },
    "type": "object",
    "required": [
     "seed",
     "count",
     "suggestions"
    ],
    "title": "NextTrackResponse",
    "description": "Ranked next-track suggestions for a seed — the candidates that mix best."
   },
   "NextTrackSuggestion": {
    "properties": {
     "track": {
      "$ref": "#/components/schemas/TrackStub"
     },
     "score": {
      "type": "integer",
      "maximum": 100.0,
      "minimum": 0.0,
      "title": "Score",
      "description": "Overall mix-compatibility score from the seed into this track."
     },
     "components": {
      "$ref": "#/components/schemas/TransitionComponents"
     },
     "reason": {
      "type": "string",
      "title": "Reason"
     },
     "genre": {
      "type": "string",
      "title": "Genre",
      "description": "The suggestion's catalogue genre — drives the genre-aware re-rank.",
      "nullable": true
     },
     "genre_relation": {
      "type": "string",
      "title": "Genre Relation",
      "description": "Genre relation to the seed: 'same' | 'compatible' | 'cross' | 'unknown'.",
      "nullable": true
     }
    },
    "type": "object",
    "required": [
     "track",
     "score",
     "components",
     "reason"
    ],
    "title": "NextTrackSuggestion"
   },
   "RadioResponse": {
    "properties": {
     "seed": {
      "$ref": "#/components/schemas/TrackStub"
     },
     "count": {
      "type": "integer",
      "title": "Count"
     },
     "constraints": {
      "type": "object",
      "title": "Constraints",
      "description": "Constraints applied to the walk: target BPM range, key adjacency depth, min similarity score."
     },
     "tracks": {
      "items": {
       "$ref": "#/components/schemas/TrackStub"
      },
      "type": "array",
      "title": "Tracks",
      "description": "Playlist in play order. Index 0 = seed, last index = closing track."
     }
    },
    "type": "object",
    "required": [
     "seed",
     "count",
     "constraints",
     "tracks"
    ],
    "title": "RadioResponse"
   },
   "RecommendationFiltersApplied": {
    "properties": {
     "applied": {
      "additionalProperties": {
       "additionalProperties": {
        "type": "number"
       },
       "type": "object"
      },
      "type": "object",
      "title": "Applied",
      "description": "The tunables we honoured, keyed by attribute — e.g. `{\"tempo\": {\"min\": 100, \"max\": 130}, \"energy\": {\"target\": 0.8}}`. An attribute you sent but don't see here was NOT applied.",
      "example": {
       "energy": {
        "target": 0.8
       },
       "tempo": {
        "max": 130.0,
        "min": 100.0
       }
      }
     },
     "dropped_by": {
      "additionalProperties": {
       "type": "integer"
      },
      "type": "object",
      "title": "Dropped By",
      "description": "Per attribute, how many otherwise-rankable catalogue tracks its `min_`/`max_` bound removed. The largest entry is the bound to loosen first. Empty when only `target_` preferences were supplied (a target re-ranks, it never removes).",
      "example": {
       "energy": 41288,
       "tempo": 612430
      }
     },
     "limit_reached": {
      "type": "boolean",
      "title": "Limit Reached",
      "description": "False when the bounds exhausted the catalogue before `limit` tracks were found — i.e. a short `tracks` list is the FILTER's doing, not a lack of similar music. Loosen the bound with the biggest `dropped_by`.",
      "default": true
     }
    },
    "type": "object",
    "required": [
     "applied"
    ],
    "title": "RecommendationFiltersApplied",
    "description": "What the `min_`/`max_`/`target_` tunables actually did — so an over-tight filter\nis visible in the response instead of looking like a thin catalogue."
   },
   "RecommendationSeed": {
    "properties": {
     "id": {
      "type": "string",
      "title": "Id",
      "description": "Seed track id as supplied."
     },
     "found": {
      "type": "boolean",
      "title": "Found",
      "description": "True if the seed was usable (in catalog with enough audio features)."
     }
    },
    "type": "object",
    "required": [
     "id",
     "found"
    ],
    "title": "RecommendationSeed"
   },
   "RecommendationsResponse": {
    "properties": {
     "seeds": {
      "items": {
       "$ref": "#/components/schemas/RecommendationSeed"
      },
      "type": "array",
      "title": "Seeds",
      "description": "Echo of the supplied seeds with a found/unfound flag."
     },
     "seed_query": {
      "allOf": [
       {
        "$ref": "#/components/schemas/TrackStub"
       }
      ],
      "description": "When seeded by name (`track`/`artist`) instead of an id, the catalogue track that name resolved to — so you can confirm the right track was picked. Null when seeding by `seed_tracks`.",
      "nullable": true
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of recommended tracks returned (≤ `limit`)."
     },
     "tracks": {
      "items": {
       "$ref": "#/components/schemas/SimilarTrackResult"
      },
      "type": "array",
      "title": "Tracks",
      "description": "Recommended tracks ordered by genre-aware relevance to the seed blend (each `track` carries its catalogue `genre`). `score` is the raw audio-feature cosine similarity (genre affinity influences ordering, not the score), so it may not be strictly descending. With `cross_genre=strict` only same-genre-family tracks are returned."
     },
     "filters": {
      "allOf": [
       {
        "$ref": "#/components/schemas/RecommendationFiltersApplied"
       }
      ],
      "description": "Present only when you supplied `min_`/`max_`/`target_` tunables. Confirms which ones took effect and what they cost — check it before concluding the catalogue is thin.",
      "nullable": true
     }
    },
    "type": "object",
    "required": [
     "seeds",
     "count",
     "tracks"
    ],
    "title": "RecommendationsResponse",
    "description": "Spotify-shaped recommendations: blend up to 5 seed tracks and return the\nnearest catalogue tracks by audio-feature similarity. Drop-in for the removed\nSpotify GET /v1/recommendations — FreqBlog catalog ids, open-data ranking."
   },
   "RelatedArtist": {
    "properties": {
     "artist_name": {
      "type": "string",
      "title": "Artist Name",
      "example": "Chaka Khan"
     },
     "score": {
      "type": "number",
      "title": "Score",
      "description": "Aggregate similarity of this artist's tracks to the seed artist (higher = closer)."
     },
     "match_count": {
      "type": "integer",
      "title": "Match Count",
      "description": "How many of this artist's tracks fell in the seed artist's neighbourhood."
     },
     "sample_track_id": {
      "type": "string",
      "title": "Sample Track Id",
      "description": "A representative catalog track id by this artist — pass to /lookup or /next-track.",
      "nullable": true
     }
    },
    "type": "object",
    "required": [
     "artist_name",
     "score",
     "match_count"
    ],
    "title": "RelatedArtist"
   },
   "RelatedArtistsResponse": {
    "properties": {
     "artist": {
      "type": "string",
      "title": "Artist",
      "description": "The seed artist as supplied."
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of related artists returned (≤ `limit`)."
     },
     "related": {
      "items": {
       "$ref": "#/components/schemas/RelatedArtist"
      },
      "type": "array",
      "title": "Related",
      "description": "Related artists, best-first."
     }
    },
    "type": "object",
    "required": [
     "artist",
     "count",
     "related"
    ],
    "title": "RelatedArtistsResponse",
    "description": "Spotify-shaped related-artists: artists who make tracks similar to the seed\nartist's. Drop-in for the removed Spotify /v1/artists/{id}/related-artists."
   },
   "SearchResponse": {
    "properties": {
     "q": {
      "type": "string",
      "title": "Q",
      "description": "The query string as submitted."
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of tracks returned (≤ `limit`)."
     },
     "results": {
      "items": {
       "$ref": "#/components/schemas/SearchTrackStub"
      },
      "type": "array",
      "title": "Results",
      "description": "Track stubs ordered by full-text relevance, then by an internal catalogue ordering key (not an audience metric). Check `seedable` before using an id as a set-builder seed."
     }
    },
    "type": "object",
    "required": [
     "q",
     "count",
     "results"
    ],
    "title": "SearchResponse"
   },
   "SearchTrackStub": {
    "properties": {
     "itunes_track_id": {
      "type": "string",
      "title": "Itunes Track Id",
      "description": "Track id (catalog `itunes_track_id` or synthetic prefix). Pass to `/track/{id}/embedding` or use the name+artist with `/lookup`.",
      "example": "1488408568"
     },
     "track_name": {
      "type": "string",
      "title": "Track Name",
      "example": "Blinding Lights"
     },
     "artist_name": {
      "type": "string",
      "title": "Artist Name",
      "example": "The Weeknd"
     },
     "album_name": {
      "type": "string",
      "title": "Album Name",
      "nullable": true,
      "example": "After Hours"
     },
     "popularity": {
      "type": "integer",
      "title": "Popularity",
      "description": "Internal catalogue ordering key — **not an audience or popularity metric**. It reflects how a track entered our catalogue, not how widely it is listened to, and is not comparable between tracks. Being redefined as a chart-position score; don't build on it.",
      "default": 0
     },
     "chart_peak": {
      "type": "integer",
      "title": "Chart Peak",
      "description": "Best chart position we know this track reached, 1-100 where **100 = a number-one**. `null` means no chart placement we hold — which is the vast majority of the catalogue, so treat null as *unknown*, not as *unpopular*. ⚠ This is NOT an audience-size measure and does not tell you monthly listeners; it is a chart placement and nothing more. Coverage is currently Billboard year-end only (~4,600 tracks); Apple and Deezer chart rows are being populated going forward. Distinct from `popularity`, which is an internal ordering key and not a metric.",
      "nullable": true,
      "example": 100
     },
     "mbid": {
      "type": "string",
      "title": "Mbid",
      "description": "MusicBrainz Recording ID, when known.",
      "nullable": true
     },
     "isrc": {
      "type": "string",
      "title": "Isrc",
      "description": "ISRC, when known.",
      "nullable": true
     },
     "genre": {
      "type": "string",
      "title": "Genre",
      "description": "Broad genre classification, when known.",
      "nullable": true
     },
     "release_date": {
      "type": "string",
      "title": "Release Date",
      "description": "ISO 8601 release date, when known.",
      "nullable": true,
      "example": "2019-11-29"
     },
     "duration_ms": {
      "type": "integer",
      "title": "Duration Ms",
      "nullable": true,
      "example": 200040
     },
     "explicit": {
      "type": "boolean",
      "title": "Explicit",
      "nullable": true
     },
     "seedable": {
      "type": "boolean",
      "title": "Seedable",
      "description": "True if this track is in the similarity index and can be used as a seed for `/recommendations`, `/similar`, `/next-track`, `/setlist` and `/transition`. False means the track is in the catalogue but has no audio analysis yet, so seeding with it returns 404 — pick another hit, or call `GET /lookup?track=…&artist=…` first to queue analysis.",
      "example": true
     }
    },
    "type": "object",
    "required": [
     "itunes_track_id",
     "track_name",
     "artist_name",
     "seedable"
    ],
    "title": "SearchTrackStub",
    "description": "A search hit, plus whether it can actually SEED the set-builder endpoints.\n\nAdded 2026-07-29. `/search` returns any catalogue row, but `/recommendations`,\n`/similar`, `/next-track`, `/setlist` and `/transition` all seed off the in-memory\nsimilarity index, which holds only rows we have ANALYSED. About 23% of the catalogue\nis unanalysed, so roughly one search hit in four could not be used as a seed — and the\ncaller only found out via a 404 that read like their id was malformed. This field lets\na client pick a usable seed up front instead of discovering it by failure."
   },
   "SetlistRequest": {
    "properties": {
     "track_ids": {
      "items": {
       "type": "string"
      },
      "type": "array",
      "maxItems": 100,
      "minItems": 2,
      "title": "Track Ids",
      "description": "Catalog `itunes_track_id`s to order. 2-100. Duplicates are collapsed."
     },
     "arc": {
      "type": "string",
      "enum": [
       "peak_time",
       "warmup",
       "cooldown",
       "flat"
      ],
      "title": "Arc",
      "description": "Target energy shape: `peak_time` (build to a peak ~3/4 in, then ease), `warmup` (steady rise), `cooldown` (steady fall), `flat` (hold — order purely for smooth transitions).",
      "default": "peak_time"
     },
     "start_track_id": {
      "type": "string",
      "title": "Start Track Id",
      "description": "Optional fixed opener (must be one of `track_ids`); otherwise chosen to fit the arc.",
      "nullable": true
     }
    },
    "type": "object",
    "required": [
     "track_ids"
    ],
    "title": "SetlistRequest",
    "description": "Order a crate of tracks into a beat-matched, energy-arced set."
   },
   "SetlistResponse": {
    "properties": {
     "arc": {
      "type": "string",
      "title": "Arc",
      "description": "The arc shape that was applied."
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of tracks placed in the set."
     },
     "flow_score": {
      "type": "integer",
      "maximum": 100.0,
      "minimum": 0.0,
      "title": "Flow Score",
      "description": "Mean transition score across the whole set — how smoothly it mixes."
     },
     "tracks": {
      "items": {
       "$ref": "#/components/schemas/TrackStub"
      },
      "type": "array",
      "title": "Tracks",
      "description": "Tracks in play order. Index 0 = opener."
     },
     "transitions": {
      "items": {
       "$ref": "#/components/schemas/SetlistStep"
      },
      "type": "array",
      "title": "Transitions",
      "description": "Per-step transition scores, one per consecutive pair."
     },
     "omitted": {
      "items": {
       "type": "string"
      },
      "type": "array",
      "title": "Omitted",
      "description": "Requested ids not found in the catalog (skipped)."
     }
    },
    "type": "object",
    "required": [
     "arc",
     "count",
     "flow_score",
     "tracks",
     "transitions"
    ],
    "title": "SetlistResponse",
    "description": "A crate ordered into a continuous, beat-matched, energy-arced set."
   },
   "SetlistStep": {
    "properties": {
     "from_index": {
      "type": "integer",
      "title": "From Index",
      "description": "Index in `tracks` of the outgoing track."
     },
     "to_index": {
      "type": "integer",
      "title": "To Index",
      "description": "Index in `tracks` of the incoming track."
     },
     "score": {
      "type": "integer",
      "maximum": 100.0,
      "minimum": 0.0,
      "title": "Score",
      "description": "Transition score for this step."
     },
     "reason": {
      "type": "string",
      "title": "Reason"
     }
    },
    "type": "object",
    "required": [
     "from_index",
     "to_index",
     "score",
     "reason"
    ],
    "title": "SetlistStep"
   },
   "SimilarResponse": {
    "properties": {
     "seed": {
      "$ref": "#/components/schemas/TrackStub",
      "description": "The seed track the recommendations are based on."
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of similar tracks returned (≤ `limit`)."
     },
     "results": {
      "items": {
       "$ref": "#/components/schemas/SimilarTrackResult"
      },
      "type": "array",
      "title": "Results",
      "description": "Tracks ordered by descending cosine similarity to the seed."
     }
    },
    "type": "object",
    "required": [
     "seed",
     "count",
     "results"
    ],
    "title": "SimilarResponse"
   },
   "SimilarTrackResult": {
    "properties": {
     "track": {
      "$ref": "#/components/schemas/TrackStub"
     },
     "genre_relation": {
      "type": "string",
      "title": "Genre Relation",
      "description": "How this track's genre family relates to the seed's: `same`, `compatible` (a different but mixable family), `cross` (unrelated), or `unknown` (either side has no mapped genre, so no genre adjustment was applied). **Read this alongside `score`.** Under the default `cross_genre=auto` the ordering is by cosine PLUS a genre adjustment, so `score` alone does not explain the order — a `cross` pick is pushed down and appears only when too few same-family neighbours exist, which is why a lower-scoring `same` track can sit above a higher-scoring `cross` one. Filter on this to keep only the in-lane picks, or use `cross_genre=strict` to have the server do it.",
      "default": "unknown",
      "example": "same"
     },
     "score": {
      "type": "number",
      "title": "Score",
      "description": "Cosine similarity in [-1, 1]. Typically [0, 1] in practice. 1.0 = effectively identical features; 0.0 = orthogonal; negative = anti-correlated. This is the RAW cosine — it is not the value results are ordered by unless `cross_genre=allow`; see `genre_relation`.",
      "example": 0.94
     }
    },
    "type": "object",
    "required": [
     "track",
     "score"
    ],
    "title": "SimilarTrackResult"
   },
   "TagItem": {
    "properties": {
     "tag": {
      "type": "string",
      "title": "Tag",
      "description": "Human-readable tag, e.g. `high-energy`, `acoustic`, `party`, `synthwave`.",
      "example": "high-energy"
     },
     "category": {
      "type": "string",
      "title": "Category",
      "description": "energy | danceability | valence | acousticness | instrumentalness | mood | genre.",
      "example": "energy"
     },
     "value": {
      "type": "number",
      "title": "Value",
      "description": "Underlying [0,1] score where the tag is numeric (measured features, mood probabilities); null for label-only tags (mood category, genre).",
      "nullable": true,
      "example": 0.91
     },
     "confidence": {
      "type": "string",
      "title": "Confidence",
      "description": "`measured` (our Essentia analysis), `derived` (computed from measures), `model-estimated` (AcousticBrainz SVM probability — research-grade), or `catalog-genre` (broad catalogue tag).",
      "example": "measured"
     },
     "provenance": {
      "type": "string",
      "title": "Provenance",
      "description": "Where the tag came from: `essentia`, `valence+energy`, `acousticbrainz`, or `catalog`.",
      "example": "essentia"
     }
    },
    "type": "object",
    "required": [
     "tag",
     "category",
     "confidence",
     "provenance"
    ],
    "title": "TagItem"
   },
   "TagResponse": {
    "properties": {
     "track": {
      "$ref": "#/components/schemas/TrackStub",
      "description": "The resolved track."
     },
     "count": {
      "type": "integer",
      "title": "Count",
      "description": "Number of tags returned."
     },
     "tags": {
      "items": {
       "$ref": "#/components/schemas/TagItem"
      },
      "type": "array",
      "title": "Tags",
      "description": "Tags, each with a confidence + provenance so you can judge reliability."
     },
     "disclaimer": {
      "type": "string",
      "title": "Disclaimer",
      "description": "How to read the confidence levels."
     }
    },
    "type": "object",
    "required": [
     "track",
     "count",
     "tags",
     "disclaimer"
    ],
    "title": "TagResponse",
    "description": "Compact, honestly-labelled tags for a track — a tag-shaped projection of the\nsame analysis `/lookup` returns, for filtering, bucketing and library tagging."
   },
   "TrackLookupResponse": {
    "properties": {
     "track_name": {
      "type": "string",
      "title": "Track Name",
      "description": "Track name as indexed from Apple Music charts or MusicBrainz.",
      "example": "Blinding Lights"
     },
     "artist_name": {
      "type": "string",
      "title": "Artist Name",
      "description": "Artist name.",
      "example": "The Weeknd"
     },
     "album_name": {
      "type": "string",
      "title": "Album Name",
      "description": "Album or single title. Populated for catalog tracks (Apple Music / iTunes source).",
      "nullable": true,
      "example": "After Hours"
     },
     "itunes_track_id": {
      "type": "string",
      "title": "Itunes Track Id",
      "description": "iTunes track ID for catalog tracks; 'mb:{mbid}' for AcousticBrainz-sourced tracks; 'fma:{hash}' for FMA-sourced tracks; 'msd:{hash}' for Million Song Dataset tracks.",
      "example": "1488408568"
     },
     "isrc": {
      "type": "string",
      "title": "Isrc",
      "description": "International Standard Recording Code. Uniquely identifies this specific master recording.",
      "nullable": true,
      "example": "GBAYE0601498"
     },
     "mbid": {
      "type": "string",
      "title": "Mbid",
      "description": "MusicBrainz Recording ID (UUID). Cross-reference to the open music encyclopedia.",
      "nullable": true,
      "example": "5b11f4ce-a62d-471e-81fc-a69a8278c7da"
     },
     "is_remix": {
      "type": "boolean",
      "title": "Is Remix",
      "description": "True when this recording is a third-party remix/bootleg (not an original mix or same-performance version). Parsed from the title + credit at ingest. Always present; false for originals and fallback-layer tracks.",
      "default": false,
      "example": true
     },
     "remixer": {
      "type": "string",
      "title": "Remixer",
      "description": "The third-party artist credited with the remix, when attributable; null for style-descriptor or unnamed remixes.",
      "nullable": true,
      "example": "Alesso"
     },
     "mix_name": {
      "type": "string",
      "title": "Mix Name",
      "description": "Version/mix descriptor for any variant (remix OR version), e.g. 'Extended Mix', 'Alesso Remix'. Null for a plain original.",
      "nullable": true,
      "example": "Extended Mix"
     },
     "remix_of_isrc": {
      "type": "string",
      "title": "Remix Of Isrc",
      "description": "ISRC of the ORIGINAL recording this remix is based on, when the original is in the catalog (matched by base title + artist). Lets you jump from a remix to its source recording. Null for originals, unmatched remixes, and fallback-layer tracks.",
      "nullable": true,
      "example": "USUM71116097"
     },
     "release_date": {
      "type": "string",
      "title": "Release Date",
      "description": "Release date in ISO 8601 format (YYYY-MM-DD). Sourced from iTunes. Null for fallback-layer tracks.",
      "nullable": true,
      "example": "2020-03-20"
     },
     "duration_ms": {
      "type": "integer",
      "title": "Duration Ms",
      "description": "Full track duration in milliseconds from the iTunes catalog. Distinct from `duration_seconds` (which is the analysed 30s window).",
      "nullable": true,
      "example": 200040
     },
     "explicit": {
      "type": "boolean",
      "title": "Explicit",
      "description": "True if the track is marked explicit by the source. Null when not reported.",
      "nullable": true,
      "example": false
     },
     "bpm": {
      "type": "number",
      "maximum": 300.0,
      "minimum": 20.0,
      "title": "Bpm",
      "description": "Tempo in beats per minute.",
      "example": 171.0
     },
     "bpm_alt": {
      "type": "number",
      "title": "Bpm Alt",
      "description": "Half-time/double-time corrected alternative BPM, when our heuristic suggests Essentia locked onto the wrong beat grid. Null when no correction is warranted. **Never** replaces `bpm` — use this when your downstream tooling expects the perceived rather than detected tempo (e.g. pop tracks like 'Blinding Lights' where Essentia returns ~85 but the perceived BPM is ~171).",
      "nullable": true,
      "example": 170.78
     },
     "bpm_confidence": {
      "type": "number",
      "title": "Bpm Confidence",
      "description": "Beat-strength score from Essentia RhythmExtractor2013. Null for AcousticBrainz-sourced tracks.",
      "nullable": true,
      "example": 3.8
     },
     "key": {
      "type": "string",
      "title": "Key",
      "description": "Musical key and scale, e.g. `C#-Minor`.",
      "example": "C#-Minor"
     },
     "key_confidence": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Key Confidence",
      "description": "Key detection confidence [0–1]. Null for AcousticBrainz-sourced tracks.",
      "nullable": true,
      "example": 0.76
     },
     "mode": {
      "type": "integer",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Mode",
      "description": "Spotify-compatible mode integer: 0 = minor, 1 = major.",
      "nullable": true,
      "example": 0
     },
     "key_int": {
      "type": "integer",
      "maximum": 11.0,
      "minimum": 0.0,
      "title": "Key Int",
      "description": "Spotify-compatible pitch-class integer for the key: 0 = C, 1 = C♯/D♭, 2 = D, … 11 = B. `null` when the key is undetected/atonal (the Spotify-shaped `/v1/audio-features/{id}` endpoint reports `-1` in that case, matching Spotify's convention).",
      "nullable": true,
      "example": 1
     },
     "camelot": {
      "type": "string",
      "title": "Camelot",
      "description": "Camelot wheel notation, e.g. `12A`.",
      "nullable": true,
      "example": "12A"
     },
     "open_key": {
      "type": "string",
      "title": "Open Key",
      "description": "Open Key notation, e.g. `5m` (C#-Minor). d=major, m=minor.",
      "nullable": true,
      "example": "5m"
     },
     "energy": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Energy",
      "description": "Normalised RMS energy [0–1]. Equivalent to Spotify's `energy` field.",
      "nullable": true,
      "example": 0.91
     },
     "loudness_db": {
      "type": "number",
      "title": "Loudness Db",
      "description": "Integrated loudness in dBFS.",
      "nullable": true,
      "example": -7.4
     },
     "danceability": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Danceability",
      "description": "Danceability score [0–1].",
      "nullable": true,
      "example": 0.78
     },
     "valence": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Valence",
      "description": "Musical positiveness [0–1].",
      "nullable": true,
      "example": 0.54
     },
     "speechiness": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Speechiness",
      "description": "Spoken word presence [0–1].",
      "nullable": true,
      "example": 0.06
     },
     "instrumentalness": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Instrumentalness",
      "description": "Instrumental likelihood [0–1].",
      "nullable": true,
      "example": 0.04
     },
     "liveness": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Liveness",
      "description": "Live performance likelihood [0–1].",
      "nullable": true,
      "example": 0.09
     },
     "acousticness": {
      "type": "number",
      "maximum": 1.0,
      "minimum": 0.0,
      "title": "Acousticness",
      "description": "Acoustic confidence [0–1].",
      "nullable": true,
      "example": 0.21
     },
     "time_signature": {
      "type": "integer",
      "title": "Time Signature",
      "description": "Estimated time signature (3, 4, 5, etc.).",
      "nullable": true,
      "example": 4
     },
     "mood": {
      "type": "string",
      "title": "Mood",
      "description": "MIREX-style mood category: happy, calm, sad, tense, energetic, melancholic, or neutral.",
      "nullable": true,
      "example": "happy"
     },
     "mood_vector": {
      "additionalProperties": {
       "type": "number"
      },
      "type": "object",
      "title": "Mood Vector",
      "description": "Dimensional mood scores [0–1] across five axes: `happy`, `sad`, `aggressive`, `relaxed`, `party`. Populated when an AcousticBrainz high-level match is found; `null` otherwise.",
      "nullable": true,
      "example": {
       "aggressive": 0.12,
       "happy": 0.82,
       "party": 0.77,
       "relaxed": 0.31,
       "sad": 0.04
      }
     },
     "onset_rate": {
      "type": "number",
      "title": "Onset Rate",
      "description": "Onset events per second from AcousticBrainz low-level analysis. Higher values indicate more rhythmic or percussive content. Populated when an AcousticBrainz match is found; `null` otherwise.",
      "nullable": true,
      "example": 2.86
     },
     "dynamic_complexity": {
      "type": "number",
      "title": "Dynamic Complexity",
      "description": "Loudness variation over the track (dB), from AcousticBrainz low-level analysis. Higher values mean wider dynamic range; lower values mean more compressed mastering. Populated when an AcousticBrainz match is found; `null` otherwise.",
      "nullable": true,
      "example": 5.61
     },
     "tuning_frequency": {
      "type": "number",
      "title": "Tuning Frequency",
      "description": "Estimated A4 reference frequency in Hz, from AcousticBrainz tonal analysis. Modern recordings cluster near 440 Hz; older or non-Western recordings may diverge. Populated when an AcousticBrainz match is found; `null` otherwise.",
      "nullable": true,
      "example": 440.0
     },
     "average_loudness": {
      "type": "number",
      "title": "Average Loudness",
      "description": "Average loudness as a normalised value [0–1] from AcousticBrainz low-level analysis. Distinct from `loudness_db` (which is dBFS from this service's own analysis). Populated when an AcousticBrainz match is found; `null` otherwise.",
      "nullable": true,
      "example": 0.703
     },
     "genre": {
      "type": "string",
      "title": "Genre",
      "description": "Broad genre classification (e.g. electronic, rock, pop, hip-hop, jazz). Sourced from AcousticBrainz or Last.fm.",
      "nullable": true,
      "example": "electronic"
     },
     "extended": {
      "allOf": [
       {
        "$ref": "#/components/schemas/ExtendedAudioFeatures"
       }
      ],
      "description": "Higher-level AcousticBrainz classifications. Populated when the track has an AcousticBrainz match — null otherwise. Counts as ONE of the 43 response fields (it is a single nested object), but its members are categorical labels rather than analysis values, so they are not counted individually.",
      "nullable": true
     },
     "feature_source": {
      "type": "string",
      "title": "Feature Source",
      "description": "Provenance of the audio feature data. One of: `essentia_preview` (analysed from a 30s preview by this service), `acousticbrainz` (matched by MBID against the AcousticBrainz dump), `fma` (FMA Echo Nest dataset), `msd` (Million Song Dataset), `user_upload` (analysed from a file the user uploaded).",
      "nullable": true,
      "example": "essentia_preview"
     },
     "backfill_status": {
      "type": "string",
      "title": "Backfill Status",
      "description": "Backfill job status if one was queued: 'queued', 'processing', 'completed', 'failed'. Null if no backfill was needed.",
      "nullable": true,
      "example": "queued"
     },
     "backfill_notification_id": {
      "type": "string",
      "title": "Backfill Notification Id",
      "description": "Notification ID for tracking backfill progress. Use to query backfill_notifications table.",
      "nullable": true,
      "example": "backfill_1488408568_1713950025"
     },
     "chart_peak": {
      "type": "integer",
      "title": "Chart Peak",
      "description": "Best chart position we know this track reached, 1-100 where **100 = a number-one**. `null` means no chart placement we hold — which is the vast majority of the catalogue, so treat null as *unknown*, not as *unpopular*. ⚠ NOT an audience-size measure: it does not tell you monthly listeners or followers. Coverage is currently Billboard year-end only (~4,600 tracks); Apple and Deezer chart rows populate going forward. Present on the browse endpoints too — see `TrackStub.chart_peak`.",
      "nullable": true,
      "example": 100
     },
     "bpm_snapped": {
      "type": "integer",
      "title": "Bpm Snapped",
      "description": "`bpm` rounded to a whole number — the grid tempo, in the form every DJ tool reports. Traktor, Serato and Rekordbox return clean integers because they derive a BEAT GRID, and a grid has exactly one tempo by construction; our `bpm` is a *measurement* of the audio, so it carries a decimal. For grid-produced music the true tempo is almost certainly the whole number and the decimal is our error — `bpm_confidence` correlates with how close we land to an integer, which is the evidence for that. **Always populated, never null.** Use `bpm` when you want the measurement (beatmatching by ear, where 127.89 vs 128.00 is the point) and `bpm_confidence` to judge how far to trust either.",
      "readOnly": true,
      "nullable": true,
      "example": 128
     }
    },
    "type": "object",
    "required": [
     "track_name",
     "artist_name",
     "itunes_track_id",
     "bpm",
     "key",
     "bpm_snapped"
    ],
    "title": "TrackLookupResponse"
   },
   "TrackStub": {
    "properties": {
     "itunes_track_id": {
      "type": "string",
      "title": "Itunes Track Id",
      "description": "Track id (catalog `itunes_track_id` or synthetic prefix). Pass to `/track/{id}/embedding` or use the name+artist with `/lookup`.",
      "example": "1488408568"
     },
     "track_name": {
      "type": "string",
      "title": "Track Name",
      "example": "Blinding Lights"
     },
     "artist_name": {
      "type": "string",
      "title": "Artist Name",
      "example": "The Weeknd"
     },
     "album_name": {
      "type": "string",
      "title": "Album Name",
      "nullable": true,
      "example": "After Hours"
     },
     "popularity": {
      "type": "integer",
      "title": "Popularity",
      "description": "Internal catalogue ordering key — **not an audience or popularity metric**. It reflects how a track entered our catalogue, not how widely it is listened to, and is not comparable between tracks. Being redefined as a chart-position score; don't build on it.",
      "default": 0
     },
     "chart_peak": {
      "type": "integer",
      "title": "Chart Peak",
      "description": "Best chart position we know this track reached, 1-100 where **100 = a number-one**. `null` means no chart placement we hold — which is the vast majority of the catalogue, so treat null as *unknown*, not as *unpopular*. ⚠ This is NOT an audience-size measure and does not tell you monthly listeners; it is a chart placement and nothing more. Coverage is currently Billboard year-end only (~4,600 tracks); Apple and Deezer chart rows are being populated going forward. Distinct from `popularity`, which is an internal ordering key and not a metric.",
      "nullable": true,
      "example": 100
     },
     "mbid": {
      "type": "string",
      "title": "Mbid",
      "description": "MusicBrainz Recording ID, when known.",
      "nullable": true
     },
     "isrc": {
      "type": "string",
      "title": "Isrc",
      "description": "ISRC, when known.",
      "nullable": true
     },
     "genre": {
      "type": "string",
      "title": "Genre",
      "description": "Broad genre classification, when known.",
      "nullable": true
     },
     "release_date": {
      "type": "string",
      "title": "Release Date",
      "description": "ISO 8601 release date, when known.",
      "nullable": true,
      "example": "2019-11-29"
     },
     "duration_ms": {
      "type": "integer",
      "title": "Duration Ms",
      "nullable": true,
      "example": 200040
     },
     "explicit": {
      "type": "boolean",
      "title": "Explicit",
      "nullable": true
     }
    },
    "type": "object",
    "required": [
     "itunes_track_id",
     "track_name",
     "artist_name"
    ],
    "title": "TrackStub",
    "description": "Minimal track shape for search and browse endpoints. No audio features.\nUse `/lookup` for full audio features once you have a track id."
   },
   "TransitionComponents": {
    "properties": {
     "harmonic": {
      "type": "integer",
      "maximum": 100.0,
      "minimum": 0.0,
      "title": "Harmonic",
      "description": "Key compatibility (Camelot wheel relationship)."
     },
     "tempo": {
      "type": "integer",
      "maximum": 100.0,
      "minimum": 0.0,
      "title": "Tempo",
      "description": "BPM compatibility — octave-aware (half/double time counts as a match)."
     },
     "energy": {
      "type": "integer",
      "maximum": 100.0,
      "minimum": 0.0,
      "title": "Energy",
      "description": "Smoothness of the energy step between the two tracks."
     }
    },
    "type": "object",
    "required": [
     "harmonic",
     "tempo",
     "energy"
    ],
    "title": "TransitionComponents",
    "description": "The three 0-100 sub-scores behind an overall mix score."
   },
   "TransitionDetail": {
    "properties": {
     "key_relation": {
      "type": "string",
      "title": "Key Relation",
      "description": "How the two keys relate on the Camelot wheel: `same`, `relative` (minor↔major), `adjacent_up` (+1), `adjacent_down` (-1), `energy_boost` (+7 fifth), `energy_drop` (-7 fourth), `two_hops`, `distant` (clash risk), or `unknown` (a key was missing).",
      "example": "adjacent_up"
     },
     "from_camelot": {
      "type": "string",
      "title": "From Camelot",
      "nullable": true,
      "example": "8A"
     },
     "to_camelot": {
      "type": "string",
      "title": "To Camelot",
      "nullable": true,
      "example": "9A"
     },
     "from_bpm": {
      "type": "number",
      "title": "From Bpm",
      "nullable": true,
      "example": 126.0
     },
     "to_bpm": {
      "type": "number",
      "title": "To Bpm",
      "nullable": true,
      "example": 128.0
     },
     "bpm_delta": {
      "type": "number",
      "title": "Bpm Delta",
      "description": "Signed BPM difference (to − from) after octave matching. null when either track's BPM is unknown.",
      "nullable": true,
      "example": 2.0
     },
     "bpm_octave_matched": {
      "type": "boolean",
      "title": "Bpm Octave Matched",
      "description": "True if the destination was matched at half/double time."
     },
     "from_energy": {
      "type": "number",
      "title": "From Energy",
      "description": "Source energy, normalised [0,1].",
      "nullable": true,
      "example": 0.62
     },
     "to_energy": {
      "type": "number",
      "title": "To Energy",
      "description": "Destination energy, normalised [0,1].",
      "nullable": true,
      "example": 0.66
     },
     "energy_delta": {
      "type": "number",
      "title": "Energy Delta",
      "description": "Signed energy difference (to − from), [-1,1]. null when either track's energy is unknown.",
      "nullable": true,
      "example": 0.04
     }
    },
    "type": "object",
    "required": [
     "key_relation",
     "bpm_octave_matched"
    ],
    "title": "TransitionDetail",
    "description": "The raw values the score was computed from — surface them so callers can build their own UI."
   },
   "TransitionResponse": {
    "properties": {
     "from_track": {
      "$ref": "#/components/schemas/TrackStub",
      "description": "The track you're mixing OUT of."
     },
     "to_track": {
      "$ref": "#/components/schemas/TrackStub",
      "description": "The track you're mixing INTO."
     },
     "score": {
      "type": "integer",
      "maximum": 100.0,
      "minimum": 0.0,
      "title": "Score",
      "description": "Overall 0-100 mix compatibility (0.50·harmonic + 0.30·tempo + 0.20·energy)."
     },
     "components": {
      "$ref": "#/components/schemas/TransitionComponents"
     },
     "detail": {
      "$ref": "#/components/schemas/TransitionDetail"
     },
     "reason": {
      "type": "string",
      "title": "Reason",
      "description": "Plain-English explanation of the score.",
      "example": "8A->9A +1 adjacent, 126->128 BPM (+2.0), energy +0.04"
     }
    },
    "type": "object",
    "required": [
     "from_track",
     "to_track",
     "score",
     "components",
     "detail",
     "reason"
    ],
    "title": "TransitionResponse",
    "description": "Pairwise mix-compatibility score between two catalog tracks."
   },
   "ValidationError": {
    "properties": {
     "loc": {
      "items": {
       "anyOf": [
        {
         "type": "string"
        },
        {
         "type": "integer"
        }
       ]
      },
      "type": "array",
      "title": "Location"
     },
     "msg": {
      "type": "string",
      "title": "Message"
     },
     "type": {
      "type": "string",
      "title": "Error Type"
     }
    },
    "type": "object",
    "required": [
     "loc",
     "msg",
     "type"
    ],
    "title": "ValidationError"
   },
   "WebhookConfig": {
    "properties": {
     "url": {
      "type": "string",
      "maxLength": 500,
      "minLength": 8,
      "title": "Url",
      "description": "HTTPS URL where backfill notifications will be POSTed.",
      "example": "https://example.com/freqblog-webhook"
     }
    },
    "type": "object",
    "required": [
     "url"
    ],
    "title": "WebhookConfig"
   },
   "WebhookStatus": {
    "properties": {
     "url": {
      "type": "string",
      "title": "Url",
      "description": "Current webhook URL, or null if none configured.",
      "nullable": true
     },
     "signing_note": {
      "type": "string",
      "title": "Signing Note",
      "description": "How to verify webhook authenticity.",
      "default": "Each POST carries X-FreqBlog-Timestamp and X-FreqBlog-Signature: sha256=<hex>, where the hex is HMAC-SHA256(secret, X-FreqBlog-Timestamp + '.' + rawBody) and secret = SHA256(SHA256(your_plaintext_api_key)). Recompute + constant-time compare, and reject a timestamp older than ~5 minutes (replay guard)."
     }
    },
    "type": "object",
    "title": "WebhookStatus"
   }
  },
  "securitySchemes": {
   "APIKeyHeader": {
    "type": "apiKey",
    "in": "header",
    "name": "X-Api-Key"
   }
  }
 },
 "tags": [
  {
   "name": "Audio",
   "description": "Audio feature extraction"
  },
  {
   "name": "Catalog",
   "description": "Pre-analyzed track catalog — instant name-based lookups"
  },
  {
   "name": "Spotify-compat",
   "description": "Spotify-shape-compatible routes for endpoints deprecated 2024-11-27. Field names and response structure match Spotify byte-for-byte; numeric values come from this pipeline's open-source analysis (Essentia for bpm/key/meter, librosa for loudness, bespoke heuristics for valence/energy/danceability) and need recalibration if you're blending against cached Spotify numbers. See /v1/audio-features description and the Field-by-Field Threshold Guide at /blog/spotify-audio-features-migration-thresholds/."
  },
  {
   "name": "System",
   "description": "Health and operational endpoints"
  }
 ],
 "security": [
  {
   "APIKeyHeader": []
  }
 ]
}