lib/lastfm_archive/livebook.ex

defmodule LastfmArchive.Livebook do
  @moduledoc """
  Livebook chart and text rendering.
  """
  alias LastfmArchive.LastfmClient.Impl, as: LastfmClient
  alias LastfmArchive.LastfmClient.LastfmApi
  alias VegaLite, as: Vl

  alias Explorer.DataFrame
  alias Explorer.Series

  require Explorer.DataFrame

  @cache LastfmArchive.Cache.Server
  @type user :: LastfmArchive.Behaviour.Archive.user()
  @type year :: integer()
  @type daily_playcounts :: %{
          {user, year} => %{data: list(%{count: integer(), date: String.t()}), total: integer(), max: integer()}
        }

  @doc """
  Display user name and total number of scrobbles to archive.
  """
  @spec info :: Kino.Markdown.t()
  def info(user \\ LastfmClient.default_user()) do
    impl = LastfmArchive.Behaviour.LastfmClient.impl()
    playcount_api = LastfmApi.new("user.getrecenttracks")
    info_api = LastfmApi.new("user.getinfo")
    time_range = {nil, nil}

    case {user, impl.info(user, info_api), impl.playcount(user, time_range, playcount_api)} do
      {"", _, _} ->
        Kino.Markdown.new("""
        Please specify a Lastfm user in configuration.
        """)

      {user, {:ok, {total, registered_time}}, {:ok, {_, latest_scrobble_time}}} ->
        Kino.Markdown.new("""
        For Lastfm user: **#{user}** with **#{total}** total number of scrobbles.
        - scrobbling since **#{registered_time |> DateTime.from_unix!() |> DateTime.to_date()}**
        - latest scrobble time **#{latest_scrobble_time |> DateTime.from_unix!() |> Calendar.strftime("%c")}**
        """)

      {_, _, _} ->
        Kino.Markdown.new("""
        Unable to fetch user info from Lastfm API, have you configured the API key?
        """)
    end
  end

  @doc """
  Display daily playcounts of scrobbles archived in VegaLite heatmaps.
  """
  @spec render_playcounts_heatmaps(user(), keyword(), module()) :: :ok
  def render_playcounts_heatmaps(user \\ LastfmClient.default_user(), opts \\ [], cache \\ @cache) do
    colour_scheme = Keyword.get(opts, :colour, "yellowgreenblue")
    stats = daily_playcounts_per_years(user, cache)
    global_max = stats |> Map.values() |> Stream.map(& &1.max) |> Enum.max()

    stats
    |> Enum.each(fn {{_user, year}, %{data: data}} ->
      render_heading(year, data)
      render_heatmap(data, year, global_max, colour_scheme)
    end)
  end

  defp render_heading(year, data) do
    stats = stats_per_year(data)

    Kino.Markdown.new("<small style=\"padding-left: 40px;\">Year <b>#{year}</b>,
      total <b>#{stats["total"]}</b>,
      per-day
      <b>#{stats["avg"] |> round()}</b> avg,
      <b>#{stats["median"] |> round()}</b> median,
      <b>#{stats["min"]}</b> min,
      <b>#{stats["max"]}</b> max
      </small>")
    |> Kino.render()
  end

  defp stats_per_year(counts) do
    DataFrame.new(counts)
    |> DataFrame.collect()
    |> DataFrame.summarise(
      avg: mean(count),
      median: median(count),
      min: min(count),
      max: max(count),
      total: sum(count)
    )
    |> DataFrame.to_rows()
    |> hd
  end

  defp render_heatmap(data, year, max, colour_scheme) do
    Vl.new(title: nil, width: 620, height: 80)
    |> Vl.transform(filter: "datum.count > 0 && datum.year == #{year}")
    |> Vl.data_from_values(data)
    |> Vl.mark(:rect, width: 9, height: 9, tooltip: true)
    |> Vl.encode_field(:x, "date",
      time_unit: :yearweek,
      type: :temporal,
      title: nil,
      axis: [format: "%b", offset: -8, domain: false, grid: false],
      scale: [domain: [[year: year, month: "jan", date: 1], [year: year, month: "dec", date: 31]]]
    )
    |> Vl.encode_field(:y, "date",
      time_unit: :yearday,
      type: :temporal,
      title: nil,
      axis: [format: "%a", offset: 16],
      sort: "descending"
    )
    |> Vl.encode_field(:color, "count",
      aggregate: :sum,
      type: :quantitative,
      legend: false,
      scale: [domain: [0, max], scheme: colour_scheme]
    )
    |> Vl.encode(:tooltip, [[field: "date", type: :temporal], [field: "count", type: :quantitative]])
    |> Vl.config(view: [stroke: nil])
    |> Kino.render()
  end

  @doc """
  Returns a list of daily scrobble playcounts and stats per years.
  """
  @spec daily_playcounts_per_years(user(), module()) :: daily_playcounts()
  def daily_playcounts_per_years(user \\ LastfmClient.default_user(), cache \\ @cache) do
    for {{user, year}, statuses} <- user |> LastfmArchive.Cache.load(cache), into: %{} do
      data = aggregate(statuses) |> List.flatten()

      {
        {user, year},
        %{
          data: data,
          max: Stream.map(data, & &1.count) |> Enum.max(),
          total: Stream.map(data, & &1.count) |> Enum.sum()
        }
      }
    end
  end

  defp aggregate(statuses) do
    statuses
    |> Enum.flat_map(fn {{from, _to}, {count, status}} ->
      case Enum.all?(status, &(&1 == :ok)) do
        true -> [{from, count}]
        false -> []
      end
    end)
    |> Enum.group_by(fn {from, _count} -> get_date(from) end, &elem(&1, 1))
    |> Enum.into([], fn {%{year: year} = date, counts} ->
      %{
        date: date |> to_string(),
        year: year,
        count: Enum.sum(counts)
      }
    end)
  end

  defp get_date(datetime), do: datetime |> DateTime.from_unix!() |> DateTime.to_date()

  @doc """
  Display faceted dataframe in VegaLite bubble plot showing first play and counts (size, colour).
  """
  @spec render_first_play_bubble_plot(DataFrame.t()) :: VegaLite.t()
  def render_first_play_bubble_plot(facet_dataframe) do
    {min_year, max_year} = find_first_play_min_max_years(facet_dataframe)

    data =
      facet_dataframe
      |> DataFrame.put(:first_play, Series.cast(facet_dataframe[:first_play], :date) |> Series.cast(:string))
      |> DataFrame.to_rows()

    Vl.new(title: nil, width: 800, height: 400)
    |> Vl.transform(calculate: "random()", as: "jitter")
    |> Vl.transform(filter: "datum.counts > 0")
    |> Vl.data_from_values(data)
    |> Vl.mark(:circle, tooltip: true)
    |> Vl.encode_field(:x, "first_play",
      time_unit: :yearmonth,
      type: :temporal,
      title: nil,
      axis: [format: "%Y"],
      scale: [domain: [[year: min_year, month: "jan", date: 1], [year: max_year, month: "dec", date: 31]]]
    )
    |> Vl.encode_field(:x_offset, "jitter", type: :quantitative)
    |> Vl.encode_field(:y_offset, "jitter", type: :quantitative)
    |> Vl.encode_field(:y, "first_play",
      time_unit: :date,
      type: :temporal,
      title: nil,
      axis: [format: "%d"]
      # sort: "ascending"
    )
    |> Vl.encode_field(:size, "counts",
      type: :quantitative,
      scale: [type: "linear", range_max: 1000, range_min: 2],
      legend: false
    )
    |> Vl.encode_field(:color, "counts",
      type: :nominal,
      opacity: 0.5,
      scale: [scheme: "turbo"],
      legend: false
    )
    |> Vl.encode(:tooltip, [
      [field: "artist", type: :nominal],
      [field: "counts", type: :quantitative],
      [field: "first_play", type: :temporal]
    ])
  end

  defp find_first_play_min_max_years(df) do
    df = df |> DataFrame.summarise(min: min(first_play), max: max(first_play)) |> DataFrame.to_rows() |> hd
    %{year: min_year} = df["min"]
    %{year: max_year} = df["max"]

    {min_year, max_year}
  end
end