Files
minstrel/internal/release/quality.go
T
bvandeusenandClaude Opus 5.5 3a16732883
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:48:20 -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.
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: 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: an album is as good as 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 }