-
Notifications
You must be signed in to change notification settings - Fork 0
Programmers 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.
Go owns the control plane. A dedicated media engine owns real-time audio.
- 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
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
- 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)
# Build both binaries
make build
# Or individually
go build ./cmd/grimnirradio
go build ./cmd/mediaengine# Terminal 1: Start media engine
./mediaengine --grpc-port=9091
# Terminal 2: Start control plane
export GRIMNIR_MEDIA_ENGINE_GRPC_ADDR=localhost:9091
./grimnirradioDevelopment stack (database + redis):
make dev-stack # Start PostgreSQL and Redis via Docker Compose
make run-control # Run control plane
make run-media # Run media enginemake 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-routesmake verify # tidy + fmt + vet + lint + test — full gate
make ci # verify + fmt-checkCritical: make test alone is not sufficient. Always run make verify before pushing.
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 binariesAll variables use GRIMNIR_* prefix. RLM_* variants are accepted as fallback for backward compatibility.
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)GRIMNIR_EVENT_BUS_BACKEND=redis # redis | nats | memory
GRIMNIR_REDIS_ADDR=localhost:6379 # Redis address
GRIMNIR_NATS_URL=nats://localhost:4222 # NATS (if selected)GRIMNIR_MEDIA_ENGINE_GRPC_ADDR=localhost:9091 # gRPC endpoint
GRIMNIR_MEDIA_ENGINE_TLS=false # Enable TLS for gRPCGRIMNIR_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=3GRIMNIR_TRACING_ENABLED=true
GRIMNIR_OTLP_ENDPOINT=localhost:4317
GRIMNIR_TRACING_SAMPLE_RATE=0.1GRIMNIR_IMPORT_MEDIA_ROOT=./media
GRIMNIR_IMPORT_BATCH_SIZE=500
GRIMNIR_IMPORT_DRY_RUN=falseexport 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/healthdocker 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# Make changes, then:
make fmt # Format
make verify # Full gate: tidy, fmt-check, vet, lint, test
make build
./grimnirradioSee docs/API_REFERENCE.md for complete documentation with schemas and examples.
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
┌─────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────┘
Schedule Generation Flow:
- Scheduler generates timeline every 30 seconds (48h rolling window)
- Timeline persisted to
schedule_entriestable - Executor polls timeline for upcoming events
- At T-30s: Executor preloads next item
- At T: Executor sends gRPC
Playcommand to media engine - Media engine starts playback, streams telemetry back
Priority Override Flow:
- User POSTs to
/api/v1/priority/override - API creates
priority_sourcesentry - Executor detects higher priority source
- Executor sends
Fadecommand to media engine (current track) - Executor sends
RouteLivecommand (new source) - State machine transitions to Live state
Webstream Relay Flow:
- Webstream scheduled in clock slot
- Executor instructs relay to connect to source URL
- Relay validates connection (preflight), starts proxying bytes
- ICY
StreamTitlemetadata read and injected; falls back toicy-name/icy-description - Play history updated with stream metadata for now-playing API
- On source failure: grace window, then failover to next URL in chain
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)
-
media_items.analysis_statemust 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
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.protoClient 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,
})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- 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)
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
}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"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.ZEvery version bump requires a git tag. Tags trigger release builds and are used by the update checker.
# 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"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/%';# 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\"}"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.
make fmt # Format code
make verify # Full gate: tidy, fmt-check, vet, lint, test
make build
./grimnirradio # Smoke test locally<type>: <subject> (vX.Y.Z)
<body>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Types: feat, fix, refactor, docs, test, chore
-
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
Getting Started
Core Concepts
Scheduling
Deployment
Integration
Operations
Development
Reference