Files
minstrel/internal/importer/upgrade.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

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

305 lines
9.9 KiB
Go

package importer
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Importing an upgrade: a better copy of an album the library
// holds. Each file is matched to the track it would replace, by recording
// and then by disc and track. It replaces that track when the track is a
// video rip or the new file is a better tier; otherwise the old track stays.
// A file with no track to replace fills a gap, as any import does.
//
// The new copy is filed before the old one goes. On the old file's own path
// it takes the path in one rename and the row carries on; at a new path it
// is scanned in as a track of its own and the old track is folded into it
// (library.ReplaceTrack), so likes, plays and playlist entries carry over.
// OldTrack is a library track an upgrade may replace.
type OldTrack struct {
ID pgtype.UUID
Path string
Recording string
Disc, Track int
Format string
Bytes int64
Ms int64
Rip bool
}
// Tier is the old track's tier, estimated as the upgrade check estimates
// an album's; ok is false for a format the ladder cannot place.
func (o OldTrack) Tier() (release.Tier, bool) {
return release.TierOfFile(o.Format, release.FileKbps(o.Bytes, o.Ms))
}
// What an upgrade does with one placement.
const (
stepPlace = "place" // no track to replace: fills a gap
stepReplace = "replace" // replaces Old from a new path
stepOverwrite = "overwrite" // replaces Old on its own path
stepKeep = "keep" // Old is as good: left as it is
)
type upgradeStep struct {
Placement int
Action string
Old int // index into olds, or -1
}
// planUpgrade matches each placement to the track it would replace and
// decides what to do with it. newTiers is each placement's tier.
func planUpgrade(plan []Placement, newTiers []release.Tier, olds []OldTrack) []upgradeStep {
used := make([]bool, len(olds))
match := make([]int, len(plan))
for i := range match {
match[i] = -1
}
for i, pl := range plan {
if pl.Recording == "" {
continue
}
for j, o := range olds {
if !used[j] && o.Recording == pl.Recording {
match[i], used[j] = j, true
break
}
}
}
for i, pl := range plan {
if match[i] >= 0 || pl.Track <= 0 {
continue
}
for j, o := range olds {
if !used[j] && o.Track == pl.Track && max(o.Disc, 1) == max(pl.Disc, 1) {
match[i], used[j] = j, true
break
}
}
}
steps := make([]upgradeStep, len(plan))
for i, pl := range plan {
j := match[i]
steps[i] = upgradeStep{Placement: i, Old: j, Action: stepPlace}
if j < 0 {
continue
}
o := olds[j]
oldTier, _ := o.Tier()
better := release.Rank(newTiers[i]) > release.Rank(oldTier)
switch {
case !o.Rip && !better:
steps[i].Action = stepKeep
case filepath.Clean(pl.Dest) == filepath.Clean(o.Path):
steps[i].Action = stepOverwrite
default:
steps[i].Action = stepReplace
}
}
return steps
}
// fileTier estimates a downloaded file's tier from its size and length, the
// way the library's are estimated, so the two compare like for like. A file
// that cannot be measured takes the release's tier.
func fileTier(f File, fallback release.Tier) release.Tier {
if info, err := os.Stat(f.Path); err == nil && f.LengthMs > 0 {
if t, ok := release.TierOfFile(filepath.Ext(f.Path), release.FileKbps(info.Size(), int64(f.LengthMs))); ok {
return t
}
}
return fallback
}
func oldTracks(rows []dbq.ListUpgradeTracksRow) []OldTrack {
out := make([]OldTrack, 0, len(rows))
for _, r := range rows {
o := OldTrack{ID: r.ID, Path: r.FilePath, Disc: int(r.Disc), Format: r.FileFormat,
Bytes: r.FileSize, Ms: int64(r.DurationMs), Rip: r.SourceVerdict != nil && *r.SourceVerdict == "suspect"}
if r.Mbid != nil {
o.Recording = *r.Mbid
}
if r.TrackNumber != nil {
o.Track = int(*r.TrackNumber)
}
out = append(out, o)
}
return out
}
// placeUpgrade files an accepted upgrade, replacing the tracks it betters.
func (im *Importer) placeUpgrade(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver, w Want,
e *Edition, files []File, plan []Placement, v Verdict, det Detail, record recordFunc) string {
rows, err := q.ListUpgradeTracks(ctx, w.AlbumID)
if err != nil {
return record(StateWaiting, "Couldn't read the album's tracks: "+err.Error(), "", det, 0, "")
}
olds := oldTracks(rows)
grabTier := release.Tier(g.Quality)
newTiers := make([]release.Tier, len(plan))
for i, pl := range plan {
newTiers[i] = fileTier(files[pl.File], grabTier)
}
steps := planUpgrade(plan, newTiers, olds)
heldRec, heldPos, err := im.held(ctx, q, w, plan)
if err != nil {
return record(StateWaiting, "Couldn't read what the library holds: "+err.Error(), "", det, 0, "")
}
type swap struct {
old OldTrack
dest string
}
var (
placed []string // every file written, for the scan
swaps []swap
inPlace []OldTrack
failed []string
)
for _, st := range steps {
pl := plan[st.Placement]
if st.Action == stepKeep {
det.Skipped++
continue
}
if st.Action == stepPlace && ((pl.Recording != "" && heldRec[pl.Recording]) || heldPos[[2]int{pl.Disc, pl.Track}]) {
det.Skipped++
continue
}
tags := Tags{Title: pl.Title, Artist: w.Artist, Album: w.Title, AlbumArtist: w.Artist, Year: w.Year,
Disc: pl.Disc, Discs: pl.Discs, Track: pl.Track, Tracks: pl.Tracks,
GroupMBID: w.GroupMBID, RecordingMBID: pl.Recording, ArtistMBID: w.ArtistMBID}
if e != nil {
tags.ReleaseMBID = e.MBID
}
src := files[pl.File].Path
var tagged bool
if st.Action == stepOverwrite {
tagged, err = Overwrite(ctx, src, pl.Dest, tags)
} else {
tagged, err = Place(ctx, src, pl.Dest, tags)
}
switch {
case errors.Is(err, ErrOccupied):
det.Skipped++
continue
case err != nil:
// Each replacement stands alone: the old track stays where a
// copy failed, and the rest go ahead.
failed = append(failed, pl.Title)
im.logger.Warn("import: copy an upgrade into the library", "grab", g.Name, "dest", pl.Dest, "err", err)
continue
}
placed = append(placed, pl.Dest)
if !tagged {
det.Untagged++
}
switch st.Action {
case stepOverwrite:
inPlace = append(inPlace, olds[st.Old])
case stepReplace:
swaps = append(swaps, swap{old: olds[st.Old], dest: pl.Dest})
}
}
det.Placed = len(placed)
if len(placed) == 0 {
why := fmt.Sprintf("Nothing in it is better than the copy of %s you have, so nothing was imported.", w.Title)
if len(failed) > 0 {
return record(StateFailed, fmt.Sprintf("Copying into the library failed for %s.", strings.Join(failed, ", ")),
"", det, 0, "")
}
// Set aside, so the weekly search doesn't take it again.
if err := acquire.NewMemory(im.pool).Block(ctx, dbq.BlockReleaseParams{
InfoHash: g.InfoHash, Name: g.Name, Reason: acquire.BlockImport, Detail: why, WantID: g.WantID,
}); err != nil {
im.logger.Warn("import: set the release aside", "grab", g.Name, "err", err)
}
if err := q.MarkWantSearched(ctx, g.WantID); err != nil {
im.logger.Warn("import: mark the want searched", "grab", g.Name, "err", err)
}
im.removeFromClient(ctx, d, g)
return record(StateHeld, why, StateHeld, det, 0, "")
}
if _, err := im.Scanner.ScanFiles(ctx, placed); err != nil {
im.logger.Warn("import: scan the new files", "grab", g.Name, "err", err)
}
for _, o := range inPlace {
if o.Rip {
if err := q.ClearTrackSourceVerdict(ctx, o.ID); err != nil {
im.logger.Warn("import: release a replaced rip", "path", o.Path, "err", err)
}
}
}
replaced, kept := len(inPlace), 0
for _, s := range swaps {
nt, err := q.GetTrackByPath(ctx, s.dest)
if err != nil {
kept++
im.logger.Warn("import: find the new track", "path", s.dest, "err", err)
continue
}
moved, err := library.ReplaceTrack(ctx, im.pool, im.logger, im.DataDir, s.old.ID, nt.ID)
if err != nil {
// The old file stays; the duplicate sweep finds the two copies.
kept++
im.logger.Warn("import: replace the old track", "old", s.old.Path, "new", s.dest, "err", err)
continue
}
replaced++
im.logger.Info("import: replaced a track with a better copy", "old", s.old.Path, "new", s.dest,
"play_events", moved.PlayEvents, "likes", moved.Likes, "playlist_entries", moved.PlaylistEntries)
}
// The want holds the album the new tracks are in, once the old album row
// has none left.
var album pgtype.UUID
if left, err := q.ListUpgradeTracks(ctx, w.AlbumID); err == nil && len(left) == 0 {
if nt, err := q.GetTrackByPath(ctx, placed[0]); err == nil {
album = nt.AlbumID
}
}
if err := q.FinishUpgrade(ctx, dbq.FinishUpgradeParams{ID: g.WantID, AlbumID: album}); err != nil {
im.logger.Warn("import: finish the upgrade", "grab", g.Name, "err", err)
}
if err := q.MarkWantSearched(ctx, g.WantID); err != nil {
im.logger.Warn("import: mark the want searched", "grab", g.Name, "err", err)
}
dest := filepath.Dir(placed[0])
if e != nil && e.Media() > 1 {
dest = filepath.Dir(dest)
}
reason := fmt.Sprintf("Replaced %s of %s with a better copy.", tracks(replaced), w.Title)
if added := len(placed) - replaced - kept; added > 0 {
reason += fmt.Sprintf(" Added %s it was missing.", tracks(added))
}
if det.Skipped > 0 {
reason += fmt.Sprintf(" %s already as good left as they were.", tracks(det.Skipped))
}
if kept > 0 {
reason += fmt.Sprintf(" %s couldn't be removed, so both copies are in the library.", tracks(kept))
}
if len(failed) > 0 {
reason += fmt.Sprintf(" Copying failed for %s; those stay as they were.", strings.Join(failed, ", "))
}
if det.Untagged > 0 {
reason += fmt.Sprintf(" %s copied with their own tags: ffmpeg could not rewrite them.", tracks(det.Untagged))
}
return record(StateImported, reason+" "+v.Reason, StateImported, det, len(placed), dest)
}