Link tracks and artists on the rvrb page to Spotify

The bot has always recorded the Spotify ids behind the names it reports, and now sends them along, so every track and artist on the page can be a link: in Now playing, on both leaderboards, and in the recent plays. Two components rather than an @if per table, because a missing id is ordinary rather than exceptional - RVRB passes through a track without one now and then, and plays recorded before the bot stored artist ids never had them. `SpotifyLink` renders a plain name in that case, and `ArtistLinks` pairs names to ids by index, leaving the trailing names unlinked when the id list is shorter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-09 16:47 UTC
commit
9d011c87380dc3b7dfa86d40fa5a6bc06528e451
parent
dfe928fa15
tree
browse at this commit

5 files changed +87 -8

Blog/Components/Pages/Rvrb.razor +15 -7

@@ -49,8 +49,10 @@
49 49 <img class="album-art" src="@art" alt="" width="128" height="128"/>
50 50 }
51 51 <div>
52 - <h3>@track.Name</h3>
53 - <p>@string.Join(", ", track.ArtistNames)</p>
52 + <h3>
53 + <SpotifyLink Kind="track" Id="@track.SpotifyTrackId" Name="@track.Name"/>
54 + </h3>
55 + <p><ArtistLinks Names="track.ArtistNames" Ids="track.SpotifyArtistIds"/></p>
54 56 @* A bar needs both halves to mean anything: a track that came without a
55 57 duration, or one already playing when the bot connected, gets neither. *@
56 58 @if (track.Duration is { } length && track.Elapsed is not null)
@@ -192,8 +194,10 @@
192 194 @foreach (var track in Snapshot.TopTracks)
193 195 {
194 196 <tr>
195 - <td>@track.TrackName</td>
196 - <td>@string.Join(", ", track.ArtistNames)</td>
197 + <td>
198 + <SpotifyLink Kind="track" Id="@track.SpotifyTrackId" Name="@track.TrackName"/>
199 + </td>
200 + <td><ArtistLinks Names="track.ArtistNames" Ids="track.SpotifyArtistIds"/></td>
197 201 <td>@track.Plays</td>
198 202 <td>@track.Score</td>
199 203 </tr>
@@ -217,7 +221,9 @@
217 221 @foreach (var artist in Snapshot.TopArtists)
218 222 {
219 223 <tr>
220 - <td>@artist.ArtistName</td>
224 + <td>
225 + <SpotifyLink Kind="artist" Id="@artist.SpotifyArtistId" Name="@artist.ArtistName"/>
226 + </td>
221 227 <td>@artist.Plays</td>
222 228 <td>@artist.Score</td>
223 229 </tr>
@@ -245,8 +251,10 @@
245 251 <tr>
246 252 <td><Timestamp At="play.PlayedAt"/></td>
247 253 <td>@play.Dj</td>
248 - <td>@play.TrackName</td>
249 - <td>@string.Join(", ", play.ArtistNames)</td>
254 + <td>
255 + <SpotifyLink Kind="track" Id="@play.SpotifyTrackId" Name="@play.TrackName"/>
256 + </td>
257 + <td><ArtistLinks Names="play.ArtistNames" Ids="play.SpotifyArtistIds"/></td>
250 258 <td>@play.Score</td>
251 259 </tr>
252 260 }

Blog/Components/_Shared/ArtistLinks.razor +25 -0

@@ -0,0 +1,25 @@
1 +@* A track's artists, comma-separated, each linked to Spotify where we have its id. *@
2 +@* Names and ids arrive positionally parallel from the bot, so an id is matched to a name *@
3 +@* by index - a shorter id list (a play recorded before they were stored) just means the *@
4 +@* trailing names go unlinked. *@
5 +
6 +@for (var i = 0; i < Names.Count; i++)
7 +{
8 + if (i > 0)
9 + {
10 + <text>, </text>
11 + }
12 +
13 + <SpotifyLink Kind="artist" Id="@IdAt(i)" Name="@Names[i]"/>
14 +}
15 +
16 +@code {
17 + [Parameter]
18 + [EditorRequired]
19 + public required IReadOnlyList<string> Names { get; set; }
20 +
21 + [Parameter]
22 + public IReadOnlyList<string> Ids { get; set; } = [];
23 +
24 + private string? IdAt(int index) => index < Ids.Count ? Ids[index] : null;
25 +}

Blog/Components/_Shared/SpotifyLink.razor +29 -0

@@ -0,0 +1,29 @@
1 +@* A name that links to Spotify when we know its id, and is plain text when we don't. *@
2 +@* Plays recorded before an id was stored, or ones RVRB passed through without one, are *@
3 +@* ordinary here rather than exceptional - hence one component instead of an @@if per table. *@
4 +
5 +@if (string.IsNullOrEmpty(Id))
6 +{
7 + @Name
8 +}
9 +else
10 +{
11 + <a href="@Url">@Name</a>
12 +}
13 +
14 +@code {
15 + /// <summary>What the id identifies: <c>track</c>, <c>artist</c>, <c>album</c>.</summary>
16 + [Parameter]
17 + [EditorRequired]
18 + public required string Kind { get; set; }
19 +
20 + /// <summary>The Spotify id. Null or empty renders the name unlinked.</summary>
21 + [Parameter]
22 + public string? Id { get; set; }
23 +
24 + [Parameter]
25 + [EditorRequired]
26 + public required string Name { get; set; }
27 +
28 + private string Url => $"https://open.spotify.com/{Kind}/{Uri.EscapeDataString(Id!)}";
29 +}

Blog/Models/RvrbSnapshot.cs +12 -1

@@ -38,6 +38,7 @@ public sealed record RvrbCurrentTrack(
38 38 string? SpotifyTrackId,
39 39 string? Name,
40 40 IReadOnlyList<string> ArtistNames,
41 + IReadOnlyList<string> SpotifyArtistIds,
41 42 TimeSpan? Duration,
42 43 string? AlbumArt,
43 44 TimeSpan? Elapsed,
@@ -85,13 +86,21 @@ public sealed record RvrbDj(string Name, string? UserName, int Plays, int Dopes,
85 86 public sealed record RvrbTrack(
86 87 string TrackName,
87 88 IReadOnlyList<string> ArtistNames,
89 + string? SpotifyTrackId,
90 + IReadOnlyList<string> SpotifyArtistIds,
88 91 int Plays,
89 92 int Dopes,
90 93 int Stars,
91 94 int Score);
92 95
93 96 /// <summary>An artist on the leaderboard.</summary>
94 -public sealed record RvrbArtist(string ArtistName, int Plays, int Dopes, int Stars, int Score);
97 +public sealed record RvrbArtist(
98 + string ArtistName,
99 + string? SpotifyArtistId,
100 + int Plays,
101 + int Dopes,
102 + int Stars,
103 + int Score);
95 104
96 105 /// <summary>One played track in the recent history.</summary>
97 106 public sealed record RvrbPlay(
@@ -99,6 +108,8 @@ public sealed record RvrbPlay(
99 108 string? Dj,
100 109 string TrackName,
101 110 IReadOnlyList<string> ArtistNames,
111 + string? SpotifyTrackId,
112 + IReadOnlyList<string> SpotifyArtistIds,
102 113 TimeSpan? Duration,
103 114 int Dopes,
104 115 int Stars,

Blog/Services/RvrbSnapshotReader.cs +6 -0

@@ -51,6 +51,7 @@ internal static class RvrbSnapshotReader
51 51 SpotifyTrackId: Text(track.Get("spotify_track_id")),
52 52 Name: Text(track.Get("name")),
53 53 ArtistNames: ReadStrings(track.Get("artist_names")),
54 + SpotifyArtistIds: ReadStrings(track.Get("spotify_artist_ids")),
54 55 Duration: Duration(track.Get("duration_ms")),
55 56 AlbumArt: Text(track.Get("album_art")),
56 57 Elapsed: Duration(track.Get("elapsed_ms")),
@@ -94,6 +95,8 @@ internal static class RvrbSnapshotReader
94 95 private static RvrbTrack ReadTrack(ErlMap track) => new(
95 96 TrackName: Text(track.Get("track_name")) ?? "unknown track",
96 97 ArtistNames: ReadStrings(track.Get("artist_names")),
98 + SpotifyTrackId: Text(track.Get("spotify_track_id")),
99 + SpotifyArtistIds: ReadStrings(track.Get("spotify_artist_ids")),
97 100 Plays: (int)Int(track.Get("plays")),
98 101 Dopes: (int)Int(track.Get("dopes")),
99 102 Stars: (int)Int(track.Get("stars")),
@@ -101,6 +104,7 @@ internal static class RvrbSnapshotReader
101 104
102 105 private static RvrbArtist ReadArtist(ErlMap artist) => new(
103 106 ArtistName: Text(artist.Get("artist_name")) ?? "unknown artist",
107 + SpotifyArtistId: Text(artist.Get("spotify_artist_id")),
104 108 Plays: (int)Int(artist.Get("plays")),
105 109 Dopes: (int)Int(artist.Get("dopes")),
106 110 Stars: (int)Int(artist.Get("stars")),
@@ -111,6 +115,8 @@ internal static class RvrbSnapshotReader
111 115 Dj: Text(play.Get("dj")),
112 116 TrackName: Text(play.Get("track_name")) ?? "unknown track",
113 117 ArtistNames: ReadStrings(play.Get("artist_names")),
118 + SpotifyTrackId: Text(play.Get("spotify_track_id")),
119 + SpotifyArtistIds: ReadStrings(play.Get("spotify_artist_ids")),
114 120 Duration: Duration(play.Get("duration_ms")),
115 121 Dopes: (int)Int(play.Get("dopes")),
116 122 Stars: (int)Int(play.Get("stars")),