Imported from Arskah/radiodiodj (
AGENTS.md). Install upstream withnpx skills add Arskah/radiodiodj. Copyright stays with the author.
AGENTS.md
Build & Run Commands
pnpm dev # tauri dev (Vite HMR for renderer, cargo watch for backend)
pnpm build # tauri build → src-tauri/target/release/bundle/<format>/
pnpm typecheck # svelte-check + tsc on tsconfig.node.json
pnpm test # vitest watch
pnpm test -- run # vitest single run
pnpm e2e # tauri-driver + WebdriverIO (Linux only — see e2e/README.md)
cargo test --manifest-path src-tauri/Cargo.toml # backend tests (db, scanner, session, playlist, audio, config)
pnpm lint # eslint
pnpm format # prettier --write .
pnpm format:check # prettier --check .
Architecture
Tauri 2 app. Two process boundaries: a Rust backend (src-tauri/src/, grouped
by domain — audio/, library/, playlist/, broadcast/, appearance/,
persist/, admin.rs) and a Svelte 5 / Vite renderer (src/, one folder per
UI feature under features/ plus shared/), talking over Tauri invoke +
emit/listen.
The module map, the event table and the boundary conventions are docs/architecture.md. Domain vocabulary is CONTEXT.md. All design docs: docs/README.md.
Key patterns
Each entry is the invariant to preserve; the linked doc carries the reasoning.
Audio playback — in-process Rust decks, no browser <audio>, no media://,
no transcoder. A deck reads the whole file into RAM (retry + 10 s watchdog)
and never streams from the filesystem, which is what survives a wedged network
share. See docs/audio.md.
Program bus — every on-air deck is a Sink on one shared OutputStream,
driven by one worker. Deck events are role-mapped: main-deck:*,
arm-deck:*, tail-deck:*. Nothing outside the bus learns which physical deck
is on air. Handover moves the main role at the outgoing track's next_start;
the engine authorises it by arm-loading and reconciles against
program:handover. At most two tracks are ever audible. The cue deck is
deliberately off the bus, on its own stream, thread and cue:* topics. See
docs/program-bus.md.
Live fades — one primitive, Cmd::Fade { to, ms, on_complete }, stepped
from the bus worker's 50 ms tick. It multiplies the operator's deck volume
rather than replacing it, and every command that changes what a deck is doing
cancels it and restores full gain. Cmd::HandOverNow is the one command acting
on two decks, so the worker intercepts it before dispatch. A completed fade to
silence on main emits program:faded-out, which the service maps to the same
stop() the Stop button runs. Durations are read per press from
tuning.player. See docs/program-bus.md.
ReplayGain — measured by the analysis pass, never read from tags.
Cmd::Load carries an already-resolved linear factor, so the worker never
consults the library, and it is applied at the source in append_span (not
sink.set_volume()) and therefore before the bus mixer. rg_measured_at is
what "measured" means. No album mode. See
docs/audio.md.
Cue points — five nullable columns on tracks, deliberately absent from
UPSERT_TRACK_SQL so a rescan cannot destroy them. Cmd::Load carries concrete
CuePoints, resolved against the decoded duration. Stored fades are a
source-level envelope (audio/envelope.rs), never a sink ramp — a live fade and
a stored fade would otherwise fight over one value. Clamping is backend-owned:
set_cue_points returns what it stored, and shared/cuePoints.ts only applies
null fallbacks. See docs/cue-points.md.
Durations mean air time — everything crossing the boundary reports
cueOut − cueIn via airDuration(); position 0 is the first audible sample.
The toolbar's library Playtime is the one exception. Never optimistically show
file time. See docs/audio.md.
Automatic cue points — cue in, cue out and (for music) next start derived
from the waveform pass's RMS windows, no second decode. Ownership is a per-track
state (pending / auto / manual); an operator write that moves the trio
makes it manual for good, and set_auto_cue only commits while the track is
still automatic. Re-analysis is scheduled for auto tracks only; changing a
threshold never re-analyses anything. Two nested switches decide what the
library reports — autoCue.apply over the whole trio, autoCue.applyNextStart
over the Next Start alone — and both gate in effective_cue_points, so nothing
downstream of the library knows they exist. See
docs/cue-auto-analysis.md.
Authoring cue points — CuePointOverlay.svelte is the only surface that
moves a marker, with the rules in shared/cueEditor.ts. The editor's
input-only neighbour stop is the one exception to backend-owned clamping. The
dialog borrows the cue deck and restores it on every exit, so no draft is left
armed behind a closed one. Saving a radio edit deliberately does not touch
currentTrack. See docs/cue-points.md.
Item overrides — cue_override: Option<CuePoints> on a playlist item.
None means the item references the track; an all-NULL override is distinct
and means "play the whole file this once". Effects carry the override because
the item is consumed before the service runs them, and prev returns the
outgoing track to the queue as an item. See
docs/playlist.md.
Playlist ownership — the backend owns the playlist, what is on air,
advancement, refill, the prefetch window and history. The renderer sends
playlist_* commands and mirrors the whole program:playlist-state snapshot;
it computes nothing. Snapshots are whole, never deltas. See
docs/playlist.md.
Auto-playlist — a lookahead buffer refilled inside playlist::engine, so a
refill and the track change that triggered it are one transition. Interleave
counters advance on music only. See docs/playlist.md.
Rotation rules — both predicates run in SQL (SelectionFilter in
library/db.rs), so a query returns exactly the count asked for; the queue
counts as already aired. One track per artist per generated block via
ROW_NUMBER() OVER (PARTITION BY artist ...). A short block refetches the
deficit down a three-rung ladder, warning per relaxation; the id exclusion is
never relaxed. Jingles and commercials are untouched by both rules. See
docs/rotation.md.
Seek — the source is reloaded, then append_span seeks in two stages:
try_seek to ~200 ms short, then skip_duration for the remainder, so a marker
lands sample-exactly. See docs/audio.md.
Search — FTS5 virtual table on title/artist/album/genre, kept in sync by
triggers. A query is tokenized as prefix match: foo bar → "foo"* "bar"*. See
docs/library-search.md for the planned fuzzy pass.
Scan + prune — a scan never deletes a track. One Db::reconcile transaction
per scan. Rows whose file is gone get missing_since, but only under a fully
listed root or outside every root. New paths reattach by fingerprint,
duplicate a present row (copying its operator state), or are inserted. Root
membership is Path::starts_with, never LIKE. Only Settings → Purge
deletes. See docs/track-identity.md.
Library health — one report (missing, exact and possible duplicates,
unreadable tracks, the latest library check), re-emitted as library-health
after scans, the analysis pass, metadata edits, path changes and purges. The app
never deletes audio files. Dismissals silence the badge only while the finding
is unchanged. The playlist engine takes missing ids from the same event. The
check reports only and never runs alongside a scan. See
docs/library-health.md.
Metadata edits — tracks.edited_fields flags each operator-changed tag
column and UPSERT_TRACK_SQL keeps a flagged column, so a rescan cannot clobber
an edit. TagWriter never writes in place (lofty truncates and rewrites): tag in
memory, check the fingerprint, write a temp file, rename it over the original.
See docs/library.md.
Theming — a theme sets colours only; the token contract is the :root
block of src/styles.css. Validation is a token-name allowlist plus one closed
value grammar, run in Rust at load, so the renderer never sees an unvalidated
token. An invalid theme is refused whole; an incomplete one is filled from its
base. Station identity is configured separately and wins over theme images.
Nothing repaints unasked — no watcher, no following the OS. See
docs/theming.md.
Admin mode — lib.rs wraps the command handler in admin_gated, which
rejects every command in admin::ADMIN_COMMANDS while locked. The unlocked flag
lives only in AppState; the renderer mirrors it and runs the idle timer, which
can lock but never unlock. Not a security boundary. See
docs/admin-mode.md.
Naming — "edit" means metadata and nothing else (MetadataOverlay.svelte,
app.editingMetadata); playback markers are always "cue points"
(CuePointOverlay.svelte, app.editingCuePoints). The rest of the vocabulary
is CONTEXT.md.
Gotchas
- A new colour token must land in three places — the
:rootblock insrc/styles.css,THEMEABLE_TOKENSinappearance/theme.rs, and every built-in theme JSON — or the contract guard fails. Colour literals outside:rootfail it too. See docs/theming.md. tauri::generate_context!()runs at compile time and validatesfrontendDist=../dist.cargo clippy/cargo testpanic with "frontendDist path doesn't exist" unlesspnpm vite buildhas run; CI does this inrust.ymlbefore cargo steps.serde(default)per-field onSessionState/AppConfiglets new fields land without a schema version bump. Match this pattern when adding fields.- DB schema changes: append a step to
MIGRATION_STEPS(never edit a shipped one), add aSEEDSentry, and regeneratesrc-tauri/src/library/schema.sqlwithUPDATE_SCHEMA=1 cargo test schema_matches_snapshot. Operator-work columns stay out ofUPSERT_TRACK_SQL'sSETlist. See docs/database.md. - pnpm
minimumReleaseAgeconstraint blocks plugin versions younger than ~3 days; pin to a slightly older stable version when addingtauri-plugin-*deps. - A new admin-only command must be added to
admin::ADMIN_COMMANDS, or it runs while admin mode is locked. See docs/admin-mode.md. - Tauri command argument name
statecollides with theState<AppState>injection; the managed state arg is namedappin command handlers. release-please-config.jsonbumpspackage.json,src-tauri/tauri.conf.json(jsonpath$.version), andsrc-tauri/Cargo.toml(# x-release-please-versionannotation) on each release. Keep all three in sync.tauri-plugin-logis initialized first in the builder chain so panics before later plugin setup still reach the file sink. Rendererconsole.*is intercepted byattachConsole()inmain.ts; vitest must not importmain.ts(it doesn't — tests usemockBackend). Log level honorsRUST_LOG(whole-app level only — no module syntax) and falls back toDebugincfg!(debug_assertions)/Infoin release.symphonia*modules are forced toWarn(symphonia_bundle_mp3toError, whose false-sync warnings on a non-MP3 file otherwise fill the 1 MB log) to keep the webview console readable.
Data files and logs
Per-user data directory paths, database backup naming, log file locations and the admin-password reset are in the README: Data files, Logs.
Testing
Pre-commit hook runs lint-staged (prettier + eslint fix) then vitest.
CI gates Rust with cargo fmt --check + cargo clippy --all-targets -- -D warnings
(.github/workflows/rust.yml); run both locally before pushing.
