Add an /rvrb page showing the rvrb bot's status and stats

The bot has been recording every play, vote and DJ in the room for months, and none of it was visible outside the room. This page shows what's playing now, the DJ queue with its rotation estimate, room-wide totals, a 14-day bar chart of plays, the DJ/track/artist leaderboards, and the last few plays. It gets them by joining the bot's Erlang cluster rather than by asking it over HTTP. The bot runs on the same server, so BeamSharp makes this site a hidden Erlang node and calls `Rvrb.Stats.snapshot/0` the way any other BEAM node would - the Elixir side grew a module, not an endpoint. BeamSharp isn't on NuGet yet, hence a ProjectReference to the checkout next door. The node starts on the first request rather than at boot, deliberately: registering with EPMD can fail - it isn't running, the bot was deployed without distribution - and the rest of the site has no business failing to start over a page that shows a bot's play count. For the same reason a failure is a value (`RvrbStatus.Unreachable`) rather than an exception, and the page renders what went wrong instead of a 500. A 15-second cache keeps a refresh loop or a crawler from turning into one round trip and a handful of queries per request. The term is decoded by hand in `RvrbSnapshotReader`: it's an Elixir map written by Elixir, not a serialized C# type, and reading it forgivingly - unknown keys ignored, missing keys defaulted - means the bot can grow a field without this being redeployed first. The page itself is static SSR like the rest of the site; its page script adds the two things a snapshot can't carry, timestamps that stay relative and a track counter that keeps running, plus an opt-in 30s auto-refresh remembered in localStorage. The cookie both nodes share stays out of appsettings.json - user secrets in development, `Rvrb__Cookie` in the environment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-07 15:00 UTC
commit
4d2a8d9c2cdb403632d953b24a6b298e63b8a89e
parent
cf1425bd88
tree
browse at this commit

14 files changed +1059 -1

Blog/Blog.csproj +8 -0

@@ -12,6 +12,14 @@
12 12 </ItemGroup>
13 13
14 14 <ItemGroup>
15 + <!--
16 + The /rvrb page reads the rvrb bot's stats over Erlang distribution. BeamSharp is not on
17 + NuGet yet, so this points at the checkout next door rather than at a package version.
18 + -->
19 + <ProjectReference Include="..\..\BeamSharp\src\BeamSharp\BeamSharp.csproj" />
20 + </ItemGroup>
21 +
22 + <ItemGroup>
15 23 <Folder Include="wwwroot\Icons\" />
16 24 </ItemGroup>
17 25

Blog/Components/Layout/SiteFooter.razor +1 -0

@@ -11,6 +11,7 @@
11 11 <li><NavLink href="/BRPTestData">BRP Test Data</NavLink></li>
12 12 <li><NavLink href="/Query">Query</NavLink></li>
13 13 <li><NavLink href="/Storage">Storage</NavLink></li>
14 + <li><NavLink href="/rvrb">rvrb bot stats</NavLink></li>
14 15 <li><NavLink href="https://git.bes.is">Git</NavLink></li>
15 16 <!-- <li><a href="/webrtc.html">WebRTC</a></li> -->
16 17 <!-- <li><a href="/spotify/index.html">Spotify</a></li> -->

Blog/Components/Pages/Rvrb.razor +264 -0

