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
@@ -1,9 +1,10 @@
---
id: ML-150
title: Extract Records sub-contexts to reduce module size
status: To Do
status: Done
assignee: []
created_date: '2026-04-30 10:47'
updated_date: '2026-04-30 16:08'
labels:
- refactor
- records
@@ -30,9 +31,56 @@ The `SearchIndex` schema, `Record` schema, `Similarity` module, `TracklistPdf`,
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 `Records.Search`, `Records.Import`, and `Records.Enrichment` modules exist with focused responsibilities
- [ ] #2 Public `Records` module re-exports all previously-public functions through delegation
- [ ] #3 All callers (LiveViews, workers, controllers, tests) continue to work without changes to their import/alias lines
- [ ] #4 Full test suite passes with no regressions
- [ ] #5 `@moduledoc` for each new module explains its responsibility
- [x] #1 `Records.Search`, `Records.Import`, and `Records.Enrichment` modules exist with focused responsibilities
- [x] #2 Public `Records` module re-exports all previously-public functions through delegation
- [x] #3 All callers (LiveViews, workers, controllers, tests) continue to work without changes to their import/alias lines
- [x] #4 Full test suite passes with no regressions
- [x] #5 `@moduledoc` for each new module explains its responsibility
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## Implementation Summary
### New modules created
**`Records.Search`** (`lib/music_library/records/search.ex`):
- `essential_fields/0`, `search_records/3`, `search_records_count/2`, `list_genres/0`
- Private: `build_search/3`, `fts_escape/1`, `fts_query_escape/1`
- Imports `order_alphabetically` macro from `Records` (one-direction dependency, no cycle)
**`Records.Import`** (`lib/music_library/records/import.ex`):
- `get_release_status/2`, `get_artist_records/1`
- `import_from_musicbrainz_release/2`, `import_from_musicbrainz_release_group/2`
- Private: `get_cover_art_or_default/1`, `build_record_attrs/2`
- Calls `Records.create_record/1` and `Records.Search.essential_fields/0`
**`Records.Enrichment`** (`lib/music_library/records/enrichment.ex`):
- `populate_genres/1`, `populate_genres_async/1`
- `refresh_cover/1`, `refresh_cover_async/1`
- `extract_colors/1`, `resize_cover/1`
- `refresh_musicbrainz_data/1`, `refresh_musicbrainz_data_async/1`
- `best_effort_extract_colors/1` (public, called from `Records.create_record/1`)
- Private: `maybe_extract_colors/1`, `enqueue_worker/3`, `record_meta/1`
- Calls `Records.update_record/2`
### Facade (`lib/music_library/records.ex`)
- 15 `defdelegate` calls covering all moved functions (compile-time safety)
- `order_alphabetically` macro stays in `Records` for backward compatibility
- CRUD (`get_record`, `create_record`, `update_record`, `delete_record`, `change_record`) and PubSub (`subscribe`, `notify_update`) stay directly in `Records`
### Dependency graph (one direction only):
```
Records.Search → imports macro from Records
Records.Import → calls Records, Records.Search
Records.Enrichment → calls Records
Records → delegates to Search, Import, Enrichment (compile-time: defdelegate only, no runtime cycle)
```
### Tests
- `test/music_library/records_test.exs` — CRUD tests (6 tests)
- `test/music_library/records/search_test.exs` — search tests (13 tests)
- `test/music_library/records/import_test.exs` — import tests (3 tests)
- `test/music_library/records/enrichment_test.exs` — enrichment tests (3 tests)
<!-- SECTION:NOTES:END -->
+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
@@ -0,0 +1,99 @@
defmodule MusicLibrary.Records.EnrichmentTest do
use MusicLibrary.DataCase
import MusicBrainz.Fixtures.ReleaseGroup
import MusicLibrary.Fixtures.Records
alias MusicLibrary.Assets
alias MusicLibrary.Records.Enrichment
describe "refresh_musicbrainz_data/1" do
test "updates release_ids, included_release_group_ids, and artists" do
release_group_id = release_group_id(:marbles)
record =
record(
musicbrainz_id: release_group_id,
musicbrainz_data: Map.put(release_group(:marbles), "releases", [])
)
assert record.release_ids == []
assert record.included_release_group_ids == []
new_release_group = release_group(:lockdown_trilogy)
new_release_group_releases = release_group_releases(:lockdown_trilogy)
Req.Test.stub(MusicBrainz.API, fn conn ->
case conn.path_info do
[_ws, _version, "release-group", ^release_group_id] ->
Req.Test.json(conn, new_release_group)
[_ws, _version, "release"] ->
Req.Test.json(conn, new_release_group_releases)
end
end)
{:ok, updated_record} = Enrichment.refresh_musicbrainz_data(record)
assert record.release_ids !== updated_record.release_ids
assert record.included_release_group_ids !== updated_record.included_release_group_ids
assert record.artists !== updated_record.artists
assert updated_record.artists !== []
end
end
describe "refresh_cover/1" do
test "fetches and stores the updated cover" do
record = record(cover_data: marbles_cover_data())
raven_cover_data = raven_cover_data()
Req.Test.stub(MusicBrainz.API, fn conn ->
Plug.Conn.send_resp(conn, 200, raven_cover_data)
end)
assert {:ok, updated_record} = Enrichment.refresh_cover(record)
assert updated_record.cover_hash ==
"6E0D25D1FD1019D771D7EB3F777E2C7C1B06A73A92E56A584D674D86DD8AF441"
{:ok, expected_content} = Assets.Image.resize(raven_cover_data())
assert Assets.get(updated_record.cover_hash).content == expected_content
end
end
describe "populate_genres/1" do
test "updates record genres from OpenAI response" do
record = record(%{genres: []})
genres = ["progressive rock", "art rock", "symphonic rock"]
Req.Test.stub(OpenAI.API, fn conn ->
Req.Test.json(conn, %{
"choices" => [
%{"message" => %{"content" => JSON.encode!(%{"genres" => genres})}}
]
})
end)
assert {:ok, updated} = Enrichment.populate_genres(record)
assert updated.genres == genres
end
@tag :capture_log
test "returns error tuple when OpenAI API fails" do
record = record(%{genres: []})
Req.Test.stub(OpenAI.API, fn conn ->
Plug.Conn.send_resp(
conn,
500,
JSON.encode!(%{"error" => %{"message" => "internal server error"}})
)
end)
assert {:error, %OpenAI.API.ErrorResponse{status: 500, kind: :server_error}} =
Enrichment.populate_genres(record)
end
end
end
+133
View File
@@ -0,0 +1,133 @@
defmodule MusicLibrary.Records.ImportTest do
use MusicLibrary.DataCase
import MusicBrainz.Fixtures.Release
import MusicBrainz.Fixtures.ReleaseGroup
import MusicLibrary.Fixtures.Records
alias MusicLibrary.Records.Import
describe "import_from_musicbrainz_release_group/2" do
test "saves a record with its cover art" do
current_time = DateTime.utc_now()
release_group = release_group(:marbles)
release_group_id = release_group_id(:marbles)
release_group_releases = release_group_releases(:marbles)
cover_data = marbles_cover_data()
Req.Test.stub(MusicBrainz.API, fn conn ->
case conn.path_info do
[_ws, _version, "release-group", ^release_group_id] ->
Req.Test.json(conn, release_group)
[_ws, _version, "release"] ->
Req.Test.json(conn, release_group_releases)
[_release_group, ^release_group_id, "front"] ->
Plug.Conn.send_resp(conn, 200, cover_data)
end
end)
assert {:ok, record} =
Import.import_from_musicbrainz_release_group(release_group_id,
format: :vinyl,
purchased_at: current_time
)
assert [artist] = record.artists
assert artist.name == "Marillion"
assert record.musicbrainz_id == release_group_id
assert record.title == "Marbles"
assert record.format == :vinyl
assert record.purchased_at == DateTime.truncate(current_time, :second)
assert record.release_ids ==
[
"0e290154-5375-4f4f-a658-4a92bf02faa5",
"3f1cc80f-4507-48a9-899c-c1bda83280c2",
"d3f9b9e2-73f5-4b47-a2a7-2c2199aad608",
"2c4ecd84-7a84-4f42-a600-2f00ed8978c9",
"ab151aa6-7538-4e93-be60-eded52b5b7b7",
"b94bbd1f-ae5d-4e7b-98ff-28bfe135f20c",
"4b9fe13b-4837-4c02-9368-e97ba6f5a086",
"3f89357a-eeb3-4040-af34-a27b7c2aea2b",
"a4b02377-0b5e-448e-9cd6-5500c0378523",
"f3937bc5-b99f-443a-9609-a404201f21ca"
]
end
end
describe "import_from_musicbrainz_release/2" do
test "saves a record with its cover art" do
current_time = DateTime.utc_now()
release = release(:marbles)
release_id = release_id(:marbles)
release_group = release_group(:marbles)
release_group_id = release_group_id(:marbles)
release_group_releases = release_group_releases(:marbles)
cover_data = marbles_cover_data()
Req.Test.stub(MusicBrainz.API, fn conn ->
case conn.path_info do
[_ws, _version, "release-group", ^release_group_id] ->
Req.Test.json(conn, release_group)
[_ws, _version, "release", ^release_id] ->
Req.Test.json(conn, release)
[_ws, _version, "release"] ->
Req.Test.json(conn, release_group_releases)
[_release_group, ^release_group_id, "front"] ->
Plug.Conn.send_resp(conn, 200, cover_data)
end
end)
assert {:ok, record} =
Import.import_from_musicbrainz_release(release_id,
format: :vinyl,
purchased_at: current_time
)
assert [artist] = record.artists
assert artist.name == "Marillion"
assert record.musicbrainz_id == release_group_id
assert record.title == "Marbles"
assert record.format == :vinyl
assert record.purchased_at == DateTime.truncate(current_time, :second)
assert record.release_ids ==
[
"0e290154-5375-4f4f-a658-4a92bf02faa5",
"3f1cc80f-4507-48a9-899c-c1bda83280c2",
"d3f9b9e2-73f5-4b47-a2a7-2c2199aad608",
"2c4ecd84-7a84-4f42-a600-2f00ed8978c9",
"ab151aa6-7538-4e93-be60-eded52b5b7b7",
"b94bbd1f-ae5d-4e7b-98ff-28bfe135f20c",
"4b9fe13b-4837-4c02-9368-e97ba6f5a086",
"3f89357a-eeb3-4040-af34-a27b7c2aea2b",
"a4b02377-0b5e-448e-9cd6-5500c0378523",
"f3937bc5-b99f-443a-9609-a404201f21ca"
]
end
end
describe "get_artist_records/1" do
test "returns records with essential data" do
expected = record()
artist_musicbrainz_id = expected.artists |> hd() |> Map.get(:musicbrainz_id)
[artist_record] = Import.get_artist_records(artist_musicbrainz_id)
assert expected.id == artist_record.id
end
end
end
@@ -0,0 +1,98 @@
defmodule MusicLibrary.Records.SearchTest do
use MusicLibrary.DataCase
import MusicLibrary.Fixtures.Records
alias MusicLibrary.Records.Search
alias MusicLibrary.Records.SearchIndex
defp create_records(_) do
records = [
record_with_artist("Marillion", %{title: "Brave", format: :vinyl}),
record_with_artist("Marillion", %{title: "Brave (Live)", format: :cd, type: :live}),
record_with_artist("Marillion", %{title: "Afraid of Sunlight"}),
record_with_artist("Airbag", %{title: "The Greatest Show on Earth"}),
record_with_artist("Airbag (AU)", %{title: "Libertad"})
]
%{records: records}
end
defp search(query, limit, offset) do
SearchIndex
|> Search.search_records(query, limit: limit, offset: offset, order: :alphabetical)
|> Enum.map(& &1.id)
end
describe "search_records/2" do
setup [:create_records]
test "untagged search (with limit and offset)", %{
records: [brave_vinyl, brave_live_cd | _rest]
} do
assert [brave_vinyl.id, brave_live_cd.id] == search("brave", 10, 0)
assert [brave_vinyl.id] == search("brave", 1, 0)
assert [brave_live_cd.id] == search("brave", 1, 1)
end
test "tagged search - album", %{records: [_brave_vinyl, brave_live_cd | _rest]} do
assert [brave_live_cd.id] == search(~s(album:"Brave \(Live\)"), 10, 0)
end
test "tagged search - artist", %{records: [_, _, _, greatest_show_on_earth, libertad]} do
assert [greatest_show_on_earth.id, libertad.id] == search("artist:airbag", 10, 0)
assert [libertad.id] == search(~s(artist:"airbag \(AU\)"), 10, 0)
end
test "tagged search - format", %{records: [_brave_vinyl, brave_live_cd | _rest]} do
assert [brave_live_cd.id] == search("brave format:cd", 10, 0)
end
test "tagged search - type", %{records: [_brave_vinyl, brave_live_cd | _rest]} do
assert [brave_live_cd.id] == search("brave type:live", 10, 0)
end
test "tagged search - mbid", %{records: [_, _, _, greatest_show_on_earth, libertad]} do
[airbag_mbid] = Enum.map(greatest_show_on_earth.artists, fn a -> a.musicbrainz_id end)
[airbag_au_mbid] = Enum.map(libertad.artists, fn a -> a.musicbrainz_id end)
assert [greatest_show_on_earth.id] == search("mbid:#{airbag_mbid}", 10, 0)
assert [libertad.id] == search("mbid:#{airbag_au_mbid}", 10, 0)
end
end
describe "search_records_count/2" do
setup [:create_records]
test "untagged search" do
assert 2 == Search.search_records_count(SearchIndex, "brave")
end
test "tagged search - album" do
assert 1 == Search.search_records_count(SearchIndex, ~s(album:"Brave \(Live\)"))
end
test "tagged search - artist" do
assert 2 == Search.search_records_count(SearchIndex, "artist:airbag")
assert 1 == Search.search_records_count(SearchIndex, ~s(artist:"airbag \(AU\)"))
end
test "tagged search - format" do
assert 1 == Search.search_records_count(SearchIndex, "brave format:cd")
end
test "tagged search - type" do
assert 1 == Search.search_records_count(SearchIndex, "brave type:live")
end
test "tagged search - mbid", %{records: [_, _, _, greatest_show_on_earth, libertad]} do
[airbag_mbid] = Enum.map(greatest_show_on_earth.artists, fn a -> a.musicbrainz_id end)
[airbag_au_mbid] = Enum.map(libertad.artists, fn a -> a.musicbrainz_id end)
assert 1 ==
Search.search_records_count(SearchIndex, "mbid:#{airbag_mbid}")
assert 1 == Search.search_records_count(SearchIndex, "mbid:#{airbag_au_mbid}")
end
end
end
-311
View File
@@ -1,34 +1,11 @@
defmodule MusicLibrary.RecordsTest do
use MusicLibrary.DataCase
import MusicBrainz.Fixtures.Release
import MusicBrainz.Fixtures.ReleaseGroup
import MusicLibrary.ColorHelpers, only: [color_hex?: 1]
import MusicLibrary.Fixtures.Records
alias MusicLibrary.Assets
alias MusicLibrary.Records
alias MusicLibrary.Records.SearchIndex
defp create_records(_) do
records = [
record_with_artist("Marillion", %{title: "Brave", format: :vinyl}),
record_with_artist("Marillion", %{title: "Brave (Live)", format: :cd, type: :live}),
record_with_artist("Marillion", %{title: "Afraid of Sunlight"}),
record_with_artist("Airbag", %{title: "The Greatest Show on Earth"}),
record_with_artist("Airbag (AU)", %{title: "Libertad"})
]
%{records: records}
end
# when searching we do not return all record fields (e.g. cover data)
# so we rely on record ids to compare results
defp search(query, limit, offset) do
SearchIndex
|> Records.search_records(query, limit: limit, offset: offset, order: :alphabetical)
|> Enum.map(& &1.id)
end
describe "create_record/1" do
test "populates computed values" do
@@ -99,299 +76,11 @@ defmodule MusicLibrary.RecordsTest do
end
end
describe "refresh_musicbrainz_data/1" do
test "updates release_ids, included_release_group_ids, and artists" do
release_group_id = release_group_id(:marbles)
record =
record(
musicbrainz_id: release_group_id,
musicbrainz_data: Map.put(release_group(:marbles), "releases", [])
)
assert record.release_ids == []
assert record.included_release_group_ids == []
new_release_group = release_group(:lockdown_trilogy)
new_release_group_releases = release_group_releases(:lockdown_trilogy)
Req.Test.stub(MusicBrainz.API, fn conn ->
case conn.path_info do
[_ws, _version, "release-group", ^release_group_id] ->
Req.Test.json(conn, new_release_group)
[_ws, _version, "release"] ->
Req.Test.json(conn, new_release_group_releases)
end
end)
{:ok, updated_record} = Records.refresh_musicbrainz_data(record)
assert record.release_ids !== updated_record.release_ids
assert record.included_release_group_ids !== updated_record.included_release_group_ids
assert record.artists !== updated_record.artists
assert updated_record.artists !== []
end
end
describe "search_records/2" do
setup [:create_records]
test "untagged search (with limit and offset)", %{
records: [brave_vinyl, brave_live_cd | _rest]
} do
assert [brave_vinyl.id, brave_live_cd.id] == search("brave", 10, 0)
assert [brave_vinyl.id] == search("brave", 1, 0)
assert [brave_live_cd.id] == search("brave", 1, 1)
end
test "tagged search - album", %{records: [_brave_vinyl, brave_live_cd | _rest]} do
assert [brave_live_cd.id] == search(~s(album:"Brave \(Live\)"), 10, 0)
end
test "tagged search - artist", %{records: [_, _, _, greatest_show_on_earth, libertad]} do
assert [greatest_show_on_earth.id, libertad.id] == search("artist:airbag", 10, 0)
assert [libertad.id] == search(~s(artist:"airbag \(AU\)"), 10, 0)
end
test "tagged search - format", %{records: [_brave_vinyl, brave_live_cd | _rest]} do
assert [brave_live_cd.id] == search("brave format:cd", 10, 0)
end
test "tagged search - type", %{records: [_brave_vinyl, brave_live_cd | _rest]} do
assert [brave_live_cd.id] == search("brave type:live", 10, 0)
end
test "tagged search - mbid", %{records: [_, _, _, greatest_show_on_earth, libertad]} do
[airbag_mbid] = Enum.map(greatest_show_on_earth.artists, fn a -> a.musicbrainz_id end)
[airbag_au_mbid] = Enum.map(libertad.artists, fn a -> a.musicbrainz_id end)
assert [greatest_show_on_earth.id] == search("mbid:#{airbag_mbid}", 10, 0)
assert [libertad.id] == search("mbid:#{airbag_au_mbid}", 10, 0)
end
end
describe "search_records_count/2" do
setup [:create_records]
test "untagged search" do
assert 2 == Records.search_records_count(SearchIndex, "brave")
end
test "tagged search - album" do
assert 1 == Records.search_records_count(SearchIndex, ~s(album:"Brave \(Live\)"))
end
test "tagged search - artist" do
assert 2 == Records.search_records_count(SearchIndex, "artist:airbag")
assert 1 == Records.search_records_count(SearchIndex, ~s(artist:"airbag \(AU\)"))
end
test "tagged search - format" do
assert 1 == Records.search_records_count(SearchIndex, "brave format:cd")
end
test "tagged search - type" do
assert 1 == Records.search_records_count(SearchIndex, "brave type:live")
end
test "tagged search - mbid", %{records: [_, _, _, greatest_show_on_earth, libertad]} do
[airbag_mbid] = Enum.map(greatest_show_on_earth.artists, fn a -> a.musicbrainz_id end)
[airbag_au_mbid] = Enum.map(libertad.artists, fn a -> a.musicbrainz_id end)
assert 1 ==
Records.search_records_count(SearchIndex, "mbid:#{airbag_mbid}")
assert 1 == Records.search_records_count(SearchIndex, "mbid:#{airbag_au_mbid}")
end
end
describe "get_record!/1" do
test "fetches the record by id" do
# while this test may seem redundant, it implicitely checks that ALL record fields are returned,
# as opposed to other code paths where we only return essential ones.
expected = record()
assert expected == Records.get_record!(expected.id)
end
end
describe "get_artist_records/1" do
test "returns records with essential data" do
expected = record()
artist_musicbrainz_id = expected.artists |> hd() |> Map.get(:musicbrainz_id)
[artist_record] = Records.get_artist_records(artist_musicbrainz_id)
assert expected.id == artist_record.id
end
end
describe "import_from_musicbrainz_release_group/2" do
test "saves a record with its cover art" do
current_time = DateTime.utc_now()
release_group = release_group(:marbles)
release_group_id = release_group_id(:marbles)
release_group_releases = release_group_releases(:marbles)
cover_data = marbles_cover_data()
Req.Test.stub(MusicBrainz.API, fn conn ->
case conn.path_info do
[_ws, _version, "release-group", ^release_group_id] ->
Req.Test.json(conn, release_group)
[_ws, _version, "release"] ->
Req.Test.json(conn, release_group_releases)
[_release_group, ^release_group_id, "front"] ->
Plug.Conn.send_resp(conn, 200, cover_data)
end
end)
assert {:ok, record} =
Records.import_from_musicbrainz_release_group(release_group_id,
format: :vinyl,
purchased_at: current_time
)
assert [artist] = record.artists
assert artist.name == "Marillion"
assert record.musicbrainz_id == release_group_id
assert record.title == "Marbles"
assert record.format == :vinyl
assert record.purchased_at == DateTime.truncate(current_time, :second)
assert record.release_ids ==
[
"0e290154-5375-4f4f-a658-4a92bf02faa5",
"3f1cc80f-4507-48a9-899c-c1bda83280c2",
"d3f9b9e2-73f5-4b47-a2a7-2c2199aad608",
"2c4ecd84-7a84-4f42-a600-2f00ed8978c9",
"ab151aa6-7538-4e93-be60-eded52b5b7b7",
"b94bbd1f-ae5d-4e7b-98ff-28bfe135f20c",
"4b9fe13b-4837-4c02-9368-e97ba6f5a086",
"3f89357a-eeb3-4040-af34-a27b7c2aea2b",
"a4b02377-0b5e-448e-9cd6-5500c0378523",
"f3937bc5-b99f-443a-9609-a404201f21ca"
]
end
end
describe "import_from_musicbrainz_release/2" do
test "saves a record with its cover art" do
current_time = DateTime.utc_now()
release = release(:marbles)
release_id = release_id(:marbles)
release_group = release_group(:marbles)
release_group_id = release_group_id(:marbles)
release_group_releases = release_group_releases(:marbles)
cover_data = marbles_cover_data()
Req.Test.stub(MusicBrainz.API, fn conn ->
case conn.path_info do
[_ws, _version, "release-group", ^release_group_id] ->
Req.Test.json(conn, release_group)
[_ws, _version, "release", ^release_id] ->
Req.Test.json(conn, release)
[_ws, _version, "release"] ->
Req.Test.json(conn, release_group_releases)
[_release_group, ^release_group_id, "front"] ->
Plug.Conn.send_resp(conn, 200, cover_data)
end
end)
assert {:ok, record} =
Records.import_from_musicbrainz_release(release_id,
format: :vinyl,
purchased_at: current_time
)
assert [artist] = record.artists
assert artist.name == "Marillion"
assert record.musicbrainz_id == release_group_id
assert record.title == "Marbles"
assert record.format == :vinyl
assert record.purchased_at == DateTime.truncate(current_time, :second)
assert record.release_ids ==
[
"0e290154-5375-4f4f-a658-4a92bf02faa5",
"3f1cc80f-4507-48a9-899c-c1bda83280c2",
"d3f9b9e2-73f5-4b47-a2a7-2c2199aad608",
"2c4ecd84-7a84-4f42-a600-2f00ed8978c9",
"ab151aa6-7538-4e93-be60-eded52b5b7b7",
"b94bbd1f-ae5d-4e7b-98ff-28bfe135f20c",
"4b9fe13b-4837-4c02-9368-e97ba6f5a086",
"3f89357a-eeb3-4040-af34-a27b7c2aea2b",
"a4b02377-0b5e-448e-9cd6-5500c0378523",
"f3937bc5-b99f-443a-9609-a404201f21ca"
]
end
end
describe "refresh_cover/1" do
test "fetches and stores the updated cover" do
record = record(cover_data: marbles_cover_data())
raven_cover_data = raven_cover_data()
Req.Test.stub(MusicBrainz.API, fn conn ->
Plug.Conn.send_resp(conn, 200, raven_cover_data)
end)
assert {:ok, updated_record} = Records.refresh_cover(record)
assert updated_record.cover_hash ==
"6E0D25D1FD1019D771D7EB3F777E2C7C1B06A73A92E56A584D674D86DD8AF441"
{:ok, expected_content} = Assets.Image.resize(raven_cover_data())
assert Assets.get(updated_record.cover_hash).content == expected_content
end
end
describe "populate_genres/1" do
test "updates record genres from OpenAI response" do
record = record(%{genres: []})
genres = ["progressive rock", "art rock", "symphonic rock"]
Req.Test.stub(OpenAI.API, fn conn ->
Req.Test.json(conn, %{
"choices" => [
%{"message" => %{"content" => JSON.encode!(%{"genres" => genres})}}
]
})
end)
assert {:ok, updated} = Records.populate_genres(record)
assert updated.genres == genres
end
@tag :capture_log
test "returns error tuple when OpenAI API fails" do
record = record(%{genres: []})
Req.Test.stub(OpenAI.API, fn conn ->
Plug.Conn.send_resp(
conn,
500,
JSON.encode!(%{"error" => %{"message" => "internal server error"}})
)
end)
assert {:error, %OpenAI.API.ErrorResponse{status: 500, kind: :server_error}} =
Records.populate_genres(record)
end
end
end