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>
272 lines
7.6 KiB
Go
272 lines
7.6 KiB
Go
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 }
|