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 }