Files
bvandeusenandClaude Opus 5.5 c54792c51e
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's
  cutoff, note #5527; "" is off), an "Upgrade until" row under Searching.
- The wanted worker judges held albums hourly: tier per format from size per
  second (the scanner records no bitrate), the album's worst. Below the
  cutoff it is wanted for quality; a video rip among its tracks always.
- The schedule searches upgrades weekly, after open wants; a release no
  better than the copy held is refused ("not_better").
- The import matches each file to the track it would replace (recording,
  then disc/track) and replaces it when it is a rip or the file is a better
  tier: over the same path in one rename, or filed beside it and the old
  track folded in (library.ReplaceTrack: file first, then foldTrackInto).
  Nothing better: the release is set aside.
- Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a
  clean copy was looked for instead of Lidarr's search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:59 -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 (M502 #5524).
//
// 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 (#3918): 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
}