Architecture
The goal: host your music library on a server, sync iPods, and eventually have either a standalone app or one detached from the server β both are on the table. Everything below follows from that.
Status: decided for Β§1β5, still waiting on a call about build order.
Map
Visual version: the map.
Three directions, routinely conflated elsewhere, kept apart here:
- Renderer β plays right now. A browser, a Sonos, a tablet.
- Device β holds a copy. An iPod. Files have to be transferred to it.
- Emitter β lets others read our library. An OpenSubsonic server.
INPUTS CORE OUTPUTS
βββββββββββββββββββββββββ ββββββββββββββββββββββ βββββββββββββββββββββββββ
Storage Index (SQLite) Renderers
local, rclone (SMB, S3, Jobs β single queue browser
Drive, Dropbox, Mega, Transcoder (ffmpeg) tablet satellite
WebDAV, FTP, SFTPβ¦) Fingerprints (Chromaprint) Pi + DAC satellite
Plex, Emby, Jellyfin Streaming endpoint UPnP / DLNA
Plugin host Sonos
Streams Scheduler AirPlay
radios (ICY, HLS) Settings + backup Chromecast (later)
podcasts (RSS) local sound card (if any)
YouTube, Twitch lives
Devices
Acquisition iPod (via satellite)
third-party plugins
Emitters
Metadata OpenSubsonic
MusicBrainz, Last.fm DLNA
ListenBrainz, AudioMuse outgoing radio stream
Side channels
scrobble (Last.fm, LB)
Home Assistant, MQTT
The positioning, in one sentence: Volumio is a player, bound to its sound card. We are a library surrounded by players. A Raspberry Pi with a DAC becomes one of our renderers instead of being the whole system.
1. Topology β decided
One API, one static frontend.
apps/server Hono β the public API. Library, files, plugins,
scheduler, DB.
apps/web React + TanStack, static build. One client among many.
packages/* shared contracts β `api-types`, `client-sdk`; `plugin-kit` to come
No privileged route for the in-house frontend. If the official frontend can do it, a third-party frontend or a device can too. That is what makes the API open β not splitting processes apart. A separate rendering server would only be justified for SSR, which is moot behind a login.
The API and the static file serving stay two Hono modules mounted together: splitting them later means changing a mount point.
2. Runtime β decided
Node, with Hono and SQLite.
| Node | No lock-in. A self-hosted server has to run on a NAS, a Pi, a Node Docker image. Bun stays possible β Hono is multi-runtime β but nothing depends on it. |
| Hono | Essential middleware built in (CORS, JWT, compress, etag), WebSocket. @hono/zod-openapi should eventually generate the spec from the validation schemas β not wired up yet, the contracts are hand-written. |
| SQLite | node:sqlite, zero dependencies. The default across self-hosted music (Navidrome, Jellyfin). |
The performance gap between HTTP frameworks is irrelevant here: the bottleneck is disk and network.
3. Plugins β decided
What comparable ecosystems do
The two most mature plugin systems in this space do not sandbox.
Home Assistant β ~3000 Python integrations. Each has a manifest.json
(domain, version, pip requirements, codeowners, integration_type). HACS, the
community store, pulls GitHub release tags and validates manifest
completeness. Custom integrations run with full Python privileges. This is not
an oversight: a public security disclosure has documented vulnerabilities in
custom integrations. The cost is known and accepted.
Volumio β Node plugins. A folder with package.json (a volumio_info
block: prettyName, icon, plugin_type), index.js, UIConfig.json,
config.json. Categories: music_service, audio_interface, system_hardware,
user_interface. No sandbox at all.
What holds the diversity together in both cases is not isolation, it is the
domain model: HA projects everything into shared entities (light, switch,
media_player), Volumio into a plugin_type. And settings are declarative β
UIConfig.json, config flows β so the host renders the form and the plugin
ships no UI.
Our model: contract first
Node plugins, installable from a git repository with tagged releases. Manifest, lifecycle, declarative settings. No sandbox to begin with.
manifest.json
id, version, hostApi: "^1.0", family, permissions, sidecars[], contributes{}
index.js
onInstall / onStart / onStop / onUninstall
ui.json
settings described as data β the host renders the form
Nearly every plugin is a protocol client. The host provides the transports; the plugin never opens them itself:
host.http(url, init) // HTTP / HTTPS
host.ws(url) // WebSocket
host.mqtt(broker, opts) // MQTT β Home Assistant's native transport
host.tcp(host, port) // raw socket
host.udp(port) // datagrams, SSDP discovery
Providing them from the host rather than letting the plugin open them has two immediate effects: accesses are logged and filterable by the manifest starting today, and the day we sandbox a family, the contract does not change by a single line.
Three reasons, the first would be enough:
- QuickJS rules out npm. Our source plugins need npm and raw sockets. A sandbox that excludes half the plugin population does not protect, it amputates.
- It is proven at 3000 integrations in our exact domain.
- It is far less work.
The sandbox, later, without breaking anything
Extism β polyglot WASM plugins, Node host
SDK, and a JavaScript PDK that embeds QuickJS β stays available. A plugin that
only speaks through host.* runs in both worlds: the contract decides, not
the engine. The day the store opens up to unknown authors, we sandbox the
families that only need fetch (analysis, scrobble, player) and sources
stay in reviewed Node. That is also Figmaβs model, sandboxing in QuickJS-WASM
because its marketplace is public and its code entirely unknown.
Python?
Home Assistant proves a Python plugin system works, and Python has the best libraries in the domain. But the server is Node and the frontend TypeScript: a Python plugin system would mean either switching the server or building a permanent bridge. Python stays where it is already excellent β in a sidecar.
Satellites β devices on other machines
A sidecar runs next to the server. A satellite runs somewhere else on the network, usually because it has to be physically near something: an iPod dock, a sound card, an isolated network.
The concrete case: an iPod sync satellite on a Raspberry Pi 1. The Pi does not run the server β it is armv6, outside Nodeβs reach. It runs the satellite, written in whatever runs on armv6 (Python fits: iOpenPod already is, and 32-bit Raspberry Pi OS still supports armv6). The server is only its HTTP client.
This is exactly what a satellite architecture makes possible: in a monolith, that machine would simply be excluded.
Splitting the work
The satellite is near the device, not near the horsepower. So:
| Where | |
|---|---|
| Transcoding (FLAC β ALAC) | server β an iPod cannot play FLAC, and a 700 MHz core would take hours |
| Chromaprint fingerprints | server β it has the files and the CPU |
| Writing the iTunesDB, copying to the device | satellite β that is its only job |
The satellite pulls, it does not receive
The server sends a list of URLs and a token; the satellite fetches the files from the streaming endpoint that already exists for the web player.
Three consequences, all good: the satellite sets its own pace, it resumes after an outage without the server having to track anything, and the server holds no long-lived connection.
On a Pi 1 Model B, USB is shared with the 100 Mbit Ethernet β roughly 5 to 8 MB/s in practice, so a few hours to fill a 74 GB iPod classic. Irrelevant for a sync scheduled overnight, which is the intended use.
The contract, shared by every satellite
GET /satellite identity, families served, API version
GET /devices connected devices
GET /devices/:id capacity, serial, firmware, accepted formats
GET /devices/:id/tracks what is actually on the device (paginated)
POST /devices/:id/jobs creation β idempotent on `id`
GET /devices/:id/jobs/:job state + aggregates
GET /devices/:id/jobs/:job/items detail, paginated
GET /devices/:id/jobs/:job/events progress (SSE)
PATCH /devices/:id/jobs/:job { action: "pause" | "resume" }
DELETE /devices/:id/jobs/:job cancellation, tokens revoked
Discovery over mDNS on the local network, or a URL entered in the settings. The
same contract serves Sonos and any future device β the device family of the
plugin system, server side, is its client.
The device declares, the server converts
GET /devices/:id
{ "acceptedFormats": ["mp3", "aac", "alac", "aiff"], "capacity": β¦, "free": β¦ }
The server compares track by track and only transcodes what would not pass. The satellite has no idea conversion even exists. That is what lets it run on a Pi 1.
The queue and the state
The queue belongs to the satellite. The server fills it and watches it; it does not drive it.
job queued β transferring β committing β done | failed | cancelled
item pending β fetching β writing β done | failed | skipped
- Idempotent on the jobβs
id, supplied by the server. Re-posting the sameidreturns the existing job. Without that, a server-side timeout while the satellite did receive the request triggers a double sync. - Persisted queue β SQLite or a log on the satellite. A power cut at 3am does
not lose the queue: on restart,
fetchingandwritinggo back topending. - Resumable transfers β HTTP
Range, partial file kept, resume at the offset. Over a three-hour transfer, that is not a luxury. - Tokens that live as long as the job, plus a margin, and revoked on cancellation. One-hour tokens kill a three-hour transfer at 33%.
committingis atomic and separate. We do not rewrite the iTunesDB per track: copy everything, then write the database once β temp file,fsync, atomic rename. This is the dangerous phase, the one where an outage corrupts the device.- Concurrency decided by the satellite β one or two simultaneous fetches on a Pi 1. The server does not impose a pace on it.
GET β¦/jobs/:jobreturns aggregates, not the item list: a 10,000-track sync must not return 10,000 entries on every poll. The detail lives on a separate paginated route. SSE for live,GETto recover after the stream drops.
Heavy engines stay sidecars
ffmpeg, Chromaprint, soco-cli, iOpenPod, AudioMuse run as services. The plugin is their HTTP client.
The store
| Registry | a JSON index β a git repo with tagged releases is enough, like HACS. Zero infrastructure |
| Compatibility | the plugin declares hostApi: "^1.0", the host refuses anything incompatible |
| Integrity | SHA-256 of the artifact in the index |
| Consent | required permissions and sidecars shown before install |
| Installation | ~/.config/<app>/plugins/<id>/<version>/, atomic switch |
| Settings | rendered by the host from ui.json β no third-party code in the frontend |
Sources β nothing gets copied, and we write no protocol client
Infuse implements SMB, NFS, FTP, SFTP and WebDAV in userspace, in its own code. Not by choice: on tvOS it can neither mount a filesystem nor launch a binary. It has no other option β hence its βSMB Legacyβ setting and its SMB behaving differently on iOS and tvOS.
On all of our targets β server, Electron, Tauri β we can ship and launch a binary. That is the only difference that matters, and it changes everything: we do not write the protocol stack, we embed rcloneβs.
Local disk first. The most common and simplest case: the library is on the
server, on its internal disk, on a USB drive, or on a volume the system already
mounted. That is node:fs and nothing else, and it is the fastest path β no
sidecar, no network latency. Everything below only exists to reduce a remote
source to that case.
For remote: rclone as a userspace sidecar.
rclone rcd exposes an HTTP API on localhost. No
FUSE, no admin rights, no kernel mount, and the same code path on the server
and in the native app. Natively covers SMB (the smb backend since 1.60, via
go-smb2), S3, Google Drive, Dropbox, Mega, OneDrive, Box, pCloud, Backblaze,
WebDAV, FTP, SFTP β around seventy backends.
Option: OS mount. For NFS β rcloneβs only gap β and for anyone who already
has their mounts set up. mount.nfs, mount.cifs. A mounted source becomes a
local path, so it is a special case of the foundation, not a second system.
Native Node. Plex, Emby, Jellyfin: these are not filesystems but indexed libraries. HTTP client + metadata import.
Nothing is copied
--vfs-cache-mode full maintains a sparse file per open file, containing only
the byte ranges actually read. It is a range cache, bounded by
--vfs-cache-max-size β not a copy of the library. That is what makes seeking
smooth without pulling anything down wholesale.
We read and modify at the source. In-place tag writing, renaming and moving on the source. The only copy in the project is the iPod sync, because an iPod has its own storage.
interface Source {
list(path): Entry[]
stat(path): Entry
read(path, range?): ReadableStream
// only if the source is declared writable
write?(path, stream): void
rename?(from, to): void
delete?(path): void
watch?(path): AsyncIterable<Change>
}
What it costs, said plainly
- An unreachable remote must time out, not block. Every source operation goes through a maximum delay; no blocking call on a request path.
- Latency. Listing Google Drive through rclone is slower than through its native API, and change detection degrades to polling. If it shows up in measurements, we add a native backend β but only once measured.
- rclone becomes a first-class dependency. One more binary to ship and keep up to date across all three targets.
The families
| Family | Plugins |
|---|---|
source |
a local path, fed by an OS mount or rclone; plus Plex/Emby/Jellyfin natively |
emitter |
OpenSubsonic (open spec, the whole mobile client ecosystem speaks it), radio, DLNA later |
player |
Sonos (soco-cli sidecar, or node-sonos-http-api without Python), Spotify Connect |
analysis |
ListenBrainz, AudioMuse |
scrobble |
Last.fm, ListenBrainz |
device |
iPod sync β via satellite |
output |
UPnP/DLNA, Sonos, AirPlay, Chromecast, browser, satellite |
live |
YouTube and Twitch stream resolution, ffmpeg relay |
acquisition |
see Β§7 |
4. The job system
Everything long-running in this project has the same shape: scanning a source, batch transcoding, fingerprinting the library, downloading episodes, syncing a device, acquisition, sonic analysis, backup. One implementation, in the core.
Job { id, kind, state, priority, parentId?, progress{done,total,bytes}, error? }
queued β running β done | failed | cancelled
β
paused
- Persisted in SQLite. A restart does not lose the queue;
runningjobs go back toqueuedon startup. - Resumable. Each job kind defines its own resume point β a transfer offset, a scan cursor, a batch index.
- Concurrency per kind. Transcoding takes the available cores, a scan stays alone per source, network is capped at two. On a Pi those caps are the difference between βslowβ and βunusableβ.
- Idempotent on
id. Re-posting the same job returns the existing one. - Progress as aggregates, detail paginated separately. 32-bit discipline.
- Log β the history is what tells you why an overnight sync failed at 4am.
The satellite protocol is this same contract, seen remotely. A local job and
a job on the iPod satellite are the same thing to the interface: POST to
create, GET for state, SSE for live, PATCH to pause. This is not an analogy,
it is the same schema and the same display component.
5. Routing audio
The server may not have a sound card, and it does not matter: it does not have to produce sound to make sound play.
Three distinct roles:
| Role | Who | Does what |
|---|---|---|
| Controller | server, web interface, tablet satellite | decides what plays, holds the play queue |
| Renderer | browser, Sonos, UPnP, AirPlay, tablet, Pi + DAC, local sound card | turns bytes into sound |
| Origin | the serverβs streaming endpoint | serves the bytes |
The server does not push audio, it hands out a URL
Same mechanism as the iPod satellite, and it works because Sonos, UPnP and AirPlay all operate this way: βhere is a URL, go get itβ.
GET /stream/:trackId?token=β¦&profile=sonos
No audio byte passes through the serverβs memory as audio β it is HTTP with
Range. The renderer handles its own buffering.
The device declares, the server converts
Exactly the iPod rule, applied to sound. A Sonos does not necessarily play 24/192 FLAC, an old UPnP renderer does not play Opus. The streaming endpointβs profile decides on-the-fly transcoding, per renderer.
The output contract
interface Output {
discover(): Renderer[] // SSDP, mDNS, Bonjour
play(renderer, url, meta): void
pause / resume / stop / seek / volume
state(renderer): PlaybackState // events, or polling
}
A single contract for UPnP, Sonos, AirPlay and Chromecast. The browser and the satellites implement it too β a local renderer is just a special case.
Live streams
YouTube and Twitch need resolving before playback (yt-dlp, streamlink, as
sidecars). After that, two paths:
- The renderer can play HLS β hand it the resolved URL, nothing more.
- It cannot (most UPnP renderers, and Sonos depending on the case) β the server
relays:
ffmpegremuxes to an acceptable format, served on/live/:sessionId.
That relay is long-running, interruptible and worth watching: it is a job, like everything else.
6. Interface extensions
A plugin can contribute to the interface. Two levels, like VS Code and Figma, and the first covers the vast majority of cases.
Declarative β named zones
The plugin declares its contributions as data in its manifest. The host renders; the plugin ships no interface code.
"contributes": {
"sidebar.section": [{ "id": "lastfm.recent", "title": "Recent scrobbles" }],
"library.tab": [{ "id": "audiomuse.map", "title": "Sound map" }],
"track.contextMenu": [{ "id": "lastfm.love", "label": "Love", "command": "love" }],
"track.column": [{ "id": "audiomuse.bpm", "header": "Real BPM" }],
"album.action": [{ "id": "import.folder", "label": "Import" }],
"nowPlaying.panel": [{ "id": "lastfm.similar", "title": "Similar" }],
"settings.panel": [{ "id": "sonos.rooms", "title": "Sonos rooms" }],
"statusbar.item": [{ "id": "sync.state" }]
}
The content of those zones is model, not DOM: lists, values, actions. The theme applies itself, and a plugin cannot break the interface or style outside its own zone.
Sandboxed iframe β for everything else
When a contribution needs a real interface (an interactive map, a graph), the
plugin ships HTML in a sandboxed iframe that talks to the host over
postMessage. This is exactly Figmaβs model, and the isolation is the browserβs
β free and battle-tested. The theme is passed into the iframe as CSS variables.
7. Reorganizing files
Everything happens at the source, with no copy. This tool is where we can genuinely stand out visually, because it is where everyone else is bad.
The principle: a naming pattern and a preview before acting.
{albumartist}/{year} - {album}/{disc:}{track:02} - {title}.{ext}
- Two-column preview β current path on the left, resulting path on the right, the changing part highlighted. Nothing moves until you confirm.
- The pattern is edited live, the preview recomputes as you type. You see immediately what one more brace does across 400 files.
- Conflicts first. Two tracks landing on the same path, a forbidden character on the target, a non-writable folder: surfaced at the top of the list, not discovered halfway through.
- Row-by-row selection β you can exclude a file from the operation without changing the pattern.
- Dry run by default. The button says what it will do: βMove 213 files, 4 conflicts skippedβ.
- Log and undo. Every operation records its source β destination pairs: a botched reorganization can be replayed backwards.
- Drag and drop within the source tree for one-off moves, with the same confirmation.
A source declared read-only simply does not expose the tool.
8. Settings and administration
Sources (adding, connection test, capabilities), destination folders β each one a source + path pair, one for podcasts, one for audio β plugins, scheduling, backup.
Backup: JSON export of the whole configuration. Source credentials are excluded by default; there is an option to include them encrypted with a passphrase requested at export time. A backup file lying around must not contain your Dropbox credentials.
9. Scheduling
Internal cron scheduler, no dependency, with named workflows: iPod sync at 3am,
podcast feeds every 6h, rescan sources overnight. Every job is resumable
and logged β an interrupted scan does not start over from zero.
10. Podcasts and radios
Podcasts: source = a folder via a source plugin or an RSS feed URL. Parsing, covers, metadata, manual editing of whatever the feed fills in badly, per-feed cron tuning, a βkeep the last N episodesβ option, destination set in the settings.
Radios: full CRUD. Cover by automatic discovery (stream favicon, ICY metadata, Radio-Browser) or upload.
11. Acquisition β held in reserve
The acquisition contract is neutral: a plugin declares a source, the host asks
it for files and files them into your library. The project ships only the
contract and the hook, never a download engine.
The supported sources are your own β local folders, network drives, mounted media, device backups. A third-party plugin talking to a service you host is just one more HTTP client, exactly like the Sonos plugin talking to soco-cli; what it does on its side is not the hostβs business.
12. Frontend
- Movies and TV Shows to be dropped (not done yet).
- iTunes Store β plugin store: catalogue, permissions shown before install, versions, required sidecars announced.
- Purchased β installed plugins: enable, configure, update, remove.
- iPod view: the deviceβs real contents, which the original iTunes never showed. Add by right-click and by dropzone.
- Keyboard shortcuts everywhere, shortcut sheet carried over from
trieur(Kbd,Shortcuts,ShortcutsDialogβ keycaps with a thick bottom edge, Lucide glyphs for special keys, groups of icon + label + keys).
13. Documentation and demo
An Astro site: documentation + a playable demo on a fake backend β the same
deterministic generator as today. One page per plugin family, the generated
OpenAPI reference, and the interface log from ui-evolution.md.
Sources
- Hono vs Express vs Fastify vs Elysia 2026 β PkgPulse
- OpenSubsonic β specification
- soco-cli β Sonos HTTP server
- node-sonos-http-api β the Python-free equivalent
- Sonos local UPnP API β svrooij
- iOpenPod β iPod engine
- AudioMuse-AI
- rclone β Remote Control / API and
rclone serve webdav - Infuse β supported protocols
- Extism β WASM plugin system
- How Figma built its plugin system
- Home Assistant β integration manifest and HACS
- Volumio β plugin structure
- node-taglib-sharp β tag reading and writing in pure JS