Files
minstrel/internal/release/quality.go
T
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

272 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package release
import (
"fmt"
"strconv"
"strings"
)
// Tier is a rung of the quality ladder, Lidarr's names for them.
type Tier string
// The ladder, best first.
const (
TierLosslessHiRes Tier = "lossless_hires"
TierLossless Tier = "lossless"
TierLossyHigh Tier = "lossy_high"
TierLossyStandard Tier = "lossy_standard"
TierLossyLow Tier = "lossy_low"
TierLossyPoor Tier = "lossy_poor"
TierLossyTrash Tier = "lossy_trash"
TierUnknown Tier = "unknown"
)
// losslessCategory is Newznab's Audio/Lossless.
const losslessCategory = 3040
// TierInfo is a rung as the settings page shows it.
type TierInfo struct {
Tier Tier `json:"tier"`
Label string `json:"label"`
What string `json:"what"`
}
// Tiers is the ladder, best first.
var Tiers = []TierInfo{
{TierLosslessHiRes, "Lossless, hi-res", "24-bit or above 48 kHz: FLAC, ALAC, DSD"},
{TierLossless, "Lossless", "FLAC, ALAC, APE, WavPack, WAV at CD quality"},
{TierLossyHigh, "High quality lossy", "MP3 320 or V0, AAC 256 and up"},
{TierLossyStandard, "Standard quality lossy", "MP3 224–256 or V1/V2, AAC 192, Vorbis, Opus, or a lossy format with no bitrate stated"},
{TierLossyLow, "Low quality lossy", "160–192 kbps"},
{TierLossyPoor, "Poor quality lossy", "128 kbps"},
{TierLossyTrash, "Trash quality lossy", "Under 128 kbps"},
{TierUnknown, "Unknown", "Nothing says what format it is"},
}
// DefaultTiers is what a new install allows: Lidarr's "Any" profile, every
// format from trash lossy to lossless (note #5527). Unknown is left out; a
// release that names no format has its .torrent read to find out.
var DefaultTiers = []Tier{
TierLosslessHiRes, TierLossless, TierLossyHigh, TierLossyStandard,
TierLossyLow, TierLossyPoor, TierLossyTrash,
}
// KnownTier says whether t is a rung of the ladder.
func KnownTier(t string) bool {
for _, info := range Tiers {
if string(info.Tier) == t {
return true
}
}
return false
}
// Rank orders tiers: higher is better, Unknown lowest.
func Rank(t Tier) int {
for i, info := range Tiers {
if info.Tier == t {
return len(Tiers) - i
}
}
return 0
}
// Label is a tier's name for a person.
func Label(t Tier) string {
for _, info := range Tiers {
if info.Tier == t {
return info.Label
}
}
return string(t)
}
// TierOf places a release on the ladder. category is the indexer's; 3040
// (Lossless) stands in for a name that states no format.
func TierOf(r Release, category int) Tier {
switch r.Format {
case FormatDSD:
return TierLosslessHiRes
case FormatFLAC, FormatALAC, FormatAPE, FormatWV, FormatWAV, FormatAIFF:
if r.BitDepth >= 24 || r.SampleRate > 48 {
return TierLosslessHiRes
}
return TierLossless
case FormatMP3:
switch r.VBR {
case "V0":
return TierLossyHigh
case "V1", "V2":
return TierLossyStandard
}
return byBitrate(r.Bitrate, 320)
case FormatAAC, FormatVorbis, FormatOpus:
return byBitrate(r.Bitrate, 256)
}
if category == losslessCategory {
return TierLossless
}
return TierUnknown
}
// byBitrate places a lossy bitrate; high is the bitrate from which the codec
// counts as high quality. An unstated bitrate is standard.
func byBitrate(kbps, high int) Tier {
switch {
case kbps == 0:
return TierLossyStandard
case kbps >= high:
return TierLossyHigh
case kbps >= 224 || (high == 256 && kbps >= 192):
return TierLossyStandard
case kbps >= 160:
return TierLossyLow
case kbps >= 128:
return TierLossyPoor
}
return TierLossyTrash
}
// Describe is a release's format in a few words: "FLAC 24-bit/96 kHz",
// "MP3 V0", "AAC 256". Empty when the name states none.
func Describe(r Release) string {
if r.Format == "" {
return ""
}
parts := []string{r.Format}
switch {
case r.BitDepth > 0 && r.SampleRate > 0:
parts = append(parts, fmt.Sprintf("%d-bit/%s kHz", r.BitDepth, khz(r.SampleRate)))
case r.BitDepth > 0:
parts = append(parts, fmt.Sprintf("%d-bit", r.BitDepth))
case r.VBR != "":
parts = append(parts, r.VBR)
case r.Bitrate > 0:
parts = append(parts, strconv.Itoa(r.Bitrate))
}
return strings.Join(parts, " ")
}
func khz(f float64) string {
return strings.TrimSuffix(strconv.FormatFloat(f, 'f', 1, 64), ".0")
}
// Profile is which tiers the operator takes.
type Profile struct {
Allowed map[Tier]bool
}
// NewProfile builds a profile from the stored tier list.
func NewProfile(tiers []string) Profile {
p := Profile{Allowed: map[Tier]bool{}}
for _, t := range tiers {
p.Allowed[Tier(t)] = true
}
return p
}
// 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 }