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 (M502 #5524): 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) }