feat(tags): folksonomy tag enricher — pluggable provider chain (#1490 Step 2)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m46s

Milestone #160 Option 1, Step 2. New internal/tags package that enriches
the taste profile's tag facet beyond raw ID3 genre, built so adding a
source later is "implement TrackTagProvider + Register()" — no enricher,
settings, or schema change (operator directive).

Mirrors the internal/coverart pattern:
- Provider interface + package registry (Register/AllProviders/ByID);
  TrackTagProvider fetch capability + TestableProvider for the admin test.
- DB-backed SettingsService over new tag_provider_settings +
  tag_sources_meta (migration 0043) — enable/key/version, boot
  reconciliation, and a provider-hash bump that re-opens 'none' rows when
  the compiled-in provider set changes.
- Slim self-contained httpClient (rate-limit + retry + User-Agent), kept
  local so tag enrichment never depends on coverart internals.

Providers:
- MusicBrainz: keyless, default-ON, recording tags by MBID (rule #26 baseline).
- Last.fm: keyed, default-OFF, track.getTopTags by artist+track — opt-in
  once a key is supplied.

Enricher uses MERGE semantics (differs from coverart's first-success-wins
for a single image): unions tags across every enabled provider, caps to
top-K by weight, and stamps tag_source musicbrainz|lastfm|mixed|none.
Writes are transactional (atomic replace of track_tags).

Unit-tested without a DB: registry mechanics, provider JSON parsing +
weight normalization via httptest, and the pure merge/top-K/source-label
helpers. Wiring (startup + on-scan), integration tests, and the taste
union (Step 3) + Settings UI (Step 4) come next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 21:43:58 -04:00
parent d6ee5a304d
commit fbfd5550ff
15 changed files with 1795 additions and 0 deletions
@@ -0,0 +1,3 @@
-- Reverse 0043_tag_provider_settings.up.sql.
DROP TABLE IF EXISTS tag_sources_meta;
DROP TABLE IF EXISTS tag_provider_settings;
@@ -0,0 +1,37 @@
-- Tag-enrichment provider settings (milestone #160, #1490 Step 2).
--
-- Mirrors the cover-art provider-settings substrate (migration 0018) so
-- adding a new tag source later is "register a provider + it auto-upserts
-- a row here" — never a schema change. The tag enricher reads the enabled
-- set + api keys from tag_provider_settings and stamps the current version
-- from tag_sources_meta onto tracks.tag_sources_version (added in 0042).
--
-- current_version starts at 1 while tracks.tag_sources_version defaults to
-- 0 (migration 0042), so every existing track is "stale" relative to
-- current and gets enriched on the first pass after this migrates.
CREATE TABLE tag_provider_settings (
provider_id text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT true,
api_key text,
display_order int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tag_sources_meta (
id boolean PRIMARY KEY DEFAULT true,
current_version int NOT NULL DEFAULT 1,
last_registered_providers_hash text NOT NULL DEFAULT '',
CONSTRAINT tag_sources_meta_singleton CHECK (id = true)
);
INSERT INTO tag_sources_meta (id, current_version) VALUES (true, 1);
-- Seed the v1 providers. MusicBrainz is the keyless always-on default;
-- Last.fm ships disabled and is opt-in once the operator supplies a key
-- (Step 4 Settings UI), per the no-coercive-settings rule (#26). The
-- SettingsService also upserts these at boot, so the seed is belt-and-
-- suspenders / documents the intended defaults.
INSERT INTO tag_provider_settings (provider_id, enabled, display_order) VALUES
('musicbrainz', true, 0),
('lastfm', false, 1);