Files
yellowjacket/backend/autotag/apply.go
T
logan 4b9114fd8d
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m46s
CI / e2e (pull_request) Successful in 6m18s
fix(tagwriter): declare the track and disc totals when tagging
An album the user holds 2 of 10 tracks of showed a green tick reading
"is in your library", and the mechanism was our own writer. tagwriter
wrote track and disc *numbers* and dropped the totals, so autotagging a
folder made the release MBID-matched -- which is what earns the tick --
while erasing the one field GetAlbumCompleteness reads. The evidence
for "2 of 10" was destroyed by the act that produced the tick.

FieldTotalTracks and FieldTotalDiscs are written as the ID3 "n/N" form
and as Vorbis TRACKTOTAL/DISCTOTAL; the autotag apply pass and the
download importer fill them from the release's own tracklist; and
dbsync persists the track total to audio_files.total_tracks so the
album page agrees with the file without waiting for a rescan.

Five things about it are load-bearing, and four fail silently:

- The total is per *disc*, not per release, because that is what the
  tag form declares and what GetAlbumCompleteness sums per disc. A
  release total on every file multiplies a two-disc album's expectation
  by two, which no library can satisfy. backend/tagtotals is that
  derivation once, since the two callers must not import the writer or
  each other.
- The Vorbis names are TRACKTOTAL and DISCTOTAL and no other spelling.
  dhowden/tag reads exactly those two keys, so TOTALTRACKS -- which
  xiph lists and several taggers write -- or a "1/12" packed into
  TRACKNUMBER writes successfully and reads back as no total at all.
  The tests therefore assert the round trip through the reader the scan
  uses, not through the bytes.
- ID3's number and total share one frame, so writing either alone must
  read the other off the existing tag or discard it. A total with no
  number is not written: "/12" parses as track 0.
- The totals are written unconditionally rather than on a diff. The
  case this exists for is a file declaring no total at all, which
  compares equal to nothing and is exactly what a "only if it changed"
  guard skips.
- A single-track download is not totalled. A RecordingMBID anchor
  resolves Expected to that one track, so the same code would tag a
  track off a twelve-track album "1 of 1" -- and a declared total
  outranks the catalog total that would have answered correctly.

autotag's field constants are a second copy of tagwriter's, deliberately
so autotag stays out of the write pipeline's import graph. A key that
drifts neither fails to compile nor fails to write -- the writer simply
finds nothing under the name it looks for -- so autotagservice, the one
package importing both, now pins them.

Steps 2 and 3 of the issue stay open under #38: the catalog fallback
already landed as completenessAnswer(), and the badge call-site audit is
the part that overlaps it.

Closes #16
2026-08-18 18:19:54 -04:00

453 lines
14 KiB
Go

