Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d18a3c808 | ||
|
|
c34753f5b0 |
@@ -32,6 +32,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -205,6 +206,25 @@ func run() error {
|
||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||
go similarityWorker.Run(ctx)
|
||||
|
||||
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||
// tag providers with tag_provider_settings, bumps the sources version if
|
||||
// the provider set changed (re-opening settled rows), then drains tracks
|
||||
// needing folksonomy tags on a periodic tick. Standalone (not in the file
|
||||
// scan chain) because tag lookups need only DB fields — MBID / artist /
|
||||
// title — that a scan has already imported.
|
||||
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
|
||||
if err != nil {
|
||||
logger.Error("tag settings service init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
|
||||
logger.Warn("tags: provider-hash boot check failed", "err", berr)
|
||||
} else if bumped {
|
||||
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
|
||||
}
|
||||
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
|
||||
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
|
||||
|
||||
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
||||
// that have no writer-side close path or retention policy:
|
||||
// orphan play_events, stale play_sessions, expired
|
||||
|
||||
@@ -87,19 +87,21 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
|
||||
}
|
||||
|
||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||
SELECT t.artist_id, t.genre
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id = $1
|
||||
`
|
||||
|
||||
type ListLikedTrackTasteInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
}
|
||||
|
||||
// (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
||||
// track-like bonus into the liked track's artist and tags.
|
||||
// (track_id, artist_id, genre) for each track the user has explicitly
|
||||
// liked. Feeds the track-like bonus into the liked track's artist and
|
||||
// tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||
if err != nil {
|
||||
@@ -109,7 +111,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
||||
var items []ListLikedTrackTasteInputsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListLikedTrackTasteInputsForUserRow
|
||||
if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
|
||||
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -123,6 +125,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
||||
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
|
||||
|
||||
SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
LEAST(GREATEST(
|
||||
@@ -146,6 +149,7 @@ type ListPlayEngagementInputsForUserParams struct {
|
||||
}
|
||||
|
||||
type ListPlayEngagementInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
Completion float64
|
||||
@@ -169,6 +173,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
|
||||
for rows.Next() {
|
||||
var i ListPlayEngagementInputsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.TrackID,
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.Completion,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
-- is split into tags in Go. Quarantined tracks are excluded.
|
||||
SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
LEAST(GREATEST(
|
||||
@@ -25,9 +26,10 @@ WHERE pe.user_id = $1
|
||||
);
|
||||
|
||||
-- name: ListLikedTrackTasteInputsForUser :many
|
||||
-- (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
||||
-- track-like bonus into the liked track's artist and tags.
|
||||
SELECT t.artist_id, t.genre
|
||||
-- (track_id, artist_id, genre) for each track the user has explicitly
|
||||
-- liked. Feeds the track-like bonus into the liked track's artist and
|
||||
-- tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id = $1;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package tags
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Worker periodically drains tracks needing tag enrichment. Unlike cover
|
||||
// art (threaded through the file-scan chain because it needs track file
|
||||
// paths), tag enrichment only needs data already in the DB — recording
|
||||
// MBID / artist / title — so it runs as a standalone background worker,
|
||||
// mirroring the ListenBrainz similarity worker. Rate limiting lives in the
|
||||
// providers' httpClients, so a tick just drains a bounded batch and the
|
||||
// external APIs pace themselves.
|
||||
type Worker struct {
|
||||
enricher *Enricher
|
||||
logger *slog.Logger
|
||||
tick time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
// NewWorker constructs a worker with production defaults: an initial drain
|
||||
// shortly after boot, then every 30 minutes, up to 200 tracks per tick.
|
||||
// MusicBrainz's 1 req/s ceiling is the real throttle, so the batch size
|
||||
// mainly bounds how long one tick runs, not the request rate.
|
||||
func NewWorker(enricher *Enricher, logger *slog.Logger) *Worker {
|
||||
return &Worker{
|
||||
enricher: enricher,
|
||||
logger: logger,
|
||||
tick: 30 * time.Minute,
|
||||
batch: 200,
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled: an initial drain, then every w.tick.
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
w.tickOnce(ctx)
|
||||
t := time.NewTicker(w.tick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.tickOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tickOnce drains one bounded batch. EnrichTrackBatch already logs a
|
||||
// category breakdown, so this only surfaces a fatal batch error.
|
||||
func (w *Worker) tickOnce(ctx context.Context) {
|
||||
if _, _, _, err := w.enricher.EnrichTrackBatch(ctx, w.batch, nil); err != nil {
|
||||
if ctx.Err() == nil {
|
||||
w.logger.Error("tags: enrichment tick failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-13
@@ -36,6 +36,16 @@ type Config struct {
|
||||
TrackLikeBonus float64
|
||||
TagLikeBonus float64
|
||||
|
||||
// EnrichedTagScale weights folksonomy tags (from the track_tags cache,
|
||||
// #1490) relative to a track's raw ID3 genre when both fold into the tag
|
||||
// facet. Each enriched tag contributes base × tag.weight × this scale,
|
||||
// where base is the play's decayed engagement (or the tag-like bonus for
|
||||
// a liked track) and tag.weight is the normalized folksonomy strength in
|
||||
// [0,1]. Below 1 so the richer-but-noisier enriched vocabulary augments
|
||||
// the ID3 genre signal without swamping it. 0 disables the enriched
|
||||
// contribution entirely (falls back to genre-only).
|
||||
EnrichedTagScale float64
|
||||
|
||||
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||
// go. Aggregation already protects an artist the user likes (one skip
|
||||
// nets out against many good plays); the floor additionally bounds the
|
||||
@@ -58,19 +68,20 @@ type Config struct {
|
||||
// DefaultConfig returns the tuned starting configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Engagement: DefaultEngagementParams(),
|
||||
HalfLifeDays: 75,
|
||||
WindowDays: 270,
|
||||
ArtistLikeBonus: 3.0,
|
||||
TrackLikeBonus: 1.0,
|
||||
TagLikeBonus: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
MaxArtists: 500,
|
||||
MaxNegArtists: 150,
|
||||
MaxTags: 200,
|
||||
MaxNegTags: 60,
|
||||
Engagement: DefaultEngagementParams(),
|
||||
HalfLifeDays: 75,
|
||||
WindowDays: 270,
|
||||
ArtistLikeBonus: 3.0,
|
||||
TrackLikeBonus: 1.0,
|
||||
TagLikeBonus: 0.5,
|
||||
EnrichedTagScale: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
MaxArtists: 500,
|
||||
MaxNegArtists: 150,
|
||||
MaxTags: 200,
|
||||
MaxNegTags: 60,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +132,19 @@ func accumulate(
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
}
|
||||
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
|
||||
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
|
||||
// "post-punk / shoegaze / melancholic" vocabulary. Window matches the
|
||||
// play window; a track with no cached tags simply contributes genre only.
|
||||
playedTagRows, err := q.ListPlayedTrackTagsForUser(ctx, dbq.ListPlayedTrackTagsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: int32(cfg.WindowDays),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
}
|
||||
playedTags := groupTagsByTrack(playedTagRows)
|
||||
|
||||
artistW := make(map[pgtype.UUID]float64)
|
||||
tagW := make(map[string]float64)
|
||||
for _, p := range plays {
|
||||
@@ -129,17 +153,24 @@ func accumulate(
|
||||
for _, tag := range splitGenres(p.Genre) {
|
||||
tagW[tag] += e
|
||||
}
|
||||
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
|
||||
}
|
||||
|
||||
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
}
|
||||
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
}
|
||||
likedTags := groupTagsByTrack(likedTagRows)
|
||||
for _, lt := range likedTracks {
|
||||
artistW[lt.ArtistID] += cfg.TrackLikeBonus
|
||||
for _, tag := range splitGenres(lt.Genre) {
|
||||
tagW[tag] += cfg.TagLikeBonus
|
||||
}
|
||||
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
|
||||
}
|
||||
|
||||
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||
@@ -152,6 +183,29 @@ func accumulate(
|
||||
return artistW, tagW, nil
|
||||
}
|
||||
|
||||
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
|
||||
// each play/like can fold in its track's enriched tags in one lookup.
|
||||
func groupTagsByTrack(rows []dbq.TrackTag) map[pgtype.UUID][]dbq.TrackTag {
|
||||
m := make(map[pgtype.UUID][]dbq.TrackTag)
|
||||
for _, r := range rows {
|
||||
m[r.TrackID] = append(m[r.TrackID], r)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// foldEnrichedTags adds each enriched tag to tagW weighted by
|
||||
// base × tag.weight × scale — base is the play's decayed engagement or the
|
||||
// tag-like bonus, tag.weight is the folksonomy strength in [0,1]. scale=0
|
||||
// disables the enriched contribution (genre-only fallback).
|
||||
func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
|
||||
if scale == 0 {
|
||||
return
|
||||
}
|
||||
for _, t := range tags {
|
||||
tagW[t.Tag] += base * t.Weight * scale
|
||||
}
|
||||
}
|
||||
|
||||
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||
func persist(
|
||||
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
|
||||
@@ -4,10 +4,39 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func strLess(a, b string) bool { return a < b }
|
||||
|
||||
func TestFoldEnrichedTags(t *testing.T) {
|
||||
tagW := map[string]float64{"rock": 1.0} // pre-existing ID3 genre weight
|
||||
tags := []dbq.TrackTag{
|
||||
{Tag: "shoegaze", Weight: 1.0},
|
||||
{Tag: "melancholic", Weight: 0.5},
|
||||
{Tag: "rock", Weight: 0.4}, // overlaps genre → accumulates
|
||||
}
|
||||
// base=2 (engagement), scale=0.5 → contribution = 2 * weight * 0.5 = weight.
|
||||
foldEnrichedTags(tagW, tags, 2.0, 0.5)
|
||||
if got := tagW["shoegaze"]; got != 1.0 {
|
||||
t.Errorf("shoegaze = %v, want 1.0", got)
|
||||
}
|
||||
if got := tagW["melancholic"]; got != 0.5 {
|
||||
t.Errorf("melancholic = %v, want 0.5", got)
|
||||
}
|
||||
if got := tagW["rock"]; got != 1.4 { // 1.0 genre + 2*0.4*0.5
|
||||
t.Errorf("rock = %v, want 1.4", got)
|
||||
}
|
||||
|
||||
// scale=0 disables the enriched contribution entirely.
|
||||
base := map[string]float64{"rock": 1.0}
|
||||
foldEnrichedTags(base, tags, 2.0, 0)
|
||||
if len(base) != 1 || base["rock"] != 1.0 {
|
||||
t.Errorf("scale=0 should leave tagW untouched; got %v", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) {
|
||||
m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0}
|
||||
got := rankWeighted(m, 0.05, 10, 10, strLess)
|
||||
|
||||
Reference in New Issue
Block a user