Skip to content

Programmers Spec

code edited this page Mar 2, 2026 · 2 revisions

Grimnir Radio — Programmer's Spec

Version: 1.18.64 Architecture: Go-Based Broadcast Automation Platform License: AGPL-3.0-or-later Status: Production-ready

This document is for developers working on Grimnir Radio. It describes the implemented codebase, package layout, build system, conventions, and API surface.


Design Philosophy

Core Principle

Go owns the control plane. A dedicated media engine owns real-time audio.

What This Means for Developers

  • No audio DSL: Configuration is declarative (YAML/JSON), not scripting
  • Separation of concerns: Scheduler generates timeline, executor executes it, media engine plays audio
  • gRPC for control: Media engine controlled via gRPC, not CLI glue scripts
  • API-first: All operations exposed via HTTP/gRPC APIs
  • Isolated failures: One component crash doesn't take down the whole system
  • No Icecast dependency: Built-in HTTP stream relay handles all listener output

Repository Layout

grimnir_radio/
├── cmd/
│   ├── grimnirradio/          # Control plane binary
│   │   └── main.go            # Entry point, service bootstrap
│   └── mediaengine/           # Media engine binary
│       └── main.go            # gRPC server, GStreamer pipeline manager
│
├── internal/
│   ├── api/                   # HTTP+WebSocket API (26+ endpoints)
│   │   └── api.go
│   ├── analyzer/              # Media analysis service
│   │   └── analyzer.go        # LUFS analysis, cue point detection
│   ├── auth/                  # JWT auth + RBAC
│   │   ├── jwt.go
│   │   └── middleware.go
│   ├── clock/                 # Clock template compilation
│   │   └── clock.go
│   ├── config/                # Configuration loading
│   │   └── config.go          # Env var parsing, GRIMNIR_* / RLM_* fallback
│   ├── db/                    # Database management
│   │   └── db.go              # GORM setup, migrations
│   ├── events/                # Event bus (in-memory, Redis, NATS)
│   │   └── bus.go
│   ├── executor/              # Per-station state machines
│   │   ├── executor.go        # Goroutine lifecycle, timeline execution
│   │   ├── statemachine.go    # Idle→Loading→Playing→Fading→Error→Stopped
│   │   └── priority.go        # Priority-based source management
│   ├── live/                  # Live DJ input
│   │   └── live.go            # RTP/SRT/WebRTC authorization, handover
│   ├── logging/               # Structured logging
│   │   └── logger.go          # Zerolog setup
│   ├── media/                 # Media service
│   │   ├── media.go           # Upload handling
│   │   └── storage_fs.go      # Filesystem backend (relative paths)
│   ├── mediaengine/           # gRPC client to media engine
│   │   ├── client.go
│   │   ├── commands.go        # LoadGraph, Play, Stop, Fade, etc.
│   │   └── telemetry.go       # Telemetry stream consumer
│   ├── migration/             # Import tools
│   │   ├── azuracast.go       # AzuraCast backup import
│   │   └── libretime.go       # LibreTime backup import
│   ├── models/                # GORM data models
│   │   └── models.go
│   ├── playout/               # Director and playback management
│   │   └── director.go        # Joins relative paths with mediaRoot for gRPC
│   ├── priority/              # 5-tier priority ladder
│   │   ├── priority.go        # Tier definitions
│   │   └── resolver.go        # Conflict resolution
│   ├── scheduler/             # Schedule builder
│   │   └── scheduler.go       # 30s tick, 48h rolling window
│   ├── smartblock/            # Rule engine
│   │   └── engine.go          # Smart Block materialization
│   ├── storage/               # Object storage abstraction
│   │   └── storage.go         # S3-compatible or filesystem
│   ├── telemetry/             # Metrics and health
│   │   └── telemetry.go       # Prometheus metrics, health checks
│   ├── version/               # Version constant
│   │   └── version.go
│   └── webstream/             # HTTP stream relay
│       └── relay.go           # ICY metadata, HLS, failover
│
├── proto/
│   └── mediaengine/v1/        # Protobuf definitions
│       └── mediaengine.proto
│
├── migrations/                # SQL migration files
│
├── docs/                      # Documentation
│   └── API_REFERENCE.md
│
├── go.mod
├── go.sum
├── Makefile
└── internal/version/version.go  # Version: 1.18.64

