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:
Claudio Ortolina
2026-04-30 17:20:37 +01:00
parent 3849b338f3
commit 3b558044b4
11 changed files with 840 additions and 683 deletions
+6 -6
View File
@@ -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
View File
@@ -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
+128
View File
@@ -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
+101
View File
@@ -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
+26
View File
@@ -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
+164
View File
@@ -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