feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s

Autotag: detect "junk drawer" folders with no artist/album consensus
and split them into synthetic per-cluster groups instead of forcing
one match on an unrelated pile of tracks; repair tagging_items rows
left behind by a prior scan orphan-cleanup gap.

Explore: fix an exact artist-name search being drowned out by its own
catalog entries in intent-prior scoring, and prune stale in_library
bookkeeping left behind when a referenced library row is deleted.

Download: fix a multi-library regression where every import failed
with "no library root configured" — the importer resolved the
library root from a legacy single-library config field that nothing
populates in the current multi-library model. It now resolves the
destination library per-request from the request's own library_id.
Also widen the Soulseek search window (12s -> 20s), measured against
real request history to be missing available peers on live queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
2026-08-10 11:52:26 -04:00
co-authored by Claude Sonnet 5
parent e190fd75b9
commit cbd82a5a74
70 changed files with 3617 additions and 129 deletions
+46 -12
View File
@@ -6,18 +6,20 @@ import (
"yellowjacket/backend/autotag"
)
// AutotagClient adapts *MusicBrainzClient to autotag.MBClient.
// AutotagClient adapts a *Service (its network MusicBrainzClient
// plus its offline dump-derived SearchIndex) to autotag.MBClient.
// Lives in the explore package so autotag stays free of
// explore-internal types — callers in app wiring construct one via
// NewAutotagClient and hand it to the scorer.
type AutotagClient struct {
inner *MusicBrainzClient
svc *Service
}
// NewAutotagClient wraps a MusicBrainzClient for use by the
// autotag scorer.
func NewAutotagClient(inner *MusicBrainzClient) *AutotagClient {
return &AutotagClient{inner: inner}
// NewAutotagClient wraps an explore Service for use by the autotag
// scorer: svc.MusicBrainz() serves the network cascade, svc's local
// SearchIndex serves the index-first pass.
func NewAutotagClient(svc *Service) *AutotagClient {
return &AutotagClient{svc: svc}
}
// SearchReleaseGroups delegates to the wrapped client and projects
@@ -25,7 +27,7 @@ func NewAutotagClient(inner *MusicBrainzClient) *AutotagClient {
func (c *AutotagClient) SearchReleaseGroups(
ctx context.Context, query string, limit int,
) ([]autotag.MBReleaseGroupHit, int, error) {
hits, total, err := c.inner.SearchReleaseGroups(ctx, query, limit)
hits, total, err := c.svc.mb.SearchReleaseGroups(ctx, query, limit)
if err != nil {
return nil, 0, err
}
@@ -44,13 +46,45 @@ func (c *AutotagClient) SearchReleaseGroups(
return out, total, nil
}
// SearchReleaseGroupsLocal searches the offline dump-derived catalog
// (explore_index) for release groups matching albumName — no network
// round-trip. Returns ok=false when the index isn't populated yet,
// telling the resolver to rely on the network cascade alone.
func (c *AutotagClient) SearchReleaseGroupsLocal(
ctx context.Context, albumName string, limit int,
) ([]autotag.MBReleaseGroupHit, bool) {
if !c.svc.index.IsReady() {
return nil, false
}
hits := c.svc.index.Search(ctx, albumName, limit)
out := make([]autotag.MBReleaseGroupHit, 0, len(hits))
for _, h := range hits {
if h.EntityType != "release_group" {
continue
}
out = append(out, autotag.MBReleaseGroupHit{
MBID: h.MBID,
Title: h.Title,
ArtistCredit: h.ArtistName,
FirstDate: h.ReleaseDate,
PrimaryType: h.PrimaryType,
})
}
return out, true
}
// BrowseReleases delegates to the wrapped client and projects each
// release (and its tracks) into autotag's shape. Length is
// millisecond-aligned to match local audio_files.
func (c *AutotagClient) BrowseReleases(
ctx context.Context, releaseGroupMBID string,
) ([]autotag.MBRelease, error) {
releases, err := c.inner.BrowseReleases(ctx, releaseGroupMBID)
releases, err := c.svc.mb.BrowseReleases(ctx, releaseGroupMBID)
if err != nil {
return nil, err
}
@@ -68,7 +102,7 @@ func (c *AutotagClient) BrowseReleases(
func (c *AutotagClient) LookupRelease(
ctx context.Context, releaseMBID string,
) (autotag.MBRelease, error) {
rel, err := c.inner.LookupRelease(ctx, releaseMBID)
rel, err := c.svc.mb.LookupRelease(ctx, releaseMBID)
if err != nil {
return autotag.MBRelease{}, err
}
@@ -81,7 +115,7 @@ func (c *AutotagClient) LookupRelease(
func (c *AutotagClient) LookupReleaseGroup(
ctx context.Context, releaseGroupMBID string,
) (autotag.MBReleaseGroupHit, error) {
rg, err := c.inner.LookupReleaseGroup(ctx, releaseGroupMBID)
rg, err := c.svc.mb.LookupReleaseGroup(ctx, releaseGroupMBID)
if err != nil {
return autotag.MBReleaseGroupHit{}, err
}
@@ -101,7 +135,7 @@ func (c *AutotagClient) LookupReleaseGroup(
func (c *AutotagClient) SearchRecordings(
ctx context.Context, query string, limit int,
) ([]autotag.MBRecordingHit, int, error) {
recs, total, err := c.inner.SearchRecordings(ctx, query, limit)
recs, total, err := c.svc.mb.SearchRecordings(ctx, query, limit)
if err != nil {
return nil, 0, err
}
@@ -125,7 +159,7 @@ func (c *AutotagClient) SearchRecordings(
func (c *AutotagClient) LookupRecordingReleases(
ctx context.Context, recordingMBID string,
) ([]autotag.MBReleaseRef, error) {
refs, err := c.inner.LookupRecordingReleases(ctx, recordingMBID)
refs, err := c.svc.mb.LookupRecordingReleases(ctx, recordingMBID)
if err != nil {
return nil, err
}
+61 -3
View File
@@ -2595,15 +2595,40 @@ func (e *Service) computeIntentPrior(
// have for "the user means this artist". Scale by listener
// count so a popular exact match dominates and an obscure
// one doesn't move the needle.
//
// As above, a Title match is evidence for the entity's own
// category; an ArtistName match is evidence for the artist
// category specifically, even when the matching row is a
// release_group/recording — otherwise an artist's own catalog
// entries in the local index would outvote the artist itself.
// Take the strongest boost per target instead of multiplying
// once per matching row.
localOwnCategoryBoost := map[string]float64{}
localArtistNameBoost := 0.0
for _, m := range exactMatches {
if !isExactNameMatch(q, m.Title) && !isExactNameMatch(q, m.ArtistName) {
titleMatch := isExactNameMatch(q, m.Title)
artistMatch := isExactNameMatch(q, m.ArtistName)
if !titleMatch && !artistMatch {
continue
}
// Confidence boost scales with log listener count.
boost := 1.0 + 1.5*normLog(m.ListenerCount) //nolint:mnd
switch m.EntityType {
if titleMatch && boost > localOwnCategoryBoost[m.EntityType] {
localOwnCategoryBoost[m.EntityType] = boost
}
if artistMatch && boost > localArtistNameBoost {
localArtistNameBoost = boost
}
}
for cat, boost := range localOwnCategoryBoost {
switch cat {
case "artist":
weights.artist *= boost
case "release_group":
@@ -2613,12 +2638,31 @@ func (e *Service) computeIntentPrior(
}
}
if localArtistNameBoost > 0 {
weights.artist *= localArtistNameBoost
}
// Signal: exact-match candidates discovered in the MB result
// list (Source 1b/2b/3b in gatherTopCandidates). These cover
// the case where the local index doesn't have the entity but
// MB does — e.g. Blue October's "Calling You" when Blue
// October isn't yet a known artist. Same scaling as
// index-sourced exact matches.
//
// A title match is evidence for that candidate's own category.
// An artist-credit match is evidence for the *artist* category
// specifically, regardless of what kind of entity carries the
// credit — searching an artist's exact name naturally surfaces
// their whole discography in the release/recording pools, and
// crediting each of those to "album"/"recording" would drown
// out the one true artist candidate. Take the strongest boost
// per target rather than multiplying once per matching item, so
// an artist with many releases doesn't compound the signal.
var (
ownCategoryBoost = map[string]float64{}
artistCreditBoost float64
)
for _, c := range exactCandidates {
var listeners int
@@ -2633,7 +2677,17 @@ func (e *Service) computeIntentPrior(
boost := 1.0 + 1.0*normLog(listeners) //nolint:mnd
switch c.category {
if isExactNameMatch(q, c.topResult.Name) && boost > ownCategoryBoost[c.category] {
ownCategoryBoost[c.category] = boost
}
if isExactNameMatch(q, c.topResult.ArtistCredit) && boost > artistCreditBoost {
artistCreditBoost = boost
}
}
for cat, boost := range ownCategoryBoost {
switch cat {
case "artist":
weights.artist *= boost
case "release_group":
@@ -2643,6 +2697,10 @@ func (e *Service) computeIntentPrior(
}
}
if artistCreditBoost > 0 {
weights.artist *= artistCreditBoost
}
// Signal: many recordings in the result list with the same
// title as the query → cover-wave pattern → strong recording.
titleMatches := 0
+113
View File
@@ -0,0 +1,113 @@
package explore
import "testing"
// TestComputeIntentPrior_ArtistNameNotDrownedByOwnCatalog reproduces a bug
// where searching an artist's exact name (e.g. "blank banshee") failed to
// surface the artist in "top results", even though it appeared correctly
// in the dedicated Artists section. The cause: every album/track by that
// artist in the candidate pool also has ArtistCredit == query, and each
// one multiplied the album/recording category weight, drowning out the
// single true artist candidate which only boosted its own category once.
func TestComputeIntentPrior_ArtistNameNotDrownedByOwnCatalog(t *testing.T) {
svc := &Service{}
q := "blank banshee"
result := &MBSearchResult{
Artists: []MBArtist{
{MBID: "artist-1", Name: "Blank Banshee", ListenerCount: 50000},
},
ReleaseGroups: []MBReleaseGroup{
{
MBID: "rg-1",
Title: "Blank Banshee 0",
ArtistCredit: "Blank Banshee",
ListenerCount: 20000,
},
{
MBID: "rg-2",
Title: "Blank Banshee 1",
ArtistCredit: "Blank Banshee",
ListenerCount: 18000,
},
{
MBID: "rg-3",
Title: "Blank Banshee 1.5",
ArtistCredit: "Blank Banshee",
ListenerCount: 15000,
},
},
Recordings: []MBRecording{
{
MBID: "rec-1",
Title: "Teen Pregnancy",
ArtistCredit: "Blank Banshee",
ListenerCount: 12000,
},
{MBID: "rec-2", Title: "Chase", ArtistCredit: "Blank Banshee", ListenerCount: 11000},
{MBID: "rec-3", Title: "Ghost", ArtistCredit: "Blank Banshee", ListenerCount: 9000},
},
}
exactCandidates := []topCandidate{
{category: "artist", topResult: TopResult{MBID: "artist-1", Name: "Blank Banshee"}},
{
category: "release_group",
topResult: TopResult{
MBID: "rg-1",
Name: "Blank Banshee 0",
ArtistCredit: "Blank Banshee",
},
},
{
category: "release_group",
topResult: TopResult{
MBID: "rg-2",
Name: "Blank Banshee 1",
ArtistCredit: "Blank Banshee",
},
},
{
category: "release_group",
topResult: TopResult{
MBID: "rg-3",
Name: "Blank Banshee 1.5",
ArtistCredit: "Blank Banshee",
},
},
{
category: "recording",
topResult: TopResult{
MBID: "rec-1",
Name: "Teen Pregnancy",
ArtistCredit: "Blank Banshee",
},
},
{
category: "recording",
topResult: TopResult{MBID: "rec-2", Name: "Chase", ArtistCredit: "Blank Banshee"},
},
{
category: "recording",
topResult: TopResult{MBID: "rec-3", Name: "Ghost", ArtistCredit: "Blank Banshee"},
},
}
prior := svc.computeIntentPrior(q, result, nil, exactCandidates)
if prior.artist <= prior.album {
t.Errorf(
"expected artist prior (%v) > album prior (%v) for an exact artist-name search",
prior.artist,
prior.album,
)
}
if prior.artist <= prior.recording {
t.Errorf(
"expected artist prior (%v) > recording prior (%v) for an exact artist-name search",
prior.artist,
prior.recording,
)
}
}
+156
View File
@@ -0,0 +1,156 @@
package explore
import (
"log/slog"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// TestPruneStaleLocalCrossReferences verifies that an explore_index row
// whose local_*_id points at a library row that no longer exists gets its
// in_library flag and local_*_id cleared, while a row still backed by a
// real library entity is left untouched. This is the fix for stale
// "in_library" bookkeeping surviving a rescan that removed the file it
// was tied to (upsertIndexConflictSQL only ever adds these references,
// never clears them).
func TestPruneStaleLocalCrossReferences(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, slog.Default())
// A library artist that still exists.
artist, err := db.Queries.UpsertArtist(t.Context(), "Still Owned")
if err != nil {
t.Fatalf("upsert artist: %v", err)
}
// Two explore_index artist rows: one pointing at the still-existing
// artist, one pointing at a local ID that has since been deleted
// (simulating a rescan that removed the owning artist).
seedArtist := func(mbid, title string, localID int64) {
t.Helper()
if _, err := db.ExecContext(
upsertIndexSQL,
"artist", mbid, title, title, mbid, "",
0, 0,
0, "", "",
"", "", "",
"", "", "", "",
1, 0,
localID, 0, 0,
0,
); err != nil {
t.Fatalf("seed explore_index row for %q: %v", mbid, err)
}
}
seedArtist("still-owned-mbid", "Still Owned", artist.ID)
seedArtist("removed-mbid", "Removed Artist", 999999)
si.pruneStaleLocalCrossReferences()
stillOwned := si.LookupArtistByMBID("still-owned-mbid")
if stillOwned == nil {
t.Fatal("expected still-owned artist row to survive pruning")
}
if !stillOwned.InLibrary || stillOwned.LocalArtistID != artist.ID {
t.Errorf(
"still-owned artist: InLibrary=%v LocalArtistID=%d, want InLibrary=true LocalArtistID=%d",
stillOwned.InLibrary,
stillOwned.LocalArtistID,
artist.ID,
)
}
removed := si.LookupArtistByMBID("removed-mbid")
if removed == nil {
t.Fatal("expected removed-artist row to still exist (only cross-references cleared)")
}
if removed.InLibrary || removed.LocalArtistID != 0 {
t.Errorf(
"removed artist: InLibrary=%v LocalArtistID=%d, want InLibrary=false LocalArtistID=0",
removed.InLibrary, removed.LocalArtistID,
)
}
}
// TestUnenrichedLibraryArtistMBIDs_OrdersByOwnedTrackCount verifies the
// backfill queue prioritizes artists by how many tracks the user actually
// owns, not by how many duplicate-mbid artist rows happen to exist (the
// previous "ORDER BY COUNT(*)" grouped on a.mbid, which is nearly always 1
// per artist and so wasn't really ordering by anything meaningful).
func TestUnenrichedLibraryArtistMBIDs_OrdersByOwnedTrackCount(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, slog.Default())
q := db.Queries
ctx := t.Context()
seedArtistWithTracks := func(name, mbid string, trackCount int) {
t.Helper()
artist, err := q.UpsertArtist(ctx, name)
if err != nil {
t.Fatalf("upsert artist %q: %v", name, err)
}
_, err = db.ExecContext("UPDATE artists SET mbid = ? WHERE id = ?", mbid, artist.ID)
if err != nil {
t.Fatalf("set mbid for %q: %v", name, err)
}
ac, err := q.UpsertArtistCredit(ctx, name)
if err != nil {
t.Fatalf("upsert artist credit %q: %v", name, err)
}
if _, err := q.CreateArtistCreditArtist(ctx, sqlcgen.CreateArtistCreditArtistParams{
ArtistID: artist.ID,
CreditID: ac.ID,
}); err != nil {
t.Fatalf("link artist credit artist %q: %v", name, err)
}
for i := range trackCount {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: name,
ArtistCreditID: ac.ID,
})
if err != nil {
t.Fatalf("create recording for %q: %v", name, err)
}
if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
FilePath: name + "/" + string(rune('a'+i)) + ".mp3",
LengthMilliseconds: 180000,
RecordingID: rec.ID,
Basename: string(rune('a'+i)) + ".mp3",
}); err != nil {
t.Fatalf("create audio file for %q: %v", name, err)
}
}
}
seedArtistWithTracks("Few Tracks", "few-mbid", 1)
seedArtistWithTracks("Many Tracks", "many-mbid", 5)
mbids := si.unenrichedLibraryArtistMBIDs(10)
if len(mbids) != 2 {
t.Fatalf("unenrichedLibraryArtistMBIDs() = %v, want 2 entries", mbids)
}
if mbids[0] != "many-mbid" {
t.Errorf(
"unenrichedLibraryArtistMBIDs()[0] = %q, want %q (most owned tracks first)",
mbids[0],
"many-mbid",
)
}
}
+62 -1
View File
@@ -359,10 +359,13 @@ func (si *SearchIndex) unenrichedLibraryArtistMBIDs(limit int) []string {
FROM artists a
LEFT JOIN explore_index ei
ON ei.entity_type = 'artist' AND ei.mbid = a.mbid
LEFT JOIN artist_credit_artist aca ON aca.artist_id = a.id
LEFT JOIN recordings r ON r.artist_credit_id = aca.credit_id
LEFT JOIN audio_files af ON af.recording_id = r.id
WHERE a.mbid IS NOT NULL AND a.mbid != ''
AND (ei.id IS NULL OR ei.discog_fetched = 0)
GROUP BY a.mbid
ORDER BY COUNT(*) DESC
ORDER BY COUNT(DISTINCT af.id) DESC
LIMIT ?
`, limit)
if err != nil {
@@ -2245,6 +2248,11 @@ func (si *SearchIndex) hasMeta(key string) bool {
// MBID-less local content is intentionally excluded — the explore index
// is MBID-keyed, and unmatched files are served by the library search.
func (si *SearchIndex) PopulateLocalCrossReferences() {
// Clear cross-references for entities no longer owned before adding
// current ones — the upsert below only ever adds/refreshes rows for
// what's currently in the library, so removals need their own pass.
si.pruneStaleLocalCrossReferences()
entries := si.collectLibraryEntities()
if len(entries) == 0 {
si.logger.Info("library sync: no MB-verified library entities to index")
@@ -2267,6 +2275,59 @@ func (si *SearchIndex) PopulateLocalCrossReferences() {
si.logger.Info("library sync: upserted library entities into index", "count", len(entries))
}
// pruneStaleLocalCrossReferences clears in_library/local_*_id on
// explore_index rows whose local row no longer exists — e.g. an artist
// whose owned files were swapped out and removed by a rescan. The
// index upsert (upsertIndexConflictSQL) is a one-way ratchet that only
// ever sets these columns, never clears them, so this is the only place
// a removal from the library is ever reflected back into the index.
// The row itself is left in place (it may still be part of the shipped
// catalog, just no longer owned) — only the "this is mine" bookkeeping
// is cleared.
func (si *SearchIndex) pruneStaleLocalCrossReferences() {
type prune struct {
entityType string
column string
table string
}
for _, p := range []prune{
{"artist", "local_artist_id", "artists"},
{"release_group", "local_release_group_id", "release_groups"},
{"recording", "local_recording_id", "recordings"},
} {
result, err := si.db.ExecContext(
`UPDATE explore_index
SET in_library = 0, `+p.column+` = NULL
WHERE entity_type = ?
AND `+p.column+` IS NOT NULL
AND `+p.column+` NOT IN (SELECT id FROM `+p.table+`)`,
p.entityType,
)
if err != nil {
si.logger.Warn(
"library sync: prune stale cross-references failed",
"entityType",
p.entityType,
"error",
err,
)
continue
}
if n, err := result.RowsAffected(); err == nil && n > 0 {
si.logger.Info(
"library sync: cleared stale cross-references",
"entityType",
p.entityType,
"count",
n,
)
}
}
}
// collectLibraryEntities builds index entries for every library artist,
// release group, and recording that carries a MusicBrainz ID. Artist
// credit strings and the primary artist MBID are resolved from the local