Build & Run

Prerequisites

  • Go 1.24
  • PostgreSQL 12+ / MySQL 8+ / SQLite 3
  • GStreamer 1.0 with plugins: base, good, bad, ugly (for media engine)
  • Redis (for multi-instance deployments)

Building

# Build both binaries
make build

# Or individually
go build ./cmd/grimnirradio
go build ./cmd/mediaengine

Running

# Terminal 1: Start media engine
./mediaengine --grpc-port=9091

# Terminal 2: Start control plane
export GRIMNIR_MEDIA_ENGINE_GRPC_ADDR=localhost:9091
./grimnirradio

Development stack (database + redis):

make dev-stack     # Start PostgreSQL and Redis via Docker Compose
make run-control   # Run control plane
make run-media     # Run media engine

Test

make test          # Run tests with race detector
go test -race ./...

# Single test
go test -v -run TestName ./path/to/package

# Integration tests
go test -v -tags=integration ./...

# E2E browser tests (go-rod)
make test-e2e

# Quick route verification (no browser)
make test-routes

Verify (always run before push)

make verify        # tidy + fmt + vet + lint + test — full gate
make ci            # verify + fmt-check

Critical: make test alone is not sufficient. Always run make verify before pushing.

Other Targets

make fmt           # Format code with gofmt
make fmt-check     # Check formatting (CI)
make vet           # Run go vet
make lint          # Run golangci-lint
make tidy          # Tidy go.mod
make proto         # Regenerate protobuf code
make clean         # Remove binaries

Environment Variables

All variables use GRIMNIR_* prefix. RLM_* variants are accepted as fallback for backward compatibility.

Core

GRIMNIR_ENV=development                     # development | production
GRIMNIR_HTTP_BIND=0.0.0.0                   # HTTP bind address
GRIMNIR_HTTP_PORT=8080                      # HTTP port
GRIMNIR_DB_BACKEND=postgres                 # postgres | mysql | sqlite
GRIMNIR_DB_DSN="postgres://..."            # Database DSN (required)
GRIMNIR_MEDIA_ROOT=/var/lib/grimnir/media  # Media base directory
GRIMNIR_JWT_SIGNING_KEY=secret             # JWT secret (required)
GRIMNIR_METRICS_BIND=127.0.0.1:9000        # Prometheus metrics endpoint
GRIMNIR_SCHEDULER_LOOKAHEAD_MINUTES=2880   # Schedule horizon (48 hours)

Event Bus

GRIMNIR_EVENT_BUS_BACKEND=redis            # redis | nats | memory
GRIMNIR_REDIS_ADDR=localhost:6379          # Redis address
GRIMNIR_NATS_URL=nats://localhost:4222     # NATS (if selected)

Media Engine

GRIMNIR_MEDIA_ENGINE_GRPC_ADDR=localhost:9091  # gRPC endpoint
GRIMNIR_MEDIA_ENGINE_TLS=false                 # Enable TLS for gRPC

Webstream Relay

GRIMNIR_WEBSTREAM_ALLOWED_SCHEMES=http,https
GRIMNIR_WEBSTREAM_CONNECT_TIMEOUT_MS=5000
GRIMNIR_WEBSTREAM_PREFLIGHT_MS=3000
GRIMNIR_WEBSTREAM_GRACE_MS=5000
GRIMNIR_WEBSTREAM_FALLBACK_LIMIT=3

Observability

GRIMNIR_TRACING_ENABLED=true
GRIMNIR_OTLP_ENDPOINT=localhost:4317
GRIMNIR_TRACING_SAMPLE_RATE=0.1

Migration Tools

GRIMNIR_IMPORT_MEDIA_ROOT=./media
GRIMNIR_IMPORT_BATCH_SIZE=500
GRIMNIR_IMPORT_DRY_RUN=false

Local Development

Quick Start with SQLite

export GRIMNIR_DB_BACKEND=sqlite
export GRIMNIR_DB_DSN="file:dev.sqlite?_foreign_keys=on"
export GRIMNIR_JWT_SIGNING_KEY="dev-secret-change-in-production"
./grimnirradio
curl http://localhost:8080/api/v1/health

PostgreSQL Development Setup

