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
@@ -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),
"medium" => asset_url(record.cover_hash, 400),
"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