Files
yellowjacket/backend/autotag/local.go
T
yonluandClaude Sonnet 5 cbd82a5a74
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
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
2026-08-10 11:52:26 -04:00

136 lines
3.9 KiB
Go

package autotag
import (
"context"
"fmt"
"yellowjacket/backend/database/sql/sqlcgen"
)
// LocalResolver turns a tagging group's album name into zero-cost
// candidate releases by looking for local release_groups (with
// MBIDs) whose normalized name matches. The user's own tagged
// albums become free candidates — if they already have another
// library where the same album was tagged correctly, reuse that.
//
// The resolver talks to the DB through the sqlc-generated Queries
// type, not the database package's DB wrapper — keeps the import
// graph acyclic with the database package (which already depends
// on autotag.GroupKey for migration backfills).
type LocalResolver struct {
q *sqlcgen.Queries
}
// NewLocalResolver returns a resolver bound to the given Queries.
func NewLocalResolver(q *sqlcgen.Queries) *LocalResolver {
return &LocalResolver{q: q}
}
// LocalTracksForGroup returns the local audio files in the given
// tagging group, projected into the scorer-ready shape.
func (r *LocalResolver) LocalTracksForGroup(
ctx context.Context, groupKey string,
) ([]LocalTrack, error) {
rows, err := r.q.ListAudioFilesInTaggingGroup(ctx, groupKey)
if err != nil {
return nil, fmt.Errorf("list local tracks: %w", err)
}
out := make([]LocalTrack, 0, len(rows))
for _, row := range rows {
out = append(out, LocalTrack{
AudioFileID: row.ID,
FilePath: row.FilePath,
Title: row.Title,
Artist: row.ArtistName,
TrackNumber: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber),
LengthMillis: row.LengthMilliseconds,
RecordingMBID: row.RecordingMbid,
AlbumTag: row.AlbumName,
AlbumArtistTag: row.AlbumArtist,
})
}
return out, nil
}
// ResolveLocal returns candidate releases sourced from the local
// DB's release_groups rows (filtered to those carrying an MBID)
// whose normalized name matches the tagging item's album name.
// No network calls. Candidates carry all tracks flat; caller runs
// AlignTracks on each to produce per-track alignments.
func (r *LocalResolver) ResolveLocal(
ctx context.Context, albumName string,
) ([]Candidate, error) {
if albumName == "" {
return nil, nil
}
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName)
if err != nil {
return nil, fmt.Errorf("list local candidates: %w", err)
}
normalizedTarget := Normalize(albumName)
byID := make(map[int64]*Candidate, 4) //nolint:mnd
tracksByID := make(map[int64][]CandidateTrack, 4) //nolint:mnd
for _, row := range rows {
// Case-insensitive SQL match is a cheap pre-filter; we
// still apply our full normalization rule in Go to reject
// false positives like "Greatest Hits" vs "Greatest Hits".
if Normalize(row.AlbumName) != normalizedTarget {
continue
}
if _, ok := byID[row.ReleaseGroupID]; !ok {
byID[row.ReleaseGroupID] = localCandidate(row)
}
tracksByID[row.ReleaseGroupID] = append(
tracksByID[row.ReleaseGroupID],
CandidateTrack{
Position: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber),
Title: row.TrackTitle,
LengthMillis: row.LengthMilliseconds,
MBID: row.RecordingMbid,
},
)
}
out := make([]Candidate, 0, len(byID))
for id, c := range byID {
c.Tracks = tracksByID[id]
c.TrackCount = len(c.Tracks)
out = append(out, *c)
}
return out, nil
}
// localCandidate converts one sqlc row (minus track-level fields)
// into a Candidate shell. Track fields and alignments are filled
// in by the caller.
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate {
date := ""
if row.Year > 0 {
date = fmt.Sprintf("%04d", row.Year)
}
mbid := ""
if row.ReleaseGroupMbid.Valid {
mbid = row.ReleaseGroupMbid.String
}
return &Candidate{
ReleaseGroupMBID: mbid,
Title: row.AlbumName,
ArtistCredit: row.ArtistCredit,
Date: date,
Source: SourceLocal,
Provenance: "local",
}
}