Metadata routing middleware for radio stations that routes metadata from inputs (playout software, APIs, and static text) to outputs (Icecast, files, and webhooks) with priority-based fallback and configurable delays. Originally designed for ZuidWest FM in the Netherlands.

go build
cp config-example.json config.json
# Edit config.json
./zwfm-metadata
Dashboard: http://localhost:9000
The application supports styling customization through the configuration file:
{
"webServerPort": 9000,
"debug": false,
"stationName": "Your Radio Station",
"brandColor": "#e6007e",
"inputs": [...],
"outputs": [...]
}
webServerPort
(default: 9000) - Port for the web dashboard and APIdebug
(default: false) - Enables debug loggingstationName
(default: "ZuidWest FM") - Station name displayed in the dashboard and browser titlebrandColor
(default: "#e6007e") - Brand color (hex) used throughout the dashboard UI
The dashboard automatically adapts to your brand colors, using them for headers, badges, and accent elements throughout the interface.
Configure metadata sources in priority order.
Input Type | Purpose | Update Method | Authentication | Expiration Support | Polling |
---|---|---|---|---|---|
Dynamic | Live playout updates | HTTP API | Secret | ✅ (Dynamic/Fixed/None) | ❌ |
URL | External API integration | HTTP polling | N/A | ✅ (via JSON field) | ✅ |
Text | Static fallback | Config file | N/A | ❌ | ❌ |
- Expiration Support: Whether metadata expires automatically (Dynamic: based on song duration, Fixed: after a set time, None: never expires)
- Polling: Whether the input polls for updates versus receives them via API
HTTP API for live updates
{
"type": "dynamic",
"name": "radio-live",
"prefix": "🎵 Now Playing: ",
"suffix": " 🎵",
"settings": {
"secret": "supersecret123",
"expiration": {
"type": "dynamic"
}
}
}
secret
(optional) - Authentication secret for API callsexpiration.type
-"dynamic"
(expires based on song duration),"fixed"
(expires after a set number of minutes), or"none"
(never expires)expiration.minutes
(required if type=fixed) - Number of minutes until expiration
# Update with all fields (duration enables auto-expiration for type=dynamic)
curl "http://localhost:9000/input/dynamic?input=radio-live&songID=123&artist=Artist&title=Song&duration=03:45&secret=supersecret123"
# Minimal update (only title required)
curl "http://localhost:9000/input/dynamic?input=radio-live&title=Song&secret=supersecret123"
input
(required) - Input name from configtitle
(required) - Song/track titlesongID
(optional) - Unique song identifierartist
(optional) - Artist nameduration
(optional) - Song duration in MM:SS (e.g.,3:45
or03:45
) or HH:MM:SS (e.g.,1:30:00
or01:30:00
) format. Leading zeros are optional. Used for auto-expiration whenexpiration.type
is"dynamic"
. Invalid formats cause immediate expiration.secret
(required if configured) - Authentication secret
Polls external APIs
{
"type": "url",
"name": "nowplaying-api",
"prefix": "From API: ",
"suffix": " [Live]",
"settings": {
"url": "https://api.example.com/nowplaying",
"jsonParsing": true,
"jsonKey": "data.current.title",
"pollingInterval": 30
}
}
url
(required) - URL to poll for metadatapollingInterval
(required) - Number of seconds between HTTP requestsjsonParsing
(optional, default: false) - Parses the response as JSONjsonKey
(required if jsonParsing=true) - Dot-notation path to extract the value (e.g.,"data.song.title"
)expiryKey
(optional) - Dot-notation path to the expiry value in the JSON response. When set, the expiry is parsed and used for metadata expiration. When the expiry is reached, polling occurs immediately in addition to regular interval polling.expiryFormat
(optional) - Format string for parsing the expiry value (e.g., RFC3339). Defaults to RFC3339 if not specified.
A static fallback
{
"type": "text",
"name": "default-text",
"settings": {
"text": "Welcome to ZuidWest FM!"
}
}
text
(required) - Static text to display
All input types support:
prefix
(optional) - Text prepended to metadatasuffix
(optional) - Text appended to metadata
Control where formatted metadata is sent.
Output Type | Purpose | Enhanced Metadata | Custom Payload Mapping | Authentication |
---|---|---|---|---|
Icecast | Update streaming server metadata | ❌ | ❌ | Basic Auth |
File | Write to local filesystem | ❌ | ❌ | N/A |
POST | Send via HTTP webhooks | ✅ | ✅ | Bearer Token |
HTTP | Serve metadata via GET endpoints | ✅ | ✅ | N/A |
DLS Plus | DAB/DAB+ radio text | ✅ | ❌ | N/A |
WebSocket | Real-time browser/app updates | ✅ | ✅ | N/A |
StereoTool | Update RDS RadioText | ❌ | ❌ | N/A |
- Enhanced Metadata: Receives full metadata details (title, artist, duration, etc.) rather than just formatted text
- Custom Payload Mapping: Supports transforming output to match any JSON structure
- Authentication: Security mechanisms supported
All output types support:
inputs
(required) - Array of input names in priority orderformatters
(optional) - Array of formatter names to apply
Updates streaming server metadata
{
"type": "icecast",
"name": "main-stream",
"inputs": ["radio-live", "nowplaying-api", "default-text"],
"formatters": ["ucwords"],
"settings": {
"delay": 2,
"server": "localhost",
"port": 8000,
"username": "source",
"password": "hackme",
"mountpoint": "/stream"
}
}
delay
(required) - Number of seconds to delay metadata updatesserver
(required) - Icecast server hostname/IPport
(required) - Icecast server portusername
(required) - Icecast username (usually "source")password
(required) - Icecast passwordmountpoint
(required) - Stream mountpoint (e.g., "/stream")
Writes to the filesystem
{
"type": "file",
"name": "nowplaying-file",
"inputs": ["radio-live", "default-text"],
"formatters": ["uppercase"],
"settings": {
"delay": 0,
"filename": "/tmp/nowplaying.txt"
}
}
delay
(required) - Number of seconds to delay metadata updatesfilename
(required) - Full path to output file
Sends metadata via HTTP webhooks
{
"type": "post",
"name": "webhook",
"inputs": ["radio-live", "nowplaying-api", "default-text"],
"formatters": ["ucwords"],
"settings": {
"delay": 1,
"url": "https://api.example.com/metadata",
"bearerToken": "your-bearer-token-here",
"payloadMapping": {...} // See Custom Payload Mapping section
}
}
delay
(required) - Number of seconds to delay metadata updatesurl
(required) - Webhook endpoint URLbearerToken
(optional) - Authorization bearer tokenpayloadMapping
(optional) - Custom JSON payload structure (see Custom Payload Mapping)
Serves metadata via GET endpoints with multiple response formats
{
"type": "http",
"name": "metadata-api",
"inputs": ["radio-live", "nowplaying-api", "default-text"],
"formatters": ["ucwords"],
"settings": {
"delay": 0,
"endpoints": [
{
"path": "/api/nowplaying.json",
"responseType": "json"
},
{
"path": "/api/current.txt",
"responseType": "plaintext"
},
{
"path": "/api/custom.json",
"responseType": "json",
"payloadMapping": {
"station": "My Radio",
"track": "{{.title}}",
"artist": "{{.artist}}"
}
}
]
}
}
delay
(required) - Number of seconds to delay metadata updatesendpoints
(required) - Array of HTTP endpoints to serve
path
(required) - URL path for the endpointresponseType
(optional) - Response format:json
(default),xml
,yaml
, orplaintext
payloadMapping
(optional) - Custom response structure (see Custom Payload Mapping)
- JSON: Standard metadata object with all fields
- XML: XML with escaped content
- YAML: YAML format for configuration files
- Plaintext: Just the formatted metadata text
Broadcasts metadata to connected clients with real-time updates.
{
"type": "websocket",
"name": "websocket-server",
"inputs": ["radio-live", "nowplaying-api", "default-text"],
"formatters": ["ucwords"],
"settings": {
"delay": 0,
"path": "/metadata",
"payloadMapping": {...} // See Custom Payload Mapping section
}
}
delay
(required) - Number of seconds to delay metadata updatespath
(required) - URL path for WebSocket connections (e.g., "/metadata", "/ws")payloadMapping
(optional) - Custom JSON message structure (see Custom Payload Mapping)
Note: WebSocket endpoints are served on the main web server port (default: 9000), not on a separate port. All WebSocket operations use write timeouts to prevent hanging connections.
JavaScript:
const ws = new WebSocket('ws://localhost:9000/metadata');
ws.onmessage = (event) => {
const metadata = JSON.parse(event.data);
console.log('Metadata update:', metadata);
// Update your UI with the new metadata
};
ws.onopen = () => {
console.log('Connected to metadata WebSocket');
// You'll immediately receive the current metadata
};
Generates DLS Plus format for DAB/DAB+ transmission
{
"type": "dlsplus",
"name": "dlsplus-output",
"inputs": ["radio-live", "nowplaying-api", "default-text"],
"formatters": [],
"settings": {
"delay": 0,
"filename": "/tmp/dlsplus.txt"
}
}
delay
(required) - Number of seconds to delay metadata updatesfilename
(required) - Full path to output file
Generates ODR-PadEnc compatible DLS Plus files with parameter blocks:
##### parameters { #####
DL_PLUS=1
DL_PLUS_TAG=4 0 5
DL_PLUS_TAG=1 9 9
DL_PLUS_ITEM_RUNNING=1
DL_PLUS_ITEM_TOGGLE=0
##### parameters } #####
Artist - Song Title
The output automatically:
- Detects artist and title positions in the formatted text
- Generates correct DL_PLUS_TAG entries (type 4 for ARTIST, type 1 for TITLE)
- Sets DL_PLUS_ITEM_RUNNING=1 for tracks (with artist+title), 0 for station/program info
- Alternates DL_PLUS_ITEM_TOGGLE between 0 and 1 on each update to indicate content changes
- Handles prefixes and suffixes correctly
- Works with any formatters applied to the text
Note: ODR-PadEnc automatically re-reads DLS files before each transmission.
Updates StereoTool's RDS RadioText and Streaming Output Metadata
{
"type": "stereotool",
"name": "stereotool-rds",
"inputs": ["radio-live", "nowplaying-api", "default-text"],
"formatters": ["rds"],
"settings": {
"delay": 2,
"hostname": "localhost",
"port": 8080
}
}
delay
(required) - Number of seconds to delay metadata updateshostname
(required) - StereoTool server hostname/IPport
(required) - StereoTool HTTP server port (typically 8080)
- Updates both FM RDS RadioText (ID: 9985) and Streaming Output Song (ID: 6751)
- Uses StereoTool's undocumented JSON API
- Recommended for use with the RDS formatter for 64-character compliance
- Automatically handles EBU Latin character set (0-255 range) for proper RDS encoding
Both POST and WebSocket outputs support custom payload mapping to transform the output format to match any JSON structure that your API expects.
- Static values: Any string without
{{}}
is used as-is - Field references: Use
{{.fieldname}}
to reference metadata fields - Template functions: Use pipe syntax for transformations, e.g.,
{{.title | upper}}
- Nested objects: Create complex JSON structures with nested mappings
- Mixed templates: Combine static text with fields, e.g.,
"Now playing: {{.title}}"
{{.formatted_metadata}}
- The formatted text after applying formatters{{.songID}}
- Song identifier{{.title}}
- Song title{{.artist}}
- Artist name{{.duration}}
- Song duration{{.updated_at}}
- When the metadata was updated (RFC3339 format){{.expires_at}}
- When the metadata expires (RFC3339 format, empty if no expiration){{.type}}
- Message type (WebSocket only: "metadata_update"){{.source}}
- Name of the input that provided this metadata{{.source_type}}
- Type of the input (e.g., "dynamic", "url", "text")
{{.field | upper}}
- Converts to uppercase{{.field | lower}}
- Converts to lowercase{{.field | trim}}
- Removes leading/trailing whitespace{{.field | formatTime}}
- Formats time.Time to RFC3339 (rarely needed as times are pre-formatted){{.field | formatTimePtr}}
- Formats *time.Time to RFC3339, returns empty string if nil
When payloadMapping
is not specified:
POST Output:
{
"formatted_metadata": "Artist - Title",
"songID": "12345",
"title": "Title",
"artist": "Artist",
"duration": "3:45",
"updated_at": "2023-12-01T15:30:00Z",
"expires_at": "2023-12-01T15:33:45Z",
"source": "radio-live",
"source_type": "dynamic"
}
WebSocket Output:
{
"type": "metadata_update",
"formatted_metadata": "Artist - Title",
"songID": "12345",
"title": "Title",
"artist": "Artist",
"duration": "3:45",
"updated_at": "2023-12-01T15:30:00Z",
"expires_at": "2023-12-01T15:33:45Z",
"source": "radio-live",
"source_type": "dynamic"
}
{
"payloadMapping": {
"event": "{{.type}}",
"station": "My Radio Station",
"now_playing": {
"song": "{{.title}}",
"artist": "{{.artist}}",
"full_text": "{{.formatted_metadata}}"
},
"metadata": {
"song_id": "{{.songID}}",
"duration": "{{.duration}}",
"timestamp": "{{.updated_at}}",
"expires": "{{.expires_at}}"
}
}
}
Output:
{
"event": "metadata_update",
"station": "My Radio Station",
"now_playing": {
"song": "Imagine",
"artist": "John Lennon",
"full_text": "John Lennon - Imagine"
},
"metadata": {
"song_id": "12345",
"duration": "3:04",
"timestamp": "2023-12-01T15:30:00Z",
"expires": "2023-12-01T15:33:04Z"
}
}
{
"payloadMapping": {
"title": "{{.title}}",
"artist": "{{.artist}}",
"timestamp": "{{.updated_at}}"
}
}
Output:
{
"title": "Imagine",
"artist": "John Lennon",
"timestamp": "2023-12-01T15:30:00Z"
}
{
"payloadMapping": {
"message": "Now playing: {{.title}} by {{.artist}}",
"station_info": {
"name": "My Radio Station",
"frequency": "101.5 FM",
"region": "Amsterdam"
},
"expires_at": "{{.expires_at}}"
}
}
{
"payloadMapping": {
"title": "{{.title}}",
"artist": "{{.artist}}",
"metadata_source": {
"input_name": "{{.source}}",
"input_type": "{{.source_type}}",
"reliability": "{{if eq .source_type \"dynamic\"}}live{{else if eq .source_type \"url\"}}automated{{else}}static{{end}}"
}
}
}
{
"payloadMapping": {
"title_upper": "{{.title | upper}}",
"artist_lower": "{{.artist | lower}}",
"clean_text": "{{.formatted_metadata | trim}}",
"display": "NOW PLAYING: {{.title | upper}} BY {{.artist | upper}}"
}
}
Output:
{
"title_upper": "IMAGINE",
"artist_lower": "john lennon",
"clean_text": "John Lennon - Imagine",
"display": "NOW PLAYING: IMAGINE BY JOHN LENNON"
}
Apply text transformations to metadata before sending it to outputs.
Converts to uppercase
"Artist - Song Title" → "ARTIST - SONG TITLE"
Converts to lowercase
"Artist - Song Title" → "artist - song title"
Converts to title case
"artist - song title" → "Artist - Song Title"
Radio Data System formatter (64-character limit)
"<b>Very Long Artist Name</b> feat. Someone - Very Long Song Title (Extended Remix Version)"
→ "Very Long Artist Name - Very Long Song Title"
Smart processing for RDS compliance:
- HTML cleaning: Strips all HTML tags (
<b>
,<i>
,<span>
,<script>
) and decodes entities (&
→&
,<
→<
,"
→"
,­
→ soft hyphen,
→ non-breaking space) - Character filtering: Keeps only the EBU Latin character set (0-255 range) for RDS compatibility. Characters outside this range, such as zero-width spaces (
\u200B
,\u200C
,\u200D
) and other Unicode characters, are removed - Single-line output: Converts newlines (
\n
,\r
) and tabs (\t
) to spaces for RDS displays - Smart truncation (applied in order until under 64 chars):
- Progressively removes content in parentheses from right to left:
Artist - Song (Important Info) (Extended Mix)
→Artist - Song (Important Info)
- Progressively removes content in brackets from right to left:
Artist - Song [Live] [Remastered]
→Artist - Song [Live]
- Removes featured artists:
feat.
,ft.
,featuring
,with
,&
- Removes remix indicators after second hyphen
- Removes common suffixes:
Remix
,Mix
,Edit
,Version
,Instrumental
,Acoustic
,Live
,Remaster
- Truncates at word boundaries with
...
if still too long
- Progressively removes content in parentheses from right to left:
{
"type": "icecast",
"name": "main-stream",
"formatters": ["ucwords", "rds"],
"settings": {...}
}
Formatters are applied in order: ucwords
first, then rds
.
- Priority fallback: Outputs use the first available input in the priority list
- Configurable delays: Synchronizes timing across different outputs
- Input expiration: Dynamic inputs expire automatically
- Prefix/suffix support: Adds station branding to inputs
- Web dashboard: Real-time status at http://localhost:9000 with WebSocket updates and a connection status indicator
curl "http://localhost:9000/input/dynamic?input=radio-live&title=Song&artist=Artist&duration=03:45"
go fmt ./...
go vet ./...
go build
Set "debug": true
in config.json
for detailed logging.
See EXTENDING.md
for detailed guidance on adding new inputs, outputs, and formatters. Key patterns include:
- Using
utils.ConvertMetadata()
for consistent metadata handling across outputs - Embedding base types (
core.InputBase
,core.OutputBase
) for common functionality - Using
core.PassiveComponent
for components without background tasks
MIT