using System.Globalization;
using BeamSharp.Terms;
using Blog.Models;
namespace Blog.Services;
///
/// Turns the term Rvrb.Stats.snapshot/0 answers with into the records the page renders.
///
///
/// Written out by hand rather than through BeamSharp.Serialization, because the shape on
/// the wire is an Elixir map written by Elixir — snake_case atom keys, binaries for strings,
/// nil for absent values — and not a C# type that happens to be serialized. Reading it is
/// deliberately forgiving: a key this side does not know about is ignored, and a missing one takes
/// its default, so the bot can grow a field without the site having to be redeployed first.
///
internal static class RvrbSnapshotReader
{
public static RvrbSnapshot Read(ErlTerm term)
{
var root = AsMap(term) ?? throw new FormatException($"expected a map, got {term}");
return new RvrbSnapshot(
GeneratedAt: Time(root.Get("generated_at")) ?? DateTimeOffset.UtcNow,
Live: ReadLive(AsMap(root.Get("live"))),
Totals: ReadTotals(AsMap(root.Get("totals"))),
PlaysPerDay: ReadList(root.Get("plays_per_day"), ReadDay),
TopDjs: ReadList(root.Get("top_djs"), ReadDj),
TopTracks: ReadList(root.Get("top_tracks"), ReadTrack),
TopArtists: ReadList(root.Get("top_artists"), ReadArtist),
RecentPlays: ReadList(root.Get("recent_plays"), ReadPlay));
}
private static RvrbLive? ReadLive(ErlMap? live) => live is null
? null
: new RvrbLive(
ChannelId: Text(live.Get("channel_id")),
CurrentTrack: ReadCurrentTrack(AsMap(live.Get("current_track"))),
Dopes: (int)Int(live.Get("dopes")),
Stars: (int)Int(live.Get("stars")),
AutoDoped: Bool(live.Get("auto_doped")),
AutoStarred: Bool(live.Get("auto_starred")),
QueuedTracks: (int)Int(live.Get("queued_tracks")),
KnownBots: (int)Int(live.Get("known_bots")),
Djs: ReadList(live.Get("djs"), ReadDjSlot),
Lap: Duration(live.Get("lap_ms")) ?? TimeSpan.Zero);
private static RvrbCurrentTrack? ReadCurrentTrack(ErlMap? track) => track is null
? null
: new RvrbCurrentTrack(
SpotifyTrackId: Text(track.Get("spotify_track_id")),
Name: Text(track.Get("name")),
ArtistNames: ReadStrings(track.Get("artist_names")),
Duration: Duration(track.Get("duration_ms")),
AlbumArt: Text(track.Get("album_art")),
Elapsed: Duration(track.Get("elapsed_ms")),
Remaining: Duration(track.Get("remaining_ms")));
private static RvrbDjSlot ReadDjSlot(ErlMap dj) => new(
RvrbId: Text(dj.Get("rvrb_id")) ?? "",
Name: Text(dj.Get("name")),
AverageTrack: Duration(dj.Get("avg_track_ms")) ?? TimeSpan.Zero,
PlayCount: (int)Int(dj.Get("play_count")),
Measured: Bool(dj.Get("measured")),
Current: Bool(dj.Get("current")),
Wait: Duration(dj.Get("wait_ms")) ?? TimeSpan.Zero);
private static RvrbTotals ReadTotals(ErlMap? totals) => new(
Plays: Int(totals?.Get("plays")),
Votes: Int(totals?.Get("votes")),
Dopes: Int(totals?.Get("dopes")),
Stars: Int(totals?.Get("stars")),
Users: Int(totals?.Get("users")),
Djs: Int(totals?.Get("djs")),
Tracks: Int(totals?.Get("tracks")),
Artists: Int(totals?.Get("artists")),
FirstPlayAt: Time(totals?.Get("first_play_at")),
LastPlayAt: Time(totals?.Get("last_play_at")));
private static RvrbDay ReadDay(ErlMap day) => new(
Date: Text(day.Get("date")) is { } date
? DateOnly.ParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture)
: default,
Plays: (int)Int(day.Get("plays")));
private static RvrbDj ReadDj(ErlMap dj) => new(
Name: Text(dj.Get("name")) ?? "someone",
UserName: Text(dj.Get("user_name")),
Plays: (int)Int(dj.Get("plays")),
Dopes: (int)Int(dj.Get("dopes")),
Stars: (int)Int(dj.Get("stars")),
Score: (int)Int(dj.Get("score")));
private static RvrbTrack ReadTrack(ErlMap track) => new(
TrackName: Text(track.Get("track_name")) ?? "unknown track",
ArtistNames: ReadStrings(track.Get("artist_names")),
Plays: (int)Int(track.Get("plays")),
Dopes: (int)Int(track.Get("dopes")),
Stars: (int)Int(track.Get("stars")),
Score: (int)Int(track.Get("score")));
private static RvrbArtist ReadArtist(ErlMap artist) => new(
ArtistName: Text(artist.Get("artist_name")) ?? "unknown artist",
Plays: (int)Int(artist.Get("plays")),
Dopes: (int)Int(artist.Get("dopes")),
Stars: (int)Int(artist.Get("stars")),
Score: (int)Int(artist.Get("score")));
private static RvrbPlay ReadPlay(ErlMap play) => new(
PlayedAt: Time(play.Get("played_at")) ?? default,
Dj: Text(play.Get("dj")),
TrackName: Text(play.Get("track_name")) ?? "unknown track",
ArtistNames: ReadStrings(play.Get("artist_names")),
Duration: Duration(play.Get("duration_ms")),
Dopes: (int)Int(play.Get("dopes")),
Stars: (int)Int(play.Get("stars")),
Score: (int)Int(play.Get("score")));
// ------------------------------------------------------------------ terms
// `nil` is a real value on the Elixir side rather than an absent key, so it has to read as
// "no map" here — otherwise a disconnected bot would decode as a live one with everything zero.
private static ErlMap? AsMap(ErlTerm? term) => term as ErlMap;
private static string? Text(ErlTerm? term) => term switch
{
ErlBinary binary => binary.AsString(),
ErlAtom { Name: "nil" or "undefined" } => null,
ErlAtom atom => atom.Name,
_ => null
};
private static long Int(ErlTerm? term) => term is ErlInt value ? (long)value.Value : 0;
private static bool Bool(ErlTerm? term) => term is ErlAtom { Name: "true" };
private static TimeSpan? Duration(ErlTerm? term) =>
term is ErlInt ms ? TimeSpan.FromMilliseconds((double)ms.Value) : null;
private static DateTimeOffset? Time(ErlTerm? term) =>
Text(term) is { } text &&
DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at)
? at.ToUniversalTime()
: null;
private static IReadOnlyList ReadStrings(ErlTerm? term) =>
term is ErlList list
? list.ToArray().Select(item => Text(item) ?? "").ToArray()
: [];
private static IReadOnlyList ReadList(ErlTerm? term, Func read) =>
term is ErlList list
? list.ToArray().OfType().Select(read).ToArray()
: [];
}