defmodule LastfmArchive.Cache do
@moduledoc """
GenServer storing archiving state to ensure scrobbles are fetched only once.
"""
use GenServer
alias LastfmArchive.Utils
require Logger
@cache_file_prefix ".cache_"
@cache_file_wildcard @cache_file_prefix <> "????"
@ticks_before_serialise 10
@file_io Application.compile_env(:lastfm_archive, :file_io, Elixir.File)
@path_io Application.compile_env(:lastfm_archive, :path_io, Elixir.Path)
@type user :: binary()
@type year :: integer()
@type start_of_day :: integer()
@type end_of_day :: integer()
@callback get({user, year}, GenServer.server()) :: map()
@callback load(user, GenServer.server(), keyword) :: map()
@callback put({user, year}, {start_of_day, end_of_day}, tuple, GenServer.server()) :: :ok
@callback serialise(user, GenServer.server(), keyword) :: term
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@spec state() :: map()
def state(), do: state(__MODULE__)
@spec state(GenServer.server()) :: map()
def state(server), do: GenServer.call(server, :state)
@spec reset(GenServer.server(), keyword) :: :ok
def reset(server \\ __MODULE__, options \\ []), do: GenServer.call(server, {:reset, options})
@spec clear(binary, GenServer.server(), keyword) :: map()
def clear(user, server \\ __MODULE__, options), do: GenServer.call(server, {:clear, user, options})
def load(user, server \\ __MODULE__, options \\ []), do: GenServer.call(server, {:load, user, options})
def serialise(user, server \\ __MODULE__, options \\ []), do: GenServer.call(server, {:serialise, user, options})
def get(key, server \\ __MODULE__)
def get({user, year}, server), do: GenServer.call(server, {:get, {user, year}})
def put({user, year}, {from, to}, value, server \\ __MODULE__) do
GenServer.call(server, {:put, {user, year}, {from, to}, value})
end
## GenServer Callbacks
@impl true
def init(opts) do
{:ok, {Keyword.get(opts, :ticks, @ticks_before_serialise), %{}}}
end
@impl true
def handle_call(:state, _from, state), do: {:reply, state, state}
@impl true
def handle_call({:reset, opts}, _from, _state) do
{:reply, :ok, {Keyword.get(opts, :ticks, @ticks_before_serialise), %{}}}
end
@impl true
def handle_call({:clear, user, opts}, _from, {ticks, state}) do
new_state =
Enum.reject(state, fn {k, _v} ->
elem(k, 0) == user
end)
|> Enum.into(%{})
{:reply, new_state, {Keyword.get(opts, :ticks, ticks), new_state}}
end
@impl true
def handle_call({:load, user, options}, _from, {ticks, state}) do
new_state =
Path.join(Utils.user_dir(user, options), @cache_file_wildcard)
|> @path_io.wildcard(match_dot: true)
|> Enum.reduce(state, fn path, acc -> merge_cache_in_state(path, user, acc) end)
{:reply, new_state, {ticks, new_state}}
end
@impl true
def handle_call({:serialise, user, options}, _from, {_ticks, state}) do
results =
for {{id, year}, value} <- state, id == user do
Path.join([Utils.user_dir(user, options), "#{@cache_file_prefix}#{year}"])
|> @file_io.write(value |> :erlang.term_to_binary())
end
{:reply, results, {@ticks_before_serialise, state}}
end
@impl true
def handle_call({:get, {user, year}}, _from, {ticks, state}) do
{:reply, Map.get(state, {user, year}, %{}), {ticks, state}}
end
@impl true
def handle_call({:put, {user, year}, {from, to}, value}, _from, {0, state}) do
path = Path.join([Utils.user_dir(user, []), "#{@cache_file_prefix}#{year}"])
@file_io.write(
path,
Map.get(state, {user, year}, %{}) |> :erlang.term_to_binary()
)
Logger.debug("serialise archiving cache status to #{path}")
{:reply, :ok, {@ticks_before_serialise, update_state(state, {user, year}, {from, to}, value)}}
end
@impl true
def handle_call({:put, {user, year}, {from, to}, value}, _from, {ticks, state}) do
{:reply, :ok, {ticks - 1, update_state(state, {user, year}, {from, to}, value)}}
end
defp update_state(state, {user, year}, {from, to}, value) do
Logger.debug("caching archive status #{Utils.date(from)}, #{inspect(value)}")
case state[{user, year}] do
cache when is_map(cache) ->
update_in(state, [{user, year}, {from, to}], &(&1 = value))
nil ->
Map.merge(state, %{{user, year} => %{{from, to} => value}})
end
end
defp merge_cache_in_state(path, user, state) do
cache_data = @file_io.read!(path) |> :erlang.binary_to_term()
year = Path.basename(path) |> cache_year()
Map.merge(state, %{{user, year} => cache_data})
end
defp cache_year(@cache_file_prefix <> year), do: year |> String.to_integer()
end