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
+10
View File
@@ -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)
+2
View File
@@ -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.
+155
View File
@@ -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.
+174
View File
@@ -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
// ---------------------------------------------------------------------------