@@ -0,0 +1,264 @@
1 +@page "/rvrb"
2 +<PageTitle>rvrb bot</PageTitle>
3 +<PageScript Src="./Components/Pages/Rvrb.razor.js"></PageScript>
4 +
5 +<main>
6 + <h1>rvrb bot</h1>
7 +
8 + <p class="flex-spread">
9 + <span class="@StatusClass">@StatusLabel</span>
10 + <label class="noselect">
11 + Auto-refresh
12 + <input type="checkbox" id="autoRefresh">
13 + </label>
14 + </p>
15 +
16 + @if (Snapshot is null)
17 + {
18 + <Panel Legend="Unreachable">
19 + <p>
20 + Nothing answered on the bot's node, so there are no stats to show.
21 + </p>
22 + @if (Status.Error is { } error)
23 + {
24 + @* A failure on the far side arrives as an Erlang term, stack trace and all, which
25 + is worth keeping but not worth putting in front of everyone. *@
26 + <details>
27 + <summary class="status-down">What went wrong</summary>
28 + <pre>@error</pre>
29 + </details>
30 + }
31 + </Panel>
32 + }
33 + else
34 + {
35 + <Panel Legend="Now playing">
36 + @if (Snapshot.Live is null)
37 + {
38 + <p>The bot isn't connected to RVRB right now, so nothing is playing.</p>
39 + }
40 + else if (Snapshot.Live.CurrentTrack is not { } track)
41 + {
42 + <p>Connected, but no track has played since the bot joined.</p>
43 + }
44 + else
45 + {
46 + <div class="now-playing">
47 + @if (track.AlbumArt is { } art)
48 + {
49 + <img class="album-art" src="@art" alt="" width="128" height="128"/>
50 + }
51 + <div>
52 + <h3>@track.Name</h3>
53 + <p>@string.Join(", ", track.ArtistNames)</p>
54 + @* A bar needs both halves to mean anything: a track that came without a
55 + duration, or one already playing when the bot connected, gets neither. *@
56 + @if (track.Duration is { } length && track.Elapsed is not null)
57 + {
58 + <p>
59 + <progress id="trackProgress" max="1000"
60 + value="@((int)((track.Progress ?? 0) * 1000))"
61 + data-elapsed-ms="@((long)track.Elapsed.Value.TotalMilliseconds)"
62 + data-duration-ms="@((long)length.TotalMilliseconds)"
63 + data-age-ms="@((long)Age.TotalMilliseconds)"></progress>
64 + <span id="trackElapsed">@Format.Duration(track.Elapsed)</span>
65 + /
66 + <span>@Format.Duration(length)</span>
67 + </p>
68 + }
69 + <p>
70 + 👍 @Snapshot.Live.Dopes
71 + 🔖 @Snapshot.Live.Stars
72 + @if (Snapshot.Live.AutoDoped)
73 + {
74 + <span class="status-up"> — the bot doped this one</span>
75 + }
76 + @if (Snapshot.Live.AutoStarred)
77 + {
78 + <span class="status-up"> — and starred it</span>
79 + }
80 + </p>
81 + </div>
82 + </div>
83 + }
84 + </Panel>
85 +
86 + @if (Snapshot.Live is { Djs.Count: > 0 } live)
87 + {
88 + <Panel Legend="@($"DJ queue — one lap ≈ {Format.Duration(live.Lap)}")">
89 + <div class="table-scroll">
90 + <table>
91 + <thead>
92 + <tr>
93 + <th>DJ</th>
94 + <th>Avg track length</th>
95 + <th>Next play in</th>
96 + </tr>
97 + </thead>
98 + <tbody>
99 + @foreach (var dj in live.Djs)
100 + {
101 + <tr>
102 + <td>@(dj.Current ? "▶ " : "")@(dj.Name ?? "someone")</td>
103 + <td>
104 + ≈ @Format.Duration(dj.AverageTrack)
105 + @if (!dj.Measured)
106 + {
107 + <span class="muted"> (assumed)</span>
108 + }
109 + </td>
110 + <td>≈ @Format.Duration(dj.Wait)</td>
111 + </tr>
112 + }
113 + </tbody>
114 + </table>
115 + </div>
116 + @if (live.QueuedTracks > 0)
117 + {
118 + <p>@live.QueuedTracks @(live.QueuedTracks == 1 ? "track" : "tracks") queued for the bot's own turn.</p>
119 + }
120 + </Panel>
121 + }
122 +
123 + <Panel Legend="Totals">
124 + <dl class="stats">
125 + <div><dt>Plays</dt><dd>@Snapshot.Totals.Plays</dd></div>
126 + <div><dt>Tracks</dt><dd>@Snapshot.Totals.Tracks</dd></div>
127 + <div><dt>Artists</dt><dd>@Snapshot.Totals.Artists</dd></div>
128 + <div><dt>DJs</dt><dd>@Snapshot.Totals.Djs</dd></div>
129 + <div><dt>Users seen</dt><dd>@Snapshot.Totals.Users</dd></div>
130 + <div><dt>Dopes</dt><dd>@Snapshot.Totals.Dopes</dd></div>
131 + <div><dt>Stars</dt><dd>@Snapshot.Totals.Stars</dd></div>
132 + </dl>
133 + @if (Snapshot.Totals.FirstPlayAt is { } first)
134 + {
135 + <p>Recording plays since <Timestamp At="first"/>.</p>
136 + }
137 + </Panel>
138 +
139 + <Panel Legend="@($"Plays per day — last {Snapshot.PlaysPerDay.Count} days")">
140 + <ol class="bars">
141 + @foreach (var day in Snapshot.PlaysPerDay)
142 + {
143 + <li title="@BarTitle(day)">
144 + <span class="bar" style="block-size: @(BarHeight(day.Plays))%"></span>
145 + <span class="bar-label">@day.Plays</span>
146 + </li>
147 + }
148 + </ol>
149 + </Panel>
150 +
151 + <Panel Legend="Top DJs">
152 + <div class="table-scroll">
153 + <table>
154 + <thead>
155 + <tr>
156 + <th>DJ</th>
157 + <th>Plays</th>
158 + <th>👍</th>
159 + <th>🔖</th>
160 + <th>Score</th>
161 + </tr>
162 + </thead>
163 + <tbody>
164 + @foreach (var dj in Snapshot.TopDjs)
165 + {
166 + <tr>
167 + <td>@dj.Name</td>
168 + <td>@dj.Plays</td>
169 + <td>@dj.Dopes</td>
170 + <td>@dj.Stars</td>
171 + <td>@dj.Score</td>
172 + </tr>
173 + }
174 + </tbody>
175 + </table>
176 + </div>
177 + </Panel>
178 +
179 + <Panel Legend="Top tracks">
180 + <div class="table-scroll">
181 + <table>
182 + <thead>
183 + <tr>
184 + <th>Track</th>
185 + <th>Artist</th>
186 + <th>Plays</th>
187 + <th>Score</th>
188 + </tr>
189 + </thead>
190 + <tbody>
191 + @foreach (var track in Snapshot.TopTracks)
192 + {
193 + <tr>
194 + <td>@track.TrackName</td>
195 + <td>@string.Join(", ", track.ArtistNames)</td>
196 + <td>@track.Plays</td>
197 + <td>@track.Score</td>
198 + </tr>
199 + }
200 + </tbody>
201 + </table>
202 + </div>
203 + </Panel>
204 +
205 + <Panel Legend="Top artists">
206 + <div class="table-scroll">
207 + <table>
208 + <thead>
209 + <tr>
210 + <th>Artist</th>
211 + <th>Plays</th>
212 + <th>Score</th>
213 + </tr>
214 + </thead>
215 + <tbody>
216 + @foreach (var artist in Snapshot.TopArtists)
217 + {
218 + <tr>
219 + <td>@artist.ArtistName</td>
220 + <td>@artist.Plays</td>
221 + <td>@artist.Score</td>
222 + </tr>
223 + }
224 + </tbody>
225 + </table>
226 + </div>
227 + </Panel>
228 +
229 + <Panel Legend="Recently played">
230 + <div class="table-scroll">
231 + <table>
232 + <thead>
233 + <tr>
234 + <th>When</th>
235 + <th>DJ</th>
236 + <th>Track</th>
237 + <th>Artist</th>
238 + <th>Score</th>
239 + </tr>
240 + </thead>
241 + <tbody>
242 + @foreach (var play in Snapshot.RecentPlays)
243 + {
244 + <tr>
245 + <td><Timestamp At="play.PlayedAt"/></td>
246 + <td>@play.Dj</td>
247 + <td>@play.TrackName</td>
248 + <td>@string.Join(", ", play.ArtistNames)</td>
249 + <td>@play.Score</td>
250 + </tr>
251 + }
252 + </tbody>
253 + </table>
254 + </div>
255 + </Panel>
256 +
257 + <p class="muted">
258 + Read from <code>@Options.Node</code> over Erlang distribution with
259 + <a href="https://github.com/Besselking/BeamSharp">BeamSharp</a>: this site joins the
260 + cluster as a hidden node and calls <code>Rvrb.Stats.snapshot/0</code> the way any other
261 + BEAM node would. Snapshot taken <Timestamp At="Snapshot.GeneratedAt"/>.
262 + </p>
263 + }
264 +</main>

