Files
music_library/lib/music_library/artists.ex
T
2026-03-25 11:52:43 +00:00

316 lines
9.5 KiB
Elixir

defmodule MusicLibrary.Artists do
@moduledoc """
Artist metadata management from MusicBrainz, Discogs, Wikipedia, and Last.fm.
"""
import Ecto.Query, warn: false
alias MusicLibrary.Artists.ArtistInfo
alias MusicLibrary.Assets
alias MusicLibrary.Collection
alias MusicLibrary.Records.ArtistRecord
alias MusicLibrary.{Repo, Worker}
@spec get_artist!(String.t()) :: map()
def get_artist!(musicbrainz_id) do
q =
from ar in ArtistRecord,
where: ar.musicbrainz_id == ^musicbrainz_id,
limit: 1,
select: ar.artist
Repo.one!(q)
end
@spec get_similar_artists(map()) :: {:ok, [map()]} | {:error, term()}
def get_similar_artists(artist) do
case LastFm.get_similar_artists(artist.musicbrainz_id, artist.name) do
{:ok, artists} ->
collected_artist_ids = Collection.collected_artist_ids()
{:ok,
Enum.filter(artists, fn a ->
MapSet.member?(collected_artist_ids, a.musicbrainz_id)
end)}
error ->
error
end
end
@spec name_id_pairs([String.t()]) :: [{String.t(), String.t()}]
def name_id_pairs(names) do
q =
from ar in ArtistRecord,
distinct: true,
where: fragment("artist ->> '$.name'") in ^names,
select: {fragment("artist ->> '$.name'"), ar.musicbrainz_id}
Repo.all(q)
end
@spec get_all_artist_ids() :: MapSet.t(String.t())
def get_all_artist_ids do
q = from ar in ArtistRecord, distinct: true, select: ar.musicbrainz_id
q |> Repo.all() |> MapSet.new()
end
@spec get_all_artist_pairs() :: [map()]
def get_all_artist_pairs do
q =
from ar in ArtistRecord,
distinct: true,
select: %{artist_id: ar.musicbrainz_id, record_id: ar.record_id}
q |> Repo.all()
end
@spec get_image_hashes([map()]) :: %{String.t() => String.t() | nil}
def get_image_hashes(lastfm_artists) do
musicbrainz_ids = Enum.map(lastfm_artists, & &1.musicbrainz_id)
q =
from ai in ArtistInfo,
where: ai.id in ^musicbrainz_ids,
select: {ai.id, ai.image_data_hash}
q
|> Repo.all()
|> Enum.into(%{})
end
@spec search_by_name(String.t(), non_neg_integer()) :: [map()]
def search_by_name(query, limit) do
case String.trim(query) do
"" ->
[]
trimmed_query ->
normalized_query = String.downcase(trimmed_query)
from(ar in ArtistRecord,
join: ai in ArtistInfo,
on: ar.musicbrainz_id == ai.id,
where:
fragment("lower(unaccent(artist ->> '$.name')) LIKE ?", ^"%#{normalized_query}%"),
group_by: ar.musicbrainz_id,
select: %{artist: ar.artist, image_data_hash: ai.image_data_hash},
limit: ^limit,
order_by: fragment("artist ->> '$.name'")
)
|> Repo.all()
end
end
@spec search_by_name_count(String.t()) :: non_neg_integer()
def search_by_name_count(query) do
case String.trim(query) do
"" ->
0
trimmed_query ->
normalized_query = String.downcase(trimmed_query)
from(ar in ArtistRecord,
where: fragment("lower(artist ->> '$.name') LIKE ?", ^"%#{normalized_query}%"),
select: ar.musicbrainz_id,
distinct: true
)
|> Repo.aggregate(:count, :musicbrainz_id)
end
end
@spec exists?(String.t()) :: boolean()
def exists?(artist_id) do
q =
from ar in ArtistRecord,
where: ar.musicbrainz_id == ^artist_id
Repo.exists?(q)
end
@spec delete_artist_info(String.t()) :: {non_neg_integer(), nil | [term()]}
def delete_artist_info(artist_id) do
Repo.delete_all(from ai in ArtistInfo, where: ai.id == ^artist_id)
end
@spec refresh_artist_info(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_artist_info(artist_id) do
with {:ok, musicbrainz_artist} <- MusicBrainz.get_artist(artist_id) do
if discogs_id = MusicBrainz.Artist.get_discogs_id(musicbrainz_artist) do
with {:ok, discogs_artist} <- Discogs.get_artist(discogs_id) do
create_artist_info(%{
id: musicbrainz_artist.id,
musicbrainz_data: musicbrainz_artist.musicbrainz_data,
discogs_data: discogs_artist
})
end
else
create_artist_info(%{
id: musicbrainz_artist.id,
musicbrainz_data: musicbrainz_artist.musicbrainz_data
})
end
end
end
@spec refresh_musicbrainz_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_musicbrainz_data(artist_id) do
with {:ok, musicbrainz_artist} <- MusicBrainz.get_artist(artist_id) do
get_artist_info!(artist_id)
|> ArtistInfo.changeset(%{musicbrainz_data: musicbrainz_artist.musicbrainz_data})
|> Repo.update()
end
end
@spec refresh_musicbrainz_data_async(ArtistInfo.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_musicbrainz_data_async(artist_info) do
enqueue_worker(Worker.ArtistRefreshMusicBrainzData, %{"id" => artist_info.id})
end
@spec refresh_discogs_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_discogs_data(artist_id) do
artist_info = get_artist_info!(artist_id)
if discogs_id = ArtistInfo.discogs_id(artist_info) do
case Discogs.get_artist(discogs_id) do
{:ok, discogs_artist} ->
artist_info
|> ArtistInfo.changeset(%{discogs_data: discogs_artist})
|> Repo.update()
error ->
error
end
else
{:ok, artist_info}
end
end
@spec refresh_discogs_data_async(ArtistInfo.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_discogs_data_async(artist_info) do
enqueue_worker(Worker.ArtistRefreshDiscogsData, %{"id" => artist_info.id})
end
@spec refresh_wikipedia_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_wikipedia_data(artist_id) do
artist_info = get_artist_info!(artist_id)
if wikidata_id = ArtistInfo.wikidata_id(artist_info) do
case Wikipedia.get_artist_summary(wikidata_id) do
{:ok, summary} ->
artist_info
|> ArtistInfo.changeset(%{wikipedia_data: summary})
|> Repo.update()
error ->
error
end
else
{:ok, artist_info}
end
end
@spec refresh_wikipedia_data_async(ArtistInfo.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_wikipedia_data_async(artist_info) do
enqueue_worker(Worker.ArtistRefreshWikipediaData, %{"id" => artist_info.id})
end
@spec create_artist_info(map()) :: {:ok, ArtistInfo.t()} | {:error, Ecto.Changeset.t()}
def create_artist_info(attrs) do
%ArtistInfo{}
|> ArtistInfo.changeset(attrs)
|> Repo.insert(on_conflict: {:replace, [:musicbrainz_data, :discogs_data]})
end
@spec get_artist_info!(String.t()) :: ArtistInfo.t()
def get_artist_info!(artist_id) do
Repo.get!(ArtistInfo, artist_id)
end
@spec get_artist_infos([String.t()]) :: [ArtistInfo.t()]
def get_artist_infos(artist_ids) do
q =
from ai in ArtistInfo, where: ai.id in ^artist_ids
Repo.all(q)
end
@spec refresh_image(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_image(artist_id) do
artist_info = get_artist_info!(artist_id)
with {:ok, image} <- ArtistInfo.extract_image(artist_info),
{:ok, image_data} <- Discogs.get_artist_image(image.url),
{:ok, asset} <- Assets.store_image(%{content: image_data, format: "image/jpeg"}) do
artist_info
|> ArtistInfo.changeset(%{
image_data_hash: asset.hash
})
|> Repo.update()
end
end
@spec refresh_lastfm_data(String.t()) :: {:ok, ArtistInfo.t()} | {:error, term()}
def refresh_lastfm_data(artist_id) do
artist_info = get_artist_info!(artist_id)
name = get_in(artist_info.musicbrainz_data, ["name"]) || ""
case LastFm.get_artist_tags(artist_id, name) do
{:ok, tags} ->
tag_names = Enum.map(tags, fn {tag_name, _count} -> tag_name end)
artist_info
|> ArtistInfo.changeset(%{lastfm_data: %{"tags" => tag_names}})
|> Repo.update()
{:error, _reason} ->
{:ok, artist_info}
end
end
@spec refresh_lastfm_data_async(String.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_lastfm_data_async(artist_id) do
enqueue_worker(Worker.FetchArtistLastFmData, %{"id" => artist_id})
end
@spec refresh_artist_info_async(String.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_artist_info_async(artist_id) do
enqueue_worker(Worker.FetchArtistInfo, %{"id" => artist_id})
end
@spec refresh_image_async(String.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_image_async(artist_id) do
enqueue_worker(Worker.FetchArtistImage, %{"id" => artist_id})
end
@spec prune_artist_info_async(String.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def prune_artist_info_async(artist_id) do
enqueue_worker(Worker.PruneArtistInfo, %{"id" => artist_id})
end
@spec change_artist_info(ArtistInfo.t(), map()) :: Ecto.Changeset.t()
def change_artist_info(artist_info, attrs \\ %{}) do
ArtistInfo.changeset(artist_info, attrs)
end
@spec update_artist_info(ArtistInfo.t(), map()) ::
{:ok, ArtistInfo.t()} | {:error, Ecto.Changeset.t()}
def update_artist_info(artist_info, attrs) do
artist_info
|> ArtistInfo.changeset(attrs)
|> Repo.update()
end
defp enqueue_worker(worker, params) do
params |> worker.new(meta: %{}) |> Oban.insert()
end
end