A personal media library stack: a headless Go server that catalogs and streams movies, TV, audiobooks, and ebooks, plus Swift clients for macOS and iOS.
The Go server is the authoritative implementation of the wire contract in
API.md. The macOS app is a library-management and playback GUI; it
still contains a legacy embedded HTTP server that the Go server supersedes.
| Path | Role | Status |
|---|---|---|
server/ |
Go media server (catalog, scan, probe, transcode, web UI, HTTP API) | Authoritative |
Packages/SonderAPI/ |
Shared Swift wire DTOs and route constants | Shared by both clients |
IOS_Client_Xcode/ |
iOS client | Active, but see note below |
xcode-TM-Sonder/ |
macOS app (library manager + legacy embedded server) | Active GUI; embedded server is legacy |
API.md |
HTTP contract for clients | Keep updated with route changes |
deploy/ |
launchd install + Apple container image |
Ops |
docs/ |
Audits and design notes | Reference |
The iOS client sources are vendored in
IOS_Client_Xcode/TM_Sonder_Client(an earlier revision committed only a bare gitlink; that is resolved). CI runs its offline-persistence package tests and an unsigned iOS build. Seedocs/review-2026-09.mdfor the architecture review.
Requires Go 1.25+ (see server/go.mod); the web client test needs Node.
make mac # → bin/sonder-darwin-arm64
make linux # → bin/sonder-linux-arm64 (containers)
make linux-amd64 # → bin/sonder-linux-amd64 (Ubuntu/x86_64 hosts)
./bin/sonder-darwin-arm64 -config ~/.config/sonder/server.jsonOn first run the server writes a commented config template. Edit libraries
to point at your media roots, then restart. Key config fields:
port/webPort, apiPort, dataDir, libraries[], allowLAN, safeScan,
probeWorkers, thumbWorkers, transcode, mediaCache, and optional Caddy paths. Every
field also has a SONDER_* environment override (see the generated template).
transcode.hwaccel accepts videotoolbox (macOS only), vaapi, qsv, or
none; Linux hosts should use none unless a VAAPI/QSV device is available.
Requirements: the FFmpeg package, which provides both ffmpeg and ffprobe,
on PATH (or configured paths) for track probing, thumbnails, chapters, and
transcoding. Prepare a native host with:
make prepare-installThe preflight detects Homebrew, apt, dnf, apk, or pacman, installs the FFmpeg package when needed, and verifies both executables. The server can start without them, but those media features will be unavailable.
The web listener defaults to Jellyfin's native HTTP port 8096; the private
readiness/API listener uses 8097 on 127.0.0.1. Local-only until LAN is enabled; enabling LAN
auto-generates a pairing token (persisted in dataDir/pairing-token). For a hosted
instance, Caddy can terminate HTTPS on the same external :8096 port while
Sonder stays on a private local web port; the API and catalog storage remain
private. The active interface selection is stored separately in
<dataDir>/runtime-state.json, so changing adapters does not rescan or alter
the catalog.
The local media cache is enabled by default at <dataDir>/media-cache with a
100 GiB cap and a 20 GiB free-space reserve. It fills on demand, keeps ebooks
and audiobooks ahead of other media, and retains newer movies ahead of older
movies when the cap is reached.
The first request still streams from the NAS while a complete local copy is
made in the background; a completed cache entry can continue serving if the
NAS mount briefly disappears.
The protected web Settings panel exposes the cache enable switch, capacity,
and free-space reserve in GiB; saving a smaller capacity evicts inactive
lower-priority entries immediately without rescanning the library.
The LAN-facing web listener is a pairing-protected frontend over the private
API listener, so existing browser and Jellyfin-compatible routes keep working
without binding the API process or catalog storage to the network. The first
browser account is created once from /account/setup?token=<pairing-token>;
after that, signed-in users can share reusable invite links from Settings, and
the server records which account referred each signup. Browsers use an
HTTP-only session cookie and Jellyfin/Audiobookshelf clients can authenticate
with any account and receive a session token.
For a dedicated BookPlayer/Audiobookshelf login, the service may also set the
environment-only SONDER_COMPAT_USERNAME and SONDER_COMPAT_PASSWORD; these
are accepted only by the compatibility login endpoints and never written to
server.json.
The systemd install includes an optional protected environment file at
~/.config/sonder/bookplayer.env for this purpose.
The server embeds its browser pages; nothing is served from disk.
| Path | Page |
|---|---|
/ |
Library browser (movies, TV, documentaries, audiobooks, books), settings, built-in player |
/audiobooks |
Audiobook player (rails layout; /audiobooks-classic is the list layout) |
/ebooks |
Ebook browser |
/read/{id} |
In-browser EPUB reader |
/youtube |
YouTube subscription manager (owner account only) |
/account/login, /account/setup, /account/signup |
Browser account pages |
Screenshots: to be added (library rails view, audiobook player, phone layout).
make test # web client tests (node --test) + go test -race ./...
make vet
make fmt
make prepare-install # install/check ffmpeg + ffprobe
make install-launchd # build + install + bootstrap + health check
make container-build && make container-run
# Ubuntu host migration: keep public Jellyfin/BookPlayer HTTPS on :8096
sudo ./deploy/enable-ubuntu-https8096.sh
sonder app interfaces --json
sonder app status --json
sonder app proxy status
sonder app restart wifi # also: ethernet, vpn, loopback, public, en0, utun4
sonder app proxy enable --domain books.example.com --health https://books.example.com/api/health- Portable data: the catalog is persisted to
<dataDir>/library.jsonas a versioned, atomically replaced snapshot. It includes catalog metadata, reading lists, list tags/order, and user activity.GET /api/data/exportdownloads a media-independent bundle;POST /api/data/importrestores or merges it without scanning or modifying media. UsepathMappingswhen moving between native paths and container mounts. Playback progress is written to a small<dataDir>/progress.jsonsidecar on each heartbeat and overlaid on load, so progress updates do not rewrite the full catalog.safeScan(default on) refuses to prune a library that suddenly yields zero files while the catalog still holds items, so an unmounted NAS share cannot wipe the catalog. Set"safeScan": falseto let a deliberately emptied library prune normally. - Bonjour: advertised as
_tmsonder._tcpwhen LAN is enabled. - Network binding:
sonder app interfaces --jsonlists only active IPv4 interfaces. Usesonder app restart loopback,wifi,ethernet,vpn,public, or an exact ID such asen0/utun4. Each swap validates first, keeps the API on loopback, updates Caddy when enabled, and rolls back on a failed health check. Live port exposure can be changed withsonder app exposure --web-port 8897 --api-port 8898or the web Settings panel;POST /api/network/exposureapplies mode, interface, and ports as one operation and reports listener health while they reconnect. Thesonder app status --jsonandsonder app proxy statuscommands expose the same state used by the menu-bar companion. - Menu-bar updates:
make install-status-appinstalls the per-user status companion. Its menu can check thetm-sonderHomebrew formula and install an update with visible progress through download, restart, and version verification. It only restarts a Homebrew-managed server; manually launched instances are left safe and report when a manual restart is needed. - Public exposure: enabling Caddy only manages Sonder's reverse-proxy
configuration. DNS records, router/firewall TCP 80/443 forwarding, and
WireGuard routes remain explicit external prerequisites. The catalog is a
local JSON snapshot in
dataDir; no database listener is opened or exposed. /debug/pprof/*: loopback-only.?transcode=1: on-the-fly fragmented-MP4 transcode for containers AVPlayer cannot play directly. SeeAPI.mdformode,ss, andsub.- Web player: the library page has a built-in persistent player. Audio
(including
.m4baudiobooks) and browser-safe video stream directly; other video containers are transcoded on demand. Playback continues in a docked bottom controller while you browse, with speed control, ±30s skips, Media Session (lock-screen/OS) controls, and progress sync. - Containers are optional: the OCI image in
deploy/Containerfilecan run under Apple Container/Orchard, Docker, or another compatible runtime. Mount media read-only and persist/data; Orchard is not required by the server or API. The app container is namedtm-sonderso it is easy to distinguish from Apple's internalbuildkitbuilder helper in container-management UIs.
VERSION is the source of truth for the server, container labels, and macOS
app marketing version. Run make release-check, commit the result, and tag it
as vX.Y.Z; .github/workflows/release.yml verifies the tag, runs the release
checks, builds arm64 macOS/Linux archives, and publishes SHA256 checksums.
The native Homebrew formula lives in Formula/tm-sonder.rb
and can be tested as a local tap using the instructions in
packaging/homebrew/README.md. It installs
the same sonder binary and configuration model as the native and container
paths; Homebrew is optional and Orchard is not required.
For scripted backups or migrations, the server binary also supports catalog maintenance without starting the web service:
sonder -config ~/.config/sonder/server.json -export-data sonder-backup.json
sonder -config ~/.config/sonder/server.json -import-data sonder-backup.json
# When the media root changed:
sonder -config ~/.config/sonder/server.json \
-import-data sonder-backup.json \
-import-path-map '/Users/sean/NAS/Audiobooks=/media/Audiobooks'Imports replace catalog data by default, accept -import-mode merge, and do
not scan media. Run the normal rescan command only when you want to reconcile
the restored catalog with the current filesystem.
# macOS app
open xcode-TM-Sonder.xcodeproj
# iOS app
open IOS_Client_Xcode/TM_Sonder_Client/TM_Sonder_Client.xcodeprojBoth clients point at the server URL + pairing token shown in the server dashboard or config.
# Go server (authoritative)
cd server && go vet ./... && go test -race -count=1 ./...
# Web client unit tests (library.js helpers)
node --test server/internal/httpapi/web/library.test.cjs
# Deploy script tests
cd deploy && go test ./...
# Shared Swift package
cd Packages/SonderAPI && swift test
# macOS host unit tests
xcodebuild test -project xcode-TM-Sonder.xcodeproj -scheme xcode-TM-Sonder \
-destination 'platform=macOS' -only-testing:xcode-TM-SonderTestsCI (.github/workflows/ci.yml) runs four jobs on every push and pull request:
the Go server (gofmt, vet, race tests, darwin/arm64 + linux/arm64 +
linux/amd64 cross-compile, web client tests, deploy script tests), the
SonderAPI package tests, a macOS Release build of the host app, and the iOS
client's persistence tests plus an unsigned iOS build.
API.md— HTTP contract, auth, streaming, playback, artwork.THEME.md— palette tokens and how to add a new one.docs/curated-shelves.md— the curated best-of lists, year-aware matching, and the discovery rails built from them.docs/mobile-layout.md— the phone layout: docked tab bar, bottom sheet, and the measurements that drove it.docs/review-2026-09.md— architecture review and prioritized improvement roadmap.docs/performance-audit.md— scan/stream hot paths.
This repo includes an OpenCode MCP entry for Pi. Start OpenCode from the repo
root, then ask it to use the pi_delegate tool for an independent coding or
review pass. The bridge starts a fresh, non-persistent Pi RPC session in the
current project for each call.
Set PI_BIN if pi is not on OpenCode's PATH:
PI_BIN="$HOME/.local/bin/pi" opencodeThe owner can open YouTube from the library toolbar (or /youtube) to add
channel URLs, choose hourly/daily checks and choose an initial backfill of
0–100 uploads. A separate polling worker keeps checks running during long
conversions. Checks inspect the latest 200 entries in the channel's Videos tab;
Shorts, active live streams and uploads absent from that tab are not included.
The queue persists in dataDir/youtube-subscriptions.json. Existing YouTube IDs
from the NAS downloaded.txt archive are imported for deduplication; the old
archive and media are preserved. Global pause cancels active subprocess groups
and keeps partial files for resume. Per-channel pause, unsubscribe, retry and
cancel are also available. Unsubscribing preserves completed and partial files.
Storage defaults to a 500 GB decimal free-space reserve. Work starts only
above that reserve plus a 24 GB workspace allowance. Downloads are serial,
limited to 20 MB/s and an 8 GiB format-size hint; a running guard stops work if
the NAS approaches its reserve, disappears, or the job workspace exceeds 24 GB.
Other writers can consume NAS space between checks, so this is a protective
policy rather than a filesystem quota. No automatic media deletion is performed.
Completed files are atomically published into Sonder Channels/<subscription>/<video>;
hidden .sonder-staging files are excluded from catalog scans. Failed jobs retain
partial files and require a retry. Download completions schedule a coalesced library
scan and snapshot update.
Formats:
- Efficient original: prefer YouTube's AV1/VP9 formats at the chosen resolution.
- Compatible original: request H.264 with AAC for broader direct playback.
- Compact: encode a new download with software AV1, two threads and AAC. Replace its source only if the result is smaller and passes audio/video/duration checks. Existing NAS videos are not bulk converted. Unsupported codecs use Sonder's normal playback transcoding path.
Run sudo ./deploy/install-youtube.sh /mounted/NAS/ytdl on the Incus host. It
verifies pinned official yt-dlp/Deno binaries, exposes only that NAS directory
at /media/ytdl, configures SONDER_YOUTUBE_DIR, adds a YouTube library, and
allows the service to write that directory. Deploy the feature branch using
the existing Deploy workflow's manual branch selector. The installer does not
restart or replace the application by itself. For other deployments, install
yt-dlp, Deno, FFmpeg and FFprobe, set SONDER_YOUTUBE_DIR, allow service writes,
and configure a library for that path. Downloads require normal source access;
no browser cookies or account credentials are imported.
MIT; see LICENSE.