Self-hosted music player: scans a library on disk, serves a GraphQL API and a transcoding playback endpoint, and ships a small web UI on top.
See CLAUDE.md for repo conventions that apply to every
change.
packages/
backend/ TypeScript monolith: GraphQL API, playback, scanner, db schema
web/ Vite + React + TanStack Router web client (installable PWA)
artwork-worker/ Rust service downloading album covers for the AlbumArt queue
The scanner lives inside packages/backend — it is not a separate
package or service.
Lofify tracks the lossy/lossless distinction end to end, from the source file on disk to the bytes that reach the player. The source file itself is never modified.
Sources. Every scanned track records an isLossless flag — lossless
containers (FLAC, ALAC, WAV…) versus lossy ones (MP3, Opus, Vorbis, AAC…).
Outputs. What the player actually receives depends on the requested
quality (Adaptive, Smart or Original; see the
web README) and on what the browser can
decode, probed once via MediaSource.isTypeSupported:
- Adaptive always transcodes to a lossy codec (Opus or MP3) at a tier chosen from measured bandwidth — so the output is lossy whatever the source.
- Original asks for the best representation the browser can play. A lossless source is delivered losslessly (FLAC) where supported; a lossy source is copied verbatim — a passthrough, no re-encode — when its codec is playable, and only transcoded as a last resort.
- Smart (the default) follows Adaptive for lossless sources — where
the original is large and a lossy tier is the sensible choice — but lets a
lossy source the browser can play through verbatim, like Original,
so it is never compressed a second time. A lossy source the browser can't
play falls back to an adaptive transcode. The effect is to avoid
multi-lossy playback wherever possible. Mechanically it rides the adaptive
ladder but adds an
autoPassthroughflag the server honours by copying a playable lossy source through at full quality.
The server only ever produces FLAC for lossless output and Opus or MP3 for lossy output; Vorbis is copied, never encoded. The exact resolution rules, including the per-tier table, live in the backend README.
Quality loss then falls into three cases:
- Lossless throughout — a lossless source delivered as FLAC. No loss.
- Single lossy step — a lossless source transcoded once to a lossy codec, or a lossy source copied verbatim (no re-encode). One generation of loss at most.
- Multi-lossy — a lossy source re-encoded to a lossy output (not
a copy), stacking a second generation of compression. The backend flags
this as
delivery.isMultiLossy, and the player shows an amber warning triangle beside the format badge.
- Node 24, pnpm 9 (managed with
asdf; see.tool-versions). - Postgres 18 (Docker, dev exposed on host port
5433). - TypeScript everywhere; Drizzle for schema + migrations via
drizzle-pgkit-migrator.
pnpm install
docker compose up # postgres, otel-lgtm, backend, web
pnpm db:migrate # apply migrationsdocker compose reads docker-compose.yml. Production uses
docker-compose.prod.yml; in prod the backend serves the built web
client itself as a catch-all SPA route, so there is no separate web
container.
cp .env.example .env.production # then edit secrets
scripts/deploy.sh --host my-server --nfs-host 10.0.0.2 --nfs-path /srv/dockercachescripts/deploy.sh builds and pushes felamaslen/lofify:latest and
felamaslen/lofify-artwork-worker:latest, copies
docker-compose.prod.yml to {directory}/docker-compose.yml on the
remote (default /opt/lofify), and copies .env.production to
{directory}/.env. Backend listens on host port 4002. Postgres data
persists at {directory}/var/db. .env.production is git-ignored.
The disk cache is an NFS-backed Docker volume. --nfs-host and
--nfs-path (both required) name the NFS server and exported directory;
the deploy splices them into the compose file before copying. Docker
creates the volume from those options on first up — to repoint it at a
different server later, remove the playback-cache volume on the remote
(docker volume rm <project>_playback-cache) before redeploying. The
volume keeps its historical playback-cache name so existing data
survives upgrades; the share root is mounted at /disk-cache, and the
backend writes into the lofify subdirectory (DISK_CACHE_DIR), which
it creates on first use, so the export can be shared with other
consumers. Within DISK_CACHE_DIR, transcoded playback entries live
under transcode/ and downloaded album art under artwork/; on first
boot after an upgrade the backend moves any legacy entries from the
cache root into transcode/.
| Script | What it does |
|---|---|
lint |
ESLint across every package |
typecheck |
tsc --noEmit across every package |
test |
Per-package test runner |
dev |
Watch-mode dev across every package |
build |
Production build |
codegen |
Run all code generators across every package |
db:generate |
Drizzle schema → schema.sql |
db:migrate |
Apply pending Postgres migrations |
db:migrate:create |
Regenerate schema.sql + write a new migration |
format |
Prettier write |
Every environment variable the system reads is documented in
.env.example. Only DATABASE_URL is exposed; the
compose stack hardcodes the Postgres bootstrap credentials.
The exception is the build-time git SHA. scripts/deploy.sh bakes the
commit it builds from into the image as the GIT_SHA build arg, which
the Dockerfile threads into both the backend (GIT_SHA) and the web
bundle (VITE_GIT_SHA). The client polls Query.isUpdateAvailable with
its own SHA and shows a reload prompt when the server is running a newer
build. These are set by the image, not by .env, so they're not listed
in .env.example.