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:
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: ML-150
|
id: ML-150
|
||||||
title: Extract Records sub-contexts to reduce module size
|
title: Extract Records sub-contexts to reduce module size
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-04-30 10:47'
|
created_date: '2026-04-30 10:47'
|
||||||
|
updated_date: '2026-04-30 16:08'
|
||||||
labels:
|
labels:
|
||||||
- refactor
|
- refactor
|
||||||
- records
|
- records
|
||||||
@@ -30,9 +31,56 @@ The `SearchIndex` schema, `Record` schema, `Similarity` module, `TracklistPdf`,
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 `Records.Search`, `Records.Import`, and `Records.Enrichment` modules exist with focused responsibilities
|
- [x] #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
|
- [x] #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
|
- [x] #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
|
- [x] #4 Full test suite passes with no regressions
|
||||||
- [ ] #5 `@moduledoc` for each new module explains its responsibility
|
- [x] #5 `@moduledoc` for each new module explains its responsibility
|
||||||
<!-- AC:END -->
|
<!-- 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 -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule MusicLibrary.Collection do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
import MusicLibrary.Records, only: [order_alphabetically: 0]
|
import MusicLibrary.Records.Query
|
||||||
|
|
||||||
alias MusicLibrary.Records
|
alias MusicLibrary.Records
|
||||||
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex}
|
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex}
|
||||||
@@ -66,7 +66,7 @@ defmodule MusicLibrary.Collection do
|
|||||||
^month_day
|
^month_day
|
||||||
),
|
),
|
||||||
order_by: [{:desc, r.release_date}, order_alphabetically()],
|
order_by: [{:desc, r.release_date}, order_alphabetically()],
|
||||||
select: ^Records.essential_fields()
|
select: ^essential_fields()
|
||||||
|
|
||||||
Repo.all(q)
|
Repo.all(q)
|
||||||
end
|
end
|
||||||
@@ -103,7 +103,7 @@ defmodule MusicLibrary.Collection do
|
|||||||
where: not is_nil(r.purchased_at),
|
where: not is_nil(r.purchased_at),
|
||||||
order_by: [{:desc, r.purchased_at}, order_alphabetically()],
|
order_by: [{:desc, r.purchased_at}, order_alphabetically()],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
select: ^Records.essential_fields()
|
select: ^essential_fields()
|
||||||
|
|
||||||
Repo.one(q)
|
Repo.one(q)
|
||||||
end
|
end
|
||||||
@@ -115,7 +115,7 @@ defmodule MusicLibrary.Collection do
|
|||||||
where: not is_nil(r.purchased_at),
|
where: not is_nil(r.purchased_at),
|
||||||
order_by: [{:desc, r.purchased_at}, order_alphabetically()],
|
order_by: [{:desc, r.purchased_at}, order_alphabetically()],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
select: ^Records.essential_fields()
|
select: ^essential_fields()
|
||||||
|
|
||||||
Repo.one!(q)
|
Repo.one!(q)
|
||||||
end
|
end
|
||||||
@@ -127,7 +127,7 @@ defmodule MusicLibrary.Collection do
|
|||||||
where: not is_nil(r.purchased_at),
|
where: not is_nil(r.purchased_at),
|
||||||
order_by: fragment("RANDOM()"),
|
order_by: fragment("RANDOM()"),
|
||||||
limit: 1,
|
limit: 1,
|
||||||
select: ^Records.essential_fields()
|
select: ^essential_fields()
|
||||||
|
|
||||||
Repo.one!(q)
|
Repo.one!(q)
|
||||||
end
|
end
|
||||||
@@ -205,7 +205,7 @@ defmodule MusicLibrary.Collection do
|
|||||||
from(r in Record,
|
from(r in Record,
|
||||||
where: not is_nil(r.purchased_at),
|
where: not is_nil(r.purchased_at),
|
||||||
order_by: [order_alphabetically()],
|
order_by: [order_alphabetically()],
|
||||||
select: ^Records.essential_fields()
|
select: ^essential_fields()
|
||||||
)
|
)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|
||||||
|
|||||||
+31
-360
@@ -1,193 +1,45 @@
|
|||||||
defmodule MusicLibrary.Records do
|
defmodule MusicLibrary.Records do
|
||||||
@moduledoc """
|
@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.Artists
|
||||||
alias MusicLibrary.Assets
|
alias MusicLibrary.Records.{Enrichment, Record}
|
||||||
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex, SearchParser}
|
alias MusicLibrary.Repo
|
||||||
alias MusicLibrary.{Repo, Worker}
|
|
||||||
|
|
||||||
@color_extractor Application.compile_env(
|
# ---- Search delegation ----
|
||||||
:music_library,
|
|
||||||
:color_extractor,
|
|
||||||
MusicLibrary.Colors.KMeansExtractor
|
|
||||||
)
|
|
||||||
|
|
||||||
@type import_opts :: [
|
defdelegate search_records(initial_search, query, opts), to: MusicLibrary.Records.Search
|
||||||
format: atom(),
|
defdelegate search_records_count(initial_search, query), to: MusicLibrary.Records.Search
|
||||||
purchased_at: DateTime.t() | nil,
|
defdelegate list_genres, to: MusicLibrary.Records.Search
|
||||||
selected_release_id: String.t() | nil
|
|
||||||
]
|
|
||||||
|
|
||||||
@spec essential_fields() :: [atom()]
|
# ---- Import delegation ----
|
||||||
def essential_fields do
|
|
||||||
SearchIndex.__schema__(:fields)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec search_records(Ecto.Queryable.t(), String.t(), MusicLibrary.Types.pagination_opts()) ::
|
defdelegate get_release_status(release_id, format), to: MusicLibrary.Records.Import
|
||||||
[SearchIndex.t()]
|
defdelegate get_artist_records(musicbrainz_id), to: MusicLibrary.Records.Import
|
||||||
def search_records(initial_search, query, opts) do
|
|
||||||
limit = Keyword.fetch!(opts, :limit)
|
|
||||||
offset = Keyword.fetch!(opts, :offset)
|
|
||||||
order = Keyword.fetch!(opts, :order)
|
|
||||||
|
|
||||||
search =
|
defdelegate import_from_musicbrainz_release(musicbrainz_id, opts \\ []),
|
||||||
initial_search
|
to: MusicLibrary.Records.Import
|
||||||
|> build_search(query, order)
|
|
||||||
|> limit(^limit)
|
|
||||||
|> offset(^offset)
|
|
||||||
|> select(^essential_fields())
|
|
||||||
|
|
||||||
Repo.all(search)
|
defdelegate import_from_musicbrainz_release_group(musicbrainz_id, opts \\ []),
|
||||||
end
|
to: MusicLibrary.Records.Import
|
||||||
|
|
||||||
@spec search_records_count(Ecto.Queryable.t(), String.t()) :: non_neg_integer()
|
# ---- Enrichment delegation ----
|
||||||
def search_records_count(initial_search, query) do
|
|
||||||
search = build_search(initial_search, query)
|
|
||||||
|
|
||||||
Repo.aggregate(search, :count)
|
defdelegate populate_genres(record), to: MusicLibrary.Records.Enrichment
|
||||||
end
|
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
|
# ---- CRUD functions ----
|
||||||
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
|
|
||||||
|
|
||||||
@spec get_record(String.t()) :: Record.t() | nil
|
@spec get_record(String.t()) :: Record.t() | nil
|
||||||
def get_record(id), do: Repo.get(Record, id)
|
def get_record(id), do: Repo.get(Record, id)
|
||||||
@@ -195,185 +47,10 @@ defmodule MusicLibrary.Records do
|
|||||||
@spec get_record!(String.t()) :: Record.t()
|
@spec get_record!(String.t()) :: Record.t()
|
||||||
def get_record!(id), do: Repo.get!(Record, id)
|
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()}
|
@spec create_record(map()) :: {:ok, Record.t()} | {:error, Ecto.Changeset.t()}
|
||||||
def create_record(attrs \\ %{}) do
|
def create_record(attrs \\ %{}) do
|
||||||
with {:ok, record} <- do_create_record(attrs),
|
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 <- refresh_artist_info_async(record) do
|
||||||
{:ok, record}
|
{:ok, record}
|
||||||
end
|
end
|
||||||
@@ -426,6 +103,8 @@ defmodule MusicLibrary.Records do
|
|||||||
Record.changeset(record, attrs)
|
Record.changeset(record, attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ---- PubSub functions ----
|
||||||
|
|
||||||
@spec subscribe(String.t()) :: :ok | {:error, term()}
|
@spec subscribe(String.t()) :: :ok | {:error, term()}
|
||||||
def subscribe(record_id) do
|
def subscribe(record_id) do
|
||||||
Phoenix.PubSub.subscribe(MusicLibrary.PubSub, "records:#{record_id}")
|
Phoenix.PubSub.subscribe(MusicLibrary.PubSub, "records:#{record_id}")
|
||||||
@@ -439,12 +118,4 @@ defmodule MusicLibrary.Records do
|
|||||||
{:update, record}
|
{:update, record}
|
||||||
)
|
)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
defmodule MusicLibrary.Records.Enrichment do
|
||||||
|
@moduledoc """
|
||||||
|
Record enrichment: genre population via OpenAI, cover management,
|
||||||
|
MusicBrainz data refresh, color extraction, and embedding dispatch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias MusicLibrary.{Assets, Repo, Worker}
|
||||||
|
alias MusicLibrary.Records
|
||||||
|
alias MusicLibrary.Records.Record
|
||||||
|
|
||||||
|
@color_extractor Application.compile_env(
|
||||||
|
:music_library,
|
||||||
|
:color_extractor,
|
||||||
|
MusicLibrary.Colors.KMeansExtractor
|
||||||
|
)
|
||||||
|
|
||||||
|
@spec populate_genres(Record.t()) :: {:ok, Record.t()} | {:error, Ecto.Changeset.t() | term()}
|
||||||
|
def populate_genres(record) do
|
||||||
|
artists = Enum.map_join(record.artists, ",", fn a -> a.name end)
|
||||||
|
|
||||||
|
completion = %OpenAI.Completion{
|
||||||
|
content: """
|
||||||
|
Provide a list of music genres applicable to the album "#{record.title}" by #{artists}.
|
||||||
|
|
||||||
|
Limit the list to 5 genres, ordered by decreasing specificity, all lowercase.
|
||||||
|
|
||||||
|
Return a response in JSON format, without any code block or formatting around it.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
with {:ok, response} <- OpenAI.gpt(completion) do
|
||||||
|
record
|
||||||
|
|> Record.add_genres(response["genres"])
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec populate_genres_async(Record.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def populate_genres_async(record) do
|
||||||
|
enqueue_worker(Worker.PopulateGenres, %{"id" => record.id}, record_meta(record))
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec refresh_cover(Record.t()) :: {:ok, Record.t()} | {:error, term()}
|
||||||
|
def refresh_cover(record) do
|
||||||
|
with {:ok, cover_data} <- MusicBrainz.get_cover_art({:url, record.cover_url}),
|
||||||
|
{:ok, thumb_data} <- Assets.Image.resize(cover_data),
|
||||||
|
{:ok, asset} <- Assets.store_image(%{content: thumb_data, format: "image/jpeg"}) do
|
||||||
|
record
|
||||||
|
|> Record.set_cover_hash(asset.hash)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec refresh_cover_async(Record.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def refresh_cover_async(record) do
|
||||||
|
enqueue_worker(Worker.RefreshCover, %{"id" => record.id}, record_meta(record))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Extract dominant colors from a record's cover image, swallowing errors.
|
||||||
|
|
||||||
|
Called during record creation. If color extraction fails, the original
|
||||||
|
record is returned unchanged and a warning is logged.
|
||||||
|
"""
|
||||||
|
@spec best_effort_extract_colors(Record.t()) :: Record.t()
|
||||||
|
def best_effort_extract_colors(record) do
|
||||||
|
case maybe_extract_colors(record) do
|
||||||
|
{:ok, record} ->
|
||||||
|
record
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Color extraction failed for record #{record.id}: #{inspect(reason)}")
|
||||||
|
record
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_extract_colors(%{dominant_colors: [_ | _]} = record), do: {:ok, record}
|
||||||
|
defp maybe_extract_colors(record), do: extract_colors(record)
|
||||||
|
|
||||||
|
@spec extract_colors(Record.t()) :: {:ok, Record.t()} | {:error, term()}
|
||||||
|
def extract_colors(record) do
|
||||||
|
with asset when not is_nil(asset) <- Assets.get(record.cover_hash),
|
||||||
|
{:ok, colors} <- @color_extractor.extract_dominant_colors(asset.content) do
|
||||||
|
Records.update_record(record, %{dominant_colors: colors})
|
||||||
|
else
|
||||||
|
nil -> {:error, :asset_not_found}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec resize_cover(Record.t()) :: {:ok, Record.t()} | {:error, term()}
|
||||||
|
def resize_cover(record) do
|
||||||
|
with {:ok, thumb_data} <- Assets.Image.resize(record.cover_data),
|
||||||
|
{:ok, asset} <- Assets.store_image(%{content: thumb_data, format: "image/jpeg"}) do
|
||||||
|
record
|
||||||
|
|> Record.set_cover_hash(asset.hash)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec refresh_musicbrainz_data(Record.t()) :: {:ok, Record.t()} | {:error, term()}
|
||||||
|
def refresh_musicbrainz_data(record) do
|
||||||
|
with {:ok, data} <- MusicBrainz.get_release_group(record.musicbrainz_id),
|
||||||
|
{:ok, releases} <- MusicBrainz.get_all_releases(record.musicbrainz_id) do
|
||||||
|
data_with_releases = Map.put(data, "releases", releases)
|
||||||
|
|
||||||
|
record
|
||||||
|
|> Record.add_musicbrainz_data(data_with_releases)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec refresh_musicbrainz_data_async(Record.t()) ::
|
||||||
|
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def refresh_musicbrainz_data_async(record) do
|
||||||
|
enqueue_worker(Worker.RecordRefreshMusicBrainzData, %{"id" => record.id}, record_meta(record))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp enqueue_worker(worker, params, meta) do
|
||||||
|
params |> worker.new(meta: meta) |> Oban.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp record_meta(record) do
|
||||||
|
%{title: record.title, artists: Enum.map(record.artists, & &1.name)}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
defmodule MusicLibrary.Records.Import do
|
||||||
|
@moduledoc """
|
||||||
|
Import records from MusicBrainz release groups and releases.
|
||||||
|
|
||||||
|
Handles cover art fetching, barcode scan integration, and release status checks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
import MusicLibrary.Records.Query
|
||||||
|
|
||||||
|
alias MusicLibrary.Assets
|
||||||
|
alias MusicLibrary.Records
|
||||||
|
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex}
|
||||||
|
alias MusicLibrary.Repo
|
||||||
|
|
||||||
|
@type import_opts :: [
|
||||||
|
format: atom(),
|
||||||
|
purchased_at: DateTime.t() | nil,
|
||||||
|
selected_release_id: String.t() | nil
|
||||||
|
]
|
||||||
|
|
||||||
|
@spec get_release_status(String.t(), atom()) ::
|
||||||
|
:new | {:wishlisted, String.t()} | {:collected, String.t()}
|
||||||
|
def get_release_status(release_id, format) do
|
||||||
|
format_str = Atom.to_string(format)
|
||||||
|
|
||||||
|
q =
|
||||||
|
from r in fragment("records, json_each(records.release_ids)"),
|
||||||
|
where: fragment("records.format = ?", ^format_str) and r.value == ^release_id,
|
||||||
|
select: %{
|
||||||
|
record_id: fragment("records.id"),
|
||||||
|
purchased_at: fragment("records.purchased_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
case Repo.one(q) do
|
||||||
|
nil -> :new
|
||||||
|
%{record_id: record_id, purchased_at: nil} -> {:wishlisted, record_id}
|
||||||
|
%{record_id: record_id} -> {:collected, record_id}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_artist_records(String.t()) :: [SearchIndex.t()]
|
||||||
|
def get_artist_records(musicbrainz_id) do
|
||||||
|
q =
|
||||||
|
from r in Record,
|
||||||
|
join: ar in ArtistRecord,
|
||||||
|
on: r.id == ar.record_id and ar.musicbrainz_id == ^musicbrainz_id,
|
||||||
|
select: ^essential_fields()
|
||||||
|
|
||||||
|
Repo.all(q)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec import_from_musicbrainz_release(String.t(), import_opts()) ::
|
||||||
|
{:ok, Record.t()} | {:error, term()}
|
||||||
|
def import_from_musicbrainz_release(musicbrainz_id, opts \\ []) do
|
||||||
|
case MusicBrainz.get_release(musicbrainz_id) do
|
||||||
|
{:ok, release} ->
|
||||||
|
release_group_id = release["release-group"]["id"]
|
||||||
|
import_from_musicbrainz_release_group(release_group_id, opts)
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec import_from_musicbrainz_release_group(String.t(), import_opts()) ::
|
||||||
|
{:ok, Record.t()} | {:error, term()}
|
||||||
|
def import_from_musicbrainz_release_group(musicbrainz_id, opts \\ []) do
|
||||||
|
format = Keyword.get(opts, :format, "cd")
|
||||||
|
purchased_at = Keyword.get(opts, :purchased_at)
|
||||||
|
selected_release_id = Keyword.get(opts, :selected_release_id, nil)
|
||||||
|
|
||||||
|
with {:ok, release_group} <- MusicBrainz.get_release_group(musicbrainz_id),
|
||||||
|
{:ok, releases} <- MusicBrainz.get_all_releases(musicbrainz_id),
|
||||||
|
release_group_with_releases = Map.put(release_group, "releases", releases),
|
||||||
|
{:ok, cover_data} <- get_cover_art_or_default(musicbrainz_id),
|
||||||
|
{:ok, asset} <- Assets.store_image(%{content: cover_data, format: "image/jpeg"}) do
|
||||||
|
release_group_with_releases
|
||||||
|
|> build_record_attrs(%{
|
||||||
|
"cover_hash" => asset.hash,
|
||||||
|
"format" => format,
|
||||||
|
"purchased_at" => purchased_at,
|
||||||
|
"selected_release_id" => selected_release_id
|
||||||
|
})
|
||||||
|
|> Records.create_record()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_cover_art_or_default(musicbrainz_id) do
|
||||||
|
case MusicBrainz.get_cover_art({:musicbrainz_id, musicbrainz_id}) do
|
||||||
|
{:error, :cover_not_available} -> {:ok, Assets.Image.fallback_data()}
|
||||||
|
{:ok, cover_data} -> Assets.Image.resize(cover_data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_record_attrs(release_group, attrs) do
|
||||||
|
release_group
|
||||||
|
|> Record.attrs_from_release_group()
|
||||||
|
|> Map.merge(attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
defmodule MusicLibrary.Records.Query do
|
||||||
|
@moduledoc """
|
||||||
|
Helpers to compose Ecto queries based on record-like schemas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias MusicLibrary.Records.SearchIndex
|
||||||
|
|
||||||
|
@spec essential_fields() :: [atom()]
|
||||||
|
def essential_fields do
|
||||||
|
SearchIndex.__schema__(:fields)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Ecto query fragment for alphabetical ordering by artist name and title.
|
||||||
|
|
||||||
|
Used by callers that compose queries (e.g. `Collection`) and by search
|
||||||
|
ordering internally via `Records.Search`.
|
||||||
|
"""
|
||||||
|
defmacro order_alphabetically do
|
||||||
|
quote do
|
||||||
|
fragment(
|
||||||
|
"unaccent(artists ->> '$[0].sort_name') COLLATE NOCASE ASC, unaccent(title) COLLATE NOCASE ASC"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
defmodule MusicLibrary.Records.Search do
|
||||||
|
@moduledoc """
|
||||||
|
FTS5 search and genre listing for records.
|
||||||
|
|
||||||
|
Integrates with `SearchParser` to support structured, tagged queries
|
||||||
|
(`artist:`, `album:`, `genre:`, `format:`, `type:`, `mbid:`, `purchase_year:`, `release_year:`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
import MusicLibrary.Records.Query
|
||||||
|
|
||||||
|
alias MusicLibrary.Records.{SearchIndex, SearchParser}
|
||||||
|
alias MusicLibrary.Repo
|
||||||
|
|
||||||
|
@spec search_records(Ecto.Queryable.t(), String.t(), MusicLibrary.Types.pagination_opts()) ::
|
||||||
|
[SearchIndex.t()]
|
||||||
|
def search_records(initial_search, query, opts) do
|
||||||
|
limit = Keyword.fetch!(opts, :limit)
|
||||||
|
offset = Keyword.fetch!(opts, :offset)
|
||||||
|
order = Keyword.fetch!(opts, :order)
|
||||||
|
|
||||||
|
search =
|
||||||
|
initial_search
|
||||||
|
|> build_search(query, order)
|
||||||
|
|> limit(^limit)
|
||||||
|
|> offset(^offset)
|
||||||
|
|> select(^essential_fields())
|
||||||
|
|
||||||
|
Repo.all(search)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec search_records_count(Ecto.Queryable.t(), String.t()) :: non_neg_integer()
|
||||||
|
def search_records_count(initial_search, query) do
|
||||||
|
search = build_search(initial_search, query)
|
||||||
|
|
||||||
|
Repo.aggregate(search, :count)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec list_genres() :: [String.t()]
|
||||||
|
def list_genres do
|
||||||
|
q =
|
||||||
|
from r in fragment("records, json_each(records.genres)"),
|
||||||
|
select: fragment("DISTINCT value"),
|
||||||
|
order_by: fragment("value COLLATE NOCASE ASC")
|
||||||
|
|
||||||
|
Repo.all(q)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fts_escape(term) do
|
||||||
|
if String.contains?(term, ["'", " ", "\"", "(", ")", "^", "-", ":", "?", ".", "&"]) do
|
||||||
|
escaped = String.replace(term, "\"", "\"\"")
|
||||||
|
"\"#{escaped}\"*"
|
||||||
|
else
|
||||||
|
"#{term}*"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fts_query_escape(query) do
|
||||||
|
query
|
||||||
|
|> String.split(~r/\s+/, trim: true)
|
||||||
|
|> Enum.map_join(" AND ", &fts_escape/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_search(initial_search, query, order \\ :alphabetical) do
|
||||||
|
{:ok, parsed_query} =
|
||||||
|
SearchParser.parse(query)
|
||||||
|
|
||||||
|
search_with_order =
|
||||||
|
case order do
|
||||||
|
:alphabetical ->
|
||||||
|
initial_search
|
||||||
|
|> order_by(order_alphabetically())
|
||||||
|
|
||||||
|
:purchase ->
|
||||||
|
initial_search
|
||||||
|
|> order_by([r], [
|
||||||
|
{:desc, r.purchased_at},
|
||||||
|
order_alphabetically()
|
||||||
|
])
|
||||||
|
|
||||||
|
:insertion ->
|
||||||
|
initial_search
|
||||||
|
|> order_by([r], [
|
||||||
|
{:desc, r.inserted_at},
|
||||||
|
order_alphabetically()
|
||||||
|
])
|
||||||
|
|
||||||
|
:release ->
|
||||||
|
initial_search
|
||||||
|
|> order_by([r], [
|
||||||
|
{:desc, r.release_date},
|
||||||
|
order_alphabetically()
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
Enum.reduce(parsed_query, search_with_order, fn
|
||||||
|
{:artist, artist}, search ->
|
||||||
|
escaped_artist = fts_escape(artist)
|
||||||
|
|
||||||
|
search
|
||||||
|
|> where(
|
||||||
|
fragment(
|
||||||
|
"records_search_index MATCH '{artists normalized_artists} : ' || ?",
|
||||||
|
^escaped_artist
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:album, album}, search ->
|
||||||
|
escaped_album = fts_escape(album)
|
||||||
|
|
||||||
|
search
|
||||||
|
|> where(
|
||||||
|
fragment(
|
||||||
|
"records_search_index MATCH '{title normalized_title} : ' || ?",
|
||||||
|
^escaped_album
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:genre, genre}, search ->
|
||||||
|
escaped_genre = fts_escape(genre)
|
||||||
|
|
||||||
|
search
|
||||||
|
|> where(fragment("records_search_index MATCH 'genres : ' || ?", ^escaped_genre))
|
||||||
|
|
||||||
|
{:mbid, mbid}, search ->
|
||||||
|
escaped_mbid = fts_escape(mbid)
|
||||||
|
|
||||||
|
search
|
||||||
|
|> where(fragment("records_search_index MATCH ?", ^escaped_mbid))
|
||||||
|
|
||||||
|
{:format, format}, search ->
|
||||||
|
search |> where([r], r.format == ^format)
|
||||||
|
|
||||||
|
{:type, type}, search ->
|
||||||
|
search |> where([r], r.type == ^type)
|
||||||
|
|
||||||
|
{:purchase_year, year}, search ->
|
||||||
|
search
|
||||||
|
|> where(
|
||||||
|
[r],
|
||||||
|
fragment(
|
||||||
|
"? >= ? and ? < ?",
|
||||||
|
r.purchased_at,
|
||||||
|
^to_string(year),
|
||||||
|
r.purchased_at,
|
||||||
|
^to_string(year + 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:release_year, year}, search ->
|
||||||
|
search
|
||||||
|
|> where([r], fragment("substr(?, 1, 4) = ?", r.release_date, ^to_string(year)))
|
||||||
|
|
||||||
|
{:query, ""}, search ->
|
||||||
|
search
|
||||||
|
|
||||||
|
{:query, raw_query}, search ->
|
||||||
|
escaped_query = fts_query_escape(raw_query)
|
||||||
|
|
||||||
|
search
|
||||||
|
|> where(fragment("records_search_index MATCH ?", ^escaped_query))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,34 +1,11 @@
|
|||||||
defmodule MusicLibrary.RecordsTest do
|
defmodule MusicLibrary.RecordsTest do
|
||||||
use MusicLibrary.DataCase
|
use MusicLibrary.DataCase
|
||||||
|
|
||||||
import MusicBrainz.Fixtures.Release
|
|
||||||
import MusicBrainz.Fixtures.ReleaseGroup
|
import MusicBrainz.Fixtures.ReleaseGroup
|
||||||
import MusicLibrary.ColorHelpers, only: [color_hex?: 1]
|
import MusicLibrary.ColorHelpers, only: [color_hex?: 1]
|
||||||
import MusicLibrary.Fixtures.Records
|
import MusicLibrary.Fixtures.Records
|
||||||
|
|
||||||
alias MusicLibrary.Assets
|
|
||||||
alias MusicLibrary.Records
|
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
|
describe "create_record/1" do
|
||||||
test "populates computed values" do
|
test "populates computed values" do
|
||||||
@@ -99,299 +76,11 @@ defmodule MusicLibrary.RecordsTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "get_record!/1" do
|
||||||
test "fetches the record by id" 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()
|
expected = record()
|
||||||
|
|
||||||
assert expected == Records.get_record!(expected.id)
|
assert expected == Records.get_record!(expected.id)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user