Architecture
Use this guide to follow components and failure paths. For a code change, start with Conventions; lifecycle contracts live in Ownership.
- Package map and extension interfaces
- Resolution and parser fallback
- Playback pipeline and status delivery
- Failure handling
- Persistence and known limitations
Melodix is a Discord music bot built on top of a playback engine that doesn’t
know Discord exists. Two frontends share one application layer,
internal/music, over that engine, and each has its own binary:
cmd/discord— the Discord bot (internal/discord): slash commands, voice, persistence, health watchdogs.cmd/cli— the terminal player (internal/cli): the bot’s music commands in a REPL, playing to your local speaker. It is also the proof that the layers under it have no Discord dependency.
A third binary, cmd/readme, regenerates the README’s command lists from
both frontends’ catalogs.
Each frontend is laid out the same way: a root package that owns the
transport (the gateway and voice for the bot, stdin and the speaker for the
CLI), a command/ tree with one package per command, and
command/catalog, the single list its binary registers.
flowchart TB
subgraph Frontends
DiscordBot["internal/discord"]
CLI["internal/cli"]
end
Music["internal/music<br/>(players per scope, Add, Search, History)"]
subgraph Engine["pkg/music (Discord-agnostic)"]
Resolver["resolve.Resolver<br/>(input → TrackInfo)"]
Player["player.Player<br/>(queue + playback loop)"]
Stream["stream.RecoveryStream<br/>(parser fallback + retry)"]
Parsers["parsers: ytnative | scnative | kkdai | ytdlp | ffmpeg<br/>(track → 20ms Opus packets)"]
SinkIface["sink.AudioSink"]
end
DiscordBot --> Music
CLI --> Music
Music --> Player
Player --> Resolver
Player --> Stream
Stream --> Parsers
Player --> SinkIface
SinkIface -->|forward Opus packets| DiscordVC["Discord voice"]
SinkIface -->|decode → oto| Speaker["Local speaker"]
What this optimizes for
- Playback that survives a bad link. The bot is expected to run from a throttled or lossy connection, so recovery, read-ahead and the cost of re-fetching are first-class concerns rather than polish. Where a choice trades startup latency for continuity, continuity wins.
- No hard dependency on external binaries for YouTube. ffmpeg and yt-dlp are optional; the passthrough paths need neither.
- An engine that does not know Discord exists.
pkg/musicis reusable on its own terms and the CLI is the standing proof. - Expendable parsers. Any single extraction path can break without notice when a platform changes. The fallback chain is what makes playback reliable — no individual parser is.
Non-goals: audio effects, mixing across guilds, a web UI, and anything that would require signature deciphering.
Glossary
These terms name separate stages of the playback system.
| Term | Means |
|---|---|
| source | Input → metadata. Resolves a URL or query to TrackInfo; never touches audio. |
| parser | Track → 20ms Opus packets. Owns extraction and the stream URL. |
| sink | Opus packets → audio out: Discord voice, or the local speaker. |
| passthrough | Forwarding a source’s Opus untouched — no decode, no re-encode, no ffmpeg. |
| transcode | The other path: ffmpeg decodes to PCM, opus.Encode re-encodes to packets. |
| link vs pipe | Two modes of one parser: hand ffmpeg a URL (link), or stream bytes through its stdin (pipe). |
| media recovery | RecoveryStream reopening a failed source. Budgeted per parser. |
| transport recovery | runPlayback reacting to a dead Discord voice connection. A separate budget. |
| instant fail | A parser that opened but errored on its first read; the chain moves on rather than retrying. |
Package map
| Path | Responsibility |
|---|---|
pkg/music/player |
Player: FIFO queue, playback goroutine, transport recovery, status channel |
pkg/music/resolve |
Resolver: input → []TrackInfo; source detection and precedence |
pkg/music/sources |
Source interface (+ optional Searcher) and youtube, soundcloud, radio implementations; YouTube also expands playlists and mixes |
pkg/music/innertube |
The YouTube InnerTube client identity — constants and the request context — shared by the ytnative parser and the youtube source so the client version has one place to be bumped |
pkg/music/parsers |
Streamer interface + ytnative, scnative, kkdai, ytdlp, ffmpeg implementations |
pkg/music/opus |
The engine’s currency: Reader (20ms Opus packets), a zero-dep WebM demuxer (passthrough), encode/decode adapters over godeps/opus, and a read-ahead BufferedReader (anti-skip); 48 kHz / stereo / 960-sample constants |
pkg/music/soundcloudapi |
Minimal SoundCloud api-v2 client (rotating client_id, resolve, stream URLs, search) shared by scnative and the soundcloud source |
pkg/music/stream |
Parser registry + RecoveryStream (packet-level recovery, live-stream reconnect; optional cache-first read and write-through tee, with the read-ahead buffer wrapped around it) |
pkg/music/cache |
Optional global, content-keyed track cache: tees played Opus packets to disk blobs and serves them on later plays (any guild); LRU size cap, persistent by default |
pkg/music/sink |
AudioSink/Provider interfaces + speaker implementation |
internal/discord |
The Bot: session lifecycle, handlers, health watchdogs, voice service |
internal/discord/voice |
Guild status messages, voice state, failure reports — the hooks the bot plugs into music.Service; survives session restarts |
internal/discord/voice/voicesink |
Joins a voice channel and forwards a track’s Opus packets to it (no encode); holds frames the transport cannot protect |
internal/discord/adapter |
Bridges melodix command types to the keshon/command registry/middleware framework |
internal/discord/slashsync |
Per-guild slash-command diff sync (create/edit/delete) |
internal/discord/audit |
The audit trail: who ran what, where, persisted — distinct from the diagnostic log |
internal/discord/reply |
Embed/response helpers shared by handlers and the voice service |
internal/discord/queue |
How commands get scheduled: one FIFO lane per guild, drained off the gateway read goroutine so a guild’s commands stay ordered and never overlap, plus the global cap on how many run at once across every guild |
internal/discord/watchdog |
Gateway-silence detection and WS/ready tracking |
internal/discord/command |
The bot’s commands, one package each (music/play, core/help, settings, …); catalog registers them all |
internal/discord/middleware |
The chain every command and click runs through: guild-only, permissions, disabled groups, the command log |
internal/cli |
The terminal frontend: the REPL, Command/Context/Registry, and the player’s status printer |
internal/cli/command |
The CLI’s commands, mirroring the bot’s layout (music/play, core/help, …); catalog registers them all |
internal/readme |
Renders both frontends’ command lists into README.md for cmd/readme |
internal/music |
The application layer the CLI and the bot share: one player per scope (guild or terminal) with its sink provider; Add (history ids, links, queries), Search, History; history recording; the optional playback layers (cache, anti-skip read-ahead). It never starts playback — each frontend does, so it can announce the start its own way |
internal/config |
Env-driven config (caarlos0/env + .env); all runtime knobs live here |
internal/storage |
Persistence: schema (guild settings, command log, playback rows, cache index) and the collections/indexes declared on the embedded datastore |
External process dependencies: ffmpeg is optional, used only by the
transcode parsers — SoundCloud AAC, radio, and the kkdai-link/ytdlp-*
fallbacks. YouTube itself plays back via Opus passthrough
(ytnative-link and kkdai-pipe), with no ffmpeg involved at all, so a
YouTube-first bot doesn’t need either binary. yt-dlp is the last resort
in the ytdlp-* chain, and optional for everything except YouTube live
broadcasts — those are HLS, which no other parser here can follow. Both paths
default to PATH but can be overridden (ffmpeg.FFmpegPath /
ytdlp.YtdlpPath).
yt-dlp additionally wants a JavaScript runtime on PATH (deno, node or
bun). Left to its own defaults it falls back to a YouTube client googlevideo
serves under restrictions, and live streams stop after twenty-odd seconds;
where a runtime is found, pkg/music/parsers/ytdlp/runtime.go selects the
embedded web client on every invocation. Where none is found it passes no
flags at all, because asking for that client without a runtime fails outright
rather than degrading. Discord is disgoorg/disgo, and end-to-end voice
encryption is thomas-vilte/dave-go — a pure-Go MLS implementation, which is
used by the six release targets. The workflows enable CGO for Linux builds
and disable it for Windows and macOS; the Linux speaker dependency needs ALSA.
The three extension layers
Everything pluggable sits behind one of three small interfaces:
// pkg/music/sources — URL/query → track metadata (no stream URLs yet)
type Source interface {
Match(input string) bool
Resolve(input string, selectedParser string) ([]TrackInfo, error)
SourceName() string
AvailableParsers() []string
}
// pkg/music/parsers — track → 20ms Opus packets (opus.Reader)
type Streamer interface {
// The track is a copy; what the parser learns comes back in Opened
// (reader, cleanup, passthrough, title/artist/duration).
Open(track Track, seekSec float64) (Opened, error)
}
// pkg/music/sink — Opus packets → audio output
type AudioSink interface {
Stream(r opus.Reader, stop <-chan struct{}) error
}
type Provider interface {
Sink(target string) (AudioSink, error)
ReleaseSink(target string) // player disconnected (leave VC)
InvalidateSink() // drop cached transport, next Sink() re-acquires
}
TrackInfo deliberately carries just a page URL, a title, the source name,
and an ordered parser preference list — nothing more. Actual stream URLs are
resolved lazily, by the parser, at open time, so a queued track never holds
an expiring CDN link.
Resolution
resolve.New() registers the three sources. Resolve(input, source, parser)
tries these in order:
- An explicit source was selected — validate the parser, then: a bare
query is only allowed for the searchable sources (YouTube, SoundCloud); a
URL has to pass
Match. - Auto-detect, bare query — always routed to YouTube.
- Auto-detect, URL — deterministic precedence: YouTube first, then SoundCloud. (Map iteration is deliberately never used for matching — a new source has to be added to this list by hand.)
- Fallback — radio, which validates the URL by probing its Content-Type.
For search: both searchable sources return []sources.SearchResult through
the optional sources.Searcher interface. YouTube posts to InnerTube’s
search endpoint with the video-only filter, so channels and shelves never
reach the caller; SoundCloud goes through api-v2’s /search/tracks, via the
shared soundcloudapi client. A result carries the source’s own compact id
rather than a URL, because /search has to round-trip it through a Discord
component id — see docs/conventions.md for why that is a frozen format.
Playlists and mixes. A YouTube link carrying a list= expands to many
tracks rather than one. A stored playlist (PL…, UU… channel uploads)
comes from InnerTube’s browse endpoint with browseId: "VL"+listID, paged
by its continuation token; a mix (RD…, including RDMM personal mixes,
RDAMVM song radio and RDCLAK curated lists) is generated per request, has
no browsable page at all, and comes from next instead. Expansion is capped
at youtube.MaxPlaylistItems (100) — playlists run to thousands of entries
and mixes are formally endless.
A link that names a video and a list starts at that video and keeps the rest, which is what YouTube itself means by such a URL. If the named video is not among the fetched entries — past the cap, or not a member — it is prepended, so the video the user pointed at is never dropped.
YouTube: Opus passthrough and the fallback chain
YouTube audio (itag 251) is already 48kHz stereo Opus inside a WebM
container — Discord’s exact wire format — so the goal is to forward it
untouched. opus.Passthrough demuxes the WebM and hands the packets to the
sink with no ffmpeg, no decode and no re-encode. It validates that the first
packet is a single 20ms frame, the only shape the Discord sender can forward,
and falls back if it is not. The chain, in order:
ytnative-link(passthrough) — the InnerTubeplayerendpoint under the VISIONOS client, giving a direct cipher-free URL, fetched in ranged chunks. Client identity, session id, fetch shape and live-stream behaviour are documented where they are implemented:pkg/music/innertube,parsers/ytnative/visitor.go,parsers/ytnative/chunked.go.kkdai-pipe(passthrough) —kkdai/youtuberesolves a WebM/Opus stream and downloads it in chunks, demuxed the same way. Rides the same client; seeparsers/kkdai/streamer.go.kkdai-link,ytdlp-*(transcode) — the ffmpeg-encode fallbacks, reached only once both passthrough paths are exhausted.
The client choice is a system-wide constraint, not a parser detail.
googlevideo applies per-issuing-client rules to the URLs it hands out: an
ANDROID_VR URL answers 403 to any open-ended request and serves only
bounded ranges of roughly 1 MiB, while a VISIONOS URL serves both shapes.
ffmpeg sends Range: bytes=0- and ytnative’s fallback opens an unbounded
response, so the identity in pkg/music/innertube is load-bearing for the
whole chain — which is why it lives in a package of its own rather than
inside one parser. TestLiveOpenEndedRequestAccepted pins it.
ytnative returns ErrCipherOnly on cipher-only responses; a passthrough
that fails framing validation returns opus.ErrNotPassthrough. Either way,
recovery moves on to the next parser.
SoundCloud (scnative)
scnative runs on pkg/music/soundcloudapi: the rotating client_id gets
scraped from the web player’s JS bundles, cached, and refreshed automatically
whenever it hits a 401/403. Tracks resolve via /resolve, and whichever
transcoding is preferred (AAC HLS over HLS over progressive) gets transcoded
by ffmpeg and encoded to Opus packets — SoundCloud’s AAC just isn’t
passthrough-able. Radio streams go through the same ffmpeg transcode path.
A track’s “Now Playing” chip shows passthrough, ffmpeg, or cached, so
you can tell at a glance which mode is actually active. The passthrough
packages also have opt-in live tests
(MELODIX_LIVE_TESTS=1 go test -run Live -v ./...) that act as canaries for
endpoint drift.
Track cache & anti-skip buffer
Two independent layers wrap the parser stream, and where each sits is the architectural point. The cache is off by default; read-ahead defaults to 30 seconds. See the configuration reference for both settings.
- Track cache (
CACHE_ENABLED) sits insideRecoveryStream, above the recovery logic, so one blob spans parser switches and transport reopens and is committed only on a clean end.Openchecks the cache before the parser list, so a later play of the same track — from any guild — serves from disk with no extraction and no ffmpeg. A miss or a bad blob falls through to the parser chain, so the cache can never block playback. Mechanics and the on-disk format live inpkg/music/cache. - Anti-skip buffer (
BUFFER_AHEAD_MS) sits outside it, wrapped aroundRecoveryStreamthroughPackets(). Below recovery, a reopen tore the buffer down along with the stream it wrapped and the consumer blocked for the whole reconnect; above it, the queued lead keeps playing while the reopen happens underneath, and it survives transport reopens too. It also movesReadPacketonto a read-ahead goroutine, which is whyRecoveryStreamguardsreader/cleanupwith a mutex and whyClosesignals, tears the source down to unblock the producer, then waits.
Whether the buffer helps at all depends on the source outrunning playback,
which is not a given — see parsers/ytnative/chunked.go for what that took
on YouTube.
Playback pipeline
sequenceDiagram
participant H as Slash handler
participant P as player.Player
participant RS as RecoveryStream
participant S as AudioSink (Discord)
H->>P: EnqueueTrackInfo(track)
H->>P: PlayNext(voiceChannelID)
P->>P: dequeue under playNextMu
P->>RS: Start(0) — first parser that opens
RS-->>P: OpenInfo (which parser, passthrough, cached, title)
P->>P: apply OpenInfo to the track under p.mu
P->>P: spawn runPlayback goroutine for run N
P-->>H: nil (track started)
H->>H: render "Now Playing" synchronously
S->>RS: ReadPacket (first)
RS->>P: OpenInfo — this parser is really producing audio
P->>P: write history row, correct "Now Playing" if the parser changed
loop every 20ms, driven by disgo's sender
S->>S: DAVE has no live epoch? hold, read nothing
S->>RS: ReadPacket (Opus)
S->>S: return the frame to ProvideOpusFrame
end
Note over S: a sender that stops pulling, or a conn the<br/>manager no longer knows, ends the track as<br/>ErrVoiceTransport
RS-->>S: EOF
P->>P: completion goroutine → PlayNext
alt queue non-empty
P->>RS: next track (new goroutine, run N+1)
P->>P: emit StatusPlaying → watcher edits status message
else queue empty
P->>P: stop(true, run N) → ReleaseSink (leave VC)
P->>P: watcher edits "Playback Finished"
end
Key mechanics:
- Queue — a plain
[]Trackunderp.mu.playNextMuserializes dequeue and open, so two tracks can never start at the same time. - Completion chain — the flow runs
runPlayback → completion goroutine → PlayNext → startTrack → new runPlayback. Iteration happens through fresh goroutines rather than recursion, and queue-end disconnect has exactly one decision point:PlayNextreturningErrNoTracksInQueueleads straight toStop(true). - Per-run ownership — each run is a
*runwith its own stop and done channels, track and flags, and its goroutines are handed it. A stale run’s goroutine can only end its own run, never a newer one;p.run == ranswers whether it is still in charge (see ownership.md rule 5). - Discord sink installs an Opus frame provider on the disgo connection.
Its sender pulls packets through
ProvideOpusFrame. The provider holds frames while DAVE has no live epoch; transport liveness and stalled pulls have bounded checks, so a dead sender can surface asErrVoiceTransport. Stop signals end the provider and release the waiting playback run. See ownership rules 8 and 9. - Pause/Resume — not supported, on purpose, since the sink owns the read
loop. Commands that try get
ErrPauseNotSupportedback.
Status delivery (single-consumer contract)
Player.PlayerStatus is a buffered channel meant to have exactly one
long-lived consumer per player, passed to music.Service as Hooks.Watch
and spawned once when the scope’s player is created. On the bot side that’s
voice.Service.WatchPlayerStatus; it only handles asynchronous transitions (auto-advance →
edit “Now Playing”, natural queue end → “Playback Finished”). Anything
interaction-driven — “Now Playing” after /play, “Track(s) Added” — is
instead rendered synchronously by the handler, since it already knows what
PlayNext returned. Don’t attach per-interaction listeners to the channel;
competing receivers will end up stealing events from each other.
The guild status UI is a single message per guild
(voice.Service.UpdatePlaybackStatus): created via interaction followup the
first time, edited from then on — which incidentally is also why updates
keep working past the 15-minute interaction-token expiry.
Failure handling
There are three distinct failure classes here, each with its own mechanism:
-
Media failures, handled by
stream.RecoveryStreamon a per-track basis. Opening a parser proves nothing on its own — the ffmpeg-backed ones only spawn a process, so a CDN 403 surfaces on the first read — which is whyOpenlogsstream_openingand the realstream_openedwaits for the first packet. An instant fail — an error or EOF on that first read — moves on to the next parser in the track’s preference list.Mid-track, any read failure that looks early reopens the same parser at the current seek position, up to three attempts per parser. Not only
io.EOF: a CDN resetting the connection arrives as a net error, and treating that as terminal was a gap that opened when passthrough removed the ffmpeg layer — with ffmpeg in front, a dropped connection reaches us as a closed pipe and reads as EOF. A natural EOF passes through untouched.What counts as “early” depends on whether the track has an end. With a known duration it means stopping before roughly 95% of it. Without one — internet radio, and YouTube live — there is no natural end to be near, so every stop is an interruption and the stream reconnects, at the live edge rather than at the old position, after a short backoff. The three-attempt budget is what keeps that bounded: a station that has genuinely gone off air is let go rather than retried forever.
- Voice transport failures, handled in
player.runPlayback, up to three attempts. AnErrVoiceTransportfrom the sink triggers eitherhardmode (invalidate the sink, forcing a voice-channel rejoin) orsoftmode (retry the stream first — governed byPLAYER_TRANSPORT_RECOVERY_MODEandPLAYER_TRANSPORT_SOFT_ATTEMPTS), then reopens media at the current position without touching the media retry budget. -
Session failures, handled in
internal/discord. A gateway-silence watchdog (WS_SILENCE_TIMEOUT) marks the session unhealthy, andDISCORD_UNHEALTHY_MODEdecides what happens next (restart-session,restart-voice, orignore).main.gorunsRunSessionin a restart loop, and sincemusic.Serviceand the voice service outlive individual sessions, queues and players survive reconnects — sinks just get invalidated and re-acquired.What makes that true rather than merely intended is that a sink provider holds nothing belonging to a session. A voice manager belongs to one
bot.Clientand closes over that client’s gateway; a DAVE registry is built fresh per session. A provider that kept either went on using a gateway that had been shut, which fails instantly and forever — and no invalidation helped, because what had gone stale was the thing that makes connections rather than a connection. Providers resolve both per acquisition, so the one memoized per guild is now safe to keep.disgo delivers heartbeat ACKs as events. The watchdog records them on arrival and reads their timestamps without taking the session’s write lock. The historical notes retain the previous library’s lock-related failure and why independent liveness checks matter.
Teardown is still bounded and abandonable (
closeWithin), for the reason that outlived the fork: it is the last thingRunSessiondoes, so a step that never returns strands the restart loop and the bot a watchdog just correctly declared dead never comes back. Every step logs its duration undershutdown_phase.
On the user-facing side: synchronous failures get answered directly by the
handler, as an ephemeral embed. Asynchronous failures — a track dying
mid-play — travel through
runPlayback → markPlaybackFailed → Options.OnPlaybackFailed → voice.Service.NotifyPlaybackFailed,
which edits the guild status message, falling back to a public message in
the last-used command channel if needed. reply.ClampEmbedText cuts the raw
error text to something Discord will accept in an embed.
ProcessStream (the ffmpeg wrapper) converts a zero-byte EOF from a failed
process into the real underlying error, so an instant ffmpeg failure — a
403, a bad URL — never gets mistaken for a clean track end. The transcode
parsers build ffmpeg via ffmpeg.NewPCMCommand (or NewPCMCommandUA, which
adds the extracting client’s User-Agent) and wrap its PCM output in
ffmpeg.OpusReader, which encodes to Opus packets via opus.Encode. ffmpeg
stderr is captured and classified (403/forbidden/conversion failures at Warn).
Discord command layer
Commands implement the melodix Handler interface and get registered
through adapter.Register into keshon/command’s DefaultRegistry,
wrapped in middleware for guild-only checks, per-guild disabled-command
gating, permission checks, and invocation logging. Optional capabilities are
discovered through interface assertion: SlashProvider,
ContextMenuProvider, ComponentInteractionHandler.
Dispatch happens through onApplicationCommand, which routes slash and
context-menu commands into queue and returns; message components are
matched by a customID prefix convention (name, name:, name_) and go
the same way. The body then runs on a worker, under the queue’s own cap
(COMMAND_PARALLELISM limits how many run at once across all guilds).
That indirection is the point. disgo dispatches events synchronously — one
goroutine reads the socket and calls every listener inline — so a command
body running there held the socket unread for as long as it took, which for
a hundred-item playlist is seconds. An interaction arriving in that window
was acknowledged past Discord’s three-second deadline and the user saw “The
application did not respond”. It also made COMMAND_PARALLELISM describe a
property the runtime did not have: commands were already serial, so the
semaphore never contended.
The gateway loop stays serial, because its ordering is load-bearing: Ready
and GuildJoin both sync commands and must not interleave. One lane per
guild, because a guild’s music is sequential — /play and /next at the
same instant have no meaningful interleaving. slashsync’s per-guild lock
became load-bearing in the same change rather than redundant: Ready syncs
on the gateway goroutine while /commands enable syncs on a worker.
Slash-command sync is handled by slashsync.Syncer, which diffs desired
against existing per-guild commands by name, type, and fingerprint whenever
INIT_SLASH_COMMANDS=true. And go run ./cmd/readme regenerates both
command listings in README.md from the bot’s and the CLI’s catalogs — that’s
a dev step, run from the repo root; neither binary writes files at runtime.
State & persistence
State lives in three places. In memory, per guild, and surviving reconnects:
voice.Service holds players, sink providers, status-message IDs, and
notify channels. In memory, per session: the Bot’s session context and
exec guard, swapped atomically on each RunSession. And on disk: an
embedded write-ahead-logged datastore (keshon/datastore) that owns the
entire STORAGE_PATH directory (LOCK, wal.log, snapshot-*.json).
Everything’s held in memory, and every commit is appended and fsynced before
it’s acknowledged. The collections are guild_settings (disabled command
groups), command_log (last 50 per guild), playback (last 750 per guild —
/play <id> replays an entry without re-resolving it), and cache_entries
(the global track-cache index). Per-guild collections are indexed by guild
ID and keyed "<guildID>:<zero-padded id>", so reading an index returns a
guild’s rows in chronological order; the IDs themselves come from the
store’s persisted tx.NextID counters.
The storage directory is locked to a single process, so if the bot already holds the lock the CLI plays on without playback history and with an in-memory cache index, rather than refusing to start.
Only tracks that actually start playing get recorded, through the
PlaybackRecorder hook.
Adding a new source or parser
See the extension checklists. Interface shapes are described under The three extension layers.
Known limitations
- YouTube live broadcasts need yt-dlp, plus a JavaScript runtime — see the dependency note under Package map. They are HLS, and no other parser here can follow a playlist.
- The track cache stores copyrighted audio to disk. It is opt-in,
size-capped, and transient when
CACHE_PERSISTENT=false, which makes it behave like a cache rather than an archive — but it remains a real tradeoff, not a footnote. /play’ssourceandparserchoice lists are maintained by hand (internal/discord/command/music/play/play.go) and can drift from the resolver andstream.registryEntries. Nothing checks this.- Pause and resume are not supported — the sink owns the read loop; see
Playback pipeline. Commands that try get
ErrPauseNotSupported. - One process per storage directory. The CLI runs without history and with an in-memory cache index when the bot already holds the lock, rather than refusing to start.
- Convention checks reject violations across the source tree — see conventions.md.
Tried and rejected
Historical measurements and retired approaches are preserved in implementation notes.
Ownership
ownership.md defines lifecycle and concurrency contracts, names their enforcement and records coverage limits. Contributor practices are in conventions.md.
Testing & verification
Development commands and manual checks are in conventions.md.