Files
music_library/docs/architecture.md
T

53 KiB
Raw Blame History

Architecture Summary

Maintenance rule: Update this file whenever you add, remove, or restructure modules, schemas, contexts, workers, routes, or external integrations. Keep descriptions factual and concise. This file exists to accelerate future iteration — treat it as a living map.

Overview

Phoenix LiveView application for managing a personal music collection and wishlist. Uses SQLite (via ecto_sqlite3) with three databases: one for the app, one for background jobs (Oban), and one for telemetry metrics. All schemas use binary_id primary keys.

Key capabilities:

  • Browse/search collected and wishlisted records
  • Import metadata from MusicBrainz, enrich with Discogs/Wikipedia/Last.fm
  • Scrobble tracks to Last.fm, import listening history
  • Similarity search via OpenAI embeddings + sqlite-vec
  • AI-powered record chat via OpenAI streaming with web search
  • Barcode scanning for quick imports
  • Encrypted secret storage (Cloak)
  • Presto companion display (MicroPython, 4" IPS LCD, "Records on this day")

Elixir 1.20.0-rc.5, Phoenix ~> 1.8, LiveView ~> 1.1, SQLite3


Supervision Tree

MusicLibrary.Application (one_for_one)
├── ErrorTracker.ErrorNotifier    # Telemetry-driven error email notifications
├── MusicLibrary.Vault           # Cloak encryption vault
├── MusicLibrary.Repo            # Main SQLite repo
├── MusicLibrary.BackgroundRepo  # Oban SQLite repo (separate DB)
├── MusicLibrary.TelemetryRepo   # Telemetry metrics SQLite repo
├── MusicLibraryWeb.Telemetry    # Telemetry supervisor
│   ├── Telemetry.Storage        # Buffered metrics storage (in-memory, 5s flush to SQLite, force-flush on read)
│   └── :telemetry_poller        # 30s periodic measurements
├── Phoenix.PubSub (:music_library)
├── Oban                         # Background job engine
├── Ecto.Migrator                # Migrations (skipped in release; run by Coolify post-deploy)
├── Task.Supervisor (MusicLibrary.TaskSupervisor)
└── MusicLibraryWeb.Endpoint

Logster v2 (production-only) is attached as a telemetry handler via application.ex when single_line_logging is true — not a supervised process.


Database & Repos

Repo DB file (dev) Purpose
MusicLibrary.Repo data/music_library_dev.db All application data
MusicLibrary.BackgroundRepo data/music_library_background_dev.db Oban job queue
MusicLibrary.TelemetryRepo data/music_library_telemetry_dev.db Telemetry metrics history (persistent across restarts)

SQLite extensions loaded at runtime: unicode, vec0 (vector search).

FTS5 virtual table records_search_index auto-synced via database triggers — do not write to it directly; insert/update the records table instead.


Schemas

Schema Table PK Key Fields
Records.Record records id (binary_id) title, type, format, cover_url, cover_hash, musicbrainz_id, genres[], release_date, purchased_at, dominant_colors[], embeds_many :artists
Records.RecordEmbedding record_embeddings id embedding (Float32 vector), text_representation, belongs_to :record
Records.SearchIndex records_search_index id FTS5 mirror of records (virtual, trigger-synced)
Records.RecordRelease record_releases none record_id, release_id, cover_hash, purchased_at — read-only, no PK
Records.ArtistRecord artist_records composite musicbrainz_id, record_id — DB view joining artists to records
Artists.Artist Embedded schema (name, sort_name, musicbrainz_id, joinphrase)
Artists.ArtistInfo artist_infos id musicbrainz_data, discogs_data, wikipedia_data, lastfm_data, image_data_hash
Assets.Asset assets hash (SHA256) content (binary), format, properties (map)
Notes.Note notes id entity (:record/:artist), content, musicbrainz_id
RecordSets.RecordSet record_sets id name, description, has_many :items
RecordSets.RecordSetItem record_set_items id position, belongs_to :record_set, belongs_to :record
ScrobbleRules.ScrobbleRule scrobble_rules id (integer) type (:album/:artist), match_value, target_musicbrainz_id, enabled
OnlineStoreTemplates.OnlineStoreTemplate online_store_templates id name, url_template, enabled
Secrets.Secret secrets name (string) value (encrypted binary)
Chats.Chat chats id (binary_id) entity (:record/:artist/:collection), musicbrainz_id, topic, has_many :messages
Chats.Message chat_messages id (binary_id) role, content, position, belongs_to :chat

Last.fm schemas (separate, not Ecto-persisted to main DB):

  • LastFm.Track — scrobbled tracks from Last.fm API responses
  • LastFm.Album, LastFm.Artist — parsed API responses

Contexts (lib/music_library/)

Context Schemas Responsibility
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
Wishlist Record (via SearchIndex) Querying wishlisted records (purchased_at is nil)
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
Notes Note Free-text notes for records and artists
Chats Chat, Message, StreamProvider, RecordChat, ArtistChat, CollectionChat Persistent AI chat conversations for records, artists, and the collection, streaming AI chat behaviour and entity-specific implementations
RecordSets RecordSet, RecordSetItem User-curated record groupings with ordering
ScrobbleRules ScrobbleRule Rules to remap Last.fm scrobble data to correct MusicBrainz IDs; searchable by match_value/target/description, orderable by alphabetical or inserted_at
ScrobbleActivity Scrobbling releases/media/tracks to Last.fm
ListeningStats (LastFm.Track, RecordRelease, ArtistRecord, ArtistInfo) Scrobble persistence, refresh scheduling, listening analytics, track CRUD, search, listing: scrobble counts, artist play counts (from DB), recent activity, top albums/artists by period
OnlineStoreTemplates OnlineStoreTemplate URL templates for buying records online; searchable by name/description
Errors ErrorTracker.Error, ErrorTracker.Occurrence Queries and mutations for production error data tracked by ErrorTracker; filtered listing with pagination, single error with preloaded occurrences and computed counts, plus mute_error/1, unmute_error/1, resolve_error/1, unresolve_error/1 for mutating error state. Muting an error suppresses future email notifications via ErrorTracker.ErrorNotifier.
Search (cross-context) Universal search dispatcher across collection, wishlist, artists, record sets (delegates to domain contexts)
Secrets Secret Encrypted key-value storage (CRUD + delete)
BarcodeScan (Result struct) Barcode → MusicBrainz lookup workflow, async batch import for multiple new records
Maintenance (Oban.Job, LastFm.Track) Background job monitoring, database vacuum/optimize, scrobble data quality diagnostics

Business Logic Modules

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
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.TracklistPdf Generates 120mm×120mm PDF tracklist from record + release data (Typst)
Batch Generic batch runner: stream + transaction + error accumulation
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)
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.SystemClock Real clock implementation using System.monotonic_time
Assets.Cache ETS-based asset cache with TTL (7-day TTL, TTL-only invalidation since assets are content-addressable and immutable)
Assets.Image / Assets.Transform Image processing via Vix (libvips)
Colors.Extractor Behaviour for dominant color extraction (configurable, allows test stubbing)
Colors.KMeansExtractor Color extraction via K-Means clustering (dominant_colors library), implements Colors.Extractor
Chats.StreamProvider Behaviour for streaming AI chat (stream_response/3 callback)
Chats.RecordChat Chat implementation for records (OpenAI streaming, web search enabled)
Chats.ArtistChat Chat implementation for artists (OpenAI streaming, uses Wikipedia/artist context)
Chats.CollectionChat Chat implementation for the collection (OpenAI streaming via gpt-5.1, uses collection summary as context)
Chats.Prompt Builds complete chat prompts by interpolating identity, content, and approach guidelines
Country Country code (alpha-2, alpha-3, subdivision, IETF) to flag emoji conversion
ErrorTracker.ErrorNotifier GenServer: attaches to ErrorTracker telemetry, skips muted errors, throttles repeated errors, dispatches email notifications
ErrorTracker.ErrorNotifier.Email Builds and sends Swoosh error notification emails with stack trace formatting
ErrorIgnorer ErrorTracker.Ignorer implementation: filters non-actionable errors (e.g., NoRouteError from bot scanners)
MusicLibrary.ErrorResponse Behaviour for structured API error responses (retryable?/1, retry_delay_seconds/1) — per-API ErrorResponse modules implement this
MusicLibrary.HttpError Default HTTP status → kind mapping (:rate_limit, :server_error, :timeout, :auth_error, :not_found, :client_error, :unknown) used as baseline by per-API ErrorResponse modules
MusicLibrary.RetryDelay Parses and clamps provider retry/reset headers into Oban snooze delays for structured API errors
MusicLibrary.Worker.ErrorHandler Translates per-API ErrorResponse structs into Oban tuples — {:snooze, seconds} for retryable, {:cancel, reason} for permanent
MusicLibraryWeb.RecordsOnThisDayEmail Builds and sends daily "records on this day" email with cover images, anniversary styling
MusicLibrary.Mailer Swoosh mailer (Mailgun in prod, local adapter in dev)
FormatNumber Number formatting utility
QueryReporter Dev-only Ecto telemetry reporter: captures executed SQL to a log file with interpolated params, source locations, and timing. Activated at runtime via start/1 / stop/0
MusicLibrary.Logger.SingleLineFormatter Production-only Logger.Formatter safety net: replaces embedded newlines (\n) with escaped \\n in all log messages, ensuring every physical log line is exactly one log event

