A local app for your Music, TV, Movies, Photos, Books, and Audiobooks, with automatic metadata matching. Written in Go: a single static binary that opens a chromeless app window on your machine (loopback-only — a single-machine app, not a network server). SQLite storage, server-rendered HTML, no external services required to run. A headless server mode is also available if you want to reach it from other devices.
Licensed under the GNU AGPL v3; third-party attributions in
THIRD_PARTY_LICENSES.md.
- Music — MusicBrainz matching with Cover Art Archive covers, artist bios and images, synced karaoke-style lyrics (LRCLIB), persistent playlists and one-click instant mixes, shuffle by era or popularity, duplicate detection, and a per-track tag editor that writes back to your files.
- TV & Movies — TMDB matching (posters, backdrops, cast, collections, related titles), direct play when your browser can handle the file and seekable on-demand HLS transcoding when it can't, embedded + on-demand OpenSubtitles subtitles, skip-intro detected by audio fingerprinting, scrub-preview thumbnails, per-episode screen-capture thumbnails, Up Next auto-advance, and watched/resume tracking.
- Photos — a capture-date timeline built from EXIF (with a folders view and year filters); home-video clips play through the same engine as TV.
- Books — EPUB, CBZ comics, and PDF in an in-app reader with covers, embedded metadata, and per-book resume; nothing to configure and no external services (metadata comes from the files themselves).
- Audiobooks — chaptered m4b (and plain audio) with embedded covers, chapter skipping, variable speed, and resume to the second, played through the same transport as TV and movies.
- Library care — a filesystem watcher auto-scans new media, corruption detection with lossless container auto-repair, loudness leveling, and jobs interrupted by a shutdown resume automatically on the next launch.
- Couch-friendly — the whole UI drives with arrow keys or a TV remote, scales itself to the physical display size, and honors hardware media keys.
- Local-first — one binary, SQLite, your files stay yours: no accounts, no telemetry, and external services are used only to fetch metadata.
Hespera ships as a self-contained binary — the web UI assets are embedded, so there is no directory to deploy alongside it. Builds are available for Linux, macOS, and Windows (amd64 + arm64).
./build.sh && ./install.shPackages and installs the .debs — hespera and hescli in /usr/bin with
an app-menu entry + icon (pulling ffmpeg via apt), plus the hesplay
music-player client, which ships as its own package so other boxes can install
it without the full Hespera (see Remote speakers).
./install.sh remote [host] deploys the server .deb to another machine over
ssh; ./install.sh client [host] deploys just the hesplay .deb. Nothing runs in
the background unless you ask for it (a headless server unit ships, disabled —
see Serving your household); launch Hespera from
your app menu (or run hespera) and it opens an app window:
hesperaBuild the binaries (or grab one from dist/ after ./build.sh) and run the
hespera binary directly — it's fully self-contained. ffmpeg must be on your
PATH for TV/movie playback (transcoding and tag recovery); music and metadata
work without it.
- macOS:
brew install ffmpeg - Windows:
winget install ffmpeg(orchoco install ffmpeg)
./hespera-<version>-<os>-<arch>Requires Go 1.23+.
make build # local ./bin/hespera + ./bin/hescli + ./bin/hesplay (quick dev build)
make dist # cross-compile all platforms + .deb packages into dist/
make install # build, package, and install on this Debian/Ubuntu machine
make test # go test ./...
make release # build + publish dist/ as GitHub release v<VERSION> (needs gh + nfpm)build.sh produces one cgo-free static hespera binary and one hesplay
client binary per OS/arch in dist/, plus .deb packages for Linux
amd64/arm64 — hespera_<ver>_<arch>.deb (server + hescli) and the standalone
hesplay_<ver>_<arch>.deb (needs
nfpm: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest).
hesperaThat's it — Hespera opens an app window automatically, bound to a random
loopback port (so it never collides with anything else). The window is a
Chromium-family app-mode window (Chrome, Chromium, Edge, or Brave — on Linux
one of these must be installed; Hespera deliberately won't hand the app
window to a non-Chromium default browser). On first run, the
window walks you through pointing it at your media folder and adding a library;
you can also set the media folder under Libraries or with
HESPERA_MEDIA_ROOT. It stores its database, caches, and downloaded artwork in
a per-user data directory (~/.config/hespera on Linux, the equivalent on
macOS/Windows).
HESPERA_NO_BROWSER=1 runs server mode instead: no window, binds
HESPERA_LISTEN (default 127.0.0.1:8080 — loopback only). To reach it from
other devices, opt in explicitly with HESPERA_LISTEN=:8080.
Focus-follows-mouse desktops. If your window manager focuses whatever the
pointer hovers (Cinnamon's focus-mode='mouse', and the sloppy-focus variants),
a newly opened window on another monitor never gets the keyboard — the WM
hands focus to whatever sits under the pointer. Install xdotool and Hespera
will move the pointer onto its own window at launch so the window manager gives
it focus (it does nothing when the pointer is already over the window, and
nothing at all on click-to-focus desktops). HESPERA_NO_FOCUS_STEAL=1 turns it
off.
To let other devices in the house use Hespera (phones, laptops, a TV
browser), run server mode on the machine that holds the media. The .deb
ships a systemd unit for exactly this — it is inert until you enable it:
echo 'HESPERA_LISTEN=:8080' | sudo tee /etc/default/hespera # serve the LAN, not just loopback
sudo systemctl enable --now hespera@$USER # start now, and at every boot
sudo ufw allow from 192.168.1.0/24 to any port 8080 proto tcp # your LAN subnetThe instance name is the account Hespera runs as (hespera@dan), so it uses
that user's library, database, and artwork — no root, no linger, and no
XDG_RUNTIME_DIR incantation when you come back over SSH. /etc/default/hespera
takes any of the environment variables below (HESPERA_MEDIA_ROOT,
HESPERA_DATA_DIR, …); sudo systemctl restart hespera@$USER applies changes,
and journalctl -u hespera@$USER -f is the log. Upgrading the package replaces
the unit file, so run sudo systemctl daemon-reload before restarting. Not
installing from the .deb? Copy build/hespera@.service to
/etc/systemd/system/ yourself — it's the same file.
Devices then browse http://<hostname>:8080. Phones and laptops get the
right UI scale automatically; a TV browser can pin the 10-foot scale once
with ?scale=tv (it persists per browser). On the server's own screen,
just launch Hespera from the app menu as usual — when a running instance
is detected (the service), the launcher attaches: it opens the same
chromeless app window onto it instead of starting a second copy, and exits.
Stop the service and the icon goes back to launching a standalone app.
Notes for shared use: Hespera has one household-wide state — watched marks, resume positions, and playlists are shared by everyone (there are no user profiles); the security posture below applies (trusted network only); and there is no shutdown control in the UI, so a phone can't stop the family server (quitting is closing the app window, or stopping the service).
hesplay turns any box with speakers — a headless Raspberry Pi in another
room — into a music player for a LAN Hespera. It installs on its own,
without the full Hespera: grab the hesplay_<version>_<arch>.deb (or the
raw hesplay-<version>-<os>-<arch> binary) from the
releases page, or
go build ./cmd/hesplay. From a repo checkout, ./install.sh client [host]
pushes the built .deb to a box over ssh and installs it there. It fetches the
same queue the web player uses (so playlists, ordering, and per-track volume
leveling all apply) and plays it through mpv (recommended:
apt install mpv) or ffplay (part of ffmpeg) — the .deb pulls mpv in
when neither is present.
hesplay server http://plex.local:8080 # save the default server once
# (--server or $HESPERA_SERVER override per call;
# hesplay server shows it, hesplay server clear forgets it)
hesplay playlists # list playlists
hesplay playlist road trip # play one (names need no quoting)
hesplay album abbey road # play an album, in track order
hesplay song bohemian rhapsody # play a single song
hesplay artist queen # an artist's whole catalog, shuffled
hesplay mix queen # a radio mix: that artist + similar artists
hesplay popular # the catalog's most popular songs, shuffled
hesplay all # the whole catalog, shuffled
hesplay --shuffle album abbey road # force a shuffle
hesplay --ordered playlist workout # play a playlist in its curated orderAn album plays in track order and a song is one track; artist, mix, and playlist
queues shuffle by default (--ordered plays them as listed).
Tab completion. The .deb wires up bash automatically; otherwise add
source <(hesplay completion bash) to your shell rc (zsh works too). Verbs
and flags complete offline, and album, artist, song, mix and playlist
complete live names from the server — so hesplay artist Black<Tab> lists
the matching artists and fills one in:
$ hesplay artist Black<Tab>
Black Sabbath Blackfield The Black KeysNames complete once two characters are typed (the server's search minimum);
playlists need none, so hesplay playlist <Tab> lists them all. Completion
takes one short-timeout request, so a server that's off or unreachable simply
offers nothing rather than hanging the shell. Multi-word names complete a word
at a time and need no quoting.
A shuffled catalog sweep — artist, popular, all, and any mix — plays one
recording per song: if a track also exists as a live take, a greatest-hits
copy or a remaster, only one of them joins the queue, picked at random, so a
shuffle can't serve you the same song twice in a handful of tracks. Deliberate
orderings keep everything: an album, a playlist, and --ordered are untouched.
Names resolve against the server's search — the closest match plays and is
printed. Finished tracks are reported back, so Recently Played and listen
counts include what played upstairs. Ctrl+C stops. The security posture below
applies: hesplay talks to the same unauthenticated LAN port as any browser.
A wedged player can't freeze the queue. hesplay runs one engine process
per track and waits for it, so an engine that neither plays nor exits would
otherwise stall playback indefinitely with no error and no skip — which mpv
does if it hangs probing an audio output the box doesn't actually run. So mpv
is started with a JSON IPC socket and polled: if its playback position stops
advancing (or never starts) for 20 seconds, the process is killed — SIGKILL,
since a wedged mpv ignores SIGTERM — and the track is skipped with a warning.
A skipped track is never reported as played, so listen counts stay honest.
ffplay has no IPC and plays unguarded.
Hespera has no authentication layer, by design — it is a single-machine
media app, and in app mode it is only reachable from your own computer.
That means anyone who can reach the port in server mode has full access:
not just playback, but the tag editor (writes into your music files), the
integrity auto-repair (the one path that rewrites media files), settings,
and shutdown. The built-in CSRF guard stops hostile web pages, not direct
network peers. Hence the loopback default: exposing Hespera to a network is
an explicit choice, and should only be made on a network you trust end to
end. For anything beyond that (shared LAN, remote access), put a reverse
proxy with authentication in front (Caddy basic_auth, nginx auth_basic,
Tailscale, etc.) — that is the supported pattern, not an app-level login.
Hespera runs all of its background work — library scans, integrity checks, loudness analysis, thumbnail and preview generation — at idle I/O priority (and nice 19), so a long scan yields the disk to anything that needs it right now, like Plex or Jellyfin streaming a movie from the same drive.
One catch: the kernel only enforces I/O priorities when the disk's scheduler
supports them, and the default on most distros (mq-deadline) ignores them.
Check yours (replace sdb with your media disk):
cat /sys/block/sdb/queue/scheduler # [mq-deadline] → priorities are ignoredFor a spinning disk (external USB drives especially), switch it to bfq:
# apply now
sudo modprobe bfq
echo bfq | sudo tee /sys/block/sdb/queue/scheduler
# make it stick across reboots
echo bfq | sudo tee /etc/modules-load.d/bfq.conf
echo 'ACTION=="add|change", KERNEL=="sdb", ATTR{queue/scheduler}="bfq"' \
| sudo tee /etc/udev/rules.d/60-media-disk.rulesWith bfq, Hespera's background jobs still use the disk's full speed when it
is otherwise idle — they only step aside under contention. On NVMe/SSD media
disks this tuning rarely matters; it is for rotational disks shared with
playback.
Hespera honors the hardware media keys a TV remote sends through an IR receiver (a Flirc, say): play/pause, fast-forward, rewind, next, previous. On a Linux desktop, though, fast-forward and rewind may never reach it — and the cause is upstream of Hespera.
Cinnamon and GNOME grab the media keysyms globally, then re-dispatch them to the browser over MPRIS. That works for play/pause, next and previous, which have MPRIS verbs. MPRIS has no fast-forward or rewind verb, so the desktop grabs those two keys, finds nothing to forward them to, and the press dies there: the browser never sees it, as a media action or a keystroke. The symptom is distinctive — play/pause works, FF/RW do nothing at all.
The fix is to release the two keys so they fall through to the focused window, which is all Hespera needs (it already handles them):
# Cinnamon
gsettings set org.cinnamon.desktop.keybindings.media-keys audio-forward "[]"
gsettings set org.cinnamon.desktop.keybindings.media-keys audio-rewind "[]"
# GNOME
gsettings set org.gnome.settings-daemon.plugins.media-keys seek-forward "[]"
gsettings set org.gnome.settings-daemon.plugins.media-keys seek-backward "[]"This only gives up a desktop-wide FF/RW shortcut that, for MPRIS players like a browser, was doing nothing in the first place. Leave the play/pause binding alone — that one is how play/pause reaches Hespera.
Day-to-day settings (media folder, API keys, feature toggles, subtitle
defaults) live in the in-app Settings page. Environment variables cover
the rest — all HESPERA_-prefixed:
Bundled provider keys. Official release binaries ship with bundled keys for TMDB, fanart.tv, and OpenSubtitles, so a fresh download matches TV/Movies and searches subtitles with no setup. A key you enter in Settings (or via the env vars below) always overrides the bundled one. Binaries you build from source carry no bundled keys — supply your own to enable those features.
| Variable | Default | Purpose |
|---|---|---|
HESPERA_NO_BROWSER |
(unset) | Set → server mode: no app window, honors HESPERA_LISTEN. Unset → app mode (chromeless window on a random loopback port) |
HESPERA_LISTEN |
127.0.0.1:8080 |
Server-mode listen address — loopback by default; LAN exposure is an explicit opt-in (:8080) |
HESPERA_DATA_DIR |
per-user config dir | Database, caches, artwork |
HESPERA_DB_PATH |
<data dir>/hespera.sqlite |
Database path |
HESPERA_MEDIA_ROOT |
home dir | Media root (the path-containment boundary; also settable in Settings → Libraries) |
HESPERA_TMDB_API_KEY |
bundled | TMDB key for TV/movie matching. Official releases ship a bundled key — set your own only for a dedicated key (also settable in Settings) |
HESPERA_FANARTTV_API_KEY |
bundled | fanart.tv key — artist image backfill. Releases ship a shared project key; set your own personal key for faster new-artwork access |
HESPERA_THEAUDIODB_API_KEY |
Optional TheAudioDB key — artist bio/image backfill (user-supplied) | |
HESPERA_LASTFM_API_KEY |
Optional Last.fm key — popularity blend for shuffles (user-supplied) | |
HESPERA_OPENSUBTITLES_API_KEY |
bundled | OpenSubtitles consumer key — on-demand subtitle search. Releases ship a bundled consumer key (5 downloads/day per IP) |
HESPERA_OPENSUBTITLES_USER_AGENT |
Hespera v1.0 |
OpenSubtitles consumer UA, formatted AppName vX.Y |
HESPERA_LOG_LEVEL |
info |
Log level (debug/info/warn/error). Per-request access logs are emitted at debug; the default info keeps request serving off the log path. Set debug to see per-request logs |
HESPERA_FFMPEG_CONCURRENCY |
4 | Max concurrent ffmpeg/ffprobe processes |
HESPERA_HLS_ENCODER |
software |
HLS video encoder: software (libx264) or vaapi (opt-in hardware encode) |
HESPERA_HLS_SEGMENT_CONCURRENCY |
1 | Max concurrent HLS segment transcodes (keeps prefetch bursts off every core) |
HESPERA_FFMPEG_ACQUIRE_TIMEOUT |
2s | How long foreground ffmpeg work waits for a slot |
HESPERA_TV_HLS_CACHE_MAX_BYTES |
20GiB | HLS transcode cache budget |
HESPERA_TV_CACHE_MAX_AGE |
72h | HLS cache entry max age |
HESPERA_TRICKPLAY_CACHE_MAX_BYTES |
10GiB | Scrub-preview sprite cache budget |