Blog/Services/RvrbSnapshotReader.cs 7.2 K · 161 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 SpotifyArtistIds: ReadStrings(track.Get("spotify_artist_ids")),
55 Duration: Duration(track.Get("duration_ms")),
56 AlbumArt: Text(track.Get("album_art")),
57 Elapsed: Duration(track.Get("elapsed_ms")),
58 Remaining: Duration(track.Get("remaining_ms")));
59
60 private static RvrbDjSlot ReadDjSlot(ErlMap dj) => new(
61 RvrbId: Text(dj.Get("rvrb_id")) ?? "",
62 Name: Text(dj.Get("name")),
63 AverageTrack: Duration(dj.Get("avg_track_ms")) ?? TimeSpan.Zero,
64 PlayCount: (int)Int(dj.Get("play_count")),
65 Measured: Bool(dj.Get("measured")),
66 Current: Bool(dj.Get("current")),
67 Wait: Duration(dj.Get("wait_ms")) ?? TimeSpan.Zero);
68
69 private static RvrbTotals ReadTotals(ErlMap? totals) => new(
70 Plays: Int(totals?.Get("plays")),
71 Votes: Int(totals?.Get("votes")),
72 Dopes: Int(totals?.Get("dopes")),
73 Stars: Int(totals?.Get("stars")),
74 Users: Int(totals?.Get("users")),
75 Djs: Int(totals?.Get("djs")),
76 Tracks: Int(totals?.Get("tracks")),
77 Artists: Int(totals?.Get("artists")),
78 FirstPlayAt: Time(totals?.Get("first_play_at")),
79 LastPlayAt: Time(totals?.Get("last_play_at")));
80
81 private static RvrbDay ReadDay(ErlMap day) => new(
82 Date: Text(day.Get("date")) is { } date
83 ? DateOnly.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture)
84 : default,
85 Plays: (int)Int(day.Get("plays")));
86
87 private static RvrbDj ReadDj(ErlMap dj) => new(
88 Name: Text(dj.Get("name")) ?? "someone",
89 UserName: Text(dj.Get("user_name")),
90 Plays: (int)Int(dj.Get("plays")),
91 Dopes: (int)Int(dj.Get("dopes")),
92 Stars: (int)Int(dj.Get("stars")),
93 Score: (int)Int(dj.Get("score")));
94
95 private static RvrbTrack ReadTrack(ErlMap track) => new(
96 TrackName: Text(track.Get("track_name")) ?? "unknown track",
97 ArtistNames: ReadStrings(track.Get("artist_names")),
98 SpotifyTrackId: Text(track.Get("spotify_track_id")),
99 SpotifyArtistIds: ReadStrings(track.Get("spotify_artist_ids")),
100 Plays: (int)Int(track.Get("plays")),
101 Dopes: (int)Int(track.Get("dopes")),
102 Stars: (int)Int(track.Get("stars")),
103 Score: (int)Int(track.Get("score")));
104
105 private static RvrbArtist ReadArtist(ErlMap artist) => new(
106 ArtistName: Text(artist.Get("artist_name")) ?? "unknown artist",
107 SpotifyArtistId: Text(artist.Get("spotify_artist_id")),
108 Plays: (int)Int(artist.Get("plays")),
109 Dopes: (int)Int(artist.Get("dopes")),
110 Stars: (int)Int(artist.Get("stars")),
111 Score: (int)Int(artist.Get("score")));
112
113 private static RvrbPlay ReadPlay(ErlMap play) => new(
114 PlayedAt: Time(play.Get("played_at")) ?? default,
115 Dj: Text(play.Get("dj")),
116 TrackName: Text(play.Get("track_name")) ?? "unknown track",
117 ArtistNames: ReadStrings(play.Get("artist_names")),
118 SpotifyTrackId: Text(play.Get("spotify_track_id")),
119 SpotifyArtistIds: ReadStrings(play.Get("spotify_artist_ids")),
120 Duration: Duration(play.Get("duration_ms")),
121 Dopes: (int)Int(play.Get("dopes")),
122 Stars: (int)Int(play.Get("stars")),
123 Score: (int)Int(play.Get("score")));
124
125 // ------------------------------------------------------------------ terms
126
127 // `nil` is a real value on the Elixir side rather than an absent key, so it has to read as
128 // "no map" here — otherwise a disconnected bot would decode as a live one with everything zero.
129 private static ErlMap? AsMap(ErlTerm? term) => term as ErlMap;
130
131 private static string? Text(ErlTerm? term) => term switch
132 {
133 ErlBinary binary => binary.AsString(),
134 ErlAtom { Name: "nil" or "undefined" } => null,
135 ErlAtom atom => atom.Name,
136 _ => null
137 };
138
139 private static long Int(ErlTerm? term) => term is ErlInt value ? (long)value.Value : 0;
140
141 private static bool Bool(ErlTerm? term) => term is ErlAtom { Name: "true" };
142
143 private static TimeSpan? Duration(ErlTerm? term) =>
144 term is ErlInt ms ? TimeSpan.FromMilliseconds((double)ms.Value) : null;
145
146 private static DateTimeOffset? Time(ErlTerm? term) =>
147 Text(term) is { } text &&
148 DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at)
149 ? at.ToUniversalTime()
150 : null;
151
152 private static IReadOnlyList<string> ReadStrings(ErlTerm? term) =>
153 term is ErlList list
154 ? list.ToArray().Select(item => Text(item) ?? "").ToArray()
155 : [];
156
157 private static IReadOnlyList<T> ReadList<T>(ErlTerm? term, Func<ErlMap, T> read) =>
158 term is ErlList list
159 ? list.ToArray().OfType<ErlMap>().Select(read).ToArray()
160 : [];
161 }