External API Integrations

Module API Rate limit Purpose
MusicBrainz / MusicBrainz.API musicbrainz.org 1000 ms Release/artist metadata, search
LastFm / LastFm.API last.fm 500 ms Scrobbling, listening history, artist info (tags, similar artists), user profile/session validation
Discogs / Discogs.API discogs.com 2000 ms Artist profiles, images
Wikipedia / Wikipedia.API wikipedia.org 1000 ms Artist biographies
BraveSearch / BraveSearch.API search.brave.com 1000 ms Cover art and artist image search
OpenAI / OpenAI.API api.openai.com 250 ms Text embeddings for similarity, streaming chat via Responses API (gpt-4.1/gpt-5.1 + web search)
MusicLibrary.Mailer Mailgun (via Swoosh) Transactional email delivery (error notifications, daily digest)

Each has a Config module reading from application env. All HTTP clients use Req with per-API rate limiting (Req.RateLimiter, ETS-backed). In tests, all HTTP calls are stubbed via Req.Test (configured in config/test.exs).

Each API also has an API.ErrorResponse module (e.g. MusicBrainz.API.ErrorResponse, OpenAI.API.ErrorResponse) implementing the MusicLibrary.ErrorResponse behaviour, so workers can uniformly classify HTTP failures as transient (snooze) or permanent (cancel) via MusicLibrary.Worker.ErrorHandler. Retry/reset headers are parsed into clamped snooze delays when providers expose them. Per-API overrides capture API-specific quirks — e.g. MusicBrainz uses HTTP 503 as the rate-limit signal, and OpenAI splits HTTP 429 into :rate_limit vs :auth_error by reading the body code (insufficient_quota → permanent).


