release / govulncheck (push) Successful in 21s
release / go (push) Failing after 33s
release / web (push) Successful in 56s
release / integration (push) Failing after 4m48s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
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
- internal/release: reads a release name (format, bitrate, bit depth and sample rate, source, year, video, discography) and judges it against the album wanted: the artist and title must be in it; video rips, discographies, other versions (drumless, instrumental, karaoke, remix, live unless the album is live…) and singles or EPs for an album are refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read for format, video files and a track count short of the album. - internal/acquire: fan-out across every search source with a narrowed search kept, a process-wide pacer per source, every candidate judged and kept with its reasons, and the grab: one live grab per want, tie-broken (a person's grab beats the schedule's; a better release replaces a worse one unless it is nearly done). Each download lands in its own folder under the client's downloads folder. - internal/wanted: the search schedule, every ten minutes, never-searched first, then requests, missing files and followed artists, taking the best release when auto-grab is on (the default). - Migration 0082: acquisition_settings (auto-grab, interval, allowed qualities seeded from Lidarr's "Any"), want search columns, want_candidates, grabs, release_blocks. - Web: the Wanted page shows each want's last search, a Search button and every release found with its verdict (Grab, Grab anyway, Never this one, Allow again). Integrations gains "Searching and grabbing". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
4.5 KiB
Go
170 lines
4.5 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] }
|