Skip to content

Commit 5f717b0

Browse files
committed
feat(webhooks): expand template variables, drop "verified" preset wording
Adds always-available globals plus payload-derived helpers so users can compose richer messages without hard-coding hostnames or guessing what's in scope. Globals (every event, every template): .Event .Timestamp .UnixTimestamp .Date .Time .Hostname .BaseURL .Version Derived (when the payload carries a Mount): .MountURL e.g. https://radio.example.com/live .PlayerURL e.g. https://radio.example.com/player/live Both are empty when BaseURL isn't configured, so {{if .MountURL}}…{{end}} is the safe pattern (Discord/Slack presets updated to use it). now_playing payload gains .Format, .Bitrate and .DurationSeconds — the streamer's TrackStartInfo callback now passes them through. The OnTrackStart signature switched from positional args to a struct so future fields don't churn every caller. Snake_case payload keys are also exposed CamelCased ({{.DurationSeconds}} in addition to {{.duration_seconds}}), matching the Go-template convention the rest of the placeholders use. Meta endpoint (/api/webhooks/meta) now returns global_placeholders separately and the editor renders them as their own "Always available" group so users discover them without needing to read docs. Drops the "verified" wording from the preset dropdown and surrounding copy — it was overstating what we can promise about third-party APIs.
1 parent 05f1c22 commit 5f717b0

8 files changed

Lines changed: 235 additions & 62 deletions

File tree

relay/streamer.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,26 @@ type StreamerManager struct {
8888
// `now_playing` webhook event without dragging the server package
8989
// into relay (which would be a cyclic import). Set by the server at
9090
// startup; nil on construction.
91-
OnTrackStart func(mount, streamerName, artist, title, album, filePath string)
91+
//
92+
// durationSeconds is 0 when the input format doesn't expose a length
93+
// up-front (typical for streaming PCM); receivers should treat 0 as
94+
// "unknown" rather than "instantaneous".
95+
OnTrackStart func(info TrackStartInfo)
96+
}
97+
98+
// TrackStartInfo is the snapshot passed to OnTrackStart subscribers. A
99+
// struct (rather than a long positional argument list) keeps the call
100+
// site readable and lets us add fields without churning every caller.
101+
type TrackStartInfo struct {
102+
Mount string
103+
StreamerName string
104+
Artist string
105+
Title string
106+
Album string
107+
FilePath string
108+
Format string // "mp3" | "opus"
109+
Bitrate int // kbps
110+
DurationSeconds float64 // 0 when unknown
92111
}
93112