docker run -d \
  --name grimnir-postgres \
  -e POSTGRES_PASSWORD=password \
  -e POSTGRES_DB=grimnir \
  -p 5432:5432 \
  postgres:15

export GRIMNIR_DB_BACKEND=postgres
export GRIMNIR_DB_DSN="postgres://postgres:password@localhost:5432/grimnir?sslmode=disable"
export GRIMNIR_JWT_SIGNING_KEY="dev-secret"
./grimnirradio

Development Workflow

# Make changes, then:
make fmt           # Format
make verify        # Full gate: tidy, fmt-check, vet, lint, test
make build
./grimnirradio

API Documentation

See docs/API_REFERENCE.md for complete documentation with schemas and examples.

Quick Reference

Authentication:

POST /api/v1/auth/login             # Login → JWT token
POST /api/v1/auth/refresh           # Refresh token

Stations:

GET  /api/v1/stations               # List stations
POST /api/v1/stations               # Create station (admin, manager)
GET  /api/v1/stations/{id}          # Get station

Mounts:

GET  /api/v1/stations/{id}/mounts   # List mounts
POST /api/v1/stations/{id}/mounts   # Create mount (admin, manager)

Media:

POST /api/v1/media/upload           # Upload audio (admin, manager, dj)
GET  /api/v1/media/{id}             # Get media details

Smart Blocks:

GET  /api/v1/smart-blocks           # List smart blocks
POST /api/v1/smart-blocks           # Create (admin, manager)
POST /api/v1/smart-blocks/{id}/materialize  # Generate playlist

Clocks:

GET  /api/v1/clocks                 # List clocks
POST /api/v1/clocks                 # Create (admin, manager)
POST /api/v1/clocks/{id}/simulate   # Preview schedule

Schedule:

GET  /api/v1/schedule               # Get upcoming entries
POST /api/v1/schedule/refresh       # Rebuild (admin, manager)
PATCH /api/v1/schedule/{id}         # Modify entry (admin, manager)

Live:

POST /api/v1/live/authorize         # Authorize live source
POST /api/v1/live/handover          # Trigger live takeover (admin, manager)

Playout:

POST /api/v1/playout/reload         # Restart pipeline (admin, manager)
POST /api/v1/playout/skip           # Skip track (admin, manager, dj)
POST /api/v1/playout/stop           # Stop (admin, manager)

Priority Management:

POST   /api/v1/priority/emergency   # Emergency takeover (priority 0)
POST   /api/v1/priority/override    # Manual override (priority 1)
GET    /api/v1/priority/sources     # List active sources
DELETE /api/v1/priority/sources/{id} # Remove source

Executor State:

GET /api/v1/executor/states              # List all executor states
GET /api/v1/executor/states/{stationID} # Get state for station

Webstreams:

GET    /api/v1/webstreams             # List webstreams
POST   /api/v1/webstreams             # Create webstream
GET    /api/v1/webstreams/{id}        # Get webstream
PATCH  /api/v1/webstreams/{id}        # Update webstream
DELETE /api/v1/webstreams/{id}        # Delete webstream
GET    /api/v1/webstreams/{id}/health # Health check

Migrations:

POST /api/v1/migrations/azuracast    # Import AzuraCast backup
POST /api/v1/migrations/libretime    # Import LibreTime backup
GET  /api/v1/migrations/{jobID}      # Status
GET  /api/v1/migrations/{jobID}/events  # SSE progress stream

Analytics:

GET  /api/v1/analytics/now-playing  # Current track
GET  /api/v1/analytics/spins        # Play history (admin, manager)

Events:

GET  /api/v1/events                 # WebSocket stream

Architecture Overview

Process Architecture

┌─────────────────────────────────────────┐
│         Control Plane (Go)              │  Port 8080
│  HTTP REST + gRPC + WebSocket + SSE     │  Binary: grimnirradio
│  Scheduler + Executor + Priority        │
│  Webstream Relay + Migration Tools      │
└──────────┬──────────────────────────────┘
           │
           │ gRPC Commands (LoadGraph, Play, Stop, Fade)
           ▼
┌─────────────────────────────────────────────┐
│    Media Engine (separate process)          │  Port 9091
│    GStreamer pipeline + gRPC server         │  Binary: mediaengine
│    Decode → DSP (12+ nodes) → Encode       │
│    → HTTP Stream Relay / HLS / Recording   │
└─────────────────────────────────────────────┘