Blog/Components/Pages/Rvrb.razor.cs +63 -0

@@ -0,0 +1,63 @@
1 +using Blog.Models;
2 +using Blog.Services;
3 +using Microsoft.AspNetCore.Components;
4 +using Microsoft.Extensions.Options;
5 +
6 +namespace Blog.Components.Pages;
7 +
8 +public partial class Rvrb : ComponentBase
9 +{
10 + [Inject]
11 + public required RvrbService Service { get; set; }
12 +
13 + [Inject]
14 + public required IOptions<RvrbOptions> Settings { get; set; }
15 +
16 + private RvrbStatus Status { get; set; } = RvrbStatus.Unreachable("not read yet");
17 +
18 + private RvrbSnapshot? Snapshot => Status.Snapshot;
19 +
20 + private RvrbOptions Options => Settings.Value;
21 +
22 + /// <summary>
23 + /// How stale the snapshot is by the time it reaches the browser. Snapshots are cached and
24 + /// shared, so the track that was 40 seconds in when it was taken is further along by now — this
25 + /// is what lets the page pick the counter up where the bot left it.
26 + /// </summary>
27 + private TimeSpan Age => DateTimeOffset.UtcNow - Status.FetchedAt;
28 +
29 + private string StatusLabel => Status switch
30 + {
31 + { Snapshot: null } => "offline",
32 + { Snapshot.Live: null } => "up, not in the room",
33 + _ => "up, in the room"
34 + };
35 +
36 + private string StatusClass => Status switch
37 + {
38 + { Snapshot: null } => "status-down",
39 + { Snapshot.Live: null } => "status-idle",
40 + _ => "status-up"
41 + };
42 +
43 + protected override async Task OnInitializedAsync()
44 + {
45 + Status = await Service.GetStatusAsync().ConfigureAwait(true);
46 + await base.OnInitializedAsync().ConfigureAwait(true);
47 + }
48 +
49 + /// <summary>The hover label on a bar: which day it is, and what it counts.</summary>
50 + private static string BarTitle(RvrbDay day) => $"{day.Date:yyyy-MM-dd}: {day.Plays} plays";
51 +
52 + /// <summary>
53 + /// A day's bar height as a percentage of the busiest day in the window, floored at something
54 + /// visible so a day with one play doesn't render as nothing at all.
55 + /// </summary>
56 + private int BarHeight(int plays)
57 + {
58 + if (plays == 0) return 0;
59 +
60 + var busiest = Snapshot?.PlaysPerDay.Max(day => day.Plays) ?? 0;
61 + return busiest == 0 ? 0 : Math.Max(4, (int)Math.Round(plays * 100.0 / busiest));
62 + }
63 +}

Blog/Components/Pages/Rvrb.razor.js +106 -0

@@ -0,0 +1,106 @@
1 +// The page itself is server-rendered and static. This adds the two things a status page wants that
2 +// a snapshot can't carry: times that stay relative to now, and a track counter that keeps running
3 +// between refreshes.
4 +
5 +const REFRESH_KEY = "rvrb-auto-refresh";
6 +const REFRESH_MS = 30_000;
7 +
8 +let timer;
9 +
10 +export function onLoad() {
11 + wireAutoRefresh();
12 + startTicking();
13 +}
14 +
15 +export function onUpdate() {
16 + // Enhanced navigation swaps the DOM without a page load, so whatever was ticking is now
17 + // pointing at elements that are gone.
18 + stopTicking();
19 + wireAutoRefresh();
20 + startTicking();
21 +}
22 +
23 +export function onDispose() {
24 + stopTicking();
25 +}
26 +
27 +function wireAutoRefresh() {
28 + const checkbox = document.getElementById("autoRefresh");
29 + if (!checkbox) return;
30 +
31 + // Read back from storage, since the setting has to survive the very reload it causes.
32 + checkbox.checked = localStorage.getItem(REFRESH_KEY) === "on";
33 + checkbox.addEventListener("change", () => {
34 + localStorage.setItem(REFRESH_KEY, checkbox.checked ? "on" : "off");
35 + });
36 +}
37 +
38 +function startTicking() {
39 + const started = performance.now();
40 + const progress = document.getElementById("trackProgress");
41 + const elapsedLabel = document.getElementById("trackElapsed");
42 + const times = document.querySelectorAll("time[data-relative]");
43 + const checkbox = document.getElementById("autoRefresh");
44 +
45 + const tick = () => {
46 + const sinceRender = performance.now() - started;
47 +
48 + if (progress) advanceTrack(progress, elapsedLabel, sinceRender);
49 + times.forEach(relabel);
50 +
51 + if (checkbox?.checked && sinceRender >= REFRESH_MS) location.reload();
52 + };
53 +
54 + tick();
55 + timer = setInterval(tick, 1000);
56 +}
57 +
58 +function stopTicking() {
59 + clearInterval(timer);
60 + timer = undefined;
61 +}
62 +
63 +// The snapshot says how far into the track the bot was when it was taken, and how long ago that
64 +// was; everything after that is just wall clock.
65 +function advanceTrack(progress, label, sinceRender) {
66 + const duration = Number(progress.dataset.durationMs);
67 + if (!duration) return;
68 +
69 + const elapsed = Math.min(
70 + Number(progress.dataset.elapsedMs) + Number(progress.dataset.ageMs) + sinceRender,
71 + duration);
72 +
73 + progress.value = Math.round((elapsed / duration) * Number(progress.max));
74 + if (label) label.textContent = formatDuration(elapsed);
75 +}
76 +
77 +function relabel(time) {
78 + const at = Date.parse(time.dateTime);
79 + if (Number.isNaN(at)) return;
80 +
81 + time.title ||= time.textContent;
82 + time.textContent = formatAgo(Date.now() - at);
83 +}
84 +
85 +function formatAgo(ms) {
86 + if (ms < 0) return "just now";
87 +
88 + const seconds = Math.floor(ms / 1000);
89 + if (seconds < 60) return `${seconds}s ago`;
90 +
91 + const minutes = Math.floor(seconds / 60);
92 + if (minutes < 60) return `${minutes}m ago`;
93 +
94 + const hours = Math.floor(minutes / 60);
95 + if (hours < 24) return `${hours}h ago`;
96 +
97 + const days = Math.floor(hours / 24);
98 + return days < 30 ? `${days}d ago` : `${Math.floor(days / 30)}mo ago`;
99 +}
100 +
101 +function formatDuration(ms) {
102 + const seconds = Math.max(Math.floor(ms / 1000), 0);
103 + const minutes = Math.floor(seconds / 60);
104 +
105 + return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
106 +}

