feat(taste): track_tags schema + enrichment queries (Opt 1 foundation)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user