feat(tags): folksonomy tag enricher — pluggable provider chain (#1490 Step 2)
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:
@@ -532,6 +532,21 @@ type SystemPlaylistRun struct {
|
||||
LastError *string
|
||||
}
|
||||
|
||||
type TagProviderSetting struct {
|
||||
ProviderID string
|
||||
Enabled bool
|
||||
ApiKey *string
|
||||
DisplayOrder int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TagSourcesMetum struct {
|
||||
ID bool
|
||||
CurrentVersion int32
|
||||
LastRegisteredProvidersHash string
|
||||
}
|
||||
|
||||
type TasteProfileArtist struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: tag_settings.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const bumpTagSourcesVersion = `-- name: BumpTagSourcesVersion :one
|
||||
UPDATE tag_sources_meta
|
||||
SET current_version = current_version + 1
|
||||
WHERE id = true
|
||||
RETURNING current_version
|
||||
`
|
||||
|
||||
// Increment + return the new version. Called only when the enabled set
|
||||
// changes (not on key edits).
|
||||
func (q *Queries) BumpTagSourcesVersion(ctx context.Context) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, bumpTagSourcesVersion)
|
||||
var current_version int32
|
||||
err := row.Scan(¤t_version)
|
||||
return current_version, err
|
||||
}
|
||||
|
||||
const bumpTagVersionAndSetProvidersHash = `-- name: BumpTagVersionAndSetProvidersHash :one
|
||||
UPDATE tag_sources_meta
|
||||
SET current_version = current_version + 1,
|
||||
last_registered_providers_hash = $1
|
||||
WHERE id = true
|
||||
RETURNING current_version
|
||||
`
|
||||
|
||||
// Atomically increment the version and store the new registered-provider
|
||||
// hash. Called at boot when the compiled-in provider set changed, so
|
||||
// 'none' rows become eligible for a retry through the new chain.
|
||||
func (q *Queries) BumpTagVersionAndSetProvidersHash(ctx context.Context, lastRegisteredProvidersHash string) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, bumpTagVersionAndSetProvidersHash, lastRegisteredProvidersHash)
|
||||
var current_version int32
|
||||
err := row.Scan(¤t_version)
|
||||
return current_version, err
|
||||
}
|
||||
|
||||
const getCurrentTagSourcesVersion = `-- name: GetCurrentTagSourcesVersion :one
|
||||
SELECT current_version FROM tag_sources_meta WHERE id = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetCurrentTagSourcesVersion(ctx context.Context) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, getCurrentTagSourcesVersion)
|
||||
var current_version int32
|
||||
err := row.Scan(¤t_version)
|
||||
return current_version, err
|
||||
}
|
||||
|
||||
const getTagProvidersHash = `-- name: GetTagProvidersHash :one
|
||||
SELECT last_registered_providers_hash
|
||||
FROM tag_sources_meta
|
||||
WHERE id = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetTagProvidersHash(ctx context.Context) (string, error) {
|
||||
row := q.db.QueryRow(ctx, getTagProvidersHash)
|
||||
var last_registered_providers_hash string
|
||||
err := row.Scan(&last_registered_providers_hash)
|
||||
return last_registered_providers_hash, err
|
||||
}
|
||||
|
||||
const listTagProviderSettings = `-- name: ListTagProviderSettings :many
|
||||
|
||||
SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
|
||||
FROM tag_provider_settings
|
||||
ORDER BY display_order, provider_id
|
||||
`
|
||||
|
||||
// Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
|
||||
// The tags.SettingsService consumes these — parallel to the cover-art
|
||||
// SettingsService (coverart_settings.sql). Keeping the two independent
|
||||
// means a new tag source is added without touching the art settings.
|
||||
// All rows, for boot reconciliation and the admin GET handler.
|
||||
func (q *Queries) ListTagProviderSettings(ctx context.Context) ([]TagProviderSetting, error) {
|
||||
rows, err := q.db.Query(ctx, listTagProviderSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []TagProviderSetting
|
||||
for rows.Next() {
|
||||
var i TagProviderSetting
|
||||
if err := rows.Scan(
|
||||
&i.ProviderID,
|
||||
&i.Enabled,
|
||||
&i.ApiKey,
|
||||
&i.DisplayOrder,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateTagProviderSettings = `-- name: UpdateTagProviderSettings :exec
|
||||
UPDATE tag_provider_settings
|
||||
SET enabled = COALESCE($2, enabled),
|
||||
api_key = CASE WHEN $3::boolean THEN $4 ELSE api_key END,
|
||||
updated_at = now()
|
||||
WHERE provider_id = $1
|
||||
`
|
||||
|
||||
type UpdateTagProviderSettingsParams struct {
|
||||
ProviderID string
|
||||
Enabled bool
|
||||
Column3 bool
|
||||
ApiKey *string
|
||||
}
|
||||
|
||||
// Admin PATCH. $2 sets enabled. Pass FALSE for the $3 apiKeyChanged flag
|
||||
// to leave api_key untouched; TRUE with empty string clears it, TRUE with
|
||||
// a value sets it.
|
||||
func (q *Queries) UpdateTagProviderSettings(ctx context.Context, arg UpdateTagProviderSettingsParams) error {
|
||||
_, err := q.db.Exec(ctx, updateTagProviderSettings,
|
||||
arg.ProviderID,
|
||||
arg.Enabled,
|
||||
arg.Column3,
|
||||
arg.ApiKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertTagProviderSettings = `-- name: UpsertTagProviderSettings :exec
|
||||
INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (provider_id) DO UPDATE
|
||||
SET display_order = EXCLUDED.display_order,
|
||||
updated_at = now()
|
||||
`
|
||||
|
||||
type UpsertTagProviderSettingsParams struct {
|
||||
ProviderID string
|
||||
Enabled bool
|
||||
DisplayOrder int32
|
||||
}
|
||||
|
||||
// INSERT a default row for a newly-registered provider, or refresh an
|
||||
// existing row's display_order on boot. Does NOT overwrite enabled /
|
||||
// api_key on conflict — the operator's settings persist across restarts.
|
||||
func (q *Queries) UpsertTagProviderSettings(ctx context.Context, arg UpsertTagProviderSettingsParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertTagProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder)
|
||||
return err
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
|
||||
-- The tags.SettingsService consumes these — parallel to the cover-art
|
||||
-- SettingsService (coverart_settings.sql). Keeping the two independent
|
||||
-- means a new tag source is added without touching the art settings.
|
||||
|
||||
-- name: ListTagProviderSettings :many
|
||||
-- All rows, for boot reconciliation and the admin GET handler.
|
||||
SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
|
||||
FROM tag_provider_settings
|
||||
ORDER BY display_order, provider_id;
|
||||
|
||||
-- name: UpsertTagProviderSettings :exec
|
||||
-- INSERT a default row for a newly-registered provider, or refresh an
|
||||
-- existing row's display_order on boot. Does NOT overwrite enabled /
|
||||
-- api_key on conflict — the operator's settings persist across restarts.
|
||||
INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (provider_id) DO UPDATE
|
||||
SET display_order = EXCLUDED.display_order,
|
||||
updated_at = now();
|
||||
|
||||
-- name: UpdateTagProviderSettings :exec
|
||||
-- Admin PATCH. $2 sets enabled. Pass FALSE for the $3 apiKeyChanged flag
|
||||
-- to leave api_key untouched; TRUE with empty string clears it, TRUE with
|
||||
-- a value sets it.
|
||||
UPDATE tag_provider_settings
|
||||
SET enabled = COALESCE($2, enabled),
|
||||
api_key = CASE WHEN $3::boolean THEN $4 ELSE api_key END,
|
||||
updated_at = now()
|
||||
WHERE provider_id = $1;
|
||||
|
||||
-- name: GetCurrentTagSourcesVersion :one
|
||||
SELECT current_version FROM tag_sources_meta WHERE id = true;
|
||||
|
||||
-- name: BumpTagSourcesVersion :one
|
||||
-- Increment + return the new version. Called only when the enabled set
|
||||
-- changes (not on key edits).
|
||||
UPDATE tag_sources_meta
|
||||
SET current_version = current_version + 1
|
||||
WHERE id = true
|
||||
RETURNING current_version;
|
||||
|
||||
-- name: GetTagProvidersHash :one
|
||||
SELECT last_registered_providers_hash
|
||||
FROM tag_sources_meta
|
||||
WHERE id = true;
|
||||
|
||||
-- name: BumpTagVersionAndSetProvidersHash :one
|
||||
-- Atomically increment the version and store the new registered-provider
|
||||
-- hash. Called at boot when the compiled-in provider set changed, so
|
||||
-- 'none' rows become eligible for a retry through the new chain.
|
||||
UPDATE tag_sources_meta
|
||||
SET current_version = current_version + 1,
|
||||
last_registered_providers_hash = $1
|
||||
WHERE id = true
|
||||
RETURNING current_version;
|
||||
Reference in New Issue
Block a user