Blog/Components/_Shared/Timestamp.razor +12 -0

@@ -0,0 +1,12 @@
1 +@* An absolute time that a page script may rewrite as a relative one ("3m ago"). *@
2 +@* The rendered text is the absolute time, so it still says something useful without script. *@
3 +
4 +<time datetime="@At.ToUniversalTime().ToString("O")" data-relative>@Absolute</time>
5 +
6 +@code {
7 + [Parameter]
8 + [EditorRequired]
9 + public DateTimeOffset At { get; set; }
10 +
11 + private string Absolute => At.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'");
12 +}

Blog/Format.cs +26 -0

@@ -0,0 +1,26 @@
1 +namespace Blog;
2 +
3 +/// <summary>
4 +/// Short human renderings of numbers that would otherwise be printed raw.
5 +/// </summary>
6 +public static class Format
7 +{
8 + /// <summary>
9 + /// A duration as the largest two units that carry information — "3d 4h", "1h 4m", "3m 20s",
10 + /// "42s" — matching what the bot itself prints in chat. Null renders as "?", since a duration
11 + /// we don't know is not the same as one of zero.
12 + /// </summary>
13 + public static string Duration(TimeSpan? span)
14 + {
15 + if (span is not { } value) return "?";
16 + if (value < TimeSpan.Zero) value = TimeSpan.Zero;
17 +
18 + return value switch
19 + {
20 + { TotalDays: >= 1 } => $"{(int)value.TotalDays}d {value.Hours}h",
21 + { TotalHours: >= 1 } => $"{(int)value.TotalHours}h {value.Minutes}m",
22 + { TotalMinutes: >= 1 } => $"{(int)value.TotalMinutes}m {value.Seconds}s",
23 + _ => $"{(int)value.TotalSeconds}s"
24 + };
25 + }
26 +}

Blog/Models/RvrbSnapshot.cs +121 -0

@@ -0,0 +1,121 @@
1 +using System.ComponentModel;
2 +
3 +namespace Blog.Models;
4 +
5 +/// <summary>
6 +/// What <c>Rvrb.Stats.snapshot/0</c> answers with: the rvrb bot's room as it is right now, and
7 +/// what it has played since the plays table started filling up. Decoded from the Erlang term in
8 +/// <see cref="Services.RvrbSnapshotReader"/>.
9 +/// </summary>
10 +public sealed record RvrbSnapshot(
11 + DateTimeOffset GeneratedAt,
12 + RvrbLive? Live,
13 + RvrbTotals Totals,
14 + IReadOnlyList<RvrbDay> PlaysPerDay,
15 + IReadOnlyList<RvrbDj> TopDjs,
16 + IReadOnlyList<RvrbTrack> TopTracks,
17 + IReadOnlyList<RvrbArtist> TopArtists,
18 + IReadOnlyList<RvrbPlay> RecentPlays);
19 +
20 +/// <summary>
21 +/// The room as the bot's websocket currently sees it. Null in a snapshot means the bot is not
22 +/// connected to RVRB — the historic half is still there, since it only needs the database.
23 +/// </summary>
24 +public sealed record RvrbLive(
25 + string? ChannelId,
26 + RvrbCurrentTrack? CurrentTrack,
27 + int Dopes,
28 + int Stars,
29 + bool AutoDoped,
30 + bool AutoStarred,
31 + int QueuedTracks,
32 + int KnownBots,
33 + IReadOnlyList<RvrbDjSlot> Djs,
34 + TimeSpan Lap);
35 +
36 +/// <summary>The track playing right now.</summary>
37 +public sealed record RvrbCurrentTrack(
38 + string? SpotifyTrackId,
39 + string? Name,
40 + IReadOnlyList<string> ArtistNames,
41 + TimeSpan? Duration,
42 + string? AlbumArt,
43 + TimeSpan? Elapsed,
44 + TimeSpan? Remaining)
45 +{
46 + /// <summary>
47 + /// How far through the track we are, 0 to 1, or null when either half of that is unknown —
48 + /// a track that arrived without a duration, or one already playing when the bot connected.
49 + /// </summary>
50 + public double? Progress => Duration is { TotalMilliseconds: > 0 } total && Elapsed is { } elapsed
51 + ? Math.Clamp(elapsed.TotalMilliseconds / total.TotalMilliseconds, 0, 1)
52 + : null;
53 +}
54 +
55 +/// <summary>One slot in the DJ queue, with the wait until that DJ next plays.</summary>
56 +public sealed record RvrbDjSlot(
57 + string RvrbId,
58 + string? Name,
59 + TimeSpan AverageTrack,
60 + int PlayCount,
61 + bool Measured,
62 + bool Current,
63 + TimeSpan Wait);
64 +
65 +/// <summary>Room-wide counts.</summary>
66 +public sealed record RvrbTotals(
67 + long Plays,
68 + long Votes,
69 + long Dopes,
70 + long Stars,
71 + long Users,
72 + long Djs,
73 + long Tracks,
74 + long Artists,
75 + DateTimeOffset? FirstPlayAt,
76 + DateTimeOffset? LastPlayAt);
77 +
78 +/// <summary>Plays on one day. Days nothing was played on are present, with a count of zero.</summary>
79 +public sealed record RvrbDay(DateOnly Date, int Plays);
80 +
81 +/// <summary>A DJ on the leaderboard, and what their plays earned.</summary>
82 +public sealed record RvrbDj(string Name, string? UserName, int Plays, int Dopes, int Stars, int Score);
83 +
84 +/// <summary>A track on the leaderboard.</summary>
85 +public sealed record RvrbTrack(
86 + string TrackName,
87 + IReadOnlyList<string> ArtistNames,
88 + int Plays,
89 + int Dopes,
90 + int Stars,
91 + int Score);
92 +
93 +/// <summary>An artist on the leaderboard.</summary>
94 +public sealed record RvrbArtist(string ArtistName, int Plays, int Dopes, int Stars, int Score);
95 +
96 +/// <summary>One played track in the recent history.</summary>
97 +public sealed record RvrbPlay(
98 + DateTimeOffset PlayedAt,
99 + string? Dj,
100 + string TrackName,
101 + IReadOnlyList<string> ArtistNames,
102 + TimeSpan? Duration,
103 + int Dopes,
104 + int Stars,
105 + int Score);
106 +
107 +/// <summary>
108 +/// The result of asking the bot for a snapshot: either one, or why there isn't one. A bot that is
109 +/// down is the normal thing a status page is there to report, so it is a value here rather than an
110 +/// exception the page has to catch.
111 +/// </summary>
112 +// Nothing here is ever mutated after it is built, and saying so is what lets HybridCache hand the
113 +// same instance to every request instead of serializing a copy per reader.
114 +[ImmutableObject(true)]
115 +public sealed record RvrbStatus(DateTimeOffset FetchedAt, RvrbSnapshot? Snapshot, string? Error)
116 +{
117 + public static RvrbStatus Reachable(RvrbSnapshot snapshot) =>
118 + new(DateTimeOffset.UtcNow, snapshot, null);
119 +
120 + public static RvrbStatus Unreachable(string error) => new(DateTimeOffset.UtcNow, null, error);
121 +}

