Self-hosted, invite-only music library with web, desktop, and mobile clients.
Point it at folders of audio files; it scans and indexes them, then serves them to every client — streaming with scrubbing, playlists, favorites, play history, a yearly Replay, public share/embed links with link previews, and an admin surface for invites and library management.
Full feature list
- Multiple music roots, scanned and indexed with a filesystem watcher; manual rescan from the admin UI
- Metadata and cover-art extraction, album/artist/track browsing, search (with a command palette on web)
- Range-request audio streaming — instant scrubbing, no full-file buffering
- Persistent player with queue, mini player, and now-playing view
- Play history (recently played) and per-user play stats
- Optional per-user Last.fm now-playing and scrobble integration
- Uploads from the web and mobile apps
- Track and album metadata editing
- Downloads come tagged with the library's metadata and album cover, for local files as for TIDAL tracks
- Playlists with create/edit, add-tracks flow, and local sorting (title, length, plays)
- Playlist collaborators — invite others to a shared playlist
- TIDAL auto-download: admins opt a playlist in, and its TIDAL tracks are saved into the library as tagged files that the playlist then plays
- Favorites across all clients
- Yearly Replay: listening recap with activity chart, animated stats, and a shareable top-songs image
- Public share pages with Open Graph tags — links unfurl with cover, title, and a playable preview in Discord and chat apps; in Discord the card is a component embed with Open, Download video, and Download audio buttons
- Embeddable players advertised in the link-preview metadata
- Server-rendered preview images/videos for shares
- HMAC-signed cover/share/preview URLs — public links need no session, but can't be forged
- Invite-only registration: admins mint tokens with role, max uses, and expiry; pending-invite overview
- Argon2id password hashing, HTTP-only cookie sessions, forced password reset for the seeded admin
- Admin surface: user management, music roots, library maintenance, and import pins
- Pull new files into the library from external sources on a poll interval: Filen share links, ArtistGrid, Lastshare, and tracker-API pins with per-pin destination folders
- Windows portable build + NSIS installer, universal macOS DMG, and Linux AppImage + Debian package
- Discord Rich Presence ("Listening to Lumen" with cover art)
- Local read-only now-playing API (JSON, cover and live events) for stream overlays such as OBS
- First-run server-setup window, always-on-top toggle, custom window controls
- Background playback with lock-screen / now-playing controls (custom native module)
- Live cross-device playback activity and remote controls over WebSockets, including queue, shuffle, and repeat sync
- AirPlay output picker on iOS
- Instagram story sharing for Replay and tracks
- Full library, playlists (incl. collaborators), favorites, uploads, metadata editing, and admin screens on the go
- Liquid-glass UI with haptics, deep-link scheme for share links
| Directory | What it is |
|---|---|
backend/ |
Go HTTP API — auth, invites, library scanning/ingest, streaming, playlists, sharing, previews. Postgres-backed. |
frontend/ |
React + Vite + TypeScript web app, also packaged for Windows, macOS, and Linux via Electron. |
mobile/ |
Expo Router / React Native app for iOS and Android. |
landing/ |
Marketing site for lumen.rocks, React + Vite, deployed to Cloudflare Workers. |
core/ |
Shared TypeScript package (@music-library/core) — API client, player state, auth, favorites — consumed by the web and mobile clients. |
Deployment lives at the repo root: docker-compose.yml runs Postgres + hifi-api + backend + frontend, with the app images built straight from this repo.
cp .env.example .env
# edit .env — at minimum set POSTGRES_PASSWORD, COVER_SIGN_KEY and PUBLIC_HOSTS
docker compose up -d --buildThat starts:
- postgres — Postgres 16, data in
./pgdata, bound to127.0.0.1:${POSTGRES_PORT} - hifi-api - internal TIDAL search/playback proxy, with token data in
./tidal-hifi - backend — Go API on
127.0.0.1:${BACKEND_PORT}(default 8080) - frontend — SPA behind nginx on
127.0.0.1:${FRONTEND_PORT}(default 8081)
Your music lives wherever MUSIC_HOST_PATH points (default /mnt/music).
It's bind-mounted at /mnt/music inside the backend container. Add music
roots using container paths: for MUSIC_HOST_PATH=/srv/music, the host folder
/srv/music/artist is /mnt/music/artist in the admin UI.
On first run the backend seeds an admin user; if you didn't set
ADMIN_PASSWORD in .env, the generated one is written (mode 0600, not to
the logs) to /mnt/music/.initial-admin-password inside the container:
docker compose exec backend cat /mnt/music/.initial-admin-passwordDelete that file once you've logged in and set a new password.
The admin is forced to set a new password on first login. From the admin UI you can add music roots and create invites — registration is invite-only.
Re-run docker compose up -d --build any time you pull new source.
TIDAL integration is handled by the internal hifi-api sidecar. The frontend
and mobile clients still talk only to Lumen's backend; the backend calls
hifi-api, rewrites the returned stream manifests, and proxies the audio
segments back through /api. TIDAL credentials and media URLs are never exposed
to clients, and no TIDAL audio is downloaded into the local library unless an
admin opts a playlist in (see Saving playlist TIDAL tracks).
This is intentionally a passthrough setup:
- Local files stay in the normal Postgres-backed library.
- TIDAL is searched live through the unified search endpoint.
- Artist details use the Lumen proxy extension (
/lumen/artist?id=…) to fetch albums, singles/EPs, and 15 top songs. The proxy returnsfailed_sectionsfor partial failures and HTTP 502 when all sections fail. The public/api/tidal/artists/{id}response includes user-facingwarningsalongside available albums and tracks; web, desktop, and mobile offer a retry. The artist's name and picture come from a fourth lookup that is optional: if it fails, the response simply omitsartistrather than adding a warning. Deploy the updatedhifi-api/lumen_hifi.pyextension and restarthifi-apiwhen upgrading, including on independently hosted proxy deployments; the upstream service alone does not provide/lumen/artist. - When TIDAL refuses a request with HTTP 403 (content restricted for the
server's region or accounts, or the accounts themselves turned away), the
hifi-api/lumen_hifi.pyextension passes on TIDAL's own reason instead of upstream's generic "Upstream API error". Lumen answers 502 with "TIDAL refused …: ": the players show it in place of the track's artist, and album, artist, track info and search views show it as their error or warning. A refused stream is remembered for a minute, so retries and preloads don't keep asking TIDAL, and refusals don't fall back to lower qualities. Restarthifi-apiafter deploying; without the updated extension the reason is missing but the rest still works. - Playlists can contain both local tracks and TIDAL tracks.
- TIDAL playlist entries store track metadata plus the remote TIDAL id, not an audio file.
- One or more subscribed TIDAL accounts power playback for all invite-only users.
No TIDAL developer app, OAuth client id, client secret, redirect URL, or scopes
are required for this path. The only TIDAL auth state is the device-token set
that hifi-api writes to ./tidal-hifi/token.json.
Configure the TIDAL values in the root .env. Use the country for the
subscribed account, for example PL:
HIFI_API_PORT=8000
HIFI_API_UID=1000
HIFI_API_GID=1000
TIDAL_COUNTRY_CODE=PL
TIDAL_QUALITY=LOSSLESS
TIDAL_HIFI_USE_PROXIES=False
TIDAL_HIFI_ROTATE_PROXIES_ON_REFRESH=False
TIDAL_HIFI_MAX_RETRIES=2
TIDAL_HIFI_FALLBACK_TO_DIRECT_CONNECTION=False
TIDAL_HIFI_DEV_MODE=FalseHIFI_API_PORT belongs next to the other port values. It is not published to
the host; Compose uses it for the private hifi-api service and derives the
backend URL as http://hifi-api:${HIFI_API_PORT} internally. Do not set
TIDAL_HIFI_API_URL in .env for the Docker setup.
HIFI_API_UID and HIFI_API_GID must match the owner of ./tidal-hifi on the
host. The 1000:1000 defaults match a typical first Linux user. Prepare the
directory before starting the stack:
mkdir -p tidal-hifi
chown -R 1000:1000 tidal-hifi
chmod 700 tidal-hifiIf your deployment user has different IDs, use the values printed by id -u
and id -g in both .env and the chown command.
Start (or recreate) the stack:
docker compose up -d --buildThen link a subscribed account from Admin → Library → TIDAL accounts in the
web/desktop client, or Settings → Admin → TIDAL in the mobile client. Lumen
opens TIDAL's device sign-in page and updates the panel as soon as approval
finishes. Accounts can be added, replaced, or unlinked there without restarting
the stack. Tokens remain in ./tidal-hifi/token.json; that directory is local
runtime state and is gitignored.
Each linked account makes one playback request at a time, because TIDAL has
been banning accounts that request playback in parallel. When more tracks
start at once than there are accounts, the extra ones wait briefly in
hifi-api's queue; linking more accounts raises the limit. To keep search and
browsing traffic off the playback accounts, add "role": "catalog" to one
entry in token.json (that account needs no subscription) and restart
hifi-api. It still shows in the accounts list and can be unlinked there.
The original command-line device flow remains available as a recovery path:
docker compose run --rm hifi-api python tidal_auth/tidal_auth.py
docker compose restart hifi-apiOnce an account is linked, normal search and playback use the same UI as local tracks. If playback fails, check both logs:
docker compose logs backend hifi-apiCommon checks:
tidal proxy is not configuredmeans the backend cannot see the generated internal URL; rundocker compose configand verifyTIDAL_HIFI_API_URLresolves tohttp://hifi-api:8000or your chosenHIFI_API_PORT.- Preview-only or low-quality playback usually means the token was generated with an account or country that TIDAL is not granting full playback for.
- Relink an account from either admin client when its authorization or
subscription changes.
TIDAL_COUNTRY_CODEstill requires recreating the containers because it is server configuration rather than account state.
An admin can turn on Auto-save TIDAL from a playlist's page (web/desktop) or Save TIDAL to Server from its menu (mobile). A background worker then downloads the playlist's TIDAL tracks, plus any added later, into the library:
- Files are tagged with ffmpeg (title, artists, album, track number, ISRC, and
cover) and written to
<music folder>/<subfolder>/Artist/Album/NN - Title.ext. The default isMUSIC_PATH/TIDAL; change it under Admin → Library → TIDAL. - A track whose ISRC already matches a library track is linked to that track instead of being downloaded again.
- Once a track is saved, every playlist entry, favorite, play count, and play history row that pointed at the TIDAL version moves to the library copy. Any later reference to that TIDAL track, like adding it from search, resolves to the library copy too.
- Failures retry with backoff, from 5 minutes up to a day. The admin panel lists recent results and has a Retry failed button.
TIDAL_DOWNLOAD_POLL_INTERVAL (default 5m) sets the fallback poll interval;
adding tracks to an opted-in playlist wakes the worker right away.
TIDAL_DOWNLOAD_FILE_TIMEOUT (default 30m) limits each download.
TIDAL_DOWNLOAD_MIN_FREE_MB (default 5120) pauses downloads while the
destination volume has less free space than that. Anyone who can edit an
opted-in playlist can add tracks to it, so only opt in playlists whose editors
you trust with library space.
Saving a track (web/desktop Download, playlist export, mobile offline
downloads) asks for GET /api/tracks/{id}/stream?download=1. For a local file
the server returns a copy tagged with what the library shows: title, artists,
album, album artist, year, track and disc number, genre, composer, comment,
ISRC, and the album cover (the file's own art stays when the album has none).
ffmpeg copies the audio as is (no re-encode) in the file's own container, and
keeps the file's other tags. The file on disk is never changed, and playback
streams it as stored.
- Retagged: FLAC, MP3, M4A/MP4, Ogg Vorbis and Opus. Ogg can't hold cover art, so Ogg and Opus copies get tags only, and an Ogg file that already has art is served as stored rather than lose it.
- WAV, raw AAC and WebM, a server without ffmpeg, or a failed remux: the file is served as stored.
- At most four downloads are retagged at a time; others wait their turn.
Both app ports bind to loopback only — put a reverse proxy in front. Copy
docs/nginx.conf.example into your nginx config and
edit the hostname + TLS setup. It routes /api/*, /share/*, and /embed/*
to the backend port and everything else to the frontend port, with buffering
and timeouts tuned for audio streaming. Keep TRUSTED_PROXIES=172.16.0.0/12
in .env (default Docker bridge range) so the backend sees real client IPs,
and set PUBLIC_HOSTS to the hostname users reach Lumen at, which share
links are built from. Behind Cloudflare, also restore the client address in
nginx (the commented real_ip block in the example) or add Cloudflare's
ranges to TRUSTED_PROXIES; otherwise every per-address limit applies to a
Cloudflare edge shared by many users.
Serving over HTTPS? Keep COOKIE_SECURE=true. Plain HTTP (local only)? Set
COOKIE_SECURE=false, or the browser drops the session cookie.
Two artifacts matter — your music directory (MUSIC_HOST_PATH) and Postgres
(all metadata, users, playlists, play history):
docker compose exec postgres pg_dump -U $POSTGRES_USER $POSTGRES_DB \
| gzip > backup-$(date +%F).sql.gz
rsync -a /mnt/music/ backups-host:/path/to/music/Each project is self-contained with its own README, but the usual loop is:
# 1. Postgres + backend
docker run --rm -e POSTGRES_PASSWORD=mlib -e POSTGRES_USER=mlib -e POSTGRES_DB=mlib -p 5432:5432 postgres:16
cd backend
DATABASE_URL=postgres://mlib:mlib@localhost:5432/mlib?sslmode=disable COOKIE_SECURE=false go run ./cmd/server
# 2. Web app (proxies /api to localhost:8080)
cd frontend
npm install
npm run dev
# 3. Mobile app
cd mobile
npm install # postinstall syncs core/ into packages/
npm run startShared client logic lives in core/. The web app imports it directly
from source; the mobile app uses a synced copy under
mobile/packages/music-library-core — run npm run sync:core in mobile/
after changing core.
docker-compose.yml Postgres + hifi-api + backend + frontend
.env.example all deployment knobs, copy to .env
docs/ nginx site-config example for host-level proxying
backend/ Go API server (own Dockerfile)
core/ shared TypeScript core package
frontend/ web + Electron client (Dockerfile builds with repo root as context)
mobile/ Expo app
Runtime data (./pgdata, ./tidal-hifi) is created next to the compose file
and gitignored. The transcode/preview cache lives in the transcode-cache
named Docker volume (a bind mount would inherit host ownership, which the
container's non-root user can't write to).
CI (.github/workflows/ci.yml) runs on pull requests
targeting main and on pushes to main: backend gofmt/vet/build/test,
core lint + typecheck + vitest, frontend lint + typecheck + build, and
mobile vitest plus a check that the vendored core copy matches core/.
Docker images are published to GHCR only on pushes to main and on version
tags.
