Skip to content
githubessonPublic

About

Self-hosted, invite-only music streaming service with web, desktop, and mobile clients.

Resources

Stars

15 stars

Watchers

0 watching

Forks

Repository files navigation

Lumen

Lumen showcase

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

Library & playback

  • 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, favorites & Replay

  • 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

Sharing

  • 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

Accounts & admin

  • 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

Importers

  • 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

Desktop app (Electron, Windows / macOS / Linux)

  • 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

Mobile app (Expo, iOS / Android)

  • 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

Components

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.

Run it

cp .env.example .env
# edit .env — at minimum set POSTGRES_PASSWORD, COVER_SIGN_KEY and PUBLIC_HOSTS
docker compose up -d --build

That starts:

  • postgres — Postgres 16, data in ./pgdata, bound to 127.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-password

Delete 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 proxy passthrough

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 returns failed_sections for partial failures and HTTP 502 when all sections fail. The public /api/tidal/artists/{id} response includes user-facing warnings alongside 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 omits artist rather than adding a warning. Deploy the updated hifi-api/lumen_hifi.py extension and restart hifi-api when 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.py extension 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. Restart hifi-api after 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=False

HIFI_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-hifi

If 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 --build

Then 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-api

Once 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-api

Common checks:

  • tidal proxy is not configured means the backend cannot see the generated internal URL; run docker compose config and verify TIDAL_HIFI_API_URL resolves to http://hifi-api:8000 or your chosen HIFI_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_CODE still requires recreating the containers because it is server configuration rather than account state.

Saving playlist TIDAL tracks

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 is MUSIC_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.

Tagged downloads

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.

Putting it on the internet

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.

Backups

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/

Development

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 start

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

Repository layout

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.

About

Self-hosted, invite-only music streaming service with web, desktop, and mobile clients.

Resources

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages