# Architecture

Use this guide to follow components and failure paths. For a code change, start
with [Conventions](<https://melodix.keshon.ru/docs/conventions.md>); lifecycle contracts live in [Ownership](<https://melodix.keshon.ru/docs/ownership.md>).

- [Package map](<https://melodix.keshon.ru/docs/architecture.md#package-map>) and [extension interfaces](<https://melodix.keshon.ru/docs/architecture.md#the-three-extension-layers>)
- [Resolution and parser fallback](<https://melodix.keshon.ru/docs/architecture.md#resolution>)
- [Playback pipeline](<https://melodix.keshon.ru/docs/architecture.md#playback-pipeline>) and [status delivery](<https://melodix.keshon.ru/docs/architecture.md#status-delivery-single-consumer-contract>)
- [Failure handling](<https://melodix.keshon.ru/docs/architecture.md#failure-handling>)
- [Persistence](<https://melodix.keshon.ru/docs/architecture.md#state--persistence>) and [known limitations](<https://melodix.keshon.ru/docs/architecture.md#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.

```mermaid
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/music` is 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:

```go
// 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:

1. **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`.
2. **Auto-detect, bare query** — always routed to YouTube.
3. **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.)
4. **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 InnerTube `player` endpoint 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/youtube` resolves a WebM/Opus
  stream and downloads it in chunks, demuxed the same way. Rides the same
  client; see `parsers/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](<https://melodix.keshon.ru/docs/running.md#configuration>) for both
settings.

- **Track cache** (`CACHE_ENABLED`) sits **inside** `RecoveryStream`, above
  the recovery logic, so one blob spans parser switches and transport reopens
  and is committed only on a clean end. `Open` checks 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 in `pkg/music/cache`.
- **Anti-skip buffer** (`BUFFER_AHEAD_MS`) sits **outside** it, wrapped
  around `RecoveryStream` through `Packets()`. 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
  moves `ReadPacket` onto a read-ahead goroutine, which is why
  `RecoveryStream` guards `reader`/`cleanup` with a mutex and why `Close`
  signals, 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

```mermaid
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 `[]Track` under `p.mu`. `playNextMu` serializes
  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: `PlayNext` returning `ErrNoTracksInQueue` leads straight to
  `Stop(true)`.
- **Per-run ownership** — each run is a `*run` with 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 == r` answers
  whether it is still in charge (see [ownership.md](<https://melodix.keshon.ru/docs/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 as `ErrVoiceTransport`. 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 `ErrPauseNotSupported` back.

### 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:

1. **Media failures**, handled by `stream.RecoveryStream` on 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
   why `Open` logs `stream_opening` and the real `stream_opened` waits 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.
2. **Voice transport failures**, handled in `player.runPlayback`, up to
   three attempts. An `ErrVoiceTransport` from the sink triggers either
   `hard` mode (invalidate the sink, forcing a voice-channel rejoin) or
   `soft` mode (retry the stream first — governed by
   `PLAYER_TRANSPORT_RECOVERY_MODE` and `PLAYER_TRANSPORT_SOFT_ATTEMPTS`),
   then reopens media at the current position without touching the media
   retry budget.
3. **Session failures**, handled in `internal/discord`. A gateway-silence
   watchdog (`WS_SILENCE_TIMEOUT`) marks the session unhealthy, and
   `DISCORD_UNHEALTHY_MODE` decides what happens next (`restart-session`,
   `restart-voice`, or `ignore`). `main.go` runs `RunSession` in a restart
   loop, and since `music.Service` and 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.Client` and 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](<https://github.com/keshon/melodix/blob/b191282f08193ccf22c8c0215d786e6069e317f8/docs/internal/implementation-notes.md>) 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 thing `RunSession` does, 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
   under `shutdown_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](<https://melodix.keshon.ru/docs/conventions.md#adding-things>). Interface shapes
are described under [The three extension layers](<https://melodix.keshon.ru/docs/architecture.md#the-three-extension-layers>).


## Known limitations

- **YouTube live broadcasts need yt-dlp**, plus a JavaScript runtime — see
  the dependency note under [Package map](<https://melodix.keshon.ru/docs/architecture.md#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`'s `source` and `parser` choice lists are maintained by hand**
  (`internal/discord/command/music/play/play.go`) and can drift from the
  resolver and `stream.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](<https://melodix.keshon.ru/docs/conventions.md#formatting--ci>).


## Tried and rejected

Historical measurements and retired approaches are preserved in
[implementation notes](<https://github.com/keshon/melodix/blob/b191282f08193ccf22c8c0215d786e6069e317f8/docs/internal/implementation-notes.md>).


## Ownership

[ownership.md](<https://melodix.keshon.ru/docs/ownership.md>) defines lifecycle and concurrency contracts, names
their enforcement and records coverage limits. Contributor practices are in
[conventions.md](<https://melodix.keshon.ru/docs/conventions.md>).


## Testing & verification

Development commands and manual checks are in
[conventions.md](<https://melodix.keshon.ru/docs/conventions.md#testing--verification>).
