Add LastFm feed for a given user

This commit is contained in:
Claudio Ortolina
2024-11-04 09:17:01 +00:00
parent c9beef6bdd
commit 1abcc5e6ad
8 changed files with 202 additions and 0 deletions
+111
View File
@@ -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, "<redacted_api_key>")
end
end
+24
View File
@@ -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
+35
View File
@@ -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
+23
View File
@@ -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
+1
View File
@@ -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