package autotag
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/tagtotals"
)
// TagChanges mirrors tagwriter.TagChanges — redefined here so the
// autotag package does not import tagwriter (and so tests can
// construct changes without pulling in the write pipeline). The
// adapter in app wiring converts this to the concrete tagwriter
// shape.
type TagChanges map[string]any
// Field name constants matching tagwriter's canonical names. Keep
// these in sync with tagwriter/tagwriter.go — the runtime adapter
// passes the map through unchanged.
const (
FieldTitle = "title"
FieldArtist = "artist"
FieldAlbum = "album"
FieldAlbumArtist = "album_artist"
FieldYear = "year"
FieldTrackNumber = "track_number"
FieldDiscNumber = "disc_number"
FieldTotalTracks = "total_tracks"
FieldTotalDiscs = "total_discs"
FieldCoverArt = "cover_art"
)
// TagWriter is the subset of tagwriter.TagWriter the apply pipeline
// needs. Defined here so scorer_test.go can stub it and autotag
// stays import-acyclic with tagwriter.
type TagWriter interface {
WriteTrackTagsByPath(filePath string, changes TagChanges) error
}
// ApplyPlan summarises what Apply is about to do for one group.
// Returned from PrepareApply so the UI can preview (and, in the
// future, pass through a dry-run flag).
type ApplyPlan struct {
GroupKey string
Candidate Candidate
Tracks []TrackApply
}
// TrackApply is the per-track slice of an ApplyPlan: the local
// audio file, the aligned candidate track, and the changes the
// writer will actually emit.
type TrackApply struct {
Local LocalTrack
CandidateTrack CandidateTrack
Changes TagChanges
Aligned bool // false when no candidate track matched (skipped)
}
// ApplyResult summarises the outcome after the writes ran.
type ApplyResult struct {
GroupKey string
Succeeded int
Failed int
Failures []ApplyFailure
}
// ApplyFailure captures one track that failed during apply.
type ApplyFailure struct {
FilePath string
Error string
}
// ErrNoCandidate is returned when Apply is asked to apply a release
// MBID that isn't in the group's current candidate list.
var ErrNoCandidate = errors.New("autotag: candidate not found for apply")
// Applier runs the per-track tag writes + DB MBID updates when the
// user accepts a candidate. Cover-art embedding is delegated to a
// separate helper (CoverArtEmbedder) that's optional — tests pass
// nil to skip the CAA path.
type Applier struct {
q *sqlcgen.Queries
tw TagWriter
coverArt CoverArtEmbedder
log *slog.Logger
}
// CoverArtEmbedder is the autotag pipeline's view of cover-art
// fetching. Split into two operations so the Applier can fetch
// the release group's art exactly once per album and only consult
// each file's existing-art state per-track:
//
// - FetchArt is a network operation: hit CAA for the release
// group, validate dimensions, return the bytes ready to embed
// (or nil when CAA has nothing / the result is below the
// minimum size). Idempotent — call once per album.
// - HasEmbeddedArt is a per-file probe: read the local file's
// tags and report whether it already carries a picture. Cheap
// compared to FetchArt; called per track.
//
// The Applier merges FieldCoverArt into a track's changes only
// when FetchArt produced bytes AND HasEmbeddedArt returned false
// for that track — preserving the rule "never replace existing
// art".
type CoverArtEmbedder interface {
FetchArt(ctx context.Context, releaseGroupMBID string) ([]byte, error)
HasEmbeddedArt(filePath string) bool
}
// NewApplier wires up the apply pipeline. Pass coverArt=nil to
// skip CAA integration (useful for tests).
func NewApplier(
q *sqlcgen.Queries,
tw TagWriter,
coverArt CoverArtEmbedder,
logger *slog.Logger,
) *Applier {
return &Applier{q: q, tw: tw, coverArt: coverArt, log: logger}
}
// BuildPlan constructs the per-track change set for a given
// candidate without executing any writes. The UI can call this to
// preview the diff before confirming. When releaseMBID doesn't
// match any candidate in score, ErrNoCandidate is returned.
func (a *Applier) BuildPlan(
score *GroupScore, releaseMBID string,
) (*ApplyPlan, error) {
var picked *Candidate
for i := range score.Candidates {
c := &score.Candidates[i]
if c.ReleaseMBID == releaseMBID ||
(releaseMBID == "" && i == 0) ||
(c.ReleaseMBID == "" && c.ReleaseGroupMBID == releaseMBID) {
picked = c
break
}
}
if picked == nil {
return nil, ErrNoCandidate
}
tracks := make([]TrackApply, 0, len(score.LocalTracks))
for li, local := range score.LocalTracks {
align := findAlignment(picked.Alignments, li)
if align == nil || align.Status == AlignmentUnmatched {
tracks = append(tracks, TrackApply{Local: local, Aligned: false})
continue
}
candTrack := CandidateTrack{
Position: align.CandidatePosition,
DiscNumber: align.CandidateDiscNumber,
Title: align.CandidateTitle,
LengthMillis: align.CandidateLength,
MBID: align.CandidateMBID,
}
tracks = append(tracks, TrackApply{
Local: local,
CandidateTrack: candTrack,
Changes: buildChanges(local, *picked, candTrack),
Aligned: true,
})
}
return &ApplyPlan{
GroupKey: score.GroupKey,
Candidate: *picked,
Tracks: tracks,
}, nil
}
// ApplyProgress is the optional per-track callback Apply invokes
// after each track is processed. Hosts pass nil to opt out (used
// in tests and the legacy synchronous Apply path); the
// autotagservice layer wires this to a Wails event emit so the
// review UI can render a progress ring.
//
// counts: current is 1-indexed (the track that just finished),
// total is len(aligned tracks), succeeded/failed are running
// totals.
type ApplyProgress func(current, total, succeeded, failed int)
// Apply runs the plan: for each aligned track, write file tags,
// update DB MBID columns, stamp audio_files.tag_status =
// 'user_confirmed'. Cover art is fetched once for the whole
// album (idempotent) and merged into each track that has no
// existing embedded art. Tracks whose changes map ends up empty
// are skipped silently and counted as succeeded — that keeps a
// "Leave as-is on a perfect match" path from spuriously failing
// every track on errNoChanges.
//
// onProgress is called once per aligned track as it completes
// (success or failure); pass nil to opt out.
//
// On completion, tagging_items status flips to 'confirmed' if at
// least one track succeeded; failed-everything jobs leave the
// group in 'pending' so it remains in the review queue.
func (a *Applier) Apply(
ctx context.Context, plan *ApplyPlan, onProgress ApplyProgress,
) (*ApplyResult, error) {
result := &ApplyResult{GroupKey: plan.GroupKey}
// Fetch the release-group's cover art once up front — same
// album, same JPEG, no point hitting CAA per track. The
// per-file existing-art check still runs inside the loop so
// we never overwrite an existing picture.
var albumArt []byte
if a.coverArt != nil && plan.Candidate.ReleaseGroupMBID != "" {
art, err := a.coverArt.FetchArt(ctx, plan.Candidate.ReleaseGroupMBID)
if err != nil {
a.log.Warn(
"cover art fetch failed — proceeding without embed",
"release_group_mbid", plan.Candidate.ReleaseGroupMBID, "err", err,
)
} else {
albumArt = art
}
}
// Total of aligned tracks for progress reporting.
total := 0
for _, tr := range plan.Tracks {
if tr.Aligned {
total++
}
}
current := 0
for _, tr := range plan.Tracks {
if !tr.Aligned {
continue
}
current++
changes := tr.Changes
if albumArt != nil && a.coverArt != nil && !a.coverArt.HasEmbeddedArt(tr.Local.FilePath) {
// Copy the map so we don't mutate a slice of shared
// maps on subsequent iterations.
merged := make(TagChanges, len(changes)+1)
for k, v := range changes {
merged[k] = v
}
merged[FieldCoverArt] = albumArt
changes = merged
}
// Skip the file write entirely when no field would change.
// The tagwriter would return errNoChanges and we'd record
// it as a failure — wrong outcome for a track that's
// already correct. The DB sync still runs so MBIDs land.
if len(changes) > 0 {
if err := a.tw.WriteTrackTagsByPath(tr.Local.FilePath, changes); err != nil {
result.Failed++
result.Failures = append(result.Failures, ApplyFailure{
FilePath: tr.Local.FilePath,
Error: err.Error(),
})
a.log.Warn(
"apply: tag write failed",
"path", tr.Local.FilePath, "err", err,
)
if onProgress != nil {
onProgress(current, total, result.Succeeded, result.Failed)
}
continue
}
}
if err := a.syncDBMBIDs(ctx, tr, plan.Candidate); err != nil {
a.log.Warn(
"apply: MBID DB sync failed (file tags written OK)",
"path", tr.Local.FilePath, "err", err,
)
}
if err := a.q.SetAudioFileTagStatus(ctx, sqlcgen.SetAudioFileTagStatusParams{
TagStatus: "user_confirmed",
ID: tr.Local.AudioFileID,
}); err != nil {
a.log.Warn(
"apply: tag_status update failed",
"audio_file_id", tr.Local.AudioFileID, "err", err,
)
}
result.Succeeded++
if onProgress != nil {
onProgress(current, total, result.Succeeded, result.Failed)
}
}
// Flip the group's status only when at least one write landed.
if result.Succeeded > 0 {
if err := a.q.SetTaggingItemStatus(ctx, sqlcgen.SetTaggingItemStatusParams{
Status: "confirmed",
GroupKey: plan.GroupKey,
}); err != nil {
return result, fmt.Errorf("mark group confirmed: %w", err)
}
}
return result, nil
}
// syncDBMBIDs writes the recording + release-group MBIDs from the
// candidate to the local DB so the app's MBID-driven features (MB
// browser "in library" indicator, smart-playlist MBID rule fields)
// pick up the new identity without needing a rescan. File-level
// MBID frames are *not* written by tagwriter today — they're a
// follow-up; the DB copy is what the app reads.
func (a *Applier) syncDBMBIDs(
ctx context.Context, tr TrackApply, cand Candidate,
) error {
if tr.CandidateTrack.MBID != "" {
if err := a.q.SetFileRecordingMBID(ctx, sqlcgen.SetFileRecordingMBIDParams{
RecordingMbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
ID: tr.Local.AudioFileID,
}); err != nil {
return fmt.Errorf("set recording mbid: %w", err)
}
}
if cand.ReleaseGroupMBID == "" {
return nil
}
// The album is reached through the file rather than through two
// join tables; SetFileAlbumMBID takes the file id and does the
// lookup in one statement.
if err := a.q.SetFileAlbumMBID(ctx, sqlcgen.SetFileAlbumMBIDParams{
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
ID: tr.Local.AudioFileID,
}); err != nil {
return fmt.Errorf("set album mbid: %w", err)
}
// Stamp the album's original-release year too - this is what the
// tracklist and the smart-playlist year rule surface by default
// once the user accepts a candidate.
if year := parseYear(cand.OriginalDate); year > 0 {
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
if err == nil && af.AlbumID.Valid {
if err := a.q.SetAlbumOriginalYear(
ctx, sqlcgen.SetAlbumOriginalYearParams{
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
ID: af.AlbumID.Int64,
},
); err != nil {
return fmt.Errorf("set album original year: %w", err)
}
}
}
return nil
}
// findAlignment returns the alignment row that corresponds to the
// given local-track index, or nil if the track wasn't aligned
// (status=missing).
func findAlignment(alignments []TrackAlignment, localIdx int) *TrackAlignment {
for i := range alignments {
if alignments[i].LocalIndex == localIdx {
return &alignments[i]
}
}
return nil
}
// buildChanges returns the whitelisted field diff for one track
// given its aligned candidate track and the parent release. Only
// non-empty candidate values become changes — we don't overwrite
// local tags with empty strings from incomplete MB data.
func buildChanges(
local LocalTrack, cand Candidate, track CandidateTrack,
) TagChanges {
changes := make(TagChanges, 8) //nolint:mnd
if track.Title != "" && track.Title != local.Title {
changes[FieldTitle] = track.Title
}
if cand.ArtistCredit != "" {
changes[FieldArtist] = cand.ArtistCredit
changes[FieldAlbumArtist] = cand.ArtistCredit
}
if cand.Title != "" {
changes[FieldAlbum] = cand.Title
}
if year := parseYear(cand.Date); year > 0 {
changes[FieldYear] = year
}
if track.Position > 0 && track.Position != local.TrackNumber {
changes[FieldTrackNumber] = track.Position
}
if track.DiscNumber > 0 && track.DiscNumber != local.DiscNumber {
changes[FieldDiscNumber] = track.DiscNumber
}
// The totals are what says "2 of 10" rather than a bare tick, and
// dropping them here is what made autotagging an album *erase* the
// evidence: the release becomes MBID-matched while the field
// GetAlbumCompleteness reads stays absent.
//
// They are written unconditionally where the candidate has a
// tracklist, not only when they differ from the local value, because
// the common case is a file that declares no total at all -- which
// compares equal to nothing and would be skipped by a diff guard.
if tracks, discs := tagtotals.For(
candidatePositions(cand), track.DiscNumber,
); tracks > 0 {
changes[FieldTotalTracks] = tracks
changes[FieldTotalDiscs] = discs
}
return changes
}
// candidatePositions is the candidate's tracklist as bare positions.
func candidatePositions(cand Candidate) []tagtotals.Position {
out := make([]tagtotals.Position, 0, len(cand.Tracks))
for _, t := range cand.Tracks {
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
}
return out
}