5.3 KiB
Architecture
VibeSonic is a single Go binary. There are no separate network services: the web UI, (in later phases) the Subsonic API, the scanner, and streaming all run in one process against one SQLite database.
Browser -----------+
|
Subsonic clients ---+--> VibeSonic (single process)
(Phase 2+) | |
| +-- HTTP/Web (internal/web)
| +-- Authentication (internal/auth)
| +-- Library (internal/library)
| +-- Scanner (internal/scanner)
| +-- Streaming (internal/streaming)
| +-- Metadata (internal/metadata)
| +-- Cover art (internal/coverart)
| +-- SQLite (internal/database)
|
+--> Local music folders
ffmpeg/ffprobe (external processes, invoked with
explicit argument lists — never a shell)
Package layout
cmd/vibesonic/ entry point: loads config, opens the app, runs it
internal/
app/ wires every subsystem together; owns the HTTP
server lifecycle and background loops
auth/ password hashing, session cookies, CSRF,
external (trusted-proxy) authentication
config/ VIBESONIC_* environment variable loading
coverart/ cover art discovery + resized-image disk cache
database/ SQLite connection, migrations, and one query
file per area of the schema (users, sessions,
library, activity, ...)
idgen/ stable opaque ID generation
library/ turns parsed tags into the artist/album/song
relational model; filesystem-style directory
listing
metadata/ ffprobe-based tag/technical-property extraction
scanner/ walks music folders, detects new/changed/
deleted/renamed files, indexes them
streaming/ range-request-aware file serving, path safety
web/ server-rendered Bootstrap 5 UI (handlers,
templates via the top-level web/ package)
migrations/ embedded SQL schema migrations
web/ embedded templates + static assets (Bootstrap,
icons, player JS/CSS)
internal/database is the only package that talks to SQLite directly.
Every other package calls into it rather than holding its own
*sql.DB query logic, so the schema and its access patterns stay in one
place.
Data flow: scanning
internal/scannerwalks each enabled music folder's filesystem tree with a bounded worker pool (4 concurrent files).- For each recognized audio file, it compares size+mtime against the
media_filesrow for that (folder, relative path) natural key:- Unchanged → just marked seen (
last_scanned_attouched), no reprobe. - Missing by path but a
media_filesrow with the same content fingerprint exists elsewhere in the folder → treated as a rename/move; the existing row's path is updated in place. - New or changed → probed via
internal/metadata(ffprobe), theninternal/library.IndexFilecreates/updates the artist, album, song, genre, and credit rows, reusing existing rows by natural key (name, title+artist, etc.) so IDs stay stable.
- Unchanged → just marked seen (
- After the walk, any file not touched during this scan pass is marked
missing(not deleted) — playlists, favorites, and play history referencing it survive if the file reappears later.
Data flow: streaming
- The web handler resolves a song ID to its
media_filesrow and owningmusic_foldersrow. internal/streaming.ResolvePathre-joins the folder root with the stored relative path and verifies (after resolving symlinks) that the result is still inside the folder root, rejecting any traversal.internal/streaming.ServeFileopens the file and hands it tohttp.ServeContent, which streams directly from disk (no full-file buffering) and handles HTTP range requests and conditional GETs.
Authorization (is this caller allowed to stream anything at all) happens
in the router, before any of the above runs: the route is wrapped in
auth.Middleware.RequireUser.
Stable IDs
See internal/idgen. IDs are random,
prefixed by type (sg_ songs, al_ albums, ar_ artists, ...), and
assigned exactly once per row. Rescans never regenerate them: the
scanner and library indexer always look up existing rows by natural key
first (file path within a folder; artist name + MusicBrainz ID; album
title + primary artist; ...) and only call idgen.New for rows that have
never been seen before.
Why no ORM / interfaces-for-purity
database/sql with explicit SQL and hand-scanned structs, one query file
per schema area. No interfaces are introduced purely for testability or
abstraction — tests use a real temporary SQLite database (see
internal/database/database_test.go and friends), because SQLite is
fast enough that this adds no meaningful cost and keeps the tests honest
about actual query behavior (foreign keys, constraints, collation).