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
+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.