feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
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>
This commit is contained in:
2026-10-10 08:56:47 -04:00
co-authored by Claude Opus 5.5
parent 1a1646a920
commit 89b3b44779
40 changed files with 5332 additions and 49 deletions
+169
View File
@@ -0,0 +1,169 @@
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] }