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.
Cover current delete behavior across LiveView index and show paths, and strengthen existing delete tests with persistence assertions.\n\nValidation: mise run dev:precommit
Add RecordSets.empty_record_set/1 that bulk-deletes all items in a set via
a single DELETE query and returns the reloaded empty set. Add Empty button
with confirmation prompt to both index and show view dropdowns.
Import workers now broadcast :records_index_changed on
"records:index_changed" after successful import via
Records.broadcast_index_changed/0.
CollectionLive.Index and WishlistLive.Index subscribe to the
topic in mount/3 and reload their record streams on receipt,
with a live_action guard to skip reloads when the grid is
hidden behind a modal (:import, :barcode_scan).
IndexActions.handle_index_changed/1 refreshes total_entries
before reloading to keep the pagination bar accurate.
Always wrap FTS5 query terms in phrase double-quotes instead of
checking a hardcoded list of special characters. Characters like
|, ;, +, #, @ were missing from the list, causing FTS5 syntax
errors (production errors #1 and #1398). Phrase-quoting every term
produces identical results for normal ASCII search (verified via
SQL) while safely handling any character FTS5 chokes on.
Add regression tests for bare special characters and special
characters mixed with normal words.
Three-layer architecture:
- Logster v2 handles HTTP request and LiveView socket telemetry in logfmt format
- Custom formatter as safety net escaping any remaining embedded newlines
- Config flag (single_line_logging) gating Logster attachment to prod only
Dev environment retains multi-line format for readability.
- Escape LIKE wildcards (% and _) in search queries using fragment ESCAPE
to prevent accidental pattern expansion and potential DoS vectors
- Use Repo.aggregate for occurrence_count instead of loading all
occurrences into memory in get_error/1
- Use Repo.aggregate(:min, :inserted_at) for first_occurrence_at instead
of fragile List.last/1 that depends on query ordering
- Add MusicLibrary.ErrorsTest with 15 tests covering list_errors/1
(default, status/muted/search filters, wildcard escaping, pagination,
empty results) and get_error/1 (with/without occurrences, not_found)
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.
Closes ML-143 review findings:
- cast_id/1 uses Integer.parse instead of String.to_integer
- add_to_cart validates against server-side release_groups
- JS.push payload trimmed to {id, format} only
- Oban.insert_all result checked for partial failures
- handle_async success resets @importing? to false
- ImportFromMusicbrainzReleaseGroup gets unique constraint
- Record.parse_datetime/1 made public with safe match
- Workers delegate to Record.parse_datetime/1
- Test coverage: change_format, clear_cart, wishlist single import
Map.has_key? verified the key existed but not the content. The
fixture is deterministic — assert the stored value equals
Wikipedia.Fixtures.article_extract_html/0.
Refs #176
Switch the 500-response stubs to Req.Test.json so Req decodes the
body, then match the map shape. Avoids coupling the assertion to
the exact JSON serialization.
Refs #176
Repo.delete always returns the struct it was given, so deleted.id
== original.id is trivially true. Thread deleted.id through the
trailing get_*! call instead, so the binding is load-bearing.
Refs #176
Pin the duplicate-rule error to the match_value field and "has already been
taken" message, matching the unique_constraint error_key in the changeset.
Refs #176
Pin invalid-attrs error to specific field, assert collection UUID round-trips
through Ecto.UUID.cast unchanged, and assert deleted.id == chat.id on delete.
Refs #176
Assert deleted.id == set.id on delete, and pin the duplicate error to the
specific field and message from the unique_constraint on record_set_id.
Refs #176
Bind returned embeddings and assert record_id / text_representation match.
Pin OpenAI error path to the actual raw string body returned by the stub.
Refs #176
Replace 9 `{:ok, _}` sites with specific response-body checks to verify
the context returns the Last.fm API payload and not just the success branch.
Refs #176