Files
minstrel/internal/library/replace.go
T
bvandeusenandClaude Opus 5.5 7e6228c13f
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:27:34 -04:00

84 lines
2.6 KiB
Go

package library
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Replacing a track with a better copy.
//
// An upgrade files the better copy first, as a track of its own, and only
// then replaces the old one: its file is removed, then its likes, plays,
// playlist entries and tags move onto the new row and the old row goes. It
// is the duplicate merge with the survivor chosen by the import, and it
// keeps the merge's order: if the old file cannot be removed nothing
// in the database changes, and the next scan finds the two copies as a
// duplicate pair.
// ErrReplaceSame: the old and new track are one row.
var ErrReplaceSame = errors.New("library: a track cannot replace itself")
// Replaced is what one replacement moved onto the new track.
type Replaced struct {
PlayEvents int64
SkipEvents int64
Likes int
PlaylistEntries int
}
// ReplaceTrack replaces oldID with newID: the old file is removed, then the
// old row folded into the new one. dataDir, when set, also clears the cached
// art of an artist the fold left with nothing. logger may be nil.
func ReplaceTrack(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, oldID, newID pgtype.UUID,
) (Replaced, error) {
if logger == nil {
logger = slog.Default()
}
if oldID == newID {
return Replaced{}, ErrReplaceSame
}
old, err := dbq.New(pool).GetTrackByID(ctx, oldID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Replaced{}, ErrTrackNotFound
}
return Replaced{}, fmt.Errorf("get track: %w", err)
}
if err := removeTrackFileOnDisk(old.FilePath); err != nil {
return Replaced{}, err
}
var f foldResult
err = pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
changes := mergeChanges{}
var ferr error
f, ferr = foldTrackInto(ctx, dbq.New(tx), newID, oldID, &changes)
if ferr != nil {
return ferr
}
return changes.log(ctx, tx)
})
if err != nil {
return Replaced{}, fmt.Errorf("fold the old track into the new: %w", err)
}
if f.DeletedArtistID != nil && dataDir != "" {
if err := coverart.CleanupArtistArt(dataDir, *f.DeletedArtistID); err != nil {
logger.Warn("track replace: artist-art cleanup failed",
"artist_id", syncpkg.FormatUUID(*f.DeletedArtistID), "err", err)
}
}
return Replaced{PlayEvents: f.PlayEvents, SkipEvents: f.SkipEvents, Likes: len(f.Likers),
PlaylistEntries: f.PlaylistEntries}, nil
}