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)
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

- 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>
This commit is contained in:
2026-10-11 12:24:59 -04:00
co-authored by Claude Opus 5.5
parent a5ad301c94
commit c54792c51e
45 changed files with 1574 additions and 99 deletions
+102
View File
@@ -167,3 +167,105 @@ func NewProfile(tiers []string) Profile {
// Allows says whether the profile takes a tier.
func (p Profile) Allows(t Tier) bool { return p.Allowed[t] }
// Held is what the library's copy of an album is: its tier, estimated from
// what the scanner records, and the same in words.
type Held struct {
Tier Tier
Words string
}
// HeldFormat is an album's present tracks of one format, summed.
type HeldFormat struct {
Format string
Bytes, Ms int64
}
// HeldCopy judges an album's copy from its tracks grouped by format: the
// lowest tier among them, as Lidarr judged an album by its worst file.
// Summing a format's tracks first keeps a near-silent VBR interlude from
// dragging the album down. ok is false when no format can be placed.
func HeldCopy(formats []HeldFormat) (Held, bool) {
var out Held
found := false
for _, f := range formats {
kbps := FileKbps(f.Bytes, f.Ms)
t, ok := TierOfFile(f.Format, kbps)
if !ok {
continue
}
if !found || Rank(t) < Rank(out.Tier) {
out = Held{Tier: t, Words: HeldWords(f.Format, t, kbps)}
found = true
}
}
return out, found
}
// FileKbps is the average bitrate of files totalling bytes over ms of audio:
// the scanner records no bitrate (tracks.bitrate is never filled), so size
// per second is the measure there is. Cover art and tags count too, which
// only ever reads high.
func FileKbps(bytes, ms int64) int {
if bytes <= 0 || ms <= 0 {
return 0
}
return int(bytes * 8 / ms)
}
// mp3High is the average bitrate from which an MP3 counts as high quality
// when only its size is known. A V0 encode averages 220 to 260 kbps, so the
// line sits below it: an estimate errs toward the copy held, and an album is
// never fetched again on a guess (#5524).
const mp3High = 220
// TierOfFile places files of one format on the ladder from their average
// bitrate (FileKbps). format is the scanner's extension, without the dot.
// ok is false for a format the ladder cannot place.
func TierOfFile(format string, kbps int) (Tier, bool) {
switch strings.ToLower(strings.TrimPrefix(format, ".")) {
case "flac", "wav", "aiff", "aif", "ape", "wv", "alac":
// 16-bit/44.1 kHz stereo is 1,411 kbps before compression; past
// 1,600 on average is more bits or a higher rate than CD.
if kbps > 1600 {
return TierLosslessHiRes, true
}
return TierLossless, true
case "dsf", "dff":
return TierLosslessHiRes, true
case "m4a", "mp4":
// ALAC or AAC: the container says nothing, the rate does. No AAC
// encode runs near 500 kbps.
if kbps > 500 {
return TierLossless, true
}
return byBitrate(kbps, 256), kbps > 0
case "mp3":
if kbps >= mp3High {
return TierLossyHigh, true
}
return byBitrate(kbps, 320), kbps > 0
case "aac", "ogg", "oga", "opus":
return byBitrate(kbps, 256), kbps > 0
}
return TierUnknown, false
}
// HeldWords says what a held copy is: "FLAC", "MP3, about 192 kbps".
func HeldWords(format string, tier Tier, kbps int) string {
f := strings.ToUpper(strings.TrimPrefix(format, "."))
switch tier {
case TierLosslessHiRes:
return f + ", hi-res"
case TierLossless:
return f
}
if kbps <= 0 {
return f
}
return fmt.Sprintf("%s, about %d kbps", f, roundKbps(kbps))
}
// roundKbps rounds an average bitrate to the nearest 8, so a 191 reads as
// the 192 it was encoded at.
func roundKbps(k int) int { return (k + 4) / 8 * 8 }