A personal lyrics server: holds the tokens your phone shouldn't, merges every source into one document, and remembers the answer.
Built for Melisma, an Android lyrics app. The app works well without it β this makes it better at the two things a phone cannot do itself:
- It holds the credentials. Apple Music has the best lyrics that exist: syllable timings with official romanizations and translations. Getting them needs two tokens that have no business being inside an APK, so they live here instead.
- It merges instead of racing. No source is best at everything. One supplies the timing, the rest lend translations, readings, background vocals and credits. The result is better than any single source returned.
Important
Run this for yourself, with your own accounts. It is not built to be shared, and sharing it is what gets accounts terminated β see Sharing it.
- What it does
- Quick start
- Point the app at it
- Tokens
- Deploying it
- Configuration
- API
- How it works
- Sharing it
- Docs
- Licence
πΌ Merges six sources into one document. Apple Music, AMLL, NetEase, Musixmatch, Spotify and LRCLIB, all asked at once β then one is chosen to own the timing and the rest are borrowed from, a field at a time.
π Renews the Spotify token by itself. The image carries a Chromium and drives it, because Spotify closed the endpoint that used to trade a cookie for a token. Paste one cookie, once.
ποΈ Archives every raw response. Improving the merge is then a local recompute rather than thousands of fresh requests to services doing this for free.
π¨ Fetches everything that is not the words. Cover art, a colour palette, tempo, and Spotify's audio analysis β down the same request, so the app's now-playing screen needs no second lookup.
π©Ή Never files an outage as an answer. A 404 means "no lyrics here" and is remembered; a timeout or a 429 means the question never got through, so the cache is left as it was.
π Has an admin page for pasting tokens, testing each source against a real track, browsing everything cached, and watching a live log β with a bulk re-lookup that can be paused mid-run when a source starts throttling.
βοΈ Installs nothing. Node 24 and zero dependencies. No build step, no lockfile, no container required to run it.
One request, one finished document β no fan-out from the phone and no tokens on it. It is ranked first in the app's source list by default, because an answer it already has cost nobody a request. Lyrics the phone found and the server could not can be contributed back β a region-locked endpoint, or a rate limit the server hit β archived so they merge in from then on.
Needs Node 24 or newer and nothing else. TypeScript runs directly; SQLite is built into Node.
git clone https://github.com/MelismaApp/melisma-server
cd melisma-server
npm startmelisma-server listening on http://127.0.0.1:8787
admin: http://127.0.0.1:8787/
api key: 41edb5ccβ¦
Open the admin page, paste that key to sign in, and it already works: LRCLIB, AMLL, NetEase and Musixmatch need no account, and between them cover most music including word-by-word timing. Tokens add to that.
| Command | |
|---|---|
npm start |
run it |
npm run dev |
run it, restarting on change |
npm test |
the suite β no network required |
npm run key |
print the API key |
node scripts/key.ts --new |
rotate it |
node scripts/key.ts --set <value> |
use one you chose |
Prefer a container? make docker-build && make docker-run runs it on :8787 with a volume.
About the API key
One key does both jobs: the admin page asks for it, the app sends it. It is generated on first boot
and printed once β and a printed key is no use once the terminal has scrolled, hence npm run key.
BL_API_KEY in the environment overrides the stored one, which is how the deployment sets it. When
that is in play npm run key says so, rather than confidently printing a key the running server is
not using.
Rotating invalidates the old key immediately: the admin page asks again on its next visit, and the app needs the new value.
In Melisma: Settings β Developer
| Field | Value |
|---|---|
| Cache server URL | http://<your-machine>:8787 β no trailing slash, no /v1 |
| Cache server key | only needed if the server is not on your own network |
A lookup from your own machine or your own Wi-Fi is let through without a key, because a lookup can only cause a lyric fetch. Anything deployed off your network always wants the key.
To check it end to end, use Try a track in the admin page: it runs a real lookup for any track you name and shows what every source returned, including why a candidate lost.
All optional. A source with no credential is skipped rather than queried, so leaving one enabled while you go and find its token costs nothing.
| I want⦠| Paste this | Lasts |
|---|---|---|
| Apple's word-by-word lyrics, romanizations and translations | Apple developer + media user token | months / session |
| The lyrics the Spotify app shows | Spotify sp_dc cookie |
~1 year |
| Reliable ISRCs and cover art | Spotify client id + secret | forever |
| A wider Musixmatch catalogue | Musixmatch user token | months |
| Regional NetEase catalogues | NetEase cookie | months |
β docs/TOKENS.md has step-by-step instructions for every one, including why the
sp_dc cookie is the only Spotify setup worth doing.
Paste them into the Tokens tab, then check them in Sources: each source has a Test button, and Test the sources runs a real lookup through every one and reports what each said. Or set them as environment variables, in which case the page shows them read-only.
Docker plus Kamal 2 (gem install kamal). kamal-proxy listens on :80;
put a Cloudflare Tunnel or another TLS terminator in front of it β TLS is not terminated here.
cp .kamal/secrets.sample .kamal/secrets # registry login, BL_API_KEY, any tokens
$EDITOR config/deploy.yml # replace the TODO(...) markers
make setup # one-time bootstrap and first deploy
make deploy # every time after that1. config/deploy.yml β four markers:
image |
your registry path, e.g. you/melisma-server |
servers.web |
the VM's IP or hostname |
ssh.user |
a user with Docker access on that VM |
registry |
Docker Hub, GHCR, OCIR β server and credentials |
Also check builder.arch. It is arm64 for an Oracle Ampere host; change it if your VM is x86.
2. .kamal/secrets β the registry login is required, and BL_API_KEY is strongly recommended so
the key is known before first boot and survives a rebuilt volume:
node -e "console.log(crypto.randomBytes(24).toString('hex'))"Tokens can go here or be pasted into the admin page afterwards β the database is on a volume, so they persist either way, and pasting needs no redeploy. Anything left blank is simply not passed to the container.
3. Day to day:
| Target | |
|---|---|
make deploy |
rebuild and ship |
make logs / make app-logs |
tail |
make remote-key |
print the deployed API key |
make console |
shell inside the container |
make backup |
copy the database here, timestamped |
make rollback |
previous image |
There is nothing to compile, so the image is node:24-alpine plus the source β no npm ci, no build
stage, no lockfile to keep in step.
Warning
Back up the volume. better-lyrics-data:/data holds the cache and the credentials. Lose it
and you re-fetch every track and re-paste every token. That is what make backup is for. It keeps
the old name on purpose β renaming a volume does not move it, it creates an empty one.
Caution
Leave BL_ALLOW_LOCAL_NETWORK: "0" alone. The local-network exception exists so a phone on your
own Wi-Fi needs no key. Behind kamal-proxy every request arrives from the Docker bridge, which
is a private address β so leaving it on would hand that exception to the whole internet. The
server independently refuses the exception whenever it sees an X-Forwarded-For header, which
kamal-proxy always sets, so there are two locks. The consequence is that a deployed server always
wants the key, including from the app, which is the right way round.
All of this is editable in the admin page and stored in the database, so none of it needs a restart. An environment variable overrides the stored value and shows read-only in the page.
| Variable | Default | |
|---|---|---|
BL_API_KEY |
generated | the one key, for the app and the admin page |
BL_HOST |
127.0.0.1 |
0.0.0.0 in a container |
BL_PORT |
8787 |
|
BL_DATA |
./data/better-lyrics.db |
database path (old name kept: it points at existing data) |
BL_ALLOW_LOCAL_NETWORK |
1 |
let this machine and the LAN look up without a key |
BL_TRANSLATION_LANG |
en |
which translation to prefer when a source ships several |
BL_TOKEN_REFRESH_MINUTES |
50 |
how often to renew the Spotify token |
BL_TOKEN_REFRESH_COMMAND |
β | replace the built-in browser harvest with your own command |
BL_LRCLIB_URL BL_NETEASE_URL BL_AMLL_URL BL_APPLE_API BL_APPLE_STOREFRONT |
endpoint overrides | |
BL_APPLE_BEARER_TOKEN BL_APPLE_MEDIA_USER_TOKEN BL_SP_DC_COOKIE BL_SPOTIFY_WEB_TOKEN BL_SPOTIFY_CLIENT_ID BL_SPOTIFY_CLIENT_SECRET BL_MUSIXMATCH_USER_TOKEN BL_NETEASE_COOKIE |
the tokens |
Admin page only, with no environment override: how long to trust "no lyrics exist" (48 h), how long before a found document is refreshed (30 days), the pause between tracks in a bulk re-lookup (1 s), and each source's on/off switch and priority.
GET /v1/health
GET /v1/lyrics?title=&artist=&album=&durationMs=&spotifyId=&isrc=
[&format=ttml|json] [&force=1] [&cacheOnly=1]
POST /v1/warm {title, artist, album, durationMs, spotifyId?}
POST /v1/contribute {track:{β¦}, provider, format:"lrc"|"ttml"|"json", body}
A lookup returns TTML in an envelope β {status, data:{format, lyrics, source, providerName}} β
which is what the app expects and any other lyrics server could produce. format=json gives the
structured model with its provenance and candidate list; format=ttml the bare file. X-Cache is
cache, remerge, network or absent.
Authentication follows what a route can reach: /admin/* always needs the key, since it is the only
surface that can read a credential, while a lookup is let through from your own network.
Full contract β docs/CACHE-SERVER.md.
Ask every enabled source at once, pick one to own the timing, then borrow everything else onto it a field at a time, checking after each borrow that nothing broke. Timing is never averaged: a lyric half a second out is harder to sing to than one with no timing at all. Identity comes first where it can β the Spotify id or ISRC is established before the others are searched, because a title matches the wrong song far more often than an ISRC does.
The merge, the cache, the token harvest and the security posture β docs/DESIGN.md.
Don't β at least not the Apple part.
Caching solves the rate limit. It does not solve the licence: lyrics fetched with your
media-user-token are licensed to you, and a server answering for other people is redistributing
Apple's content however the bytes got there. One person, their own tokens, their own device is a
defensible line. Account termination is the ordinary outcome of the alternative.
The version of this idea that does help other people already exists: the
AMLL TTML Database is CC0, community-made, and exactly
this cache built in the open. format=ttml exists so timings from here can be contributed back to it.
If you expose it anyway, know what is at stake: the database holds your Spotify cookie and Apple
tokens in the clear. The file is chmod 600, the server binds to 127.0.0.1 by default, the
admin surface always demands the key, and secrets are masked in the API and stripped from the log β
that is the whole of it (the full posture). Put nothing in front
of this that you would not put a password manager behind.
| TOKENS.md | Every token, step by step, and what each one buys |
| DESIGN.md | The merge, the cache, the token harvest, the security posture |
| CACHE-SERVER.md | The request/response contract, and why each call was settled that way |
AGPL-3.0 β see LICENSE.
Chosen for what it says about running this as a service rather than inherited from anywhere: Β§13 means anyone who runs a modified version where others can reach it has to offer them the source. Plain GPL does not, and a lyrics server is precisely the case that gap was written for. It also matches the app.
Unlike the app β which is AGPL because it is a genuine port of Spicy Lyrics, animation curves and all β nothing here derives from it. The server shares the app's TTML dialect and its line/syllable shape so the two can talk to each other, and a format and a data shape are not a port. There is no rendering code here at all.
None of this touches the lyrics themselves: they belong to their writers and publishers, and no code licence changes what the sources' own terms allow. See Sharing it.
The sources, none of them affiliated with this: AMLL TTML Database (CC0, community-timed), LRCLIB, NetEase Cloud Music, Musixmatch, Spotify, Apple Music.
Lyrics belong to their writers and publishers. This stores a cache on one machine.