Blog/Program.cs +5 -0

@@ -15,6 +15,11 @@ builder.Services.AddHttpClient<BrpService>(
15 15 client.BaseAddress = new Uri("https://brp.bes.is/");
16 16 });
17 17
18 +// /rvrb reads the rvrb bot's stats straight off its BEAM, over Erlang distribution. The node this
19 +// site dials with is started on the first request, not here - see RvrbService.
20 +builder.Services.Configure<RvrbOptions>(builder.Configuration.GetSection(RvrbOptions.Section));
21 +builder.Services.AddSingleton<RvrbService>();
22 +
18 23 // builder.Services.AddTransient<BrpService>();
19 24 // builder.Services.AddTransient<NsService>();
20 25

Blog/Services/RvrbService.cs +154 -0

@@ -0,0 +1,154 @@
1 +using System.Net.Sockets;
2 +using BeamSharp.Node;
3 +using Blog.Models;
4 +using Microsoft.Extensions.Caching.Hybrid;
5 +using Microsoft.Extensions.Options;
6 +
7 +namespace Blog.Services;
8 +
9 +/// <summary>Where the rvrb bot is, and how patient to be with it.</summary>
10 +public sealed class RvrbOptions
11 +{
12 + public const string Section = "Rvrb";
13 +
14 + /// <summary>The bot's node, as <c>name@host</c>. It runs on this machine, hence loopback.</summary>
15 + public string Node { get; set; } = "rvrb@127.0.0.1";
16 +
17 + /// <summary>The name this site registers with EPMD under.</summary>
18 + public string LocalNode { get; set; } = "blog@127.0.0.1";
19 +
20 + /// <summary>
21 + /// The Erlang cookie both nodes share. Not in appsettings.json — it is the only thing standing
22 + /// between a local process and the bot's node, so it comes from user secrets in development and
23 + /// from the environment (<c>Rvrb__Cookie</c>) in production.
24 + /// </summary>
25 + public string? Cookie { get; set; }
26 +
27 + /// <summary>How long to wait for the bot to answer before calling it unreachable.</summary>
28 + public TimeSpan CallTimeout { get; set; } = TimeSpan.FromSeconds(5);
29 +
30 + /// <summary>
31 + /// How long a snapshot is served to everyone who asks. The live half moves in seconds, so this
32 + /// is short — but it is what keeps a refresh loop, or a crawler, from turning into one
33 + /// round trip and a handful of queries per request.
34 + /// </summary>
35 + public TimeSpan CacheFor { get; set; } = TimeSpan.FromSeconds(15);
36 +}
37 +
38 +/// <summary>
39 +/// Reads the rvrb bot's stats over Erlang distribution.
40 +/// </summary>
41 +/// <remarks>
42 +/// The site joins the cluster as a hidden Erlang node of its own — BeamSharp speaks the
43 +/// distribution protocol, so from the bot's side this is an ordinary <c>:rpc.call/4</c> arriving
44 +/// from a peer, and nothing on the Elixir side had to grow an HTTP endpoint for it.
45 +///
46 +/// The node is started on the first request rather than at boot, deliberately: registering with
47 +/// EPMD can fail (it isn't running, the bot was deployed without distribution), and the rest of
48 +/// this site has no business failing to start over a page that shows a bot's play count. A failure
49 +/// here is a value, not an exception — see <see cref="RvrbStatus"/>.
50 +/// </remarks>
51 +public sealed class RvrbService(
52 + HybridCache cache,
53 + IOptions<RvrbOptions> options,
54 + ILogger<RvrbService> logger) : IAsyncDisposable
55 +{
56 + private readonly RvrbOptions _options = options.Value;
57 + private readonly SemaphoreSlim _startGate = new(1, 1);
58 + private ErlangNode? _node;
59 +
60 + public async ValueTask<RvrbStatus> GetStatusAsync(CancellationToken cancellationToken = default)
61 + {
62 + return await cache.GetOrCreateAsync(
63 + "rvrb/snapshot",
64 + this,
65 + static (service, cancel) => service.FetchAsync(cancel),
66 + new HybridCacheEntryOptions
67 + {
68 + Expiration = _options.CacheFor,
69 + LocalCacheExpiration = _options.CacheFor
70 + },
71 + cancellationToken: cancellationToken)
72 + .ConfigureAwait(false);
73 + }
74 +
75 + private async ValueTask<RvrbStatus> FetchAsync(CancellationToken cancellationToken)
76 + {
77 + try
78 + {
79 + var node = await StartedNodeAsync(cancellationToken).ConfigureAwait(false);
80 +
81 + // Elixir modules are atoms prefixed with `Elixir.`, which is what `Rvrb.Stats` is on
82 + // the wire. `snapshot/0` exists because its options argument has a default.
83 + var reply = await node
84 + .RpcAsync(_options.Node, "Elixir.Rvrb.Stats", "snapshot", [], _options.CallTimeout,
85 + cancellationToken)
86 + .ConfigureAwait(false);
87 +
88 + return RvrbStatus.Reachable(RvrbSnapshotReader.Read(reply));
89 + }
90 + catch (Exception ex) when (ex is IOException or SocketException or TimeoutException
91 + or ErlangRpcException or ErlangExitException
92 + or InvalidOperationException or FormatException)
93 + {
94 + logger.LogWarning(ex, "could not read stats from {Node}", _options.Node);
95 + return RvrbStatus.Unreachable(Describe(ex));
96 + }
97 + }
98 +
99 + /// <summary>
100 + /// This site's own node, started on first use. A start that fails leaves nothing behind, so the
101 + /// next request tries again — which is what recovers the page once the bot comes back.
102 + /// </summary>
103 + private async ValueTask<ErlangNode> StartedNodeAsync(CancellationToken cancellationToken)
104 + {
105 + if (_node is { } running) return running;
106 +
107 + await _startGate.WaitAsync(cancellationToken).ConfigureAwait(false);
108 + try
109 + {
110 + if (_node is { } started) return started;
111 +
112 + var node = new ErlangNode(_options.LocalNode, new ErlangNodeOptions
113 + {
114 + Cookie = _options.Cookie,
115 + // Both nodes are on this machine, so neither the listener nor the lookup has any
116 + // business leaving it. Erlang distribution authenticates with a shared cookie and
117 + // then sends everything in the clear.
118 + BindAddress = "127.0.0.1",
119 + EpmdHost = "127.0.0.1",
120 + Log = line => logger.LogDebug("beamsharp: {Message}", line)
121 + });
122 +
123 + try
124 + {
125 + await node.StartAsync(cancellationToken).ConfigureAwait(false);
126 + }
127 + catch
128 + {
129 + // StartAsync binds the listener before it registers with EPMD, so a failure after
130 + // that point would otherwise leak a socket per attempt.
131 + await node.DisposeAsync().ConfigureAwait(false);
132 + throw;
133 + }
134 +
135 + _node = node;
136 + return node;
137 + }
138 + finally
139 + {
140 + _startGate.Release();
141 + }
142 + }
143 +
144 + // The useful half of these is usually the inner exception: BeamSharp reports an unknown node,
145 + // a bad cookie and a refused connection as one IOException carrying the reason underneath.
146 + private static string Describe(Exception ex) =>
147 + ex.InnerException is { } inner ? $"{ex.Message}: {inner.Message}" : ex.Message;
148 +
149 + public async ValueTask DisposeAsync()
150 + {
151 + if (_node is { } node) await node.DisposeAsync().ConfigureAwait(false);
152 + _startGate.Dispose();
153 + }
154 +}

