|
2 | 2 | title: Building Drift FM |
3 | 3 | --- |
4 | 4 |
|
5 | | -The opinions, trade-offs, and philosophy behind how Drift FM is built. For the technical architecture (packages, data model, request flow), see [Architecture](architecture). |
| 5 | +The story behind Drift FM — why it exists, why it's built the way it is. |
| 6 | + |
| 7 | +For the technical details, see [Architecture](architecture). For the frontend design system, see [Design Language](design). |
6 | 8 |
|
7 | 9 | --- |
8 | 10 |
|
9 | 11 | ## The Problem |
10 | 12 |
|
11 | | -Most music apps optimize for engagement. Algorithmic recommendations, social features, infinite scroll, notifications pulling you back in. The goal is retention, not listening. |
| 13 | +Every music app wants your attention. Recommendations, social feeds, notifications — they optimize for engagement, not listening. I wanted something that plays music for a mood and gets out of the way. |
12 | 14 |
|
13 | | -Drift FM optimizes for mood. You pick a feeling — focus, calm, energize, late night — and the music plays. No recommendations, no feed, no decisions after the first one. You drift. |
| 15 | +Drift FM has one interaction: pick a mood. After that, music plays. No decisions, no feed, no algorithm nudging you toward something else. |
14 | 16 |
|
15 | 17 | --- |
16 | 18 |
|
17 | 19 | ## Why Self-Hosted |
18 | 20 |
|
19 | | -Your music should live on your hardware. Not behind a subscription. Not gated by a service that might change its terms, raise its price, or shut down. |
| 21 | +Your files, your server. No subscription, no licensing gaps, no "this track is no longer available." No analytics, no cookies, no third-party scripts. |
20 | 22 |
|
21 | | -Self-hosting means: |
22 | | -- **Your library, your rules.** No licensing gaps. No region locks. No "this track is no longer available." |
23 | | -- **No tracking.** Zero analytics, zero cookies, zero third-party scripts. Listen events are stored locally in SQLite for playlist optimization only. |
24 | | -- **Runs anywhere.** A $5 VPS, a Raspberry Pi, your laptop. The binary is ~15 MB. Deploy it next to your files and forget about it. |
| 23 | +It runs on a $5 VPS, a Raspberry Pi, or localhost. The binary is ~15 MB. |
25 | 24 |
|
26 | 25 | --- |
27 | 26 |
|
28 | | -## Why Go + SQLite + Vanilla JS |
29 | | - |
30 | | -The stack is deliberately boring. |
31 | | - |
32 | | -### Go |
33 | | - |
34 | | -Go compiles to a single binary. No runtime, no JVM, no dependency tree. Cross-compile to any platform with one command. The standard library includes a production-grade HTTP server — no framework needed. |
35 | | - |
36 | | -For a music server that handles a handful of concurrent users serving files from disk, Go is wildly overqualified. That's the point. The server will never be the bottleneck. |
| 27 | +## The Stack |
37 | 28 |
|
38 | | -### SQLite |
| 29 | +Go + SQLite + vanilla JS. Boring on purpose. |
39 | 30 |
|
40 | | -A music library of a few thousand tracks is a small dataset. SQLite handles it trivially. WAL mode gives concurrent reads. The database is a single file you can copy, back up, or inspect with the SQLite CLI. |
| 31 | +**Go** compiles to a single binary. Cross-compile to any platform. The stdlib HTTP server is more than enough for serving files to a handful of users. |
41 | 32 |
|
42 | | -Drift FM uses [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — a pure Go SQLite implementation. No CGO, no C compiler, clean cross-compilation. It's slightly slower than the C driver, but "slightly slower" on a workload this small is immeasurable. |
| 33 | +**SQLite** because a few thousand tracks is a tiny dataset. WAL mode for concurrent reads. One file to back up. No Postgres, no connection pooling, no Docker Compose. We use [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — pure Go, no CGO, clean cross-compilation. |
43 | 34 |
|
44 | | -No Postgres means no connection pooling, no migrations server, no Docker Compose for development. `make db-init` and you're done. |
45 | | - |
46 | | -### Vanilla JS |
47 | | - |
48 | | -The frontend is vanilla JavaScript using ES6 modules — the core player logic in `app.js` is about 1000 lines. No React, no Vue, no build step, no node_modules, no bundler. |
49 | | - |
50 | | -This isn't a dogmatic stance. It's a scope decision. The player has one page, a few interactive elements, and a straightforward state model. A framework would add a build pipeline, a package manager, and a layer of abstraction — all to solve problems this app doesn't have. |
51 | | - |
52 | | -CSS variables handle theming. The `<audio>` element handles playback. The browser is the framework. |
| 35 | +**Vanilla JS** because the player is one page with a few interactive elements. A framework would add a build pipeline and a package manager to solve problems this app doesn't have. CSS variables handle theming. The `<audio>` element handles playback. |
53 | 36 |
|
54 | 37 | --- |
55 | 38 |
|
56 | 39 | ## Shuffle with Memory |
57 | 40 |
|
58 | | -Random shuffle has a problem: true randomness feels repetitive. In a library of 20 tracks, hearing the same song twice in an hour doesn't feel random — it feels broken. |
59 | | - |
60 | | -Drift FM's shuffle uses **recency avoidance**: |
| 41 | +True randomness feels repetitive. Hearing the same track twice in an hour feels broken, not random. |
61 | 42 |
|
62 | | -1. Fetch all tracks for the mood from SQLite |
63 | | -2. Partition into "not recently played" and "recently played" (last 3 track IDs) |
64 | | -3. Fisher-Yates shuffle only the non-recent tracks |
65 | | -4. Rebuild: shuffled non-recent first, recent appended at the end (unshuffled) |
66 | | -5. When a track plays, its ID is added to the recent list (FIFO, capped at 3) |
| 43 | +The shuffle uses recency avoidance: |
67 | 44 |
|
68 | | -This is simple and works well for small libraries. You won't hear a recently played track again until at least 3 others have played. With larger libraries the recency window is barely noticeable — but it still prevents the jarring back-to-back repeat. |
| 45 | +1. Partition tracks into "not recently played" and "recently played" (last 3) |
| 46 | +2. Fisher-Yates shuffle only the non-recent tracks |
| 47 | +3. Append recent tracks at the end, unshuffled |
| 48 | +4. When a track plays, add it to the recent list (FIFO, capped at 3) |
69 | 49 |
|
70 | | -The algorithm is stateful per mood. Switching moods resets the recency window. This is intentional: if you switch to calm and back to focus, you might hear a recently played focus track — but the mood change creates enough perceptual distance that it doesn't feel like a repeat. |
| 50 | +Simple, works well for small libraries. Stateful per mood — switching moods resets the window. |
71 | 51 |
|
72 | 52 | --- |
73 | 53 |
|
74 | 54 | ## The .txt Convention |
75 | 55 |
|
76 | | -Drift FM needs to know if a track has vocals. Focus mood enforces instrumental-only — a vocal track in a focus playlist breaks concentration. |
| 56 | +Focus mood enforces instrumental-only. Rather than import flags, we use a file convention: if a `.txt` file exists next to an `.mp3` with the same name, the track is vocal. Content becomes displayable lyrics. Empty file still marks it as vocal. |
77 | 57 |
|
78 | | -Rather than adding flags to the import command, Drift FM uses a file convention: if a `.txt` file with the same base name exists next to an `.mp3`, the track is marked as vocal. If the `.txt` has content, it's imported as displayable lyrics. If it's empty, the track is still vocal — just without lyrics. |
79 | | - |
80 | | -See [Quickstart — Vocals and lyrics](quickstart#vocals-and-lyrics) for the full convention and examples. |
81 | | - |
82 | | -This convention is zero-config. You don't need to remember import flags. Drop your files in a directory, add `.txt` files for vocal tracks, and batch import. The script figures it out. |
83 | | - |
84 | | ---- |
85 | | - |
86 | | -## Single Binary, Deploy Anywhere |
87 | | - |
88 | | -`make build` produces one binary. Copy it to a server along with the `web/` directory, your `config.yaml`, and your audio files. Run it. |
89 | | - |
90 | | -```bash |
91 | | -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/server ./cmd/server |
92 | | -``` |
93 | | - |
94 | | -No container orchestration required. No process manager required (though systemd is sensible for production). No reverse proxy required (though Caddy or nginx in front for TLS is recommended). |
95 | | - |
96 | | -The goal is the smallest operational footprint that still feels complete. One binary, one database file, one directory of audio files. Back it up by copying three things. |
| 58 | +Zero-config. Drop files, batch import, the script figures it out. See [Quickstart — Vocals and lyrics](quickstart#vocals-and-lyrics) for examples. |
97 | 59 |
|
98 | 60 | --- |
99 | 61 |
|
100 | | -## What This Isn't |
101 | | - |
102 | | -Drift FM is not a music discovery service. It doesn't fetch album art, it doesn't look up metadata from external APIs, it doesn't suggest tracks. It plays what you give it, in the mood you choose, with shuffle that respects your recent listening. |
| 62 | +## Deploy |
103 | 63 |
|
104 | | -It's a player, not a platform. The value is in the simplicity: mood selection, continuous playback, and getting out of the way. |
| 64 | +One binary, one database file, one directory of audio files. Copy three things, run it. See the [README](https://github.com/1mb-dev/driftfm#deploy) for the full deploy guide. |
0 commit comments