A self-hosted music library in one Go binary. It serves your own tracks (audio and video) from a directory, signed with Ed25519. Share links preview properly in Signal, Messenger and Discord. The server has no dependencies, no database and no accounts.
Full documentation: docs/ (publishing, server setup, configuration, logs, troubleshooting). See PLAN.md for the design.
On the workstation:
spor keygen # ~/.config/spor/spor.key + spor.pub
mkdir -p library/neon-fjord
cp ~/Downloads/neon-fjord.mp3 library/neon-fjord/track.mp3
cp ~/Downloads/cover.jpg library/neon-fjord/cover.jpg
cat > library/neon-fjord/meta.toml <<'EOF'
title = "Neon Fjord"
date = "2026-09-30" # published
created = "2024-05" # originally made: YYYY, YYYY-MM or YYYY-MM-DD (optional)
style = "synthwave"
description = "First one out."
visibility = "public" # public | unlisted | draft
EOF
spor sign library/neon-fjord # writes peaks.bin (ffmpeg) and track.sig
spor verify -pub ~/.config/spor/spor.pub library/Publish in one step (sign, rsync, SIGHUP, print the share link):
export SPOR_REMOTE=root@vps:/srv/spor/library
spor publish library/neon-fjordOr let scripts/add-track.sh do all of it from a single file. It reads title, genre and the original creation date from the tags (the publish date is today), takes the cover from an image next to the file or the embedded artwork, transcodes WAV/AIFF to opus + mp3, writes meta.toml and signs:
scripts/add-track.sh ~/Downloads/neon-fjord.mp3 # -> library/<slug from title>, signed
scripts/add-track.sh -s synthwave -v unlisted -p ~/Exports/demo.wav # -p: publish to $SPOR_REMOTE
scripts/add-track.sh -h # all options (title, slug, cover, lyrics, ...)neon-fjord/ # directory name = slug = /t/neon-fjord
track.mp3 # audio: mp3, m4a, opus, ogg, webm, flac (several = <source> fallbacks)
track.mp4 # video (optional, alone or with audio)
cover.jpg # jpg/png; for video, sign grabs a frame if missing
lyrics.txt # optional
meta.toml # optional (meta.json also works)
peaks.bin # waveform, generated by `spor sign` when ffmpeg is present
track.sig # signed manifest; the directory is ignored without a valid one
Every file in track.sig is hashed and signed. If a listed file is missing or changed, the whole track is rejected. Files that are not listed are ignored and logged. For unlisted tracks, spor sign adds a random 128-bit token to meta.toml and prints the ?k= link.
spor serve -config /etc/spor/spor.toml
# or: spor serve -library lib -cache cache -pub spor.pub -url https://lytt.txtv.no -listen 127.0.0.1:8740The server only needs the public key. It verifies once at scan time and caches hashes in cache/state.json, keyed by (dev, inode, size, mtime). It rescans on SIGHUP and every rescan_interval. Media is content-addressed (/m/<sha256>.<ext>, immutable), served with sendfile and Range support. Bytes that change after verification are refused until the next scan.
| Route | |
|---|---|
/ |
library UI |
/t/<slug>[?k=token] |
share page with OpenGraph tags, works without JS |
/api/library.json |
public tracks, pre-gzipped, ETag |
/api/sig/<slug>, /api/keys |
manifest and public keys for in-browser verification |
/m/…, /c/…, /p/… |
media, cover thumbnails (320/1200), waveform peaks |
POST /api/play/<slug> |
append-only play log (cache/plays.log, no IPs, no cookies) |
POST /api/like/<slug> |
thumbs up; header X-Spor-Session: <32 hex>; 409 if that session already liked it (cache/likes.log) |
GET /api/stats, /api/stats/<slug> |
play and like counts, liked for the requesting session |
The site is Norwegian (bokmål) for everyone, crawlers included; ?lang=en gives the English strings. Likes are one per browser session per track: the browser keeps a random session id in localStorage and the server stores a hash of it. That stops double clicks, not someone determined to inflate a count. Set likes = false to turn them off.
The footer shows web/static/brand.png and two buttons: suno_url (the artist profile, default https://suno.com/@skumx) and referral_url (a Suno referral link; hidden until set). Both must be https; an empty value hides the button. spor serve -suno URL -referral URL sets them without a config file. The full-size original artwork is kept in assets/ so it is not embedded in the binary.
git clone https://github.com/pynezz/spor && cd spor
sudo GO="$(command -v go)" deploy/install.sh # builds with Go >= 1.25, installs binary, unit and config
sudo cp spor.pub /etc/spor/spor.pub
sudo systemctl enable --now sporOr download a binary from Releases and run install.sh's steps by hand. Add deploy/spor.snippet to Caddy. The systemd unit runs as a DynamicUser with the library read-only, state in /var/lib/spor, the public key via LoadCredential= and a 128 MB memory cap.
- gzip, not brotli, for precompressed text, since Go's stdlib has no brotli encoder. Caddy can still apply zstd/br on top for HTML.
- systemd unit instead of Quadlet, since the VPS builds and runs the binary natively.
peaks.binand the video cover are made at sign time on the workstation and signed with everything else, so the server does not need ffmpeg. If ffmpeg is on the server, it fills in missing peaks and covers itself.- Config and
meta.tomluse a tiny built-in flat-TOML parser, so there are still zero dependencies. - Fonts are bundled (Inter, Inter Tight; SIL OFL, licenses in
web/static/fonts/) because the CSP allows no third-party origins.