From 20a76f4b399f01264379d8c7b9c32f2378e7c2e9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 18:46:49 -0400 Subject: [PATCH] feat(taste): track_tags schema + enrichment queries (Opt 1 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of taste-profile fidelity via metadata enrichment (milestone #160, task #1490) — no ML sidecar, operator's constraint. The taste profile's tag facet is built purely from raw ID3 tracks.genre (splitGenres in internal/taste/profile.go). This lands the data layer for enriching it with track-level folksonomy tags: - track_tags(track_id, tag, weight) — a global cache of style/mood tags, top-K per track, weight = normalized folksonomy strength [0,1]. - tracks.tag_source / tag_sources_version — versioned enrichment bookkeeping mirroring artists.artist_art_source (NULL = eligible, provider name = found, 'none' = settled, version bump = re-process). - Queries: ListTracksMissingTags (batch drainer), DeleteTrackTags + InsertTrackTag (atomic per-track replace), SetTrackTagSource, and ListPlayed/LikedTrackTagsForUser for the recompute to union enriched tags into the tag facet alongside genre. No consumer yet — the enricher (MusicBrainz + Last.fm providers, track-level) and the taste-recompute integration land next. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/dbq/track_tags.sql.go | 185 ++++++++++++++++++ .../db/migrations/0042_track_tags.down.sql | 5 + internal/db/migrations/0042_track_tags.up.sql | 26 +++ internal/db/queries/track_tags.sql | 57 ++++++ 4 files changed, 273 insertions(+) create mode 100644 internal/db/dbq/track_tags.sql.go create mode 100644 internal/db/migrations/0042_track_tags.down.sql create mode 100644 internal/db/migrations/0042_track_tags.up.sql create mode 100644 internal/db/queries/track_tags.sql diff --git a/internal/db/dbq/track_tags.sql.go b/internal/db/dbq/track_tags.sql.go new file mode 100644 index 00000000..c0a4fbc5 --- /dev/null +++ b/internal/db/dbq/track_tags.sql.go @@ -0,0 +1,185 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: track_tags.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteTrackTags = `-- name: DeleteTrackTags :exec +DELETE FROM track_tags WHERE track_id = $1 +` + +// Clear a track's cached tags before rewriting (atomic replace by the caller). +func (q *Queries) DeleteTrackTags(ctx context.Context, trackID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTrackTags, trackID) + return err +} + +const insertTrackTag = `-- name: InsertTrackTag :exec +INSERT INTO track_tags (track_id, tag, weight) +VALUES ($1, $2, $3) +ON CONFLICT (track_id, tag) + DO UPDATE SET weight = GREATEST(track_tags.weight, EXCLUDED.weight) +` + +type InsertTrackTagParams struct { + TrackID pgtype.UUID + Tag string + Weight float64 +} + +// Upsert one (track, tag); keep the stronger weight when two providers +// agree on a tag with different folksonomy strengths. +func (q *Queries) InsertTrackTag(ctx context.Context, arg InsertTrackTagParams) error { + _, err := q.db.Exec(ctx, insertTrackTag, arg.TrackID, arg.Tag, arg.Weight) + return err +} + +const listLikedTrackTagsForUser = `-- name: ListLikedTrackTagsForUser :many +SELECT tt.track_id, tt.tag, tt.weight + FROM track_tags tt + JOIN general_likes gl ON gl.track_id = tt.track_id + WHERE gl.user_id = $1 +` + +// Enriched tags for the user's liked tracks — the like-bonus path in the +// recompute mirrors the play path, so it needs the same tag lookup. +func (q *Queries) ListLikedTrackTagsForUser(ctx context.Context, userID pgtype.UUID) ([]TrackTag, error) { + rows, err := q.db.Query(ctx, listLikedTrackTagsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TrackTag + for rows.Next() { + var i TrackTag + if err := rows.Scan(&i.TrackID, &i.Tag, &i.Weight); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlayedTrackTagsForUser = `-- name: ListPlayedTrackTagsForUser :many +SELECT tt.track_id, tt.tag, tt.weight + FROM track_tags tt + WHERE tt.track_id IN ( + SELECT DISTINCT pe.track_id + FROM play_events pe + WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND pe.started_at > now() - make_interval(days => $2::int) + ) +` + +type ListPlayedTrackTagsForUserParams struct { + UserID pgtype.UUID + Column2 int32 +} + +// The enriched tags for every track this user played inside the taste +// window, so the recompute can union them into the tag facet alongside +// ID3 genre. $1 = user_id, $2 = window_days. One row per (track, tag). +func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayedTrackTagsForUserParams) ([]TrackTag, error) { + rows, err := q.db.Query(ctx, listPlayedTrackTagsForUser, arg.UserID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TrackTag + for rows.Next() { + var i TrackTag + if err := rows.Scan(&i.TrackID, &i.Tag, &i.Weight); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTracksMissingTags = `-- name: ListTracksMissingTags :many + +SELECT t.id, t.mbid, t.title, a.name AS artist_name + FROM tracks t + JOIN artists a ON a.id = t.artist_id + WHERE t.tag_source IS NULL + OR (t.tag_source = 'none' AND t.tag_sources_version < $1) + ORDER BY t.id + LIMIT $2 +` + +type ListTracksMissingTagsParams struct { + TagSourcesVersion int32 + Limit int32 +} + +type ListTracksMissingTagsRow struct { + ID pgtype.UUID + Mbid *string + Title string + ArtistName string +} + +// Track-tag enrichment queries (milestone #160, #1490). The tag enricher +// drains ListTracksMissingTags, fetches from the provider chain, and +// writes the merged top-K via DeleteTrackTags + InsertTrackTag, then +// stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape. +// Tracks eligible for tag enrichment: never processed (tag_source NULL) +// or previously settled 'none' under an older provider version. Returns +// the fields the provider chain needs — recording MBID (nullable) for +// keyed lookups, plus title + artist name for name-based fallback. +// $1 = current tag_sources_version, $2 = limit. +func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) { + rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTracksMissingTagsRow + for rows.Next() { + var i ListTracksMissingTagsRow + if err := rows.Scan( + &i.ID, + &i.Mbid, + &i.Title, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setTrackTagSource = `-- name: SetTrackTagSource :exec +UPDATE tracks SET tag_source = $2, tag_sources_version = $3 WHERE id = $1 +` + +type SetTrackTagSourceParams struct { + ID pgtype.UUID + TagSource *string + TagSourcesVersion int32 +} + +// Stamp the enrichment outcome so the batch drainer skips settled rows. +// $2 = 'lastfm' | 'musicbrainz' | 'mixed' | 'none', $3 = current version. +func (q *Queries) SetTrackTagSource(ctx context.Context, arg SetTrackTagSourceParams) error { + _, err := q.db.Exec(ctx, setTrackTagSource, arg.ID, arg.TagSource, arg.TagSourcesVersion) + return err +} diff --git a/internal/db/migrations/0042_track_tags.down.sql b/internal/db/migrations/0042_track_tags.down.sql new file mode 100644 index 00000000..e60b8a3d --- /dev/null +++ b/internal/db/migrations/0042_track_tags.down.sql @@ -0,0 +1,5 @@ +-- Reverse 0042_track_tags.up.sql. +DROP INDEX IF EXISTS tracks_tag_source_idx; +ALTER TABLE tracks DROP COLUMN IF EXISTS tag_sources_version; +ALTER TABLE tracks DROP COLUMN IF EXISTS tag_source; +DROP TABLE IF EXISTS track_tags; diff --git a/internal/db/migrations/0042_track_tags.up.sql b/internal/db/migrations/0042_track_tags.up.sql new file mode 100644 index 00000000..212da925 --- /dev/null +++ b/internal/db/migrations/0042_track_tags.up.sql @@ -0,0 +1,26 @@ +-- Track-level folksonomy tags for taste enrichment (milestone #160, #1490). +-- +-- A global (not per-user) cache of style/mood tags fetched from +-- MusicBrainz (keyless) + Last.fm (when a key is configured), keyed by +-- track. Feeds the taste recompute's tag facet alongside raw ID3 genre, +-- lifting resolution from coarse "Rock" to "post-punk / shoegaze / +-- melancholic". The enricher caps to the top-K tags per track at write +-- time so a heavily-tagged track can't dominate the profile. weight is a +-- normalized folksonomy strength in [0,1]. Mirrors the coverart +-- enricher's versioned source-tracking pattern. +CREATE TABLE track_tags ( + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + tag text NOT NULL, + weight double precision NOT NULL DEFAULT 1, + PRIMARY KEY (track_id, tag) +); +CREATE INDEX track_tags_track_idx ON track_tags (track_id); + +-- Enrichment bookkeeping (mirror artists.artist_art_source): +-- tag_source NULL → not yet processed (eligible) +-- tag_source 'lastfm' / 'musicbrainz' / 'mixed' → found, cached +-- tag_source 'none' → providers returned nothing +-- tag_sources_version → bump to re-process none/stale +ALTER TABLE tracks ADD COLUMN tag_source text; +ALTER TABLE tracks ADD COLUMN tag_sources_version integer NOT NULL DEFAULT 0; +CREATE INDEX tracks_tag_source_idx ON tracks (tag_source, tag_sources_version); diff --git a/internal/db/queries/track_tags.sql b/internal/db/queries/track_tags.sql new file mode 100644 index 00000000..c5557fc3 --- /dev/null +++ b/internal/db/queries/track_tags.sql @@ -0,0 +1,57 @@ +-- Track-tag enrichment queries (milestone #160, #1490). The tag enricher +-- drains ListTracksMissingTags, fetches from the provider chain, and +-- writes the merged top-K via DeleteTrackTags + InsertTrackTag, then +-- stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape. + +-- name: ListTracksMissingTags :many +-- Tracks eligible for tag enrichment: never processed (tag_source NULL) +-- or previously settled 'none' under an older provider version. Returns +-- the fields the provider chain needs — recording MBID (nullable) for +-- keyed lookups, plus title + artist name for name-based fallback. +-- $1 = current tag_sources_version, $2 = limit. +SELECT t.id, t.mbid, t.title, a.name AS artist_name + FROM tracks t + JOIN artists a ON a.id = t.artist_id + WHERE t.tag_source IS NULL + OR (t.tag_source = 'none' AND t.tag_sources_version < $1) + ORDER BY t.id + LIMIT $2; + +-- name: DeleteTrackTags :exec +-- Clear a track's cached tags before rewriting (atomic replace by the caller). +DELETE FROM track_tags WHERE track_id = $1; + +-- name: InsertTrackTag :exec +-- Upsert one (track, tag); keep the stronger weight when two providers +-- agree on a tag with different folksonomy strengths. +INSERT INTO track_tags (track_id, tag, weight) +VALUES ($1, $2, $3) +ON CONFLICT (track_id, tag) + DO UPDATE SET weight = GREATEST(track_tags.weight, EXCLUDED.weight); + +-- name: SetTrackTagSource :exec +-- Stamp the enrichment outcome so the batch drainer skips settled rows. +-- $2 = 'lastfm' | 'musicbrainz' | 'mixed' | 'none', $3 = current version. +UPDATE tracks SET tag_source = $2, tag_sources_version = $3 WHERE id = $1; + +-- name: ListPlayedTrackTagsForUser :many +-- The enriched tags for every track this user played inside the taste +-- window, so the recompute can union them into the tag facet alongside +-- ID3 genre. $1 = user_id, $2 = window_days. One row per (track, tag). +SELECT tt.track_id, tt.tag, tt.weight + FROM track_tags tt + WHERE tt.track_id IN ( + SELECT DISTINCT pe.track_id + FROM play_events pe + WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND pe.started_at > now() - make_interval(days => $2::int) + ); + +-- name: ListLikedTrackTagsForUser :many +-- Enriched tags for the user's liked tracks — the like-bonus path in the +-- recompute mirrors the play path, so it needs the same tag lookup. +SELECT tt.track_id, tt.tag, tt.weight + FROM track_tags tt + JOIN general_likes gl ON gl.track_id = tt.track_id + WHERE gl.user_id = $1;