Playing audio through plugins
Play audio produced by a configured plugin host, with host affinity, lifecycle, and media rules.
Use an operation source when the audio must be resolved at playback time. The assigned server calls the configured host, redeems its media response, and feeds encoded bytes to the decoder. For JSON results, use operation forwarding.
Play it from your application
const voice = remedia.createDiscordSession({
guildId,
channelId,
userId,
pluginHosts: ['source-host-1'], // only use nodes that have this host configured
});
await voice.connect();
await voice.play({
hostId: 'source-host-1',
pluginId: 'example.source',
operation: 'source.stream',
input: { id: 'opaque to Remedia' },
});stageNext() accepts the same source for crossfading.
hostIdis the ID configured on the server, not a display name. It carries no URL or credentials. Store it in your own track model, never the resulting media URL.inputis opaque JSON that Remedia passes through unchanged.- Only remote nodes can play operation sources. Embedded nodes and servers without the host reject them with
UnsupportedMediaSourceErrorbefore anything is sent. - A guild is never moved to another node, and the call never falls back to another host.
- Resolve audio at playback time.
probe()rejects operation sources because their media links are single use. - Errors are
RemediaRpcErrorwith codes such asplugin_host_unavailable,plugin_host_unauthorizedandcancelled(stop or skip while the operation was pending). - Host-origin media grants are forward-only. A backward seek rejects with
unsupported. Explicitly permitted external media URLs follow ordinary HTTP seeking behavior.
Register a host
The quickest way is the CLI, which discovers the host and writes the entry for you:
remedia-server plugins install https://plugins.example.com (see Manage plugin hosts).
The entry it creates looks like this, and you can also write it by hand:
Hosts are operator-owned entries in remedia.config.json. A
host ID is a stable deployment identifier chosen by you. A manifest name is only a display name.
{
"pluginHosts": [
{
"id": "source-host-1",
"url": "https://plugins.example.com",
"auth": { "type": "bearer", "token": "env('REMEDIA_PLUGIN_HOST_TOKEN')" },
"operations": [
{ "pluginId": "example.source", "operation": "source.stream", "replaySafe": true }
]
}
]
}Only allow-listed (pluginId, operation) pairs can run, even if the host advertises more. Duplicate
host IDs, unknown fields, cleartext HTTP without allowHttp,
and invalid limits stop startup. Explicitly configured localhost and LAN origins are allowed. Endpoints are origins (no path, query
or credentials in the URL). The v1 routes are fixed.
Discovery and health
At startup, and then every limits.discoveryTtlMs (default 60 s), the node fetches
/.well-known/remedia using the host's authentication and validates it:
protocolVersionmust be1. The execute route must be/remedia/v1/executeand the media template must be under/remedia/v1/media/. Advertised paths are verified, never followed.- An optional
hostIdfield, if the host emits one, must equal the configured ID. - Allowed operations should be advertised.
doctorandnode.statsreport the ones that are not. A missing operation triggers at most one rediscovery per five seconds before the request fails withplugin_operation_unavailable.
A host being down at startup is not fatal. Its state is unavailable until discovery succeeds. State
values are unknown, healthy, degraded (recent failures), unavailable (three consecutive
availability, authentication, protocol or timeout failures) and shutting_down. A host which restarts
with the same ID and endpoint recovers on its next successful request or discovery. The node does not
restore old grant URLs.
Wire format
player.play and player.stage.next accept a third source type:
{
"type": "operation",
"hostId": "source-host-1",
"pluginId": "example.source",
"operation": "source.stream",
"input": { "id": "opaque to Remedia" }
}The operation runs on the node selected for the guild, only when playback or staging prepares the
source. Do not resolve encoded audio during metadata search. The host ID selects exactly one
configured deployment: there is no fallback to another host or another node, so overlapping plugin
and operation IDs on different deployments route independently. media.probe rejects operation
sources because media grants are single use.
The operation must return a descriptor { "url": "...", "expiresAtMs"?: number }. This is what
@remedia/plugin-host produces for a returned createMediaSource() or stream. The URL is a bearer
capability for one redemption and is never stored or exposed as the source identity. Replaying a
queue entry executes the operation again.
| Descriptor URL | Handling |
|---|---|
Origin is the host endpoint or in mediaOrigins | Fetched by the server's own client: pinned DNS, no redirects, no credentials, bounded size, forward-only. Counts against maxActiveStreams. |
| Any other origin | Rejected with plugin_media_invalid, unless allowExternalMedia is true. Then the ordinary media.* URL policy and FFmpeg HTTP input apply, outside the host's credential and origin boundary. |
v1 media grants are sequential
A v1 grant is single use and answers every Range request with HTTP 416. Remedia therefore never
lets FFmpeg talk to it. The node redeems the URL with exactly one plain GET and feeds a bounded
queue to the decoder, so default playback needs no FFmpeg flags and no -seekable 0:
- No
Range, reopen, reconnect or second request is ever made for a grant. player.seekcan move forward by decoding and discarding audio. Seeking backward, or into source that has already been consumed, returnsunsupported. The source is not silently reopened.- A transfer interrupted midway fails the playback generation (
player.failed). A freshplayer.playruns the operation again and resolves a new grant.
The same strategy is available to Rust callers of remedia-core as InputSource::sequential_http_url
(see Decoding). Ordinary URLs keep FFmpeg's seekable behavior.
Lifecycle and cancellation
Operation requests share the guild's generation and cancellation model:
- Stop, skip or replacement cancels a pending
player.playoperation. The request fails withcancelledand the HTTP request to the host is dropped (a well-behaved host sees its operation signal abort). A late result from a handler which ignores cancellation is discarded and cannot start an outdated generation. - Staged-next preparation has its own scope. A newer
queueRevision(orsourceRevisionfor the same entry) supersedes a pending one,player.stage.clear,player.stop, a replacement play, guild destruction and shutdown cancel it, and a result which arrives after the timeline moved on fails withstale_generationwithout being staged. - Client disconnect. If the WebSocket closes while an operation is pending, the operation is cancelled. Active playback follows the existing session-resumption rules.
- Shutdown cancels pending operations and media transfers, stops players, and waits briefly for transfer threads to finish before the process exits.
- Backpressure. Each stream has a fixed queue (
mediaBufferBytes, default 256 KiB). A paused or slow player stops pulling from the host. Nothing buffers without bound. - Expired grants. If the descriptor has already expired, or redemption returns 404 (expired or
already redeemed), the node executes the operation again once per remaining attempt, but only for
operations marked
replaySafe. Other operations fail withplugin_media_expired. - Retries. Only
replaySafeoperations are retried, only for connection failures, timeouts and408/429/5xxresponses (except a reported plugin failure), up tolimits.maxAttempts(default 2, maximum 3). Authentication failures (plugin_host_unauthorized) and policy or validation failures are never retried. Each attempt carries a freshX-Remedia-Operation-Idheader so hosts can correlate or deduplicate.
Security
Network, credential and size rules are shared with every plugin feature and are described in
Permissions and security. For media specifically: credentials are sent
only to the host origin for discovery and execution and never to media URLs, mediaOrigins, or
redirect targets. Media must not declare a textual or JSON content type. And maxMediaBytes,
maxPendingOperations and maxActiveStreams bound each host (exceeding a concurrency limit returns
resource_exhausted).
Errors
| Code | Meaning |
|---|---|
plugin_host_disabled | The configured host is disabled |
plugin_host_not_found | The host ID is not configured on this node |
plugin_operation_not_allowed | The operation is not in the host's allowlist |
plugin_operation_unavailable | The host does not advertise the operation |
plugin_host_unavailable | Connection failure, DNS policy rejection, or transient 429/5xx |
plugin_host_unauthorized | The host rejected the configured credentials |
plugin_host_incompatible | Wrong protocol version, identity or routes, or a redirect |
plugin_operation_failed | The plugin failed or returned an invalid response |
plugin_operation_declined | The operation returned no result (handled: false) while resolving media |
plugin_result_too_large | A forwarded result exceeds the RPC message limit |
plugin_media_invalid | Bad descriptor, disallowed origin, redirect, oversized or non-audio media |
plugin_media_expired | The grant was expired or consumed and could not be refreshed |
plugin_media_failed | The media origin could not be reached or transferred |
resource_exhausted | Pending-operation or active-stream limit reached |
cancelled, request_timeout, stale_generation, invalid_state | Lifecycle outcomes described above |
Messages never contain URLs, tokens, or operation input.
Diagnostics
node.info.capabilities.pluginOperationsis{ available, hosts, forwarding?: { available } }. Older nodes omit it (orforwarding), and clients treat that as unsupported.node.stats.pluginHostsandremedia-server --list --jsonreport each host's state, consecutive failures, last error label, pending and active-stream counts, and cumulative counters (operations, failures, retries, declined, unauthorized, timeouts, cancelled, grants opened and expired, media bytes and failures, discoveries, and the forwardedgenericOperations,genericFailuresandgenericUnhandled).remedia-server doctorperforms a live discovery for each host. See the CLI guide.- Structured events
server.plugin_host.discovered,discovery_failed,operation_completedandoperation_failed(targetremedia_server::plugin_host) carry the host ID, plugin, operation, attempt, duration and error label, never URLs or input.
Requirements for plugin-host authors
- Return
createMediaSource()(or a stream) from the operation, withpublicUrlset to an origin which the Remedia node can reach and that appears in the node'sendpointormediaOrigins. - Configure a
token(at least 16 characters) and put the same value in the node configuration. - Make allow-listed operations side-effect free if you mark them
replaySafe. - Respect the operation
signal. Return audio only (MP3, Ogg/Opus, and anything FFmpeg can decode). - Keep
mediaTtlMslong enough for the node to redeem immediately after the operation returns. The node redeems at once. The TTL does not limit how long playback may take. - Do not rely on seeking, ranges or reopening. V1 grants support none of them.
Limitations
- Operation sources are remote-only. Embedded
@remedia/noderejects them explicitly. - They are not migrated between nodes. A guild stays on the node (and host affinity) it started on.
- Backward seeking and seeking into already-consumed audio are unsupported for sequential grants.
- No seekable or reopenable media contract exists yet. It would need explicit capability negotiation,
validated
Range/206/416semantics and content validators in@remedia/plugin-hostfirst. - A host can only be added or removed by editing configuration and restarting the node.