A one-page overview of how Days Between is wired internally. Read this before making structural changes.
days-between/
├── app/
│ ├── main.js ← Electron main process (Node + Chromium control)
│ ├── preload.js ← contextBridge — defines window.ipc.* in renderer
│ ├── cast.js ← Chromecast (castv2-client) wrapper, main-process
│ ├── nugs-scraper.js ← Headless ghost BrowserWindow + CDP capture
│ ├── shared/ ← Pure-JS helpers (no DOM, no Node, no Electron)
│ │ └── helpers.js ← Tested in test/helpers.test.js
│ └── renderer/ ← Browser context — index.html and ES modules
│ ├── index.html
│ ├── app.js ← Boot + global event wiring
│ ├── api.js ← Relisten + Nugs HTTP clients
│ ├── audio-engine.js← Dual-buffer gapless audio + EQ chain
│ ├── eq-engine.js ← 5-band BiquadFilter chain (singleton AudioContext)
│ ├── archive.js ← Local archival queue (sequential downloader)
│ ├── lastfm.js ← Scrobbling + Wikipedia/Last.fm artist images
│ ├── nugs-scraper.js← Renderer-side parser of ghost-scraped HTML
│ ├── personalization.js ← Affinity blend → "For You" SOTD picker
│ ├── player.js ← Now-playing state, queue, repeat, shuffle
│ ├── setlistfm.js ← setlist.fm API client (rate-limited, cached)
│ ├── state.js ← In-memory cache + localforage persistence
│ ├── theme.js ← Theme / accent / density / glass settings
│ ├── update-check.js← In-app GitHub release notifier
│ ├── views-core.js ← Relisten browsing views (artists / shows / search)
│ ├── views-nugs.js ← Nugs browsing views (welcome / artist / release)
│ ├── views-user.js ← Settings, stats, history, tapes, saved
│ ├── video-player.js← Inline HLS video for nugs livestreams
│ └── mixlr-player.js← Mixlr embed for live audio webcasts
├── assets/ ← icon.svg, icon.png, tray.png
├── test/
│ └── helpers.test.js ← node:test suite for app/shared/helpers.js
└── .github/workflows/
└── release.yml ← Test → build matrix → publish on `v*` tag
Days Between is an Electron app with two trust boundaries:
- Node-capable, full filesystem and OS access.
- Owns the BrowserWindow, the system tray, MPRIS (Linux media keys), the
Chromecast client, the headless ghost BrowserWindow, all
webRequestheader rewrites, and Last.fm signing (LFM_SECRETnever leaves here). - Exposes capability-narrow IPC handlers — see the bottom of
main.jsandpreload.jsfor the complete surface.
- Sandboxed Chromium context. No
require, no Node. - Talks to the main process exclusively via
window.ipc.*(defined inpreload.js). - All UI lives here. ES modules loaded directly — no bundler, no transpiler, no build step.
- A headless BrowserWindow with
partition: 'persist:nugs'so cookies + Cloudflare clearance survive across launches. - Used for two things:
- Loading nugs.net pages that require a real Chromium browser to bypass Cloudflare / Demandware bot challenges.
- Capturing JSON response bodies via Chrome DevTools Protocol
(
webContents.debugger+Network.getResponseBody) — thewebRequestAPI exposes URLs and headers but not bodies.
- Each scrape uses a per-call
AbortController. Hard timer fires → controller aborts → polling loops exit cleanly.
All user data lives in IndexedDB via localforage.
The state.js module wraps localforage with an in-memory cache so reads
are synchronous and writes are fire-and-forget:
state.js → in-memory cache (synchronous reads)
↓ async writes
localforage → IndexedDB (per-key db-* entries)
Persisted keys (all prefixed db-):
| Key | Type | Notes |
|---|---|---|
db-faves, db-fav-artists |
Array | Saved shows + favourite artists |
db-history |
Array | Listening history (capped at 100 entries) |
db-ratings |
Object | Personal show ratings 1–5 |
db-tapes |
Array | User-built playlists |
db-settings |
Object | Theme, accent, EQ bands, density, etc. |
db-nugs-auth |
Object | Nugs OAuth tokens (access, refresh, exp) |
db-lfm-session |
String | Last.fm session key |
Plus localStorage (smaller, simpler, used for things that need synchronous top-level access on boot):
| Key | Notes |
|---|---|
nugs_pinned_artists |
Array of {id, name} |
db-wiki-cache, db-lfm-img-cache |
Artist-image lookup caches (30-day TTL) |
dismissedUpdateVersion |
Last update-notifier dismissal |
sotd |
Today's Show-of-the-Day pick |
Migration from localStorage → IndexedDB happens once on loadAll().
audio-engine.js exposes a single audio element to the rest of the app
but maintains a primary + staging pair internally for gapless playback:
audio (proxy) → primary ─┐
├─ AudioContext → 5x BiquadFilter → destination
staging ──┘
- Tracks load on the staging element while the primary is playing.
- At the end of a track, the engine fades over 0.4s and swaps the roles.
- Both elements are permanently connected to the same EQ filter chain.
eq-engine.jsis a singleton. Bypass mode sets all gains to 0 dB rather than disconnecting nodes — preserves the audio graph state.
Two separate paths for two different problems:
api.relisten.net— public, no auth, served via plainfetch.streamapi.nugs.net,id.nugs.net,subscriptions.nugs.net— Nugs's documented OAuth APIs. Renderer-sidenugsApi(inapi.js) calls these directly with the cached Bearer token.
- Some hosts reject Electron's default User-Agent or require specific Referer/Origin headers (Akamai HLS, archive.org CORS, nugs CDN images).
main.jsregisters a singlesession.defaultSession.webRequest. onBeforeSendHeaderslistener that rewrites these per-host before the request leaves the box. See the giant switch in main.js — it's the one source of truth for "which host needs which spoofed headers".
- Used for nugs.net web pages (live hub, library, video player) where no documented JSON API exists. The ghost loads the page, the renderer parses the resulting HTML or consumes JSON captured via CDP.
A small module of side-effect-free functions used in many places:
sanitizeSegment/extFromUrl/trackFilename— filename safety for the local archival downloader.nugsContainerImage/nugsIsoDate/parseNugsDate— Nugs catalog data normalisation.sortByRecent/sortByPopular/applyNugsFilters— Nugs artist-page tab logic.resolveShowArtist— Relistenshow → artistresolution that walks the three different payload shapes the API ships.compareVersions— semver-ish comparison for the update notifier.normaliseSongTitle/dedupeRelistenSongs/trackContainsSong/aggregateRelistenShowsToSongs/aggregateNugsSongs— Setlist Intelligence (v1.12) song-catalog dedup and per-show aggregation.aggregateSongCountsFromSetlists— collapse setlist.fm setlist payloads into authoritative per-song play counts (v1.13).computeArtistAffinities/formatAffinityReason— blend local signals (attended / pinned / plays / favorited shows) into per-artist scores with a human-readable "why" string (v1.14).
These have no browser or Node dependencies by construction. Tests
in test/helpers.test.js exercise the full surface; CI runs them
before any build job and gates publishing.
- Decide which trust boundary it belongs to.
- Filesystem / OS / cookies / Cast → main process
- UI / playback → renderer
- Pure data transformation →
app/shared/helpers.js+ a test
- If it crosses the boundary, define a narrow IPC channel in
main.js+preload.js. Don't exposeipcRendererdirectly. - If it deals with a Relisten or Nugs response shape, write the
pure transformation in
helpers.jsand pin its behaviour with a test. The UI imports from there. - Run
npm testbefore committing.
| Trigger | Action |
|---|---|
Push to main |
Test → build matrix (artifacts only, no release) |
Push tag v* |
Test → build matrix → publish GitHub Release |
Local Linux Arch package:
makepkg -f
sudo pacman -U days-between-X.Y.Z-1-any.pkg.tar.zstFor day-to-day source iteration without rebuilding the package:
./days-update.sh # rsyncs the working tree into /opt/days-between/For a clean version-bumped install:
makepkg -f # build the .pkg.tar.zst
./install-pkg.sh # pacman -U with --overwrite, picks the newest pkginstall-pkg.sh uses --overwrite because days-update.sh rsyncs introduce
files that pacman doesn't track — without the flag, pacman -U rejects the
upgrade with a "conflicting files" error any time the release adds a new
source file.