lib/lastfm_archive/lastfm_client.ex

defmodule LastfmArchive.LastfmClient do
  @moduledoc """
  Client for extracting Lastfm user info and scrobbles data via the official API.
  """

  @behaviour LastfmArchive.Behaviour.LastfmClient

  @api Application.compile_env(:lastfm_archive, :api)
  @config_user Application.compile_env(:lastfm_archive, :user)

  defstruct [:api_key, :endpoint, :method]

  @type t :: %__MODULE__{
          api_key: binary,
          endpoint: binary,
          method: binary
        }

  def default_user, do: System.get_env("LB_LFM_USER") || @config_user

  def new(method) do
    %__MODULE__{
      api_key: System.get_env("LB_LFM_API_KEY") || @api[:api_key] || "",
      endpoint: System.get_env("LB_LFM_API_ENDPOINT") || @api[:endpoint] || "",
      method: method
    }
  end

  @doc """
  Returns the total playcount and earliest scrobble date for a user.
  """
  @impl true
  def info(user \\ default_user(), client \\ new("user.getinfo")) do
    "#{client.endpoint}2.0/?method=#{client.method}&user=#{user}&api_key=#{client.api_key}&format=json"
    |> get(client.api_key)
    |> handle_response(:info)
  end

  @doc """
  Returns the scrobbles of a user for a given time range.

  See Lastfm API [documentation](https://www.last.fm/api/show/user.getRecentTracks) for more details.
  """
  @impl true
  def scrobbles(user \\ default_user(), page_params \\ {1, 1, nil, nil}, client \\ new("user.getrecenttracks"))

  def scrobbles(user, {page, limit, from, to}, client) do
    extra_query = [limit: limit, page: page, from: from, to: to, extended: 1] |> encode() |> Enum.join()

    "#{client.endpoint}2.0/?method=#{client.method}&user=#{user}&api_key=#{client.api_key}&format=json#{extra_query}"
    |> get(client.api_key)
    |> handle_response(:scrobbles)
  end

  @doc """
  Returns the playcount of a user for a given time range.
  """
  @impl true
  def playcount(user \\ default_user(), {from, to} \\ {nil, nil}, client \\ new("user.getrecenttracks")) do
    extra_query = [limit: 1, page: 1, from: from, to: to] |> encode() |> Enum.join()

    "#{client.endpoint}2.0/?method=#{client.method}&user=#{user}&api_key=#{client.api_key}&format=json#{extra_query}"
    |> get(client.api_key)
    |> handle_response(:playcount)
  end

  defp handle_response(%{"user" => _} = resp, _type) do
    {
      :ok,
      {
        resp["user"]["playcount"] |> format(),
        resp["user"]["registered"]["unixtime"] |> format()
      }
    }
  end

  defp handle_response(%{"recenttracks" => _} = resp, :playcount) do
    {
      :ok,
      {
        resp["recenttracks"]["@attr"]["total"] |> format(),
        resp["recenttracks"]["track"]
        |> List.wrap()
        |> Enum.find(& &1["date"])
        |> get_in(["date", "uts"])
        |> format()
      }
    }
  end

  defp handle_response(%{"recenttracks" => _} = resp, _type), do: {:ok, resp}
  defp handle_response(%{"error" => _, "message" => message}, _type), do: {:error, message}
  defp handle_response({:error, reason}, _type), do: {:error, reason}

  defp format(number) when is_integer(number), do: number
  defp format(number) when is_binary(number), do: number |> String.to_integer()
  defp format(nil), do: nil

  defp get(url, key) do
    :httpc.request(:get, {to_charlist(url), [{'Authorization', to_charlist("Bearer #{key}")}]}, [], [])
    |> case do
      {:ok, {{_scheme, _status, _}, _headers, body}} -> body |> Jason.decode!()
      {:error, error} -> {:error, error}
    end
  end

  defp encode(nil), do: ""
  defp encode({_k, 0}), do: ""
  defp encode({k, v}), do: "&#{k}=#{v}"
  defp encode(args), do: for({k, v} <- args, v != nil, do: encode({k, v}))
end