Component Interaction

Schedule Generation Flow:

  1. Scheduler generates timeline every 30 seconds (48h rolling window)
  2. Timeline persisted to schedule_entries table
  3. Executor polls timeline for upcoming events
  4. At T-30s: Executor preloads next item
  5. At T: Executor sends gRPC Play command to media engine
  6. Media engine starts playback, streams telemetry back

Priority Override Flow:

  1. User POSTs to /api/v1/priority/override
  2. API creates priority_sources entry
  3. Executor detects higher priority source
  4. Executor sends Fade command to media engine (current track)
  5. Executor sends RouteLive command (new source)
  6. State machine transitions to Live state

Webstream Relay Flow:

  1. Webstream scheduled in clock slot
  2. Executor instructs relay to connect to source URL
  3. Relay validates connection (preflight), starts proxying bytes
  4. ICY StreamTitle metadata read and injected; falls back to icy-name/icy-description
  5. Play history updated with stream metadata for now-playing API
  6. On source failure: grace window, then failover to next URL in chain

Database Schema

Core Tables

See internal/models/models.go for complete definitions.

  • users - Authentication (id, email, password_hash, role)
  • stations - Station definitions (id, name, timezone)
  • mounts - Output streams (id, station_id, url, format, bitrate)
  • encoder_presets - GStreamer encoders
  • media_items - Audio files (id, title, artist, duration, path, loudness_lufs, cue_points, original_filename, file_modified_at)
  • tags - Metadata labels
  • media_tag_links - Many-to-many media↔tags
  • smart_blocks - Rule definitions (id, station_id, rules, sequence)
  • clock_hours - Hour templates (id, station_id, name)
  • clock_slots - Clock elements (id, clock_hour_id, type, payload)
  • schedule_entries - Materialized schedule (id, station_id, starts_at, ends_at, source_type, metadata)
  • play_history - Played tracks (id, station_id, media_id, started_at, ended_at); updated with ICY metadata
  • analysis_jobs - Analyzer work queue (id, media_id, status)
  • executor_states - Runtime executor state (state enum, current priority, source type, last heartbeat)
  • priority_sources - Active priority sources (priority tier, starts_at, ends_at, active)
  • webstreams - External stream definitions (url, fallback_urls, health_state, last_health_check)

Data Integrity Rules

  • media_items.analysis_state must never be blank — DB DEFAULT + BeforeCreate hook enforced
  • Smart block and playout queries use analysis_state != 'failed' AND duration > 0 (not = 'complete')
  • Media paths in database must be relative — absolute paths cause double-prefix errors

gRPC Media Engine Interface

Protocol Buffer Definition (proto/mediaengine/v1/mediaengine.proto):

syntax = "proto3";
package mediaengine.v1;

service MediaEngine {
  rpc LoadGraph(GraphConfig) returns (GraphHandle);
  rpc Play(PlayRequest) returns (PlayResponse);
  rpc Stop(StopRequest) returns (StopResponse);
  rpc Fade(FadeRequest) returns (FadeResponse);
  rpc InsertEmergency(InsertRequest) returns (InsertResponse);
  rpc RouteLive(RouteRequest) returns (RouteResponse);
  rpc StreamTelemetry(TelemetryRequest) returns (stream Telemetry);
}

Regenerate Go code:

make proto
# or
protoc --go_out=. --go-grpc_out=. proto/mediaengine/v1/mediaengine.proto

Client usage (internal/mediaengine/client.go):

conn, err := grpc.Dial("localhost:9091", grpc.WithTransportCredentials(insecure.NewCredentials()))
client := mediav1.NewMediaEngineClient(conn)

handle, err := client.LoadGraph(ctx, &mediav1.GraphConfig{
    Nodes: []*mediav1.DSPNode{
        {Type: "loudness", Config: map[string]string{"target_lufs": "-16.0"}},
        {Type: "limiter",  Config: map[string]string{"threshold_db": "-1.0"}},
    },
})

resp, err := client.Play(ctx, &mediav1.PlayRequest{
    FilePath: "/var/lib/grimnir/media/station-1/ab/cd/track.mp3",
    FadeInMs: 500,
})

