ML-170: add enrichment fields to collection API

Add MusicLibrary.Collection.Enrichment module with batch scrobble, artist
country, and selected release enrichment from existing tables.

Wire enrichment into all four collection API endpoints (index, latest,
random, on_this_day). New JSON fields: scrobble_count, last_listened_at,
artist_country, selected_release.

Uses 3 fixed-count batch queries regardless of page size — no N+1 risk.
No schema changes or migrations required.
This commit is contained in:
Claudio Ortolina
2026-05-22 13:18:56 +01:00
parent c41299f6c1
commit ff3f809c2c
7 changed files with 643 additions and 50 deletions
@@ -1,10 +1,10 @@
--- ---
id: ML-170 id: ML-170
title: Expose additional data points for api/v1/collection/* records title: Expose additional data points for api/v1/collection/* records
status: To Do status: Done
assignee: [] assignee: []
created_date: "2026-05-08 13:02" created_date: "2026-05-08 13:02"
updated_date: "2026-05-12 10:03" updated_date: "2026-05-22 12:20"
labels: labels:
- api - api
- ready - ready
@@ -39,14 +39,14 @@ The API endpoints affected are:
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 `GET /api/v1/collection` (index) responses include `scrobble_count`, `last_listened_at`, `artist_country`, and `selected_release` for each record - [x] #1 `GET /api/v1/collection` (index) responses include `scrobble_count`, `last_listened_at`, `artist_country`, and `selected_release` for each record
- [ ] #2 `GET /api/v1/collection/latest` response includes all four new fields - [x] #2 `GET /api/v1/collection/latest` response includes all four new fields
- [ ] #3 `GET /api/v1/collection/random` response includes all four new fields - [x] #3 `GET /api/v1/collection/random` response includes all four new fields
- [ ] #4 `GET /api/v1/collection/on_this_day` responses include all four new fields for each record - [x] #4 `GET /api/v1/collection/on_this_day` responses include all four new fields for each record
- [ ] #5 Records without scrobble data return `scrobble_count: 0` and `last_listened_at: null` - [x] #5 Records without scrobble data return `scrobble_count: 0` and `last_listened_at: null`
- [ ] #6 Records without artist info return `artist_country: null` - [x] #6 Records without artist info return `artist_country: null`
- [ ] #7 Records without a selected release return `selected_release: null` - [x] #7 Records without a selected release return `selected_release: null`
- [ ] #8 All existing tests pass (backward compatible — no existing field removed or renamed) - [x] #8 All existing tests pass (backward compatible — no existing field removed or renamed)
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -334,3 +334,11 @@ The `release_summary` component in `record_components.ex` already derives a rele
**Simplify Step 4**: instead of building a custom format map, call `ReleaseSearchResult.format(selected_release)` on the release returned by `Record.find_release/2`. Convert the result to a string with `to_string/1` for the API response. No new format parsing code needed. **Simplify Step 4**: instead of building a custom format map, call `ReleaseSearchResult.format(selected_release)` on the release returned by `Record.find_release/2`. Convert the result to a string with `to_string/1` for the API response. No new format parsing code needed.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added `MusicLibrary.Collection.Enrichment` module that batch-hydrates collection API results with scrobble stats, artist country, and selected release data using 3 fixed-count queries per request. Extended `CollectionJSON.record/1` with four new fields (`scrobble_count`, `last_listened_at`, `artist_country`, `selected_release`) using `Map.get/3` for nil-safety. Wired enrichment into all four endpoints (`index`, `latest`, `random`, `on_this_day`). 19 new unit tests + updated controller tests. Full test suite passes (1134 tests, no regressions).
<!-- SECTION:FINAL_SUMMARY:END -->
+3 -2
View File
@@ -97,7 +97,7 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
| Context | Schemas | Responsibility | | Context | Schemas | Responsibility |
| ---------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Records` | Record, RecordEmbedding, SearchIndex | CRUD, search, import from MusicBrainz, cover/genre/color management, PubSub notifications | | `Records` | Record, RecordEmbedding, SearchIndex | CRUD, search, import from MusicBrainz, cover/genre/color management, PubSub notifications |
| `Collection` | Record (via SearchIndex) | Querying collected records (purchased_at != nil), stats, collected artist IDs, collection summary for AI chat | | `Collection` | Record (via SearchIndex) | Querying collected records (purchased_at != nil), stats, collected artist IDs, collection summary for AI chat. Sub-module `Collection.Enrichment` batch-hydrates API results with scrobble stats, artist country, and selected release info. |
| `Wishlist` | Record (via SearchIndex) | Querying wishlisted records (purchased_at is nil) | | `Wishlist` | Record (via SearchIndex) | Querying wishlisted records (purchased_at is nil) |
| `Artists` | ArtistInfo, ArtistRecord | Artist metadata from MusicBrainz/Discogs/Wikipedia/Last.fm, images, search | | `Artists` | ArtistInfo, ArtistRecord | Artist metadata from MusicBrainz/Discogs/Wikipedia/Last.fm, images, search |
| `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking, pruning unreferenced assets | | `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking, pruning unreferenced assets |
@@ -119,7 +119,7 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
## Business Logic Modules ## Business Logic Modules
| Module | Purpose | | Module | Purpose |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Records.SearchParser` | Parses search syntax: `artist:X`, `album:X`, `genre:"Y"`, `format:cd`, `type:album`, `purchase_year:2024`, `release_year:2024`, free text | | `Records.SearchParser` | Parses search syntax: `artist:X`, `album:X`, `genre:"Y"`, `format:cd`, `type:album`, `purchase_year:2024`, `release_year:2024`, free text |
| `ListeningStats.SearchParser` | Parses scrobbled tracks search syntax: `record:X`, `album_mbid:X`, `artist_mbid:X`, `artist:X`, `album:X`, `track:X`, free text | | `ListeningStats.SearchParser` | Parses scrobbled tracks search syntax: `record:X`, `album_mbid:X`, `artist_mbid:X`, `artist:X`, `album:X`, `track:X`, free text |
| `Records.Similarity` | Embedding generation and async enqueue (OpenAI, enriched with Last.fm tags, skips API call when text representation unchanged), artist-cascade regeneration when upstream metadata changes, cosine-distance search (sqlite-vec) | | `Records.Similarity` | Embedding generation and async enqueue (OpenAI, enriched with Last.fm tags, skips API call when text representation unchanged), artist-cascade regeneration when upstream metadata changes, cosine-distance search (sqlite-vec) |
@@ -127,6 +127,7 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
| `Batch` | Generic batch runner: stream + transaction + error accumulation | | `Batch` | Generic batch runner: stream + transaction + error accumulation |
| `Records.Batch` | Batch operations: refresh all MusicBrainz data, generate all embeddings (uses `Batch`) | | `Records.Batch` | Batch operations: refresh all MusicBrainz data, generate all embeddings (uses `Batch`) |
| `Artists.Batch` | Batch refresh: MusicBrainz, Discogs, Wikipedia, Last.fm for all artists (uses `Batch`) | | `Artists.Batch` | Batch refresh: MusicBrainz, Discogs, Wikipedia, Last.fm for all artists (uses `Batch`) |
| `Collection.Enrichment` | Batch-hydrates collection API results with scrobble stats (from `scrobbled_tracks`), artist country (from `artist_infos`), and selected release details (from `records.musicbrainz_data`). Uses 3 fixed-count queries regardless of page size. |
| `Req.RateLimiter` | ETS-backed Req request step enforcing per-API minimum intervals between requests | | `Req.RateLimiter` | ETS-backed Req request step enforcing per-API minimum intervals between requests |
| `Req.RateLimiter.Clock` | Behaviour for time operations (allows test clock injection) | | `Req.RateLimiter.Clock` | Behaviour for time operations (allows test clock injection) |
| `Req.RateLimiter.SystemClock` | Real clock implementation using System.monotonic_time | | `Req.RateLimiter.SystemClock` | Real clock implementation using System.monotonic_time |
+259
View File
@@ -0,0 +1,259 @@
defmodule MusicLibrary.Collection.Enrichment do
@moduledoc """
Batch-hydrates SearchIndex (and Record struct) results with additional data
points: scrobble stats, artist country, and selected release info.
All enrichment runs in fixed-count batch queries — 3 total, regardless of
result size. No N+1 risk and no title+artist fallback queries.
"""
import Ecto.Query
alias LastFm.Track
alias MusicBrainz.ReleaseSearchResult
alias MusicLibrary.Artists.ArtistInfo
alias MusicLibrary.Records.Record
alias MusicLibrary.Repo
@doc """
Enriches a list of records with scrobble stats, artist country, and selected
release information.
Accepts any map or struct list where each element has:
- `:id` (binary_id or string)
- `:release_ids` (array of strings)
- `:artists` (embedded list with `.musicbrainz_id`)
- `:selected_release_id` (string or nil)
Returns the same list with four additional fields on each element:
- `:scrobble_count` (integer, always present, defaults to 0)
- `:last_listened_at` (ISO8601 string or nil)
- `:artist_country` (%{name: ..., code: ...} or nil)
- `:selected_release` (map or nil)
"""
@spec enrich([map()]) :: [map()]
def enrich(records) do
records
|> enrich_scrobbles()
|> enrich_artist_country()
|> enrich_selected_release()
end
@doc """
Adds `:scrobble_count` and `:last_listened_at` to each record.
Uses a single batch query against `scrobbled_tracks` keyed by
`json_extract(album, '$.musicbrainz_id')` (the release MusicBrainz ID).
For records with multiple release_ids (different editions of the same
release group), scrobble counts are summed and the most recent
`last_listened_at` across all releases is kept.
"""
@spec enrich_scrobbles([map()]) :: [map()]
def enrich_scrobbles(records) do
release_ids =
records
|> Enum.flat_map(&Map.get(&1, :release_ids, []))
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
if release_ids == [] do
Enum.map(records, fn record ->
record
|> Map.put(:scrobble_count, 0)
|> Map.put(:last_listened_at, nil)
end)
else
lookup = build_scrobble_lookup(release_ids)
Enum.map(records, fn record ->
enrich_one_record_scrobbles(record, lookup)
end)
end
end
defp enrich_one_record_scrobbles(record, lookup) do
record_release_ids = Map.get(record, :release_ids, []) || []
{total_count, latest_uts} =
record_release_ids
|> Enum.reduce({0, nil}, fn release_id, {count_acc, uts_acc} ->
case Map.get(lookup, release_id) do
nil ->
{count_acc, uts_acc}
%{scrobble_count: sc, last_listened_at: la} ->
new_count = count_acc + sc
new_uts = max_uts(uts_acc, la)
{new_count, new_uts}
end
end)
record
|> Map.put(:scrobble_count, total_count)
|> Map.put(:last_listened_at, format_last_listened_at(latest_uts))
end
defp build_scrobble_lookup(release_ids) do
from(t in Track,
where: fragment("json_extract(?, '$.musicbrainz_id')", t.album) in ^release_ids,
group_by: fragment("json_extract(?, '$.musicbrainz_id')", t.album),
select: %{
release_id: fragment("json_extract(?, '$.musicbrainz_id')", t.album),
scrobble_count: fragment("COUNT(DISTINCT ?)", t.scrobbled_at_uts),
last_listened_at: max(t.scrobbled_at_uts)
}
)
|> Repo.all()
|> Map.new(fn row ->
{row.release_id,
%{scrobble_count: row.scrobble_count, last_listened_at: row.last_listened_at}}
end)
end
defp max_uts(nil, nil), do: nil
defp max_uts(nil, val), do: val
defp max_uts(val, nil), do: val
defp max_uts(a, b), do: max(a, b)
defp format_last_listened_at(nil), do: nil
defp format_last_listened_at(uts) when is_integer(uts) do
uts
|> DateTime.from_unix!()
|> DateTime.to_iso8601()
end
@doc """
Adds `:artist_country` to each record, derived from the main artist.
Extracts the first artist from the embedded `:artists` list, looks up
`artist_infos` by the artist's MusicBrainz ID, and extracts the country
via `ArtistInfo.country/1`.
Returns `%{name: String, code: String}` or nil if no artist info exists.
"""
@spec enrich_artist_country([map()]) :: [map()]
def enrich_artist_country(records) do
artist_ids =
records
|> Enum.map(&main_artist_mbid/1)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
if artist_ids == [] do
Enum.map(records, fn record ->
Map.put(record, :artist_country, nil)
end)
else
lookup = build_country_lookup(artist_ids)
Enum.map(records, fn record ->
mbid = main_artist_mbid(record)
artist_country = if mbid, do: Map.get(lookup, mbid), else: nil
Map.put(record, :artist_country, artist_country)
end)
end
end
defp main_artist_mbid(record) do
artists = Map.get(record, :artists, [])
case artists do
[] ->
nil
[first | _] ->
%{musicbrainz_id: mbid} = first
mbid
end
end
defp build_country_lookup(artist_ids) do
from(ai in ArtistInfo,
where: ai.id in ^artist_ids,
select: %{id: ai.id, musicbrainz_data: ai.musicbrainz_data}
)
|> Repo.all()
|> Map.new(fn row ->
{row.id, ArtistInfo.country(%ArtistInfo{musicbrainz_data: row.musicbrainz_data})}
end)
end
@doc """
Adds `:selected_release` to each record with details about the selected
release (edition).
Looks up the `records` table by the record's `:id`, extracts the selected
release from `musicbrainz_data` using `Record.releases/1` and
`Record.find_release/2`, then exposes six fields: format, date, country,
catalog_number, packaging, disambiguation.
Returns a map or nil if no selected release is available.
"""
@spec enrich_selected_release([map()]) :: [map()]
def enrich_selected_release(records) do
record_ids_with_selected =
records
|> Enum.filter(fn r ->
sr_id = Map.get(r, :selected_release_id)
sr_id != nil and sr_id != ""
end)
|> Enum.map(&Map.get(&1, :id))
|> Enum.uniq()
if record_ids_with_selected == [] do
Enum.map(records, fn record ->
Map.put(record, :selected_release, nil)
end)
else
lookup = build_selected_release_lookup(record_ids_with_selected)
Enum.map(records, fn record ->
record
|> Map.put(:selected_release, Map.get(lookup, Map.get(record, :id)))
end)
end
end
defp build_selected_release_lookup(record_ids) do
from(r in Record,
where: r.id in ^record_ids,
select: %{
id: r.id,
musicbrainz_data: r.musicbrainz_data,
selected_release_id: r.selected_release_id
}
)
|> Repo.all()
|> Map.new(fn row ->
selected_release =
extract_selected_release(row.musicbrainz_data, row.selected_release_id)
{row.id, selected_release}
end)
end
defp extract_selected_release(nil, _), do: nil
defp extract_selected_release(_, nil), do: nil
defp extract_selected_release(_, ""), do: nil
defp extract_selected_release(musicbrainz_data, selected_release_id) do
# Build a minimal map with :musicbrainz_data for Record.releases/1 and Record.find_release/2
temp_record = %{musicbrainz_data: musicbrainz_data}
case Record.find_release(temp_record, selected_release_id) do
nil ->
nil
release ->
%{
format: to_string(ReleaseSearchResult.format(release)),
date: release.date,
country: release.country,
catalog_number: release.catalog_number,
packaging: release.packaging,
disambiguation: release.disambiguation
}
end
end
end
@@ -3,17 +3,26 @@ defmodule MusicLibraryWeb.CollectionController do
alias MusicBrainz alias MusicBrainz
alias MusicLibrary.Collection alias MusicLibrary.Collection
alias MusicLibrary.Collection.Enrichment
alias MusicLibrary.Records alias MusicLibrary.Records
alias MusicLibrary.ScrobbleActivity alias MusicLibrary.ScrobbleActivity
def latest(conn, _params) do def latest(conn, _params) do
latest_record = Collection.get_latest_record!() latest_record =
Collection.get_latest_record!()
|> List.wrap()
|> Enrichment.enrich()
|> hd()
render(conn, :show, record: latest_record) render(conn, :show, record: latest_record)
end end
def random(conn, _params) do def random(conn, _params) do
random_record = Collection.get_random_record!() random_record =
Collection.get_random_record!()
|> List.wrap()
|> Enrichment.enrich()
|> hd()
render(conn, :show, record: random_record) render(conn, :show, record: random_record)
end end
@@ -25,7 +34,10 @@ defmodule MusicLibraryWeb.CollectionController do
date_string -> Date.from_iso8601!(date_string) date_string -> Date.from_iso8601!(date_string)
end end
records_on_this_day = Collection.get_records_on_this_day(current_date) records_on_this_day =
current_date
|> Collection.get_records_on_this_day()
|> Enrichment.enrich()
render(conn, :on_this_day, records: records_on_this_day) render(conn, :on_this_day, records: records_on_this_day)
end end
@@ -37,7 +49,10 @@ defmodule MusicLibraryWeb.CollectionController do
total = Collection.search_records_count(query) total = Collection.search_records_count(query)
records = Collection.search_records(query, limit: limit, offset: offset) records =
query
|> Collection.search_records(limit: limit, offset: offset)
|> Enrichment.enrich()
render(conn, :index, total: total, limit: limit, offset: offset, records: records) render(conn, :index, total: total, limit: limit, offset: offset, records: records)
end end
@@ -38,7 +38,11 @@ defmodule MusicLibraryWeb.CollectionJSON do
selected_release_id: record.selected_release_id, selected_release_id: record.selected_release_id,
artists: Enum.map(record.artists, & &1.name), artists: Enum.map(record.artists, & &1.name),
title: record.title, title: record.title,
covers: cover_urls(record.cover_hash) covers: cover_urls(record.cover_hash),
scrobble_count: Map.get(record, :scrobble_count, 0),
last_listened_at: Map.get(record, :last_listened_at),
artist_country: Map.get(record, :artist_country),
selected_release: Map.get(record, :selected_release)
} }
end end
@@ -0,0 +1,295 @@
defmodule MusicLibrary.Collection.EnrichmentTest do
use MusicLibrary.DataCase
alias MusicLibrary.Collection.Enrichment
alias MusicLibrary.Fixtures.Records
alias MusicLibrary.ScrobbledTracksFixtures
describe "enrich_scrobbles/1" do
test "adds scrobble_count and last_listened_at when scrobbles exist" do
record = Records.record()
release_id = hd(record.release_ids)
# Insert a scrobbled track referencing this release_id
_track =
ScrobbledTracksFixtures.track_fixture(%{
album_musicbrainz_id: release_id,
album_title: record.title,
scrobbled_at_uts: 1_700_000_000
})
_track2 =
ScrobbledTracksFixtures.track_fixture(%{
album_musicbrainz_id: release_id,
album_title: record.title,
scrobbled_at_uts: 1_700_000_100
})
[enriched] = Enrichment.enrich_scrobbles([record])
assert enriched.scrobble_count == 2
assert enriched.last_listened_at != nil
assert String.contains?(enriched.last_listened_at, "T")
end
test "returns zero and nil when no scrobbles match" do
record = Records.record()
[enriched] = Enrichment.enrich_scrobbles([record])
assert enriched.scrobble_count == 0
assert enriched.last_listened_at == nil
end
test "returns zero and nil when release_ids is empty" do
record = Records.record(%{release_ids: []})
[enriched] = Enrichment.enrich_scrobbles([record])
assert enriched.scrobble_count == 0
assert enriched.last_listened_at == nil
end
test "returns zero and nil when release_ids is nil" do
record = Records.record(%{release_ids: nil})
[enriched] = Enrichment.enrich_scrobbles([record])
assert enriched.scrobble_count == 0
assert enriched.last_listened_at == nil
end
test "sums scrobbles across multiple release_ids for the same record" do
release_ids = ["release-a", "release-b"]
record = Records.record(%{release_ids: release_ids, musicbrainz_data: nil})
_track =
ScrobbledTracksFixtures.track_fixture(%{
album_musicbrainz_id: "release-a",
album_title: "Album A",
scrobbled_at_uts: 1_700_000_000
})
_track2 =
ScrobbledTracksFixtures.track_fixture(%{
album_musicbrainz_id: "release-b",
album_title: "Album B",
scrobbled_at_uts: 1_700_000_100
})
[enriched] = Enrichment.enrich_scrobbles([record])
assert enriched.scrobble_count == 2
end
test "takes the max last_listened_at across release_ids" do
release_ids = ["release-a", "release-b"]
record = Records.record(%{release_ids: release_ids, musicbrainz_data: nil})
earlier = 1_700_000_000
later = 1_700_000_500
_track =
ScrobbledTracksFixtures.track_fixture(%{
album_musicbrainz_id: "release-a",
album_title: "Album A",
scrobbled_at_uts: earlier
})
_track2 =
ScrobbledTracksFixtures.track_fixture(%{
album_musicbrainz_id: "release-b",
album_title: "Album B",
scrobbled_at_uts: later
})
[enriched] = Enrichment.enrich_scrobbles([record])
expected_iso = later |> DateTime.from_unix!() |> DateTime.to_iso8601()
assert enriched.last_listened_at == expected_iso
end
test "two records sharing a release group both get scrobble counts" do
release_ids = ["shared-release"]
record1 = Records.record(%{release_ids: release_ids, musicbrainz_data: nil})
record2 = Records.record(%{release_ids: release_ids, musicbrainz_data: nil})
_track =
ScrobbledTracksFixtures.track_fixture(%{
album_musicbrainz_id: "shared-release",
album_title: "Shared",
scrobbled_at_uts: 1_700_000_000
})
[enriched1, enriched2] = Enrichment.enrich_scrobbles([record1, record2])
assert enriched1.scrobble_count == 1
assert enriched2.scrobble_count == 1
end
end
describe "enrich_artist_country/1" do
test "adds artist_country when artist_info exists with country data" do
record = Records.record()
main_artist = hd(record.artists)
artist_mbid = main_artist.musicbrainz_id
# Create artist_info with area data (UK)
Records.artist_info(artist_mbid, %{
musicbrainz_data: %{
"name" => "Steven Wilson",
"area" => %{
"name" => "United Kingdom",
"iso-3166-1-codes" => ["GB"]
}
}
})
[enriched] = Enrichment.enrich_artist_country([record])
assert enriched.artist_country == %{name: "United Kingdom", code: "GB"}
end
test "returns nil when artist_info row does not exist" do
record = Records.record()
[enriched] = Enrichment.enrich_artist_country([record])
assert enriched.artist_country == nil
end
test "returns nil when artists list is empty" do
record = Records.record(%{artists: []})
[enriched] = Enrichment.enrich_artist_country([record])
assert enriched.artist_country == nil
end
test "reuses lookup for multiple records with same main artist" do
artist_name = "Pink Floyd"
record1 = Records.record_with_artist(artist_name)
record2 = Records.record_with_artist(artist_name)
artist_mbid = hd(record1.artists).musicbrainz_id
# Only one artist_info created, shared by both records
Records.artist_info(artist_mbid, %{
musicbrainz_data: %{
"name" => "Pink Floyd",
"area" => %{
"name" => "United Kingdom",
"iso-3166-1-codes" => ["GB"]
}
}
})
[enriched1, enriched2] = Enrichment.enrich_artist_country([record1, record2])
assert enriched1.artist_country == %{name: "United Kingdom", code: "GB"}
assert enriched2.artist_country == %{name: "United Kingdom", code: "GB"}
end
test "returns nil for record whose artist lacks an artist_infos row" do
artist_name = "Unknown Artist"
record = Records.record_with_artist(artist_name)
[enriched] = Enrichment.enrich_artist_country([record])
assert enriched.artist_country == nil
end
end
describe "enrich_selected_release/1" do
test "adds selected_release with all six fields when matching release exists" do
record = Records.record()
[enriched] = Enrichment.enrich_selected_release([record])
assert enriched.selected_release != nil
assert is_map(enriched.selected_release)
sr = enriched.selected_release
assert Map.has_key?(sr, :format)
assert Map.has_key?(sr, :date)
assert Map.has_key?(sr, :country)
assert Map.has_key?(sr, :catalog_number)
assert Map.has_key?(sr, :packaging)
assert Map.has_key?(sr, :disambiguation)
end
test "returns nil when selected_release_id is nil" do
record = Records.record(%{selected_release_id: nil})
[enriched] = Enrichment.enrich_selected_release([record])
assert enriched.selected_release == nil
end
test "returns nil when selected_release_id is empty string" do
record = Records.record(%{selected_release_id: ""})
[enriched] = Enrichment.enrich_selected_release([record])
assert enriched.selected_release == nil
end
test "returns nil when selected_release_id does not match any release" do
record = Records.record(%{selected_release_id: "nonexistent-release-id"})
[enriched] = Enrichment.enrich_selected_release([record])
assert enriched.selected_release == nil
end
test "multiple records enriched independently" do
record1 = Records.record()
record2 = Records.record(%{selected_release_id: nil})
[enriched1, enriched2] = Enrichment.enrich_selected_release([record1, record2])
assert enriched1.selected_release != nil
assert enriched2.selected_release == nil
end
end
describe "enrich/1" do
test "composes all three enrichments, adding all four fields" do
record = Records.record()
main_artist = hd(record.artists)
artist_mbid = main_artist.musicbrainz_id
Records.artist_info(artist_mbid, %{
musicbrainz_data: %{
"name" => "Steven Wilson",
"area" => %{
"name" => "United Kingdom",
"iso-3166-1-codes" => ["GB"]
}
}
})
[enriched] = Enrichment.enrich([record])
# All four new fields are present
assert Map.has_key?(enriched, :scrobble_count)
assert Map.has_key?(enriched, :last_listened_at)
assert Map.has_key?(enriched, :artist_country)
assert Map.has_key?(enriched, :selected_release)
# Original fields are preserved
assert enriched.id == record.id
assert enriched.title == record.title
assert enriched.musicbrainz_id == record.musicbrainz_id
assert enriched.artists == record.artists
end
test "handles empty list gracefully" do
assert Enrichment.enrich([]) == []
end
end
end
@@ -327,6 +327,17 @@ defmodule MusicLibraryWeb.CollectionControllerTest do
"large" => asset_url(record.cover_hash, 1000), "large" => asset_url(record.cover_hash, 1000),
"medium" => asset_url(record.cover_hash, 400), "medium" => asset_url(record.cover_hash, 400),
"small" => asset_url(record.cover_hash, 80) "small" => asset_url(record.cover_hash, 80)
},
"scrobble_count" => 0,
"last_listened_at" => nil,
"artist_country" => nil,
"selected_release" => %{
"catalog_number" => "",
"country" => "GB",
"date" => "2004-05-03",
"disambiguation" => "",
"format" => "multi",
"packaging" => "Jewel Case"
} }
} }
end end