# Unison

[Unison](https://unison.boidu.dev/) is a public database of community-synced lyrics, the community-fed source Better Lyrics reads from. Each entry is keyed on a `videoId`. Reading from it needs no key and no sign-in. Every `/lyrics` response is wrapped as `{ success, data }`, so check `success` before you touch `data`. Reads are rate limited to 120 requests per minute per IP.

## Base URL

```bash
https://unison.boidu.dev
```

## Quick start

If you have a video id, one call gets you the top-ranked lyrics for it. The synced text is in `data.lyrics` and its format is in `data.format`.

    

```bash
curl "https://unison.boidu.dev/lyrics?v=dQw4w9WgXcQ"
```

```javascript
const res = await fetch("https://unison.boidu.dev/lyrics?v=dQw4w9WgXcQ")
const { success, data } = await res.json()

if (success) {
  // data.format is "ttml" | "lrc" | "plain"
  // data.lyrics holds the synced text in that format
  render(data.lyrics, data.format)
}
```

```python
import requests

res = requests.get("https://unison.boidu.dev/lyrics", params={"v": "dQw4w9WgXcQ"})
body = res.json()

if body["success"]:
    data = body["data"]
    # data["format"] is "ttml" | "lrc" | "plain"
    render(data["lyrics"], data["format"])
```

```go
res, err := http.Get("https://unison.boidu.dev/lyrics?v=dQw4w9WgXcQ")
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()

// body.data.lyrics holds the synced text in body.data.format
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
```

```rust
// reqwest = { version = "0.12", features = ["blocking"] }
let body = reqwest::blocking::get(
    "https://unison.boidu.dev/lyrics?v=dQw4w9WgXcQ",
)?
.text()?;

// body.data.lyrics holds the synced text in body.data.format
println!("{body}");
```

## Fetch by video

`GET /lyrics?v=<videoId>` returns the single best version for that video. The fields you need to render are `lyrics`, `format`, and `syncType`; `confidence` tells you how much the community has vetted it. A miss returns `404`.

```json
{
  "success": true,
  "data": {
    "id": 4821,
    "videoId": "dQw4w9WgXcQ",
    "song": "Never Gonna Give You Up",
    "artist": "Rick Astley",
    "album": "Whenever You Need Somebody",
    "duration": 213,
    "format": "ttml",
    "syncType": "richsync",
    "language": "en",
    "score": 42,
    "voteCount": 47,
    "confidence": "high",
    "lyrics": "<tt xmlns=\"http://www.w3.org/ns/ttml\"> ... </tt>"
  }
}
```

## Fetch by song and artist

No video id? Look up by `song` and `artist` instead. Both are matched case-insensitively. Add `album` or `duration` (seconds, matched within a small tolerance) when a title has more than one recording.

    

```bash
curl "https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley"
```

```javascript
const res = await fetch("https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley")
const { success, data } = await res.json()
```

```python
import requests

body = requests.get("https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley").json()
```

```go
res, err := http.Get("https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley")
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
```

```rust
let body = reqwest::blocking::get(
    "https://unison.boidu.dev/lyrics?song=Never+Gonna+Give+You+Up&artist=Rick+Astley",
)?
.text()?;
```

## Search

`GET /lyrics/search?q=<text>` is for discovery. It returns ranked candidates with a `matchScore` but no lyric body, so pick the `videoId` you want and fetch it with `/lyrics?v=`. Passing `song` and `artist` instead of `q` runs an exact match and does include the full records. Use `limit` to cap results.

    

```bash
curl "https://unison.boidu.dev/lyrics/search?q=never+gonna+give"
```

```javascript
const res = await fetch("https://unison.boidu.dev/lyrics/search?q=never+gonna+give")
const { success, data } = await res.json()
```

```python
import requests

body = requests.get("https://unison.boidu.dev/lyrics/search?q=never+gonna+give").json()
```

```go
res, err := http.Get("https://unison.boidu.dev/lyrics/search?q=never+gonna+give")
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
```

```rust
let body = reqwest::blocking::get(
    "https://unison.boidu.dev/lyrics/search?q=never+gonna+give",
)?
.text()?;
```

```json
{
  "success": true,
  "data": [
    {
      "id": 4821,
      "videoId": "dQw4w9WgXcQ",
      "song": "Never Gonna Give You Up",
      "artist": "Rick Astley",
      "format": "ttml",
      "syncType": "richsync",
      "confidence": "high",
      "matchScore": 0.98
    }
  ]
}
```

## Other versions

A video can have several submitted versions. `GET /lyrics/variants/:videoId` lists them all, best-ranked first (up to 50), so you can offer alternatives if the default is not the one you want. `GET /lyrics/:id` fetches one specific version by its numeric id.

    

```bash
curl "https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5"
```

```javascript
const res = await fetch("https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5")
const { success, data } = await res.json()
```

```python
import requests

body = requests.get("https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5").json()
```

```go
res, err := http.Get("https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5")
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
```

```rust
let body = reqwest::blocking::get(
    "https://unison.boidu.dev/lyrics/variants/dQw4w9WgXcQ?limit=5",
)?
.text()?;
```

## Translating lyrics

`POST /translate` turns a block of lyrics into another language and romanizes it in one call, so every client isn't hitting Google's rate-limited endpoint on its own. Send the `lines` you want, the target language in `to`, and the source language in `from`. Leave `from` out and Unison detects it, then echoes what it picked back as `detectedLang`.

    

```bash
curl -X POST "https://unison.boidu.dev/translate" \
  -H "content-type: application/json" \
  -d '{"lines":["안녕하세요","반갑습니다"],"to":"en","from":"ko"}'
```

```javascript
const res = await fetch("https://unison.boidu.dev/translate", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ lines: ["안녕하세요", "반갑습니다"], to: "en", from: "ko" }),
})
const { lines, detectedLang } = await res.json()
```

```python
import requests

body = requests.post("https://unison.boidu.dev/translate", json={
    "lines": ["안녕하세요", "반갑습니다"],
    "to": "en",
    "from": "ko",
}).json()
```

```go
payload := strings.NewReader(`{"lines":["안녕하세요","반갑습니다"],"to":"en","from":"ko"}`)
res, err := http.Post("https://unison.boidu.dev/translate", "application/json", payload)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
```

```rust
// reqwest = { version = "0.12", features = ["blocking", "json"] }
let body = reqwest::blocking::Client::new()
    .post("https://unison.boidu.dev/translate")
    .json(&serde_json::json!({
        "lines": ["안녕하세요", "반갑습니다"],
        "to": "en",
        "from": "ko",
    }))
    .send()?
    .text()?;
```

Each line comes back with a `translation`, a `romanization` when the source isn't already in the Latin alphabet, and a `needsTranslation` flag so you can leave lines that were already in the target language alone. Answers are cached per lyrics-and-language pair, so the same request only reaches Google once; a cache hit sets `cached` to `true`. Blank lines and lone ♪ markers keep their place and come back empty.

```json
{
  "lines": [
    { "translation": "Hello", "romanization": "annyeonghaseyo", "needsTranslation": true },
    { "translation": "Nice to meet you", "romanization": "bangapseumnida", "needsTranslation": true }
  ],
  "detectedLang": "ko",
  "provider": "google-lyrics-translate",
  "cached": false
}
```

When `from` and `to` are the same language there's nothing to do, so every line comes back with a null `translation` and `needsTranslation` false instead of a copy of the original. A request with no real lyric lines, or one where the source language can't be worked out, returns `400`; more than 200 lines or an overlong line or language code returns `422`; if Google is unreachable or rate limited you get `502` or `503`. A transient failure isn't cached, but the same lines failing to parse three times over is remembered as a negative and short-circuits to `502` for about a month so the upstream isn't hammered.

## Formats

The `format` field tells you how to read `lyrics`, and `syncType` tells you how precise the timing is.

-   `ttml` is timed XML, the richest format. Pair it with `syncType` `richsync` for syllable-level timing or `linesync` for per-line timing.
-   `lrc` is timestamped lines, the classic karaoke format.
-   `plain` is unsynced text, with `syncType` `plain`.

## Errors and limits

Failed `/lyrics` calls keep the envelope with `success: false` plus a `code` and a human-readable `error` and `hint`. Common ones are `404` when nothing matches and `400` when the query is missing a required param. Stay under 120 requests per minute per IP or you get `429`.

```json
{
  "success": false,
  "error": "Not found",
  "code": "NOT_FOUND",
  "hint": "No lyrics matched that lookup yet."
}
```

Source: https://docs.betterlyrics.org/unison
