3b558044b4
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.
129 lines
4.4 KiB
Elixir
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
|