Blog/Services/RvrbSnapshotReader.cs +155 -0

@@ -0,0 +1,155 @@
1 +using System.Globalization;
2 +using BeamSharp.Terms;
3 +using Blog.Models;
4 +
5 +namespace Blog.Services;
6 +
7 +/// <summary>
8 +/// Turns the term <c>Rvrb.Stats.snapshot/0</c> answers with into the records the page renders.
9 +/// </summary>
10 +/// <remarks>
11 +/// Written out by hand rather than through <c>BeamSharp.Serialization</c>, because the shape on
12 +/// the wire is an Elixir map written by Elixir — snake_case atom keys, binaries for strings,
13 +/// <c>nil</c> for absent values — and not a C# type that happens to be serialized. Reading it is
14 +/// deliberately forgiving: a key this side does not know about is ignored, and a missing one takes
15 +/// its default, so the bot can grow a field without the site having to be redeployed first.
16 +/// </remarks>
17 +internal static class RvrbSnapshotReader
18 +{
19 + public static RvrbSnapshot Read(ErlTerm term)
20 + {
21 + var root = AsMap(term) ?? throw new FormatException($"expected a map, got {term}");
22 +
23 + return new RvrbSnapshot(
24 + GeneratedAt: Time(root.Get("generated_at")) ?? DateTimeOffset.UtcNow,
25 + Live: ReadLive(AsMap(root.Get("live"))),
26 + Totals: ReadTotals(AsMap(root.Get("totals"))),
27 + PlaysPerDay: ReadList(root.Get("plays_per_day"), ReadDay),
28 + TopDjs: ReadList(root.Get("top_djs"), ReadDj),
29 + TopTracks: ReadList(root.Get("top_tracks"), ReadTrack),
30 + TopArtists: ReadList(root.Get("top_artists"), ReadArtist),
31 + RecentPlays: ReadList(root.Get("recent_plays"), ReadPlay));
32 + }
33 +
34 + private static RvrbLive? ReadLive(ErlMap? live) => live is null
35 + ? null
36 + : new RvrbLive(
37 + ChannelId: Text(live.Get("channel_id")),
38 + CurrentTrack: ReadCurrentTrack(AsMap(live.Get("current_track"))),
39 + Dopes: (int)Int(live.Get("dopes")),
40 + Stars: (int)Int(live.Get("stars")),
41 + AutoDoped: Bool(live.Get("auto_doped")),
42 + AutoStarred: Bool(live.Get("auto_starred")),
43 + QueuedTracks: (int)Int(live.Get("queued_tracks")),
44 + KnownBots: (int)Int(live.Get("known_bots")),
45 + Djs: ReadList(live.Get("djs"), ReadDjSlot),
46 + Lap: Duration(live.Get("lap_ms")) ?? TimeSpan.Zero);
47 +
48 + private static RvrbCurrentTrack? ReadCurrentTrack(ErlMap? track) => track is null
49 + ? null
50 + : new RvrbCurrentTrack(
51 + SpotifyTrackId: Text(track.Get("spotify_track_id")),
52 + Name: Text(track.Get("name")),
53 + ArtistNames: ReadStrings(track.Get("artist_names")),
54 + Duration: Duration(track.Get("duration_ms")),
55 + AlbumArt: Text(track.Get("album_art")),
56 + Elapsed: Duration(track.Get("elapsed_ms")),
57 + Remaining: Duration(track.Get("remaining_ms")));
58 +
59 + private static RvrbDjSlot ReadDjSlot(ErlMap dj) => new(
60 + RvrbId: Text(dj.Get("rvrb_id")) ?? "",
61 + Name: Text(dj.Get("name")),
62 + AverageTrack: Duration(dj.Get("avg_track_ms")) ?? TimeSpan.Zero,
63 + PlayCount: (int)Int(dj.Get("play_count")),
64 + Measured: Bool(dj.Get("measured")),
65 + Current: Bool(dj.Get("current")),
66 + Wait: Duration(dj.Get("wait_ms")) ?? TimeSpan.Zero);
67 +
68 + private static RvrbTotals ReadTotals(ErlMap? totals) => new(
69 + Plays: Int(totals?.Get("plays")),
70 + Votes: Int(totals?.Get("votes")),
71 + Dopes: Int(totals?.Get("dopes")),
72 + Stars: Int(totals?.Get("stars")),
73 + Users: Int(totals?.Get("users")),
74 + Djs: Int(totals?.Get("djs")),
75 + Tracks: Int(totals?.Get("tracks")),
76 + Artists: Int(totals?.Get("artists")),
77 + FirstPlayAt: Time(totals?.Get("first_play_at")),
78 + LastPlayAt: Time(totals?.Get("last_play_at")));
79 +
80 + private static RvrbDay ReadDay(ErlMap day) => new(
81 + Date: Text(day.Get("date")) is { } date
82 + ? DateOnly.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture)
83 + : default,
84 + Plays: (int)Int(day.Get("plays")));
85 +
86 + private static RvrbDj ReadDj(ErlMap dj) => new(
87 + Name: Text(dj.Get("name")) ?? "someone",
88 + UserName: Text(dj.Get("user_name")),
89 + Plays: (int)Int(dj.Get("plays")),
90 + Dopes: (int)Int(dj.Get("dopes")),
91 + Stars: (int)Int(dj.Get("stars")),
92 + Score: (int)Int(dj.Get("score")));
93 +
94 + private static RvrbTrack ReadTrack(ErlMap track) => new(
95 + TrackName: Text(track.Get("track_name")) ?? "unknown track",
96 + ArtistNames: ReadStrings(track.Get("artist_names")),
97 + Plays: (int)Int(track.Get("plays")),
98 + Dopes: (int)Int(track.Get("dopes")),
99 + Stars: (int)Int(track.Get("stars")),
100 + Score: (int)Int(track.Get("score")));
101 +
102 + private static RvrbArtist ReadArtist(ErlMap artist) => new(
103 + ArtistName: Text(artist.Get("artist_name")) ?? "unknown artist",
104 + Plays: (int)Int(artist.Get("plays")),
105 + Dopes: (int)Int(artist.Get("dopes")),
106 + Stars: (int)Int(artist.Get("stars")),
107 + Score: (int)Int(artist.Get("score")));
108 +
109 + private static RvrbPlay ReadPlay(ErlMap play) => new(
110 + PlayedAt: Time(play.Get("played_at")) ?? default,
111 + Dj: Text(play.Get("dj")),
112 + TrackName: Text(play.Get("track_name")) ?? "unknown track",
113 + ArtistNames: ReadStrings(play.Get("artist_names")),
114 + Duration: Duration(play.Get("duration_ms")),
115 + Dopes: (int)Int(play.Get("dopes")),
116 + Stars: (int)Int(play.Get("stars")),
117 + Score: (int)Int(play.Get("score")));
118 +
119 + // ------------------------------------------------------------------ terms
120 +
121 + // `nil` is a real value on the Elixir side rather than an absent key, so it has to read as
122 + // "no map" here — otherwise a disconnected bot would decode as a live one with everything zero.
123 + private static ErlMap? AsMap(ErlTerm? term) => term as ErlMap;
124 +
125 + private static string? Text(ErlTerm? term) => term switch
126 + {
127 + ErlBinary binary => binary.AsString(),
128 + ErlAtom { Name: "nil" or "undefined" } => null,
129 + ErlAtom atom => atom.Name,
130 + _ => null
131 + };
132 +
133 + private static long Int(ErlTerm? term) => term is ErlInt value ? (long)value.Value : 0;
134 +
135 + private static bool Bool(ErlTerm? term) => term is ErlAtom { Name: "true" };
136 +
137 + private static TimeSpan? Duration(ErlTerm? term) =>
138 + term is ErlInt ms ? TimeSpan.FromMilliseconds((double)ms.Value) : null;
139 +
140 + private static DateTimeOffset? Time(ErlTerm? term) =>
141 + Text(term) is { } text &&
142 + DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at)
143 + ? at.ToUniversalTime()
144 + : null;
145 +
146 + private static IReadOnlyList<string> ReadStrings(ErlTerm? term) =>
147 + term is ErlList list
148 + ? list.ToArray().Select(item => Text(item) ?? "").ToArray()
149 + : [];
150 +
151 + private static IReadOnlyList<T> ReadList<T>(ErlTerm? term, Func<ErlMap, T> read) =>
152 + term is ErlList list
153 + ? list.ToArray().OfType<ErlMap>().Select(read).ToArray()
154 + : [];
155 +}