Oban Workers (lib/music_library/worker/)

Queues

Queue Concurrency Purpose
default 10 General async tasks
heavy_writes 1 DB-intensive or serialized operations
music_brainz 1 MusicBrainz calls (rate-limited at Req layer via Req.RateLimiter)
discogs 1 Discogs calls (rate-limited at Req layer via Req.RateLimiter)
wikipedia 1 Wikipedia calls
last_fm 1 Last.fm calls (rate-limited at Req layer via Req.RateLimiter)

Plugins (prod)

Plugin Config Purpose
Oban.Plugins.Pruner max_age: 604800 (7 days) Prune completed/cancelled/discarded jobs older than 7 days
Oban.Plugins.Reindexer schedule: "@weekly" Weekly reindex of Oban tables for query performance
Oban.Plugins.Cron timezone: "Europe/London" Scheduled recurring workers (see Cron Workers table)

On-Demand Workers

Worker Queue Trigger
FetchArtistInfo default Artist page visit / import (also fetches Last.fm data inline)
FetchArtistLastFmData last_fm Manual / batch
FetchArtistImage heavy_writes Artist info fetched
RefreshCover heavy_writes Manual action / import
ImportFromMusicbrainzRelease music_brainz Barcode scan batch import (2+ new records)
ImportFromMusicbrainzReleaseGroup music_brainz Cart-style multi-record import in AddRecord component (2+ records selected)
PopulateGenres heavy_writes Manual action (chains → GenerateRecordEmbedding)
GenerateRecordEmbedding heavy_writes Manual / after genre population (delegates to Similarity, skips unchanged)
RecordRefreshMusicBrainzData music_brainz Manual / batch
ArtistRefreshMusicBrainzData music_brainz Manual / batch
ArtistRefreshDiscogsData discogs Manual / batch
ArtistRefreshWikipediaData wikipedia Manual / batch
PruneArtistInfo default Record deleted (cleanup orphaned artist data)
RecordRefreshAllMusicBrainzData music_brainz Manual / cron (bulk refresh via Records.Batch)
RecordGenerateAllEmbeddings heavy_writes Manual / cron (bulk generate via Records.Batch)
ArtistRefreshAllMusicBrainzData music_brainz Manual / cron (bulk refresh via Artists.Batch)
ArtistRefreshAllDiscogsData discogs Manual / cron (bulk refresh via Artists.Batch)
ArtistRefreshAllWikipediaData wikipedia Manual / cron (bulk refresh via Artists.Batch)
RefreshScrobbles last_fm Cron / manual (fetch recent Last.fm scrobbles)
BackfillScrobbledTracks heavy_writes Manual (self-chaining batch import)
SendRecordsOnThisDayEmail default Cron (daily "records on this day" email)

