Crowdsourced lyrics API for Better Lyrics.
All write operations require signed requests using ECDSA P-256:
{
"payload": {
"keyId": "<sha256-hash-of-public-key>",
"timestamp": 1703520000000,
"nonce": "<random-16+-char-string>",
...request data
},
"signature": "<base64-ecdsa-signature>",
"publicKey": { "kty": "EC", "crv": "P-256", ... }
}keyId: SHA-256 hash of the public key (hex)timestamp: Must be within ±5 minutes of server timenonce: Unique per request (replay prevention)signature: ECDSA signature over the canonical JSON payloadpublicKey: Required on first request to register the key
The Better Lyrics extension owns the user's ECDSA keypair. To prove identity to a web client, open a long-lived port to the extension and exchange one signed challenge for one signed response.
Open a port named bl-auth-site against the extension's Chrome Web Store id. Send one request, await one response.
Request:
{ type: "bl-auth-request", nonce: string, origin: string }nonce is issued by your backend per challenge. origin must equal window.location.origin.
Success:
{
ok: true,
signedBody: {
payload: { origin: string, timestamp: number, nonce: string, keyId: string },
signature: string, // base64
publicKey: JsonWebKey,
}
}Failure: { ok: false, reason }, with reason one of ORIGIN_MISMATCH, INVALID_REQUEST, USER_CANCELLED, USER_DISMISSED, SIGN_FAILED.
const BL_EXTENSION_ID = "effdbpeggelllpfkjppbokhmmiinhlmg"
async function signInWithBetterLyrics(): Promise<SignedBody> {
const { nonce } = await fetch("/auth/challenge").then(r => r.json())
return new Promise((resolve, reject) => {
let port: chrome.runtime.Port
try {
port = chrome.runtime.connect(BL_EXTENSION_ID, { name: "bl-auth-site" })
} catch {
reject(new Error("Extension not installed or origin not allowed"))
return
}
let settled = false
port.onMessage.addListener(msg => {
if (settled) return
settled = true
if (msg.ok) resolve(msg.signedBody)
else reject(new Error(msg.reason))
try { port.disconnect() } catch {}
})
port.onDisconnect.addListener(() => {
if (settled) return
settled = true
reject(new Error(chrome.runtime.lastError?.message ?? "Port closed"))
})
port.postMessage({ type: "bl-auth-request", nonce, origin: window.location.origin })
})
}Listeners must attach before postMessage, and the calling button should disable itself until the promise settles so a second click doesn't open a second popup.
Verify signedBody with the same scheme as a per-request signature (see Authentication): nonce is unused, origin matches, timestamp within ±5 minutes, ECDSA P-256 over canonical-JSON payload against publicKey, keyId === sha256(normalized publicKey). If all five pass, treat keyId as the stable user id.
To talk to the extension from a new origin, open a PR against better-lyrics/better-lyrics:
- Add the HTTPS origin to
externally_connectable.matchesinmanifest.json. - Add an
AUTH_PARTNER_METADATAentry insrc/core/constants.tswith anidand optionaliconUrl.
Chrome and Chromium-based browsers only. Firefox does not implement externally_connectable for web pages (Bugzilla 1319168).
GET /lyrics?v=<videoId>
GET /lyrics?song=<song>&artist=<artist>
GET /lyrics?song=<song>&artist=<artist>&album=<album>&duration=<seconds>
Returns the highest-scored match. Optional album and duration narrow results.
Duration matching uses ±2s tolerance (configurable in src/config.ts).
Returns all matching entries sorted by score (highest first).
GET /lyrics/search?q=<query>
GET /lyrics/search?song=<song>&artist=<artist>
GET /lyrics/search?song=<song>&artist=<artist>&album=<album>
GET /lyrics/search?song=<song>&artist=<artist>&duration=<seconds>
The q parameter searches across video ID, ISRC, metadata (song/artist/album via trigram similarity), and lyrics content (full-text search). Results are ranked in three tiers: exact identifier match > metadata similarity > lyrics content match.
GET /lyrics/:id
Translates and romanizes a block of lyric lines. Unison fetches and parses Google's lyrics_translate server-side and returns clean per-line results, so clients do not parse Google output and do not each hit the rate-limited endpoint. No signature required. This endpoint is on by default. Set TRANSLATION_PROXY_DISABLED to a truthy value (true, 1, or yes) to turn it off, which unmounts the route so any call returns the generic 404.
POST /translate
{
"lines": ["안녕하세요", "", "반갑습니다"],
"to": "en",
"from": "ko",
"videoId": "dQw4w9WgXcQ"
}
lines: lyric lines in display order. Blank lines and lone♪markers are allowed and preserved by position in the response. Up to 200 lines, each up to 500 characters.to: target language, ISO 639-1. Up to 35 characters.from: source language, ISO 639-1. Optional; omit it to detect the source in-process. Do not send"auto", omit the field instead. Up to 35 characters.videoId: optional, stored on the cache row for provenance.
The response is a bare object, not the { success, data } envelope the read endpoints use:
{
"lines": [
{ "translation": "Hello", "romanization": "annyeonghaseyo", "needsTranslation": true },
{ "translation": null, "romanization": null, "needsTranslation": false },
{ "translation": "Nice to meet you", "romanization": "bangapseumnida", "needsTranslation": true }
],
"detectedLang": "ko",
"provider": "google-lyrics-translate",
"cached": false
}linesis positionally aligned with the request: same length, blanks preserved. Maplines[i]onto displayed linei.translation: translated text, ornullfor blank and no-op lines.romanization: romanized source, ornullwhen the source is already Latin-script or Google returned none.needsTranslation:falsewhen the line was already in the target language or is blank,truewhen it was actually translated. Gate any overlay on this flag.detectedLang: the source language used, whether it was given or detected.cached:truewhen served from Unison's cache. Results are keyed on source and target language, the exact lines, provider, and parser version.
When from (given or detected) equals to there is nothing to translate, so every line returns { "translation": null, "romanization": null, "needsTranslation": false } with a 200. Errors are { "success": false, "error": "..." }: 400 when no line is translatable or the source language cannot be detected, 422 when the request exceeds the line or length limits above, 502 when the upstream fails, and 503 when the outbound throttle is rate limited. When the same lines fail to parse repeatedly (three times), the result is cached as a negative and further requests short-circuit to 502 for about a month instead of hammering the upstream.
Accepts TTML, LRC, or plain text via the format field.
POST /lyrics/submit
{
"videoId": "dQw4w9WgXcQ",
"song": "Song Title",
"artist": "Artist Name",
"duration": 180,
"lyrics": "[00:15.00]First line...",
"format": "lrc",
"album": "Album Name",
"language": "en",
"syncType": "linesync"
}
Formats: ttml, lrc, plain
Sync Types: richsync, linesync, plain
POST /lyrics/:id/vote
{ "vote": 1 } // upvote
{ "vote": -1 } // downvote
DELETE /lyrics/:id/vote // remove vote
POST /lyrics/:id/report
{
"reason": "wrong_song",
"details": "optional"
}
Reasons: wrong_song, bad_sync, offensive, spam, other
{
"success": true,
"data": {
"id": 123,
"videoId": "dQw4w9WgXcQ",
"song": "Never Gonna Give You Up",
"artist": "Rick Astley",
"lyrics": "...",
"format": "lrc",
"language": "en",
"syncType": "linesync",
"score": 5,
"effectiveScore": 4.2,
"voteCount": 12,
"confidence": "high"
}
}low: Fewer than 5 votesmedium: 5+ votes from similar usershigh: 5+ votes with diversity bonus (both harsh and generous raters agree)
pnpm install
pnpm run dev # local server
pnpm run test # tests
pnpm run check # lint
There's a daily snapshot of the public lyrics corpus at
https://unison-dumps.boidu.dev/dumps/latest.dump. Sha256, row counts, and
the rest of the metadata are at dumps/manifest.json on the same host.
It's a pg_dump -Fc against Postgres 18, scoped to a public_dump schema
with lyrics, requested_songs, and lyrics_requests. No user IDs, no
votes, no reports, no auth.
# 1. Download and verify
curl -O https://unison-dumps.boidu.dev/dumps/latest.dump
curl -O https://unison-dumps.boidu.dev/dumps/latest.dump.sha256
sha256sum -c latest.dump.sha256
# 2. Create a fresh database
createdb unison_mirror
# 3. Restore
pg_restore -d unison_mirror --no-owner --no-privileges latest.dumpThe lyrics column is stored gzip-compressed, same as in the live DB. Full-text
search is omitted from the dump. If you need search on a mirror, decompress the
column and run the project's backfill-text-search job against your restored
DB (see src/jobs/backfill-text-search.ts).
The dump pipeline only writes to the public_dump schema. There's no
INSERT, UPDATE, DELETE, or ALTER against public.* anywhere in the
code, and a test in src/jobs/dump.test.ts fails CI if anyone adds one.
If you want database-level enforcement on top of that, run the dump under a restricted Postgres role:
CREATE ROLE unison_dump WITH LOGIN PASSWORD '<choose-one>';
GRANT USAGE ON SCHEMA public TO unison_dump;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO unison_dump;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO unison_dump;
GRANT CREATE ON DATABASE <db-name> TO unison_dump;
CREATE SCHEMA IF NOT EXISTS public_dump AUTHORIZATION unison_dump;Set DUMP_DATABASE_URL to a connection string that authenticates as that
role. If it's unset, the pipeline uses DATABASE_URL as before.
Source code: AGPL-3.0.
The dump itself is dual-licensed.
- Open: ODbL-1.0. Attribution and share-alike on derivative databases. If you're building a FOSS player that displays the lyrics, you only need to attribute (the "Produced Works" clause).
- Commercial: anyone selling a product on top of the corpus (streaming
services, labels, distributors) needs a commercial license. Email
enterprise@boidu.devwith "Unison" in the subject.
Required attribution: Lyrics from Unison (https://unison.boidu.dev).