Blog/appsettings.json +5 -1

@@ -5,5 +5,9 @@
5 5 "Microsoft.AspNetCore": "Warning"
6 6 }
7 7 },
8 - "AllowedHosts": "*"
8 + "AllowedHosts": "*",
9 + "Rvrb": {
10 + "Node": "rvrb@127.0.0.1",
11 + "LocalNode": "blog@127.0.0.1"
12 + }
9 13 }

Blog/wwwroot/app.css +119 -0

@@ -527,6 +527,125 @@ dialog > .panel > legend {
527 527 border-radius: var(--radius);
528 528 }
529 529
530 +/* -- Tables --------------------------------------------------------------- */
531 +
532 +table {
533 + inline-size: 100%;
534 + border-collapse: collapse;
535 +}
536 +
537 +:is(th, td) {
538 + text-align: start;
539 + padding: var(--s-2) var(--s-1);
540 + padding-inline-start: 0;
541 + border-block-end: var(--border-width) solid var(--border);
542 + vertical-align: baseline;
543 +}
544 +
545 +tbody tr:last-child :is(th, td) {
546 + border-block-end: none;
547 +}
548 +
549 +/* A table with more columns than the page has room for scrolls inside its own
550 + box rather than pushing the whole page sideways. */
551 +.table-scroll {
552 + overflow-x: auto;
553 +}
554 +
555 +
556 +/* -- rvrb stats ----------------------------------------------------------- */
557 +
558 +.status-up {
559 + color: var(--green);
560 +}
561 +
562 +.status-idle {
563 + color: var(--info);
564 +}
565 +
566 +.status-down {
567 + color: var(--error);
568 +}
569 +
570 +.muted {
571 + opacity: 50%;
572 +}
573 +
574 +.now-playing {
575 + display: flex;
576 + flex-wrap: wrap;
577 + gap: var(--s0);
578 + align-items: start;
579 +}
580 +
581 +/* Overrides the block margin and auto-centring the base `img` rule gives every
582 + image, which would otherwise push the art away from the track it belongs to. */
583 +.now-playing .album-art {
584 + margin: 0;
585 + flex: none;
586 + inline-size: 8em;
587 + block-size: 8em;
588 + border-radius: var(--radius);
589 +}
590 +
591 +.now-playing > div {
592 + flex: 1 1 20ch;
593 +}
594 +
595 +progress {
596 + inline-size: 100%;
597 + block-size: var(--s-1);
598 +}
599 +
600 +/* Counts big enough to read at a glance, laid out as many per row as fit. */
601 +.stats {
602 + display: grid;
603 + grid-template-columns: repeat(auto-fit, minmax(10ch, 1fr));
604 + gap: var(--s-1);
605 + margin-block: 0;
606 +}
607 +
608 +.stats dt {
609 + opacity: 60%;
610 +}
611 +
612 +.stats dd {
613 + margin-inline-start: 0;
614 + font-size: calc(1em * var(--ratio));
615 +}
616 +
617 +/* Plays per day. One <li> per day, so a quiet day is an empty column rather
618 + than a gap - see Rvrb.Stats.plays_per_day/1. */
619 +.bars {
620 + display: flex;
621 + align-items: end;
622 + gap: var(--border-width);
623 + block-size: 6em;
624 + margin-block: 0;
625 + padding-inline: 0;
626 + list-style: none;
627 +}
628 +
629 +.bars li {
630 + display: flex;
631 + flex: 1;
632 + flex-direction: column;
633 + align-items: center;
634 + justify-content: end;
635 + block-size: 100%;
636 +}
637 +
638 +.bars .bar {
639 + inline-size: 100%;
640 + background-color: var(--color);
641 +}
642 +
643 +.bars .bar-label {
644 + font-size: 0.7em;
645 + opacity: 60%;
646 +}
647 +
648 +
530 649 /* -- Storage ------------------------------------------------------------- */
531 650
532 651 .grid-stores {

CLAUDE.md +20 -0

@@ -63,6 +63,26 @@ are third-party or semi-vendored libraries used by specific pages (e.g. `Query.r
63 63 uses both `dactal.js` and `jsonql-js` to run two query languages against the same mock
64 64 BRP dataset in `wwwroot/brp.json`).
65 65
66 +### rvrb feature (stats read off another BEAM node)
67 +
68 +`Rvrb.razor`/`.razor.cs`/`.razor.js` (route `/rvrb`) shows the status and stats of the
69 +rvrb Elixir bot (`~/Developer/elixir/rvrb`), which runs on the same server. `Services/RvrbService.cs`
70 +gets them by joining the bot's Erlang cluster: [BeamSharp](https://github.com/Besselking/BeamSharp)
71 +(referenced as a project from the sibling checkout, it is not on NuGet yet) makes this site a
72 +hidden Erlang node and calls `Rvrb.Stats.snapshot/0` on the bot the way any BEAM node would —
73 +no HTTP endpoint on the Elixir side.
74 +
75 +The node is started on the *first request* rather than at boot, and a failure is a value
76 +(`RvrbStatus.Unreachable`) rather than an exception: the site must not fail to start, or a page
77 +fail to render, because EPMD is down or the bot was deployed without distribution. `Models/RvrbSnapshot.cs`
78 +holds the shapes and `Services/RvrbSnapshotReader.cs` decodes the Erlang term into them by hand —
79 +the term is an Elixir map written by Elixir, not a serialized C# type.
80 +
81 +Configuration lives under `Rvrb` (`RvrbOptions`): `Node`, `LocalNode`, `CallTimeout`, `CacheFor`
82 +in `appsettings.json`, and `Cookie` — the shared Erlang cookie — from user secrets in development
83 +(`dotnet user-secrets set "Rvrb:Cookie" ...`) or `Rvrb__Cookie` in the environment. The bot's own
84 +side of this (enabling distribution on its release) is documented in the rvrb repo's README.
85 +
66 86 ### BRP feature (the one page with a real backend)
67 87
68 88 `BRP.razor`/`.razor.cs` and `BrpTestData.razor` are backed by `Services/BrpService.cs`,