Files
music_library/lib/music_library/records/enrichment.ex
T
Claudio Ortolina 3b558044b4 refactor: extract Records sub-contexts (Search, Import, Enrichment)
Split the 450+ line Records context into focused modules:

- Records.Search — FTS5 search, SearchParser integration, genre listing
- Records.Import — MusicBrainz release/group import, release status
- Records.Enrichment — genre population, cover refresh, color extraction,
  MusicBrainz data refresh
- Records.Query — shared helpers (essential_fields, order_alphabetically macro)

Records module is now a facade with defdelegate for compile-time safety.
CRUD and PubSub stay directly in Records. Tests moved alongside each
sub-module. Zero callers changed, 886 tests pass.
2026-04-30 17:20:37 +01:00

129 lines
4.4 KiB
Elixir

defmodule MusicLibrary.Records.Enrichment do
@moduledoc """
Record enrichment: genre population via OpenAI, cover management,
MusicBrainz data refresh, color extraction, and embedding dispatch.
"""
require Logger
alias MusicLibrary.{Assets, Repo, Worker}
alias MusicLibrary.Records
alias MusicLibrary.Records.Record
@color_extractor Application.compile_env(
:music_library,
:color_extractor,
MusicLibrary.Colors.KMeansExtractor
)
@spec populate_genres(Record.t()) :: {:ok, Record.t()} | {:error, Ecto.Changeset.t() | term()}
def populate_genres(record) do
artists = Enum.map_join(record.artists, ",", fn a -> a.name end)
completion = %OpenAI.Completion{
content: """
Provide a list of music genres applicable to the album "#{record.title}" by #{artists}.
Limit the list to 5 genres, ordered by decreasing specificity, all lowercase.
Return a response in JSON format, without any code block or formatting around it.
"""
}
with {:ok, response} <- OpenAI.gpt(completion) do
record
|> Record.add_genres(response["genres"])
|> Repo.update()
end
end
@spec populate_genres_async(Record.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def populate_genres_async(record) do
enqueue_worker(Worker.PopulateGenres, %{"id" => record.id}, record_meta(record))
end
@spec refresh_cover(Record.t()) :: {:ok, Record.t()} | {:error, term()}
def refresh_cover(record) do
with {:ok, cover_data} <- MusicBrainz.get_cover_art({:url, record.cover_url}),
{:ok, thumb_data} <- Assets.Image.resize(cover_data),
{:ok, asset} <- Assets.store_image(%{content: thumb_data, format: "image/jpeg"}) do
record
|> Record.set_cover_hash(asset.hash)
|> Repo.update()
end
end
@spec refresh_cover_async(Record.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_cover_async(record) do
enqueue_worker(Worker.RefreshCover, %{"id" => record.id}, record_meta(record))
end
@doc """
Extract dominant colors from a record's cover image, swallowing errors.
Called during record creation. If color extraction fails, the original
record is returned unchanged and a warning is logged.
"""
@spec best_effort_extract_colors(Record.t()) :: Record.t()
def best_effort_extract_colors(record) do
case maybe_extract_colors(record) do
{:ok, record} ->
record
{:error, reason} ->
Logger.warning("Color extraction failed for record #{record.id}: #{inspect(reason)}")
record
end
end
defp maybe_extract_colors(%{dominant_colors: [_ | _]} = record), do: {:ok, record}
defp maybe_extract_colors(record), do: extract_colors(record)
@spec extract_colors(Record.t()) :: {:ok, Record.t()} | {:error, term()}
def extract_colors(record) do
with asset when not is_nil(asset) <- Assets.get(record.cover_hash),
{:ok, colors} <- @color_extractor.extract_dominant_colors(asset.content) do
Records.update_record(record, %{dominant_colors: colors})
else
nil -> {:error, :asset_not_found}
error -> error
end
end
@spec resize_cover(Record.t()) :: {:ok, Record.t()} | {:error, term()}
def resize_cover(record) do
with {:ok, thumb_data} <- Assets.Image.resize(record.cover_data),
{:ok, asset} <- Assets.store_image(%{content: thumb_data, format: "image/jpeg"}) do
record
|> Record.set_cover_hash(asset.hash)
|> Repo.update()
end
end
@spec refresh_musicbrainz_data(Record.t()) :: {:ok, Record.t()} | {:error, term()}
def refresh_musicbrainz_data(record) do
with {:ok, data} <- MusicBrainz.get_release_group(record.musicbrainz_id),
{:ok, releases} <- MusicBrainz.get_all_releases(record.musicbrainz_id) do
data_with_releases = Map.put(data, "releases", releases)
record
|> Record.add_musicbrainz_data(data_with_releases)
|> Repo.update()
end
end
@spec refresh_musicbrainz_data_async(Record.t()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def refresh_musicbrainz_data_async(record) do
enqueue_worker(Worker.RecordRefreshMusicBrainzData, %{"id" => record.id}, record_meta(record))
end
defp enqueue_worker(worker, params, meta) do
params |> worker.new(meta: meta) |> Oban.insert()
end
defp record_meta(record) do
%{title: record.title, artists: Enum.map(record.artists, & &1.name)}
end
end