diff --git a/backlog/tasks/ml-150 - Extract-Records-sub-contexts-to-reduce-module-size.md b/backlog/tasks/ml-150 - Extract-Records-sub-contexts-to-reduce-module-size.md index 3e002988..9a877342 100644 --- a/backlog/tasks/ml-150 - Extract-Records-sub-contexts-to-reduce-module-size.md +++ b/backlog/tasks/ml-150 - Extract-Records-sub-contexts-to-reduce-module-size.md @@ -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 -- [ ] #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 + +## Implementation Notes + + +## 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) + diff --git a/lib/music_library/collection.ex b/lib/music_library/collection.ex index 9aff71f7..93ee3739 100644 --- a/lib/music_library/collection.ex +++ b/lib/music_library/collection.ex @@ -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() diff --git a/lib/music_library/records.ex b/lib/music_library/records.ex index 27fbee80..d264950d 100644 --- a/lib/music_library/records.ex +++ b/lib/music_library/records.ex @@ -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 diff --git a/lib/music_library/records/enrichment.ex b/lib/music_library/records/enrichment.ex new file mode 100644 index 00000000..04bfcf35 --- /dev/null +++ b/lib/music_library/records/enrichment.ex @@ -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 diff --git a/lib/music_library/records/import.ex b/lib/music_library/records/import.ex new file mode 100644 index 00000000..db4480a8 --- /dev/null +++ b/lib/music_library/records/import.ex @@ -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 diff --git a/lib/music_library/records/query.ex b/lib/music_library/records/query.ex new file mode 100644 index 00000000..c237e078 --- /dev/null +++ b/lib/music_library/records/query.ex @@ -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 diff --git a/lib/music_library/records/search.ex b/lib/music_library/records/search.ex new file mode 100644 index 00000000..cb30cfdd --- /dev/null +++ b/lib/music_library/records/search.ex @@ -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 diff --git a/test/music_library/records/enrichment_test.exs b/test/music_library/records/enrichment_test.exs new file mode 100644 index 00000000..1cf55f08 --- /dev/null +++ b/test/music_library/records/enrichment_test.exs @@ -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 diff --git a/test/music_library/records/import_test.exs b/test/music_library/records/import_test.exs new file mode 100644 index 00000000..474e549b --- /dev/null +++ b/test/music_library/records/import_test.exs @@ -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 diff --git a/test/music_library/records/search_test.exs b/test/music_library/records/search_test.exs new file mode 100644 index 00000000..be61667a --- /dev/null +++ b/test/music_library/records/search_test.exs @@ -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 diff --git a/test/music_library/records_test.exs b/test/music_library/records_test.exs index 7ba0c8e2..1cf284b6 100644 --- a/test/music_library/records_test.exs +++ b/test/music_library/records_test.exs @@ -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