Cron Workers

Schedule Worker Queue
Every 12h ApplyScrobbleRules heavy_writes
Every 12h PruneAssetCache default
Daily 2 AM PruneAssets default
Daily 3 AM RepoVacuum heavy_writes
Daily 4 AM RepoOptimize heavy_writes
Monthly 1st, 6 AM RecordRefreshAllMusicBrainzData music_brainz
Monthly 1st, 7 AM RecordGenerateAllEmbeddings heavy_writes
Monthly 1st, 8 AM ArtistRefreshAllMusicBrainzData music_brainz
Monthly 1st, 9 AM ArtistRefreshAllDiscogsData discogs
Monthly 1st, 10 AM ArtistRefreshAllWikipediaData wikipedia
Daily 7 AM SendRecordsOnThisDayEmail default
Every 5 min RefreshScrobbles last_fm

PubSub Topics

PubSub Topic Pattern Message Used By
:music_library "records:#{id}" {:update, record} CollectionLive.Show, WishlistLive.Show — subscribe in handle_params, unsubscribe on navigation
:music_library "listening_stats:update" %{track_count: n} StatsLive.Index, ScrobbledTracksLive.Index — new scrobbles arrived

Web Layer (lib/music_library_web/)

Router Structure

All authenticated routes live inside a single live_session with three on_mount hooks:

  • StaticAssets — detects app updates, shows toast
  • GetTimezone — reads timezone from connect params
  • ShowToast — enables put_toast!/2 in LiveViews

LiveViews

LiveView Route Purpose
StatsLive.Index / Dashboard: counts, recent activity, records on this day
CollectionLive.Index /collection Browse/search collected records (grid/list, paginated)
CollectionLive.Show /collection/:id Record detail: metadata, scrobbles, similar, colors
WishlistLive.Index /wishlist Browse/search wishlisted records
WishlistLive.Show /wishlist/:id Wishlist record detail with store links
ArtistLive.Show /artists/:musicbrainz_id Artist bio, discography, similar artists
RecordSetLive.Index /record-sets Browse/manage curated record sets
RecordSetLive.Show /record-sets/:id Set detail with reorderable items
ScrobbleLive.Index /scrobble Search MusicBrainz release groups to scrobble
ScrobbleLive.ReleaseGroupShow /scrobble/:rg_id List releases within a release group
ScrobbleLive.ReleaseShow /scrobble/:rg_id/releases/:release_id Select tracks and scrobble (uses Release live_component)
ScrobbledTracksLive.Index /scrobbled-tracks Browse/search Last.fm history
ScrobbleRulesLive.Index /scrobble-rules Browse/search/sort scrobble remapping rules (paginated, 50 per page)
OnlineStoreTemplateLive.Index /online-store-templates Manage store URL templates
MaintenanceLive.Index /maintenance Admin: batch jobs, DB maintenance, Last.fm connection

LiveComponents

