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