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