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.
This commit is contained in:
@@ -4,7 +4,7 @@ defmodule MusicLibrary.Collection do
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
import MusicLibrary.Records, only: [order_alphabetically: 0]
|
||||
import MusicLibrary.Records.Query
|
||||
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex}
|
||||
@@ -66,7 +66,7 @@ defmodule MusicLibrary.Collection do
|
||||
^month_day
|
||||
),
|
||||
order_by: [{:desc, r.release_date}, order_alphabetically()],
|
||||
select: ^Records.essential_fields()
|
||||
select: ^essential_fields()
|
||||
|
||||
Repo.all(q)
|
||||
end
|
||||
@@ -103,7 +103,7 @@ defmodule MusicLibrary.Collection do
|
||||
where: not is_nil(r.purchased_at),
|
||||
order_by: [{:desc, r.purchased_at}, order_alphabetically()],
|
||||
limit: 1,
|
||||
select: ^Records.essential_fields()
|
||||
select: ^essential_fields()
|
||||
|
||||
Repo.one(q)
|
||||
end
|
||||
@@ -115,7 +115,7 @@ defmodule MusicLibrary.Collection do
|
||||
where: not is_nil(r.purchased_at),
|
||||
order_by: [{:desc, r.purchased_at}, order_alphabetically()],
|
||||
limit: 1,
|
||||
select: ^Records.essential_fields()
|
||||
select: ^essential_fields()
|
||||
|
||||
Repo.one!(q)
|
||||
end
|
||||
@@ -127,7 +127,7 @@ defmodule MusicLibrary.Collection do
|
||||
where: not is_nil(r.purchased_at),
|
||||
order_by: fragment("RANDOM()"),
|
||||
limit: 1,
|
||||
select: ^Records.essential_fields()
|
||||
select: ^essential_fields()
|
||||
|
||||
Repo.one!(q)
|
||||
end
|
||||
@@ -205,7 +205,7 @@ defmodule MusicLibrary.Collection do
|
||||
from(r in Record,
|
||||
where: not is_nil(r.purchased_at),
|
||||
order_by: [order_alphabetically()],
|
||||
select: ^Records.essential_fields()
|
||||
select: ^essential_fields()
|
||||
)
|
||||
|> Repo.all()
|
||||
|
||||
|
||||
+31
-360
@@ -1,193 +1,45 @@
|
||||
defmodule MusicLibrary.Records do
|
||||
@moduledoc """
|
||||
Provides function to work with records _irrespectively_ of their status as port of the collection or of the wishlist.
|
||||
Provides functions to work with records irrespective of their status
|
||||
as part of the collection or the wishlist.
|
||||
|
||||
Search, import, and enrichment functions are delegated to focused sub-contexts:
|
||||
`Records.Search`, `Records.Import`, and `Records.Enrichment`.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias MusicLibrary.Artists
|
||||
alias MusicLibrary.Assets
|
||||
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex, SearchParser}
|
||||
alias MusicLibrary.{Repo, Worker}
|
||||
alias MusicLibrary.Records.{Enrichment, Record}
|
||||
alias MusicLibrary.Repo
|
||||
|
||||
@color_extractor Application.compile_env(
|
||||
:music_library,
|
||||
:color_extractor,
|
||||
MusicLibrary.Colors.KMeansExtractor
|
||||
)
|
||||
# ---- Search delegation ----
|
||||
|
||||
@type import_opts :: [
|
||||
format: atom(),
|
||||
purchased_at: DateTime.t() | nil,
|
||||
selected_release_id: String.t() | nil
|
||||
]
|
||||
defdelegate search_records(initial_search, query, opts), to: MusicLibrary.Records.Search
|
||||
defdelegate search_records_count(initial_search, query), to: MusicLibrary.Records.Search
|
||||
defdelegate list_genres, to: MusicLibrary.Records.Search
|
||||
|
||||
@spec essential_fields() :: [atom()]
|
||||
def essential_fields do
|
||||
SearchIndex.__schema__(:fields)
|
||||
end
|
||||
# ---- Import delegation ----
|
||||
|
||||
@spec search_records(Ecto.Queryable.t(), String.t(), MusicLibrary.Types.pagination_opts()) ::
|
||||
[SearchIndex.t()]
|
||||
def search_records(initial_search, query, opts) do
|
||||
limit = Keyword.fetch!(opts, :limit)
|
||||
offset = Keyword.fetch!(opts, :offset)
|
||||
order = Keyword.fetch!(opts, :order)
|
||||
defdelegate get_release_status(release_id, format), to: MusicLibrary.Records.Import
|
||||
defdelegate get_artist_records(musicbrainz_id), to: MusicLibrary.Records.Import
|
||||
|
||||
search =
|
||||
initial_search
|
||||
|> build_search(query, order)
|
||||
|> limit(^limit)
|
||||
|> offset(^offset)
|
||||
|> select(^essential_fields())
|
||||
defdelegate import_from_musicbrainz_release(musicbrainz_id, opts \\ []),
|
||||
to: MusicLibrary.Records.Import
|
||||
|
||||
Repo.all(search)
|
||||
end
|
||||
defdelegate import_from_musicbrainz_release_group(musicbrainz_id, opts \\ []),
|
||||
to: MusicLibrary.Records.Import
|
||||
|
||||
@spec search_records_count(Ecto.Queryable.t(), String.t()) :: non_neg_integer()
|
||||
def search_records_count(initial_search, query) do
|
||||
search = build_search(initial_search, query)
|
||||
# ---- Enrichment delegation ----
|
||||
|
||||
Repo.aggregate(search, :count)
|
||||
end
|
||||
defdelegate populate_genres(record), to: MusicLibrary.Records.Enrichment
|
||||
defdelegate populate_genres_async(record), to: MusicLibrary.Records.Enrichment
|
||||
defdelegate refresh_cover(record), to: MusicLibrary.Records.Enrichment
|
||||
defdelegate refresh_cover_async(record), to: MusicLibrary.Records.Enrichment
|
||||
defdelegate extract_colors(record), to: MusicLibrary.Records.Enrichment
|
||||
defdelegate resize_cover(record), to: MusicLibrary.Records.Enrichment
|
||||
defdelegate refresh_musicbrainz_data(record), to: MusicLibrary.Records.Enrichment
|
||||
defdelegate refresh_musicbrainz_data_async(record), to: MusicLibrary.Records.Enrichment
|
||||
|
||||
defmacro order_alphabetically do
|
||||
quote do
|
||||
fragment(
|
||||
"unaccent(artists ->> '$[0].sort_name') COLLATE NOCASE ASC, unaccent(title) COLLATE NOCASE ASC"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp fts_escape(term) do
|
||||
# For FTS5, if the term contains special characters, we need to wrap it in double quotes
|
||||
if String.contains?(term, ["'", " ", "\"", "(", ")", "^", "-", ":", "?", ".", "&"]) do
|
||||
# Escape internal double quotes and wrap in double quotes
|
||||
escaped = String.replace(term, "\"", "\"\"")
|
||||
"\"#{escaped}\"*"
|
||||
else
|
||||
"#{term}*"
|
||||
end
|
||||
end
|
||||
|
||||
defp fts_query_escape(query) do
|
||||
query
|
||||
|> String.split(~r/\s+/, trim: true)
|
||||
|> Enum.map_join(" AND ", &fts_escape/1)
|
||||
end
|
||||
|
||||
defp build_search(initial_search, query, order \\ :alphabetical) do
|
||||
{:ok, parsed_query} =
|
||||
SearchParser.parse(query)
|
||||
|
||||
search_with_order =
|
||||
case order do
|
||||
:alphabetical ->
|
||||
initial_search
|
||||
|> order_by(order_alphabetically())
|
||||
|
||||
:purchase ->
|
||||
initial_search
|
||||
|> order_by([r], [
|
||||
{:desc, r.purchased_at},
|
||||
order_alphabetically()
|
||||
])
|
||||
|
||||
:insertion ->
|
||||
initial_search
|
||||
|> order_by([r], [
|
||||
{:desc, r.inserted_at},
|
||||
order_alphabetically()
|
||||
])
|
||||
|
||||
:release ->
|
||||
initial_search
|
||||
|> order_by([r], [
|
||||
{:desc, r.release_date},
|
||||
order_alphabetically()
|
||||
])
|
||||
end
|
||||
|
||||
Enum.reduce(parsed_query, search_with_order, fn
|
||||
{:artist, artist}, search ->
|
||||
escaped_artist = fts_escape(artist)
|
||||
|
||||
search
|
||||
|> where(
|
||||
fragment(
|
||||
"records_search_index MATCH '{artists normalized_artists} : ' || ?",
|
||||
^escaped_artist
|
||||
)
|
||||
)
|
||||
|
||||
{:album, album}, search ->
|
||||
escaped_album = fts_escape(album)
|
||||
|
||||
search
|
||||
|> where(
|
||||
fragment(
|
||||
"records_search_index MATCH '{title normalized_title} : ' || ?",
|
||||
^escaped_album
|
||||
)
|
||||
)
|
||||
|
||||
{:genre, genre}, search ->
|
||||
escaped_genre = fts_escape(genre)
|
||||
|
||||
search
|
||||
|> where(fragment("records_search_index MATCH 'genres : ' || ?", ^escaped_genre))
|
||||
|
||||
{:mbid, mbid}, search ->
|
||||
escaped_mbid = fts_escape(mbid)
|
||||
|
||||
search
|
||||
|> where(fragment("records_search_index MATCH ?", ^escaped_mbid))
|
||||
|
||||
{:format, format}, search ->
|
||||
search |> where([r], r.format == ^format)
|
||||
|
||||
{:type, type}, search ->
|
||||
search |> where([r], r.type == ^type)
|
||||
|
||||
{:purchase_year, year}, search ->
|
||||
search
|
||||
|> where(
|
||||
[r],
|
||||
fragment(
|
||||
"? >= ? and ? < ?",
|
||||
r.purchased_at,
|
||||
^to_string(year),
|
||||
r.purchased_at,
|
||||
^to_string(year + 1)
|
||||
)
|
||||
)
|
||||
|
||||
{:release_year, year}, search ->
|
||||
search
|
||||
|> where([r], fragment("substr(?, 1, 4) = ?", r.release_date, ^to_string(year)))
|
||||
|
||||
{:query, ""}, search ->
|
||||
search
|
||||
|
||||
{:query, raw_query}, search ->
|
||||
escaped_query = fts_query_escape(raw_query)
|
||||
|
||||
search
|
||||
|> where(fragment("records_search_index MATCH ?", ^escaped_query))
|
||||
end)
|
||||
end
|
||||
|
||||
@spec list_genres() :: [String.t()]
|
||||
def list_genres do
|
||||
q =
|
||||
from r in fragment("records, json_each(records.genres)"),
|
||||
select: fragment("DISTINCT value"),
|
||||
order_by: fragment("value COLLATE NOCASE ASC")
|
||||
|
||||
Repo.all(q)
|
||||
end
|
||||
# ---- CRUD functions ----
|
||||
|
||||
@spec get_record(String.t()) :: Record.t() | nil
|
||||
def get_record(id), do: Repo.get(Record, id)
|
||||
@@ -195,185 +47,10 @@ defmodule MusicLibrary.Records do
|
||||
@spec get_record!(String.t()) :: Record.t()
|
||||
def get_record!(id), do: Repo.get!(Record, id)
|
||||
|
||||
@spec get_release_status(String.t(), atom()) ::
|
||||
:new | {:wishlisted, String.t()} | {:collected, String.t()}
|
||||
def get_release_status(release_id, format) do
|
||||
format_str = Atom.to_string(format)
|
||||
|
||||
q =
|
||||
from r in fragment("records, json_each(records.release_ids)"),
|
||||
where: fragment("records.format = ?", ^format_str) and r.value == ^release_id,
|
||||
select: %{
|
||||
record_id: fragment("records.id"),
|
||||
purchased_at: fragment("records.purchased_at")
|
||||
}
|
||||
|
||||
case Repo.one(q) do
|
||||
nil -> :new
|
||||
%{record_id: record_id, purchased_at: nil} -> {:wishlisted, record_id}
|
||||
%{record_id: record_id} -> {:collected, record_id}
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_artist_records(String.t()) :: [SearchIndex.t()]
|
||||
def get_artist_records(musicbrainz_id) do
|
||||
q =
|
||||
from r in Record,
|
||||
join: ar in ArtistRecord,
|
||||
on: r.id == ar.record_id and ar.musicbrainz_id == ^musicbrainz_id,
|
||||
select: ^essential_fields()
|
||||
|
||||
Repo.all(q)
|
||||
end
|
||||
|
||||
@spec import_from_musicbrainz_release(String.t(), import_opts()) ::
|
||||
{:ok, Record.t()} | {:error, term()}
|
||||
def import_from_musicbrainz_release(musicbrainz_id, opts \\ []) do
|
||||
case MusicBrainz.get_release(musicbrainz_id) do
|
||||
{:ok, release} ->
|
||||
release_group_id = release["release-group"]["id"]
|
||||
import_from_musicbrainz_release_group(release_group_id, opts)
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@spec import_from_musicbrainz_release_group(String.t(), import_opts()) ::
|
||||
{:ok, Record.t()} | {:error, term()}
|
||||
def import_from_musicbrainz_release_group(musicbrainz_id, opts \\ []) do
|
||||
format = Keyword.get(opts, :format, "cd")
|
||||
purchased_at = Keyword.get(opts, :purchased_at)
|
||||
selected_release_id = Keyword.get(opts, :selected_release_id, nil)
|
||||
|
||||
with {:ok, release_group} <- MusicBrainz.get_release_group(musicbrainz_id),
|
||||
{:ok, releases} <- MusicBrainz.get_all_releases(musicbrainz_id),
|
||||
release_group_with_releases = Map.put(release_group, "releases", releases),
|
||||
{:ok, cover_data} <- get_cover_art_or_default(musicbrainz_id),
|
||||
{:ok, asset} <- Assets.store_image(%{content: cover_data, format: "image/jpeg"}) do
|
||||
release_group_with_releases
|
||||
|> build_record_attrs(%{
|
||||
"cover_hash" => asset.hash,
|
||||
"format" => format,
|
||||
"purchased_at" => purchased_at,
|
||||
"selected_release_id" => selected_release_id
|
||||
})
|
||||
|> create_record()
|
||||
end
|
||||
end
|
||||
|
||||
@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
|
||||
|
||||
defp get_cover_art_or_default(musicbrainz_id) do
|
||||
case MusicBrainz.get_cover_art({:musicbrainz_id, musicbrainz_id}) do
|
||||
{:error, :cover_not_available} -> {:ok, Assets.Image.fallback_data()}
|
||||
{:ok, cover_data} -> Assets.Image.resize(cover_data)
|
||||
end
|
||||
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
|
||||
|
||||
defp 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
|
||||
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 build_record_attrs(release_group, attrs) do
|
||||
release_group
|
||||
|> Record.attrs_from_release_group()
|
||||
|> Map.merge(attrs)
|
||||
end
|
||||
|
||||
@spec create_record(map()) :: {:ok, Record.t()} | {:error, Ecto.Changeset.t()}
|
||||
def create_record(attrs \\ %{}) do
|
||||
with {:ok, record} <- do_create_record(attrs),
|
||||
record = best_effort_extract_colors(record),
|
||||
record = Enrichment.best_effort_extract_colors(record),
|
||||
:ok <- refresh_artist_info_async(record) do
|
||||
{:ok, record}
|
||||
end
|
||||
@@ -426,6 +103,8 @@ defmodule MusicLibrary.Records do
|
||||
Record.changeset(record, attrs)
|
||||
end
|
||||
|
||||
# ---- PubSub functions ----
|
||||
|
||||
@spec subscribe(String.t()) :: :ok | {:error, term()}
|
||||
def subscribe(record_id) do
|
||||
Phoenix.PubSub.subscribe(MusicLibrary.PubSub, "records:#{record_id}")
|
||||
@@ -439,12 +118,4 @@ defmodule MusicLibrary.Records do
|
||||
{:update, 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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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
|
||||
@@ -0,0 +1,101 @@
|
||||
defmodule MusicLibrary.Records.Import do
|
||||
@moduledoc """
|
||||
Import records from MusicBrainz release groups and releases.
|
||||
|
||||
Handles cover art fetching, barcode scan integration, and release status checks.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
import MusicLibrary.Records.Query
|
||||
|
||||
alias MusicLibrary.Assets
|
||||
alias MusicLibrary.Records
|
||||
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex}
|
||||
alias MusicLibrary.Repo
|
||||
|
||||
@type import_opts :: [
|
||||
format: atom(),
|
||||
purchased_at: DateTime.t() | nil,
|
||||
selected_release_id: String.t() | nil
|
||||
]
|
||||
|
||||
@spec get_release_status(String.t(), atom()) ::
|
||||
:new | {:wishlisted, String.t()} | {:collected, String.t()}
|
||||
def get_release_status(release_id, format) do
|
||||
format_str = Atom.to_string(format)
|
||||
|
||||
q =
|
||||
from r in fragment("records, json_each(records.release_ids)"),
|
||||
where: fragment("records.format = ?", ^format_str) and r.value == ^release_id,
|
||||
select: %{
|
||||
record_id: fragment("records.id"),
|
||||
purchased_at: fragment("records.purchased_at")
|
||||
}
|
||||
|
||||
case Repo.one(q) do
|
||||
nil -> :new
|
||||
%{record_id: record_id, purchased_at: nil} -> {:wishlisted, record_id}
|
||||
%{record_id: record_id} -> {:collected, record_id}
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_artist_records(String.t()) :: [SearchIndex.t()]
|
||||
def get_artist_records(musicbrainz_id) do
|
||||
q =
|
||||
from r in Record,
|
||||
join: ar in ArtistRecord,
|
||||
on: r.id == ar.record_id and ar.musicbrainz_id == ^musicbrainz_id,
|
||||
select: ^essential_fields()
|
||||
|
||||
Repo.all(q)
|
||||
end
|
||||
|
||||
@spec import_from_musicbrainz_release(String.t(), import_opts()) ::
|
||||
{:ok, Record.t()} | {:error, term()}
|
||||
def import_from_musicbrainz_release(musicbrainz_id, opts \\ []) do
|
||||
case MusicBrainz.get_release(musicbrainz_id) do
|
||||
{:ok, release} ->
|
||||
release_group_id = release["release-group"]["id"]
|
||||
import_from_musicbrainz_release_group(release_group_id, opts)
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@spec import_from_musicbrainz_release_group(String.t(), import_opts()) ::
|
||||
{:ok, Record.t()} | {:error, term()}
|
||||
def import_from_musicbrainz_release_group(musicbrainz_id, opts \\ []) do
|
||||
format = Keyword.get(opts, :format, "cd")
|
||||
purchased_at = Keyword.get(opts, :purchased_at)
|
||||
selected_release_id = Keyword.get(opts, :selected_release_id, nil)
|
||||
|
||||
with {:ok, release_group} <- MusicBrainz.get_release_group(musicbrainz_id),
|
||||
{:ok, releases} <- MusicBrainz.get_all_releases(musicbrainz_id),
|
||||
release_group_with_releases = Map.put(release_group, "releases", releases),
|
||||
{:ok, cover_data} <- get_cover_art_or_default(musicbrainz_id),
|
||||
{:ok, asset} <- Assets.store_image(%{content: cover_data, format: "image/jpeg"}) do
|
||||
release_group_with_releases
|
||||
|> build_record_attrs(%{
|
||||
"cover_hash" => asset.hash,
|
||||
"format" => format,
|
||||
"purchased_at" => purchased_at,
|
||||
"selected_release_id" => selected_release_id
|
||||
})
|
||||
|> Records.create_record()
|
||||
end
|
||||
end
|
||||
|
||||
defp get_cover_art_or_default(musicbrainz_id) do
|
||||
case MusicBrainz.get_cover_art({:musicbrainz_id, musicbrainz_id}) do
|
||||
{:error, :cover_not_available} -> {:ok, Assets.Image.fallback_data()}
|
||||
{:ok, cover_data} -> Assets.Image.resize(cover_data)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_record_attrs(release_group, attrs) do
|
||||
release_group
|
||||
|> Record.attrs_from_release_group()
|
||||
|> Map.merge(attrs)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
defmodule MusicLibrary.Records.Query do
|
||||
@moduledoc """
|
||||
Helpers to compose Ecto queries based on record-like schemas.
|
||||
"""
|
||||
|
||||
alias MusicLibrary.Records.SearchIndex
|
||||
|
||||
@spec essential_fields() :: [atom()]
|
||||
def essential_fields do
|
||||
SearchIndex.__schema__(:fields)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ecto query fragment for alphabetical ordering by artist name and title.
|
||||
|
||||
Used by callers that compose queries (e.g. `Collection`) and by search
|
||||
ordering internally via `Records.Search`.
|
||||
"""
|
||||
defmacro order_alphabetically do
|
||||
quote do
|
||||
fragment(
|
||||
"unaccent(artists ->> '$[0].sort_name') COLLATE NOCASE ASC, unaccent(title) COLLATE NOCASE ASC"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,164 @@
|
||||
defmodule MusicLibrary.Records.Search do
|
||||
@moduledoc """
|
||||
FTS5 search and genre listing for records.
|
||||
|
||||
Integrates with `SearchParser` to support structured, tagged queries
|
||||
(`artist:`, `album:`, `genre:`, `format:`, `type:`, `mbid:`, `purchase_year:`, `release_year:`).
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
import MusicLibrary.Records.Query
|
||||
|
||||
alias MusicLibrary.Records.{SearchIndex, SearchParser}
|
||||
alias MusicLibrary.Repo
|
||||
|
||||
@spec search_records(Ecto.Queryable.t(), String.t(), MusicLibrary.Types.pagination_opts()) ::
|
||||
[SearchIndex.t()]
|
||||
def search_records(initial_search, query, opts) do
|
||||
limit = Keyword.fetch!(opts, :limit)
|
||||
offset = Keyword.fetch!(opts, :offset)
|
||||
order = Keyword.fetch!(opts, :order)
|
||||
|
||||
search =
|
||||
initial_search
|
||||
|> build_search(query, order)
|
||||
|> limit(^limit)
|
||||
|> offset(^offset)
|
||||
|> select(^essential_fields())
|
||||
|
||||
Repo.all(search)
|
||||
end
|
||||
|
||||
@spec search_records_count(Ecto.Queryable.t(), String.t()) :: non_neg_integer()
|
||||
def search_records_count(initial_search, query) do
|
||||
search = build_search(initial_search, query)
|
||||
|
||||
Repo.aggregate(search, :count)
|
||||
end
|
||||
|
||||
@spec list_genres() :: [String.t()]
|
||||
def list_genres do
|
||||
q =
|
||||
from r in fragment("records, json_each(records.genres)"),
|
||||
select: fragment("DISTINCT value"),
|
||||
order_by: fragment("value COLLATE NOCASE ASC")
|
||||
|
||||
Repo.all(q)
|
||||
end
|
||||
|
||||
defp fts_escape(term) do
|
||||
if String.contains?(term, ["'", " ", "\"", "(", ")", "^", "-", ":", "?", ".", "&"]) do
|
||||
escaped = String.replace(term, "\"", "\"\"")
|
||||
"\"#{escaped}\"*"
|
||||
else
|
||||
"#{term}*"
|
||||
end
|
||||
end
|
||||
|
||||
defp fts_query_escape(query) do
|
||||
query
|
||||
|> String.split(~r/\s+/, trim: true)
|
||||
|> Enum.map_join(" AND ", &fts_escape/1)
|
||||
end
|
||||
|
||||
defp build_search(initial_search, query, order \\ :alphabetical) do
|
||||
{:ok, parsed_query} =
|
||||
SearchParser.parse(query)
|
||||
|
||||
search_with_order =
|
||||
case order do
|
||||
:alphabetical ->
|
||||
initial_search
|
||||
|> order_by(order_alphabetically())
|
||||
|
||||
:purchase ->
|
||||
initial_search
|
||||
|> order_by([r], [
|
||||
{:desc, r.purchased_at},
|
||||
order_alphabetically()
|
||||
])
|
||||
|
||||
:insertion ->
|
||||
initial_search
|
||||
|> order_by([r], [
|
||||
{:desc, r.inserted_at},
|
||||
order_alphabetically()
|
||||
])
|
||||
|
||||
:release ->
|
||||
initial_search
|
||||
|> order_by([r], [
|
||||
{:desc, r.release_date},
|
||||
order_alphabetically()
|
||||
])
|
||||
end
|
||||
|
||||
Enum.reduce(parsed_query, search_with_order, fn
|
||||
{:artist, artist}, search ->
|
||||
escaped_artist = fts_escape(artist)
|
||||
|
||||
search
|
||||
|> where(
|
||||
fragment(
|
||||
"records_search_index MATCH '{artists normalized_artists} : ' || ?",
|
||||
^escaped_artist
|
||||
)
|
||||
)
|
||||
|
||||
{:album, album}, search ->
|
||||
escaped_album = fts_escape(album)
|
||||
|
||||
search
|
||||
|> where(
|
||||
fragment(
|
||||
"records_search_index MATCH '{title normalized_title} : ' || ?",
|
||||
^escaped_album
|
||||
)
|
||||
)
|
||||
|
||||
{:genre, genre}, search ->
|
||||
escaped_genre = fts_escape(genre)
|
||||
|
||||
search
|
||||
|> where(fragment("records_search_index MATCH 'genres : ' || ?", ^escaped_genre))
|
||||
|
||||
{:mbid, mbid}, search ->
|
||||
escaped_mbid = fts_escape(mbid)
|
||||
|
||||
search
|
||||
|> where(fragment("records_search_index MATCH ?", ^escaped_mbid))
|
||||
|
||||
{:format, format}, search ->
|
||||
search |> where([r], r.format == ^format)
|
||||
|
||||
{:type, type}, search ->
|
||||
search |> where([r], r.type == ^type)
|
||||
|
||||
{:purchase_year, year}, search ->
|
||||
search
|
||||
|> where(
|
||||
[r],
|
||||
fragment(
|
||||
"? >= ? and ? < ?",
|
||||
r.purchased_at,
|
||||
^to_string(year),
|
||||
r.purchased_at,
|
||||
^to_string(year + 1)
|
||||
)
|
||||
)
|
||||
|
||||
{:release_year, year}, search ->
|
||||
search
|
||||
|> where([r], fragment("substr(?, 1, 4) = ?", r.release_date, ^to_string(year)))
|
||||
|
||||
{:query, ""}, search ->
|
||||
search
|
||||
|
||||
{:query, raw_query}, search ->
|
||||
escaped_query = fts_query_escape(raw_query)
|
||||
|
||||
search
|
||||
|> where(fragment("records_search_index MATCH ?", ^escaped_query))
|
||||
end)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user