diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..0a603623 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +dotenv_if_exists .secrets diff --git a/.gitignore b/.gitignore index 9c3ea21f..2a63bd97 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ npm-debug.log # Database files /data/*.db /data/*.db-* + +# Environment secrets +/.secrets diff --git a/config/runtime.exs b/config/runtime.exs index 91818a04..26a63e17 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -20,6 +20,10 @@ if System.get_env("PHX_SERVER") do config :music_library, MusicLibraryWeb.Endpoint, server: true end +if api_key = System.get_env("LAST_FM_API_KEY") do + config :music_library, LastFm, api_key: api_key +end + if config_env() == :prod do database_path = System.get_env("DATABASE_PATH") || diff --git a/lib/last_fm/api.ex b/lib/last_fm/api.ex new file mode 100644 index 00000000..050f81fb --- /dev/null +++ b/lib/last_fm/api.ex @@ -0,0 +1,111 @@ +defmodule LastFm.API do + require Logger + + @base_url "http://ws.audioscrobbler.com/2.0/" + + def get_recent_tracks(user, api_key) do + options = [ + method: "user.getrecenttracks", + user: user, + api_key: api_key, + format: "json", + limit: 20 + ] + + url = @base_url <> "?" <> URI.encode_query(options) + + Logger.debug("Fetching data from #{sanitize_url(url, api_key)}") + + case json_get(url) do + {:ok, response} -> + {:ok, + response + |> get_in(["recenttracks", "track"]) + |> parse_tracks()} + + other -> + msg = "Failed to fetch data from #{sanitize_url(url, api_key)}, reason: #{inspect(other)}" + Logger.error(msg) + {:error, msg} + end + end + + defp json_get(url) do + req = + Finch.build(:get, url, [ + {"User-Agent", "MusicLibrary/0.1.0 ( cloud8421@gmail.com )"} + ]) + + case Finch.request(req, MusicLibrary.Finch) do + {:ok, response} when response.status == 200 -> + Jason.decode(response.body) + + other -> + other + end + end + + defmodule Artist do + defstruct [:musicbrainz_id, :name] + end + + defmodule Album do + defstruct [:musicbrainz_id, :title] + end + + defmodule Track do + @moduledoc """ + Data is not always guaranteed: + + - musicbrainz_id can be an empty string + """ + defstruct [ + :musicbrainz_id, + :title, + :artist, + :album, + :cover_url, + :scrobbled_at_uts, + :scrobbled_at_label + ] + end + + defp parse_tracks(raw_tracks) do + Enum.map(raw_tracks, fn t -> + album = %Album{ + musicbrainz_id: t["album"]["mbid"], + title: t["album"]["#text"] + } + + artist = %Artist{ + musicbrainz_id: t["artist"]["mbid"], + name: t["artist"]["#text"] + } + + %Track{ + musicbrainz_id: t["mbid"], + title: t["name"], + artist: artist, + album: album, + cover_url: parse_cover_url(t), + scrobbled_at_uts: parse_scrobble_at_uts(t), + scrobbled_at_label: t["date"]["#text"] + } + end) + end + + defp parse_cover_url(track) do + track["image"] + |> Enum.find(%{"#text" => nil}, fn i -> i["size"] == "small" end) + |> Map.get("#text") + end + + defp parse_scrobble_at_uts(track) do + track["date"]["uts"] + |> String.to_integer() + end + + defp sanitize_url(url, api_key) do + String.replace(url, api_key, "") + end +end diff --git a/lib/last_fm/feed.ex b/lib/last_fm/feed.ex new file mode 100644 index 00000000..3f0cedc8 --- /dev/null +++ b/lib/last_fm/feed.ex @@ -0,0 +1,24 @@ +defmodule LastFm.Feed do + @moduledoc """ + Holds a in-memory cache of scrobbled tracks. + + Tracks are keyed and ASC ordered by their scrobbling unix timestamp. While this + is technically prone to collision, it's very unlikely for that to happen due to the + nature of the data. + """ + def create_table! do + __MODULE__ = :ets.new(__MODULE__, [:ordered_set, :named_table, :public]) + :ok + end + + def update(tracks) do + data = Enum.map(tracks, fn t -> {t.scrobbled_at_uts, t} end) + + :ets.insert(__MODULE__, data) + end + + def all do + :ets.tab2list(__MODULE__) + |> Enum.reverse() + end +end diff --git a/lib/last_fm/refresh.ex b/lib/last_fm/refresh.ex new file mode 100644 index 00000000..44c3ff3b --- /dev/null +++ b/lib/last_fm/refresh.ex @@ -0,0 +1,35 @@ +defmodule LastFm.Refresh do + use GenServer + + require Logger + + alias LastFm.{API, Feed} + + @refresh_interval System.convert_time_unit(30, :second, :millisecond) + + def start_link(config) do + GenServer.start_link(__MODULE__, config, name: __MODULE__) + end + + def init(config) do + if config.api_key do + {:ok, config, {:continue, :refresh}} + else + :ignore + end + end + + def handle_continue(:refresh, config) do + case API.get_recent_tracks(config.user, config.api_key) do + {:ok, tracks} -> + Feed.update(tracks) + Process.send_after(self(), :refresh, @refresh_interval) + {:noreply, config} + + {:error, _reason} -> + # TODO: think about failure scenario - error is logged at the API level + Process.send_after(self(), :refresh, @refresh_interval) + {:noreply, config} + end + end +end diff --git a/lib/last_fm/supervisor.ex b/lib/last_fm/supervisor.ex new file mode 100644 index 00000000..ef157de6 --- /dev/null +++ b/lib/last_fm/supervisor.ex @@ -0,0 +1,23 @@ +defmodule LastFm.Supervisor do + use Supervisor + + def start_link(init_arg) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + :ok = LastFm.Feed.create_table!() + + children = [ + {LastFm.Refresh, %{user: "cloud8421", api_key: api_key()}} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + defp api_key do + Application.get_env(:music_library, LastFm, []) + |> Keyword.get(:api_key) + end +end diff --git a/lib/music_library/application.ex b/lib/music_library/application.ex index e34bf564..5cf2a0c6 100644 --- a/lib/music_library/application.ex +++ b/lib/music_library/application.ex @@ -15,6 +15,7 @@ defmodule MusicLibrary.Application do repos: Application.fetch_env!(:music_library, :ecto_repos), skip: skip_migrations?()}, {DNSCluster, query: Application.get_env(:music_library, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: MusicLibrary.PubSub}, + LastFm.Supervisor, # Start a worker by calling: MusicLibrary.Worker.start_link(arg) # {MusicLibrary.Worker, arg}, # Start to serve requests, typically the last entry