Component Used In Purpose
RecordForm Collection/Wishlist (edit) Record editing: cover search, genre autocomplete, color picker, file upload
ArtistLive.Form ArtistLive.Show Edit artist image (upload + Brave image search)
RecordSetLive.Form RecordSetLive.Index Create/edit record set
RecordSetLive.RecordPicker RecordSetLive.Show Search and add records to set
ScrobbledTracksLive.Form ScrobbledTracksLive.Index Edit scrobbled track
ScrobbleRulesLive.Form ScrobbleRulesLive.Index Create/edit scrobble rule
ScrobbleRulePicker ScrobbledTracksLive.Index, StatsLive.Index Search records and create scrobble rules inline
OnlineStoreTemplateLive.Form OnlineStoreTemplateLive.Index Create/edit store template
StatsLive.TopByPeriod StatsLive.TopAlbums, StatsLive.TopArtists Generic period-tabbed stats display (7d, 30d, 90d, 1y, all-time)
StatsLive.TopAlbums StatsLive.Index Top albums by period (uses TopByPeriod)
StatsLive.TopArtists StatsLive.Index Top artists by period (uses TopByPeriod)
UniversalSearchLive.Index Layout (global) Cmd+K search modal with quick actions (add to wishlist/collection, scrobble, collection chat)
Chat CollectionLive.Index, CollectionLive.Show, WishlistLive.Show, ArtistLive.Show AI chat sheet (OpenAI streaming, configurable per entity)
Notes CollectionLive.Show, WishlistLive.Show, ArtistLive.Show Markdown note rendering and editing
AddRecord CollectionLive.Index, WishlistLive.Index MusicBrainz import interface
BarcodeScanner CollectionLive.Index Barcode scanning UI (uses barcode-detector JS)
Release CollectionLive.Show, ScrobbleLive.ReleaseShow MusicBrainz release display with scrobble (form-based with auto-recovery)

Shared Component Modules (lib/music_library_web/components/)

Module Purpose
CoreComponents Forms, buttons, icons, tables, flash messages
Layouts Application layout templates, navigation components (dropdown_nav/1)
RecordComponents Record cards, cover images, artist images, labels, grids, release status icon badge, shared show-page sections (title, external links, genres, releases, timestamps, debug)
ChartComponents Charts for stats dashboard
StatsComponents Stats dashboard widgets (section/1 layout, counters, album preview, records on this day)
ScrobbleComponents Scrobble activity displays: status badges, import dropdowns, metadata tooltips, and record-matching UI
SearchComponents Search result rendering
CartComponents cart_sidebar/1 — shared cart aside used by AddRecord and BarcodeScanner
Pagination Pagination UI and logic

Web Utility Modules (lib/music_library_web/)

Module Purpose
ErrorMessages Maps internal error terms (atoms, structs) to user-friendly gettext strings via friendly_message/1
Markdown Markdown-to-HTML conversion (MDEx with ammonia sanitization) with [[double bracket]] link syntax and streaming document support for chat
Duration Milliseconds to human-readable duration formatting
Auth Authentication plugs: login password check, API token validation, session enforcement
ArtistLive.Biography Artist biography building/rendering from Wikipedia and Last.fm data
LiveHelpers.Params URL query param parsing: pagination, search query, sort order, display mode, fallback index
LiveHelpers.IndexActions Shared index page logic (search, pagination, import, delete, display mode) for Collection/Wishlist index pages, parameterized by config map
LiveHelpers.RecordActions Shared record action handlers (refresh cover, genres, embeddings, MusicBrainz data) for Collection/Wishlist show pages
Logster v2 handles [:phoenix, :socket_connected] telemetry — not a custom module

Controllers

