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
+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 MusicLibrary.Collection
alias MusicLibrary.Collection.Enrichment
alias MusicLibrary.Records
alias MusicLibrary.ScrobbleActivity
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)
end
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)
end
@@ -25,7 +34,10 @@ defmodule MusicLibraryWeb.CollectionController do
date_string -> Date.from_iso8601!(date_string)
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)
end
@@ -37,7 +49,10 @@ defmodule MusicLibraryWeb.CollectionController do
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)
end
@@ -38,7 +38,11 @@ defmodule MusicLibraryWeb.CollectionJSON do
selected_release_id: record.selected_release_id,
artists: Enum.map(record.artists, & &1.name),
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