Testing

Run Tests

make test                          # Race detector enabled
go test -race ./...
go test -v -run TestName ./pkg/... # Single test

# Integration tests (requires database)
go test -v -tags=integration ./...

# E2E tests (requires browser via go-rod)
make test-e2e

# Route smoke tests
make test-routes

# Coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

Test Coverage Areas

  • Smart Block rule evaluation (internal/smartblock)
  • Schedule generation (internal/scheduler)
  • API endpoint validation (internal/api)
  • JWT auth (internal/auth)
  • Executor state machine transitions (internal/executor)
  • Priority resolution logic (internal/priority)
  • Webstream relay (internal/webstream)

Code Style & Conventions

Go Best Practices

Error handling:

// Good
result, err := DoSomething()
if err != nil {
    return fmt.Errorf("do something: %w", err)
}

// Bad — never ignore errors
result, _ := DoSomething()

Context propagation:

// Good — context as first parameter
func ProcessSchedule(ctx context.Context, stationID string) error { ... }

// Bad — context stored in struct
type Service struct {
    ctx context.Context
}

Project Conventions

Logging:

logger.Info().
    Str("station_id", stationID).
    Str("track_id", trackID).
    Msg("starting track playback")

Database queries:

// Use GORM — avoid raw SQL unless absolutely necessary
var station models.Station
if err := db.Where("id = ?", stationID).First(&station).Error; err != nil {
    return err
}

Media paths:

// Store relative path in DB
relPath, err := mediaStore.Store(ctx, stationID, reader)
// relPath = "station-uuid/ab/cd/filename.mp3"

// Resolve to absolute before sending to media engine
absPath := filepath.Join(cfg.MediaRoot, relPath)
// absPath = "/var/lib/grimnir/media/station-uuid/ab/cd/filename.mp3"

Versioning

Version is defined in internal/version/version.go. When bumping:

# 1. Update version in internal/version/version.go
# 2. Commit, tag, and push:
git add -A && git commit -m "Message (vX.Y.Z)" && git tag -a vX.Y.Z -m "Version X.Y.Z" && git push origin main && git push origin vX.Y.Z

Every version bump requires a git tag. Tags trigger release builds and are used by the update checker.


Troubleshooting

Database connection errors

# PostgreSQL:
GRIMNIR_DB_DSN="postgres://user:password@host:5432/dbname?sslmode=disable"

# MySQL:
GRIMNIR_DB_DSN="user:password@tcp(host:3306)/dbname?parseTime=true"

# SQLite:
GRIMNIR_DB_DSN="file:dev.sqlite?_foreign_keys=on"

Media path double-prefix errors

If you see /var/lib/grimnir/media/var/lib/grimnir/media/..., the database contains absolute paths. Fix with:

-- migrations/002_fix_media_paths.sql
UPDATE media_items
SET path = REPLACE(path, '/var/lib/grimnir/media/', '')
WHERE path LIKE '/var/lib/grimnir/media/%';

Schedule gaps

# Check clocks are defined for the station
curl http://localhost:8080/api/v1/clocks?station_id=$STATION_ID \
  -H "Authorization: Bearer $TOKEN"

# Check smart blocks have matching analyzed media
curl http://localhost:8080/api/v1/smart-blocks \
  -H "Authorization: Bearer $TOKEN"

# Manually trigger schedule refresh
curl -X POST http://localhost:8080/api/v1/schedule/refresh \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"station_id\":\"$STATION_ID\"}"

analysis_state blank

If media items have blank analysis_state, they will be skipped by smart block queries. Check the BeforeCreate hook in internal/models/models.go and verify the DB column has a DEFAULT.


Contributing Workflow

Before Committing

make fmt       # Format code
make verify    # Full gate: tidy, fmt-check, vet, lint, test
make build
./grimnirradio # Smoke test locally

Commit Message Format

<type>: <subject> (vX.Y.Z)

<body>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Types: feat, fix, refactor, docs, test, chore


Further Reading

  • API Reference: docs/API_REFERENCE.md
  • Engineering Spec: Engineering-Spec - Architecture details
  • Architecture Overview: Architecture - System diagrams and data flows
  • Sales Spec: Sales-Spec - Business perspective

Clone this wiki locally