Blog/Services/RvrbSnapshotReader.cs 6.8 K · 155 lines · raw · history

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 }