Controller Routes Purpose
SessionController /login, /sessions/create Login/logout
HealthController /health Health check
LastFmController /auth/last_fm/callback Last.fm OAuth
ArchiveController /backup, /api/v1/backup Database backup download (API route requires token)
AssetController /assets/:transform_payload, /public/assets/:transform_payload, /api/v1/assets/:transform_payload Serve images with transforms (public route for emails, API route requires token)
CollectionController /api/v1/collection/*, POST /api/v1/collection/:record_id/scrobble JSON API for collection queries (search with ?q=, returns 4 cover sizes), and scrobbling records from Presto
ErrorController /api/v1/errors, /api/v1/errors/:id, /api/v1/errors/:id/mute, /api/v1/errors/:id/unmute, /api/v1/errors/:id/resolve, /api/v1/errors/:id/unresolve JSON API for production error queries and mutations (requires Bearer token); POST endpoints for mute/unmute/resolve/unresolve

Frontend (assets/)

  • Bundler: esbuild
  • CSS: Tailwind CSS + Fluxon UI component library
  • JS entry: assets/js/app.js

JS Hooks

Hook Type Purpose
FormatNumber External (assets/js/hooks/) Client-side number formatting
UniversalSearchNavigation External Keyboard navigation in search modal (via create-navigation-hook factory)
RecordPickerNavigation External Keyboard navigation in record picker (via create-navigation-hook factory)
RulePickerNavigation External Keyboard navigation in scrobble rule picker (via create-navigation-hook factory)
SortableList External (assets/js/hooks/) Drag-and-drop reordering of record set items (uses sortablejs)
LiveToast External (via createLiveToastHook) Toast notification rendering
Various .ColocatedHooks Colocated (in .heex) Inline hooks prefixed with . (includes .ScrollBottom for Chat)

JS Event Listeners (app.js)

All events are namespaced with music_library: prefix.

Event Action
music_library:clipcopy Copy text to clipboard
music_library:scroll_top Scroll window to top
music_library:confetti Trigger canvas-confetti animation
music_library:download Decode base64 blob and trigger browser file download (dispatched via push_event, prefixed phx: on client)

NPM Dependencies

  • barcode-detector — Barcode scanning API
  • canvas-confetti — Confetti animation
  • sortablejs — Drag-and-drop list reordering
  • live_toast — Toast notifications (local dep)
  • @tailwindcss/typography (dev) — Prose CSS classes for markdown rendering

Testing Patterns

Test Support

Module Purpose
ConnCase HTTP test setup, auto-logged-in session
DataCase Database test setup with Ecto sandbox
LiveTestHelpers escape/1 for HTML-escaped text assertions

Fixture Modules (test/support/fixtures/)

Module Creates
MusicLibrary.RecordsFixtures Records with MusicBrainz data
MusicLibrary.RecordSetsFixtures Record sets with items
MusicLibrary.OnlineStoreTemplatesFixtures Store templates
MusicLibrary.ArtistInfoFixtures ArtistInfo records
ScrobbleRulesFixtures Scrobble rules
ScrobbledTracksFixtures Last.fm tracks
Discogs.ArtistFixtures Discogs API responses
LastFm.ArtistFixtures Last.fm API responses
MusicBrainz.*Fixtures MusicBrainz API responses
Wikipedia.Fixtures Wikipedia API responses

Test Styles

  1. PhoenixTest (visit, assert_has, click_button, click_link) — used for page-level LiveView tests (Collection, Wishlist, Stats, etc.)
  2. Phoenix.LiveViewTest (live/2, form/3, render_submit/1, element/2, render_click/1) — needed for LiveComponent interactions (phx-target={@myself})
  3. Context tests — standard ExUnit with DataCase

SQLite Test Gotchas

  • utc_datetime has second-level precision — rapid inserts get identical timestamps
  • Use Repo.update_all to manually set timestamps for deterministic ordering tests
  • External APIs stubbed via Req.Test (see config/test.exs)
  • Oban runs in manual testing mode (jobs don't auto-execute)

Key Conventions

  • Streams everywhere: Collection/Wishlist lists, recent activity, record sets, scrobbled tracks all use LiveView streams (not assigns) for memory efficiency
  • Async enrichment: Record/artist import triggers a cascade of Oban workers for metadata, covers, colors, embeddings — all non-blocking
  • Search: FTS5 for text search, custom SearchParser for structured queries, sqlite-vec for similarity
  • Record lifecycle: purchased_at distinguishes collection (set) from wishlist (nil)
  • use MusicLibraryWeb, :live_view imports: Phoenix.LiveView, Fluxon, Gettext, LiveToast, CoreComponents, Phoenix.HTML, verified routes, JS alias
  • use MusicLibraryWeb, :live_component additionally imports put_toast!/2
  • Telemetry buffering: Telemetry.Storage keeps incoming datapoints in an in-memory map keyed by metric and flushes to SQLite on a 5 s timer, on shutdown, and synchronously when metrics_history/1 is called (only for the requested metric). Keeps the cast path O(1) and the dashboard read path consistent.