feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user