94113
func NewStreamerManager(r *Relay, cfg *config.Config) *StreamerManager {
@@ -1060,7 +1079,17 @@ func (sm *StreamerManager) streamFile(ctx context.Context, s *Streamer, path str
10601079
// goroutine: the server callback may block briefly on JSON marshal /
10611080
// HTTP send setup and we don't want to delay the encoder start.
10621081
if cb := sm.OnTrackStart; cb != nil {
1063-
go cb(s.OutputMount, s.Name, s.CurrentArtist, s.CurrentTitle, s.CurrentAlbum, path)
1082+
go cb(TrackStartInfo{
1083+
Mount: s.OutputMount,
1084+
StreamerName: s.Name,
1085+
Artist: s.CurrentArtist,
1086+
Title: s.CurrentTitle,
1087+
Album: s.CurrentAlbum,
1088+
FilePath: path,
1089+
Format: s.Format,
1090+
Bitrate: s.Bitrate,
1091+
DurationSeconds: s.CurrentFileDuration.Seconds(),
1092+
})
10641093
}
10651094

10661095
// Apply the streamer's volume setting (0..1) to the PCM stream before

server/frontend/dist/assets/admin-BptAas6P.js renamed to server/frontend/dist/assets/admin-xUGzoVVk.js

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/frontend/dist/src/entries/admin.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width,initial-scale=1">
66
<title>TinyIce</title>
7-
<script type="module" crossorigin src="/assets/admin-BptAas6P.js"></script>
7+
<script type="module" crossorigin src="/assets/admin-xUGzoVVk.js"></script>
88
<link rel="modulepreload" crossorigin href="/assets/hooks.module-bNCB_ZdL.js">
99
<link rel="modulepreload" crossorigin href="/assets/signals.module-Tnv-RP0P.js">
1010
<link rel="modulepreload" crossorigin href="/assets/sse-BzDsymyq.js">

server/frontend/src/pages/admin/Webhooks.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ interface FuncDoc {
4747

4848
interface WebhookMeta {
4949
events: EventInfo[]
50+
global_placeholders: string[]
5051
presets: Preset[]
5152
funcs: FuncDoc[]
5253
}
@@ -350,6 +351,10 @@ function WebhookCard({ wh }: { wh: Webhook }) {
350351
function PlaceholderHelper() {
351352
if (!meta.value) return null
352353
const events = meta.value.events
354+
const globals = meta.value.global_placeholders || []
355+
const insertPlaceholder = (ph: string) => {
356+
formBodyTemplate.value = formBodyTemplate.value + `{{.${ph}}}`
357+
}
353358
return (
354359
<div class="bg-[rgba(255,255,255,0.02)] border border-border rounded-lg p-3">
355360
<div class="flex items-center justify-between mb-2">
@@ -366,15 +371,37 @@ function PlaceholderHelper() {
366371
))}
367372
</select>
368373
</div>
374+
375+
{globals.length > 0 && (
376+
<div class="mb-2">
377+
<div class="text-[9px] font-mono uppercase tracking-wider text-text-tertiary mb-1">
378+
Always available
379+
</div>
380+
<div class="flex flex-wrap gap-1">
381+
{globals.map((ph) => (
382+
<button
383+
key={ph}
384+
type="button"
385+
onClick={() => insertPlaceholder(ph)}
386+
class="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-text-secondary hover:text-accent hover:border-accent/30 transition-colors"
387+
title={`Insert {{.${ph}}}`}
388+
>
389+
{`{{.${ph}}}`}
390+
</button>
391+
))}
392+
</div>
393+
</div>
394+
)}
395+
396+
<div class="text-[9px] font-mono uppercase tracking-wider text-text-tertiary mb-1">
397+
From this event's payload
398+
</div>
369399
<div class="flex flex-wrap gap-1 mb-2">
370400
{activePlaceholders.value.map((ph) => (
371401
<button
372402
key={ph}
373403
type="button"
374-
onClick={() => {
375-
const insertion = `{{.${ph}}}`
376-
formBodyTemplate.value = formBodyTemplate.value + insertion
377-
}}
404+
onClick={() => insertPlaceholder(ph)}
378405
class="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-text-secondary hover:text-accent hover:border-accent/30 transition-colors"
379406
title={`Insert {{.${ph}}}`}
380407
>
@@ -447,7 +474,7 @@ function FormModal() {
447474
}}
448475
class="bg-[rgba(255,255,255,0.03)] border border-border rounded-lg px-3 py-2 text-text-primary font-mono text-sm focus:border-accent outline-none w-full"
449476
>
450-
<option value="">— Pick a verified template —</option>
477+
<option value="">— Pick a template —</option>
451478
{meta.value.presets.map((p) => (
452479
<option key={p.id} value={p.id} title={p.description}>{p.name}</option>
453480
))}

server/handlers_api_webhooks.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ func (s *Server) requireSuperAdminJSON(w http.ResponseWriter, r *http.Request) b
3434

3535
// apiGetWebhookMeta serves the static metadata the admin UI needs to
3636
// build the form: the canonical event list with sample payloads, the
37-
// available placeholders per event, and the verified preset templates.
38-
// One round-trip on page load means the editor doesn't need to hard-code
37+
// available placeholders per event, and the preset templates. One
38+
// round-trip on page load means the editor doesn't need to hard-code
3939
// anything that lives on the server.
4040
func (s *Server) apiGetWebhookMeta(w http.ResponseWriter, r *http.Request) {
4141
if !s.requireSuperAdminJSON(w, r) {
@@ -74,18 +74,29 @@ func (s *Server) apiGetWebhookMeta(w http.ResponseWriter, r *http.Request) {
7474
for i := range events {
7575
sample := SampleEventData(events[i].Name)
7676
events[i].Sample = sample
77-
ph := []string{"Event", "Timestamp", "Hostname"}
77+
// Per-event placeholders are derived from the sample payload:
78+
// each key shows up CamelCased so users can write the same form
79+
// the renderer actually accepts ({{.DurationSeconds}}, not
80+
// {{.duration_seconds}} — even though both work).
81+
ph := []string{}
7882
for k := range sample {
79-
if len(k) > 0 {
80-
ph = append(ph, strings.ToUpper(k[:1])+k[1:])
83+
if k != "" {
84+
ph = append(ph, snakeToCamel(k))
8185
}
8286
}
87+
// When the payload carries a Mount, the dispatcher synthesises
88+
// MountURL / PlayerURL from the configured BaseURL. Surface them
89+
// so users discover the helpers in the editor.
90+
if _, ok := sample["mount"]; ok {
91+
ph = append(ph, "MountURL", "PlayerURL")
92+
}
8393
events[i].Placeholders = ph
8494
}
8595

8696
jsonResponse(w, map[string]interface{}{
87-
"events": events,
88-
"presets": builtinPresets,
97+
"events": events,
98+
"global_placeholders": WebhookGlobalPlaceholders,
99+
"presets": builtinPresets,
89100
"funcs": []map[string]string{
90101
{"name": "urlencode", "description": "URL-encode a string for query params or URLs.", "example": "{{urlencode .Title}}"},
91102
{"name": "json", "description": "Marshal any value to a JSON literal.", "example": "{{json .}}"},

server/server.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,17 @@ func NewServer(cfg *config.Config, authLog *zap.SugaredLogger, version, commit,
199199
// subscribers can react to "now playing" changes. Done here (rather
200200
// than in NewStreamerManager) because the dispatcher needs the
201201
// fully-built Server reference and runtime config.
202-
srv.StreamerM.OnTrackStart = func(mount, name, artist, title, album, filePath string) {
202+
srv.StreamerM.OnTrackStart = func(info relay.TrackStartInfo) {
203203
srv.dispatchWebhook("now_playing", map[string]interface{}{
204-
"mount": mount,
205-
"name": name,
206-
"artist": artist,
207-
"title": title,
208-
"album": album,
209-
"file": filePath,
204+
"mount": info.Mount,
205+
"name": info.StreamerName,
206+
"artist": info.Artist,
207+
"title": info.Title,
208+
"album": info.Album,
209+
"file": info.FilePath,
210+
"format": info.Format,
211+
"bitrate": info.Bitrate,
212+
"duration_seconds": info.DurationSeconds,
210213
})
211214
}
212215

0 commit comments

Comments
 (0)