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
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>
113 lines
3.7 KiB
Go
113 lines
3.7 KiB
Go
package library
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SurvivorCandidate is what choosing which copy to keep needs to know about one
|
|
// member of a duplicate group.
|
|
type SurvivorCandidate struct {
|
|
TrackID string
|
|
FileFormat string
|
|
FileSize int64
|
|
AddedAt time.Time
|
|
|
|
// TagFit is TagFitScore: how well the copy's tags and name fit the album.
|
|
TagFit int
|
|
}
|
|
|
|
// TagFitScore counts the signs that a copy belongs where it is filed, one
|
|
// point each:
|
|
// - it has a track number that no other track on its album also claims
|
|
// - its file name carries no video-rip marker (SourceMarkersFor)
|
|
// - it has a MusicBrainz recording id
|
|
//
|
|
// Two copies of one song differ in these far more often than in anything a
|
|
// listener hears: rips of one song can differ by a few bytes, which makes file
|
|
// size a poor guide.
|
|
func TagFitScore(hasTrackNumber, positionClash bool, filePath string, hasMBID bool) int {
|
|
score := 0
|
|
if hasTrackNumber && !positionClash {
|
|
score++
|
|
}
|
|
if len(SourceMarkersFor(filePath)) == 0 {
|
|
score++
|
|
}
|
|
if hasMBID {
|
|
score++
|
|
}
|
|
return score
|
|
}
|
|
|
|
// losslessFormats are the scanned extensions that are lossless by definition.
|
|
// m4a is left out on purpose: it holds either ALAC or AAC, and the scanner
|
|
// records only the extension, so calling it lossless would sometimes prefer an
|
|
// AAC copy over a FLAC one.
|
|
var losslessFormats = map[string]bool{"flac": true, "wav": true}
|
|
|
|
// ProposeSurvivor picks which copy of a duplicate group to keep, and gives the
|
|
// reason in words an admin reads beside it. It is a default, not a verdict:
|
|
// the report shows it and the merge lets an admin choose another.
|
|
//
|
|
// In order:
|
|
// 1. lossless over lossy — the one difference no later step can recover. It
|
|
// comes before tag fit: tags can be fixed, a deleted FLAC cannot
|
|
// 2. the copy whose tags fit the album best (TagFitScore). It is zero for
|
|
// every copy when the caller does not know it, and the order falls through
|
|
// to the rules below
|
|
// 3. the larger file — for one recording at one duration that is the higher
|
|
// bitrate. The scanner does not record bitrate (tracks.bitrate is never
|
|
// filled), so file size is the signal that actually exists
|
|
// 4. the copy in the library longest — the one most likely to carry the play
|
|
// history and likes, so the merge moves the least
|
|
// 5. the lowest track id, so the choice is stable between page loads
|
|
func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
|
|
if len(cands) == 0 {
|
|
return "", ""
|
|
}
|
|
ranked := append([]SurvivorCandidate(nil), cands...)
|
|
sort.SliceStable(ranked, func(i, j int) bool { return survivorBefore(ranked[i], ranked[j]) })
|
|
best := ranked[0]
|
|
if len(ranked) == 1 {
|
|
return best.TrackID, "the only copy"
|
|
}
|
|
|
|
// The reason names the first rule that separated the best copy from the
|
|
// runner-up — the rule that actually decided, not every rule it passed.
|
|
next := ranked[1]
|
|
switch {
|
|
case isLossless(best) != isLossless(next):
|
|
return best.TrackID, "lossless (" + strings.ToLower(best.FileFormat) + ")"
|
|
case best.TagFit != next.TagFit:
|
|
return best.TrackID, "tags fit the album"
|
|
case best.FileSize != next.FileSize:
|
|
return best.TrackID, "largest file"
|
|
case !best.AddedAt.Equal(next.AddedAt):
|
|
return best.TrackID, "in the library longest"
|
|
default:
|
|
return best.TrackID, "copies are otherwise identical"
|
|
}
|
|
}
|
|
|
|
func survivorBefore(a, b SurvivorCandidate) bool {
|
|
if isLossless(a) != isLossless(b) {
|
|
return isLossless(a)
|
|
}
|
|
if a.TagFit != b.TagFit {
|
|
return a.TagFit > b.TagFit
|
|
}
|
|
if a.FileSize != b.FileSize {
|
|
return a.FileSize > b.FileSize
|
|
}
|
|
if !a.AddedAt.Equal(b.AddedAt) {
|
|
return a.AddedAt.Before(b.AddedAt)
|
|
}
|
|
return a.TrackID < b.TrackID
|
|
}
|
|
|
|
func isLossless(c SurvivorCandidate) bool {
|
|
return losslessFormats[strings.ToLower(c.FileFormat)]
|
|
}
|