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:
@@ -116,6 +116,16 @@ func (l *Library) AddLibrary(path string) (*sqlcgen.Library, error) {
|
||||
return &lib, nil
|
||||
}
|
||||
|
||||
// LibraryPath resolves a library's root directory by id.
|
||||
func (l *Library) LibraryPath(id int64) (string, error) {
|
||||
lib, err := l.db.ReadQueries.GetLibrary(l.ctx, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get library %d: %w", id, err)
|
||||
}
|
||||
|
||||
return lib.Path, nil
|
||||
}
|
||||
|
||||
// RenameLibrary validates and updates a library's display name.
|
||||
func (l *Library) RenameLibrary(id int64, newName string) error {
|
||||
newName = strings.TrimSpace(newName)
|
||||
|
||||
@@ -16,6 +16,8 @@ var staleTolerated = map[string]string{
|
||||
"stale entries are filtered by joining track_metadata and are " +
|
||||
"cleared by a full rescan",
|
||||
"lyrics_index": "contentless FTS5, same constraint as search_index",
|
||||
"schema_migrations": "global migration bookkeeping, not scoped to any " +
|
||||
"library; removing the only library must not touch it",
|
||||
}
|
||||
|
||||
// Removing the only library must leave no owned or derived rows behind.
|
||||
|
||||
@@ -776,6 +776,39 @@ func (l *Library) scanInternal(
|
||||
return true
|
||||
}
|
||||
|
||||
// Keep the file's tagging group in sync: drop the group's
|
||||
// track count and clear it out once empty, mirroring the
|
||||
// bookkeeping maybeRebindTaggingGroup does for a group_key
|
||||
// change. Without this, a folder whose files are removed
|
||||
// and replaced leaves a stale tagging_items row behind —
|
||||
// its track_count still counts the deleted files, and it
|
||||
// never clears from the autotag queue.
|
||||
if audioFile.GroupKey != "" {
|
||||
if err := l.db.Queries.DecrementTaggingItemTrackCount(
|
||||
l.ctx, audioFile.GroupKey,
|
||||
); err != nil {
|
||||
l.logger.Warn(
|
||||
"failed to decrement tagging group for orphan",
|
||||
"path", path,
|
||||
"group_key", audioFile.GroupKey,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
metrics.addWarning(path, "orphan", err)
|
||||
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
|
||||
l.ctx, audioFile.GroupKey,
|
||||
); err != nil {
|
||||
l.logger.Warn(
|
||||
"failed to clean up emptied tagging group for orphan",
|
||||
"path", path,
|
||||
"group_key", audioFile.GroupKey,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
metrics.addWarning(path, "orphan", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from FTS5 search index.
|
||||
if err := l.db.DeleteSearchIndex(
|
||||
audioFile.ID,
|
||||
@@ -795,6 +828,14 @@ func (l *Library) scanInternal(
|
||||
})
|
||||
|
||||
metrics.OrphanCleanup = time.Since(orphanStart)
|
||||
|
||||
// --- Phase 5b: orphaned metadata cleanup ---
|
||||
// Deleting an audio_files row above doesn't cascade to the
|
||||
// recording/release_group/artist_credit/artist rows it was the
|
||||
// last owner of — clean those up too, so a swapped-out artist
|
||||
// doesn't leave stale rows behind for the Explore index to
|
||||
// keep pointing at.
|
||||
l.pruneOrphanedMetadata()
|
||||
}
|
||||
|
||||
// --- Phase 6: repopulate + resolve phantom playlist tracks ---
|
||||
@@ -944,6 +985,120 @@ func (l *Library) flushStatBackfill(
|
||||
)
|
||||
}
|
||||
|
||||
// pruneOrphanedMetadata removes recording/release_group/artist_credit/
|
||||
// artist rows left behind once the audio_files rows that justified them
|
||||
// are gone — deleting an audio_files row doesn't cascade to any of
|
||||
// these. Runs in dependency order: recordings first (and their
|
||||
// release_group_recordings/recording_genres rows), then release groups
|
||||
// left with no recordings, then artist credits left with no
|
||||
// recordings/release groups, then artists left with no credits. Best
|
||||
// effort — logs and continues on error rather than failing the scan.
|
||||
func (l *Library) pruneOrphanedMetadata() {
|
||||
tx, err := l.db.BeginTx()
|
||||
if err != nil {
|
||||
l.logger.Warn("could not begin orphaned metadata cleanup transaction", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = tx.Rollback() }() // no-op after commit
|
||||
|
||||
txq := l.db.Queries.WithTx(tx)
|
||||
|
||||
recordingIDs, err := txq.GetOrphanedRecordingIDs(l.ctx)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not find orphaned recordings", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range recordingIDs {
|
||||
if err := txq.DeleteReleaseGroupRecordingsByRecording(l.ctx, id); err != nil {
|
||||
l.logger.Warn(
|
||||
"could not delete release group links for orphaned recording",
|
||||
"id",
|
||||
id,
|
||||
"err",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := txq.DeleteRecordingGenres(l.ctx, id); err != nil {
|
||||
l.logger.Warn("could not delete genres for orphaned recording", "id", id, "err", err)
|
||||
}
|
||||
|
||||
if err := txq.DeleteRecording(l.ctx, id); err != nil {
|
||||
l.logger.Warn("could not delete orphaned recording", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
releaseGroupIDs, err := txq.GetOrphanedReleaseGroupIDs(l.ctx)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not find orphaned release groups", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range releaseGroupIDs {
|
||||
if err := txq.DeleteReleaseGroup(l.ctx, id); err != nil {
|
||||
l.logger.Warn("could not delete orphaned release group", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
artistCreditIDs, err := txq.GetOrphanedArtistCreditIDs(l.ctx)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not find orphaned artist credits", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range artistCreditIDs {
|
||||
if err := txq.DeleteArtistCreditArtistByCredit(l.ctx, id); err != nil {
|
||||
l.logger.Warn(
|
||||
"could not delete artist links for orphaned artist credit",
|
||||
"id",
|
||||
id,
|
||||
"err",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := txq.DeleteArtistCredit(l.ctx, id); err != nil {
|
||||
l.logger.Warn("could not delete orphaned artist credit", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
artistIDs, err := txq.GetOrphanedArtistIDs(l.ctx)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not find orphaned artists", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range artistIDs {
|
||||
if err := txq.DeleteArtist(l.ctx, id); err != nil {
|
||||
l.logger.Warn("could not delete orphaned artist", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
l.logger.Warn("could not commit orphaned metadata cleanup", "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(recordingIDs) > 0 || len(releaseGroupIDs) > 0 || len(artistCreditIDs) > 0 ||
|
||||
len(artistIDs) > 0 {
|
||||
l.logger.Info(
|
||||
"pruned orphaned library metadata",
|
||||
"recordings", len(recordingIDs),
|
||||
"releaseGroups", len(releaseGroupIDs),
|
||||
"artistCredits", len(artistCreditIDs),
|
||||
"artists", len(artistIDs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// countAudioFiles performs a fast walk of the library directory,
|
||||
// counting only files with supported audio extensions. No per-file
|
||||
// I/O is performed — this reads only directory entries.
|
||||
|
||||
@@ -676,6 +676,180 @@ func TestOrphanDeletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneOrphanedMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, db := setupTestLibrary(t)
|
||||
ctx := context.Background()
|
||||
q := db.Queries
|
||||
|
||||
// Seed a full chain: artist -> artist_credit -> recording -> audio_file,
|
||||
// plus a release group crediting the same artist.
|
||||
artist, err := q.UpsertArtist(ctx, "Orphaned Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist: %v", err)
|
||||
}
|
||||
|
||||
ac, err := q.UpsertArtistCredit(ctx, "Orphaned Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateArtistCreditArtist(ctx, sqlcgen.CreateArtistCreditArtistParams{
|
||||
ArtistID: artist.ID,
|
||||
CreditID: ac.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("link artist credit artist: %v", err)
|
||||
}
|
||||
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: "Orphaned Song",
|
||||
ArtistCreditID: ac.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
rg, err := q.CreateReleaseGroupFull(ctx, sqlcgen.CreateReleaseGroupFullParams{
|
||||
Name: "Orphaned Album",
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create release group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rg.ID,
|
||||
RecordingID: rec.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("link release group recording: %v", err)
|
||||
}
|
||||
|
||||
af, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
|
||||
FilePath: "/music/orphaned.mp3",
|
||||
LengthMilliseconds: 180000,
|
||||
FileTypeID: 0,
|
||||
RecordingID: rec.ID,
|
||||
Basename: "orphaned.mp3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
|
||||
// Simulate a rescan removing the file: delete the audio_files row
|
||||
// (what the existing Phase 5 orphan cleanup does), then run the new
|
||||
// metadata cleanup that's supposed to cascade the rest.
|
||||
if err := q.DeleteAudioFile(ctx, af.ID); err != nil {
|
||||
t.Fatalf("delete audio file: %v", err)
|
||||
}
|
||||
|
||||
lib.pruneOrphanedMetadata()
|
||||
|
||||
if _, err := q.GetRecording(ctx, rec.ID); err == nil {
|
||||
t.Error("expected orphaned recording to be deleted")
|
||||
}
|
||||
|
||||
if _, err := q.GetReleaseGroup(ctx, rg.ID); err == nil {
|
||||
t.Error("expected orphaned release group to be deleted")
|
||||
}
|
||||
|
||||
if _, err := q.GetArtistCredit(ctx, ac.ID); err == nil {
|
||||
t.Error("expected orphaned artist credit to be deleted")
|
||||
}
|
||||
|
||||
if _, err := q.GetArtist(ctx, artist.ID); err == nil {
|
||||
t.Error("expected orphaned artist to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneOrphanedMetadata_KeepsStillOwnedEntities verifies that pruning
|
||||
// only removes rows with no remaining audio_files, leaving an artist who
|
||||
// still owns other tracks untouched.
|
||||
func TestPruneOrphanedMetadata_KeepsStillOwnedEntities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, db := setupTestLibrary(t)
|
||||
ctx := context.Background()
|
||||
q := db.Queries
|
||||
|
||||
artist, err := q.UpsertArtist(ctx, "Still Owned Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist: %v", err)
|
||||
}
|
||||
|
||||
ac, err := q.UpsertArtistCredit(ctx, "Still Owned Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateArtistCreditArtist(ctx, sqlcgen.CreateArtistCreditArtistParams{
|
||||
ArtistID: artist.ID,
|
||||
CreditID: ac.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("link artist credit artist: %v", err)
|
||||
}
|
||||
|
||||
// Two recordings under the same artist credit; only one loses its file.
|
||||
recGone, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: "Removed Song",
|
||||
ArtistCreditID: ac.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording (removed): %v", err)
|
||||
}
|
||||
|
||||
recKept, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: "Kept Song",
|
||||
ArtistCreditID: ac.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording (kept): %v", err)
|
||||
}
|
||||
|
||||
afGone, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
|
||||
FilePath: "/music/gone.mp3",
|
||||
LengthMilliseconds: 180000,
|
||||
FileTypeID: 0,
|
||||
RecordingID: recGone.ID,
|
||||
Basename: "gone.mp3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create audio file (gone): %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
|
||||
FilePath: "/music/kept.mp3",
|
||||
LengthMilliseconds: 180000,
|
||||
FileTypeID: 0,
|
||||
RecordingID: recKept.ID,
|
||||
Basename: "kept.mp3",
|
||||
}); err != nil {
|
||||
t.Fatalf("create audio file (kept): %v", err)
|
||||
}
|
||||
|
||||
if err := q.DeleteAudioFile(ctx, afGone.ID); err != nil {
|
||||
t.Fatalf("delete audio file: %v", err)
|
||||
}
|
||||
|
||||
lib.pruneOrphanedMetadata()
|
||||
|
||||
if _, err := q.GetRecording(ctx, recGone.ID); err == nil {
|
||||
t.Error("expected orphaned recording to be deleted")
|
||||
}
|
||||
|
||||
if _, err := q.GetRecording(ctx, recKept.ID); err != nil {
|
||||
t.Errorf("expected still-owned recording to survive, got: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.GetArtistCredit(ctx, ac.ID); err != nil {
|
||||
t.Errorf("expected still-referenced artist credit to survive, got: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.GetArtist(ctx, artist.ID); err != nil {
|
||||
t.Errorf("expected still-referenced artist to survive, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty/missing metadata tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user