Files
minstrel/internal/release/release_test.go
T
bvandeusenandClaude Opus 5.5 89b3b44779
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
feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
- 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>
2026-10-10 08:56:47 -04:00

252 lines
9.9 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 (
"crypto/sha1"
"encoding/hex"
"fmt"
"strings"
"testing"
)
func TestParse_ReadsWhatTheNameSays(t *testing.T) {
for _, c := range []struct {
name string
format, vbr, src string
bitrate, depth, yr int
rate float64
tier Tier
}{
// The wrong edition Lidarr grabbed on 2026-10-09 (note #5527).
{"Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", FormatFLAC, "", "", 0, 24, 2023, 88.2, TierLosslessHiRes},
{"Gorillaz - Humanz - (Japanese Edition) - [CD-FLAC] 2017", FormatFLAC, "", SourceCD, 0, 0, 2017, 0, TierLossless},
{"Radiohead - OK Computer (1997) [MP3 320]", FormatMP3, "", "", 320, 0, 1997, 0, TierLossyHigh},
{"Artist - Album 2019 MP3 V0", FormatMP3, "V0", "", 0, 0, 2019, 0, TierLossyHigh},
{"Artist - Album (2020) [WEB] [AAC 256]", FormatAAC, "", SourceWEB, 256, 0, 2020, 0, TierLossyHigh},
{"Artist_-_Album-(2004)-192kbps", FormatMP3, "", "", 192, 0, 2004, 0, TierLossyLow},
{"Artist - Album [24bit-96kHz] FLAC Vinyl", FormatFLAC, "", SourceVinyl, 0, 24, 0, 96, TierLosslessHiRes},
{"Taylor Swift - 1989 (2014) [FLAC]", FormatFLAC, "", "", 0, 0, 2014, 0, TierLossless},
{"Artist - Album", "", "", "", 0, 0, 0, 0, TierUnknown},
} {
r := Parse(c.name)
got := fmt.Sprint(r.Format, r.VBR, r.Source, r.Bitrate, r.BitDepth, r.Year, r.SampleRate, TierOf(r, 0))
want := fmt.Sprint(c.format, c.vbr, c.src, c.bitrate, c.depth, c.yr, c.rate, c.tier)
if got != want {
t.Errorf("Parse(%q)\n got %s\nwant %s", c.name, got, want)
}
}
}
func TestParse_VideoAndDiscographies(t *testing.T) {
if v := Parse("Artist.Album.2018.1080p.BluRay.x264").Video; v != "1080p" {
t.Errorf("video = %q", v)
}
if d := Parse("Artist Discography 1994-2010 FLAC").Discography; d != "discography" {
t.Errorf("discography = %q", d)
}
if d := Parse("Artist - Studio Albums 1994 - 2010 [MP3]").Discography; d != "1994–2010" {
t.Errorf("year range = %q", d)
}
if d := Parse("Artist - Album (2010) [FLAC]").Discography; d != "" {
t.Errorf("one year read as a range: %q", d)
}
}
func judge(w Want, name string) Match { return Judge(w, Parse(name)) }
func TestJudge_TheAlbumAndWhatIsNot(t *testing.T) {
ram := Want{Artist: "Daft Punk", Title: "Random Access Memories", Year: 2013, PrimaryType: "Album"}
for name, veto := range map[string]string{
"Daft Punk - Random Access Memories (2013) [FLAC]": "",
"Daft_Punk-Random_Access_Memories-WEB-2013": "",
"Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]": VetoVersion,
"Daft Punk - Random Access Memories (Instrumentals) [MP3]": VetoVersion,
"Daft Punk - Random Access Memories Remixes": VetoVersion,
"Daft Punk - Random Access Memories (Live) FLAC": VetoVersion,
"Daft Punk - Discovery (2001) [FLAC]": VetoTitle,
"Random Access Memories (2013) [FLAC]": VetoArtist,
"Daft Punk - Discography 1997-2013 FLAC": VetoTitle,
"Daft Punk - Random Access Memories + Discovery + Homework Collection": VetoDiscography,
"Daft Punk - Random Access Memories (2013) 1080p BluRay": VetoVideo,
"Daft Punk - Random Access Memories (Single) 2013": VetoType,
"Daft Punk - Random Access Memories (Deluxe 10th Anniversary Edition) 2023": "",
} {
if got := judge(ram, name).Veto; got != veto {
t.Errorf("%q: veto %q, want %q", name, got, veto)
}
}
if !judge(ram, "Daft Punk - Random Access Memories (2013) [FLAC]").YearAgrees {
t.Error("the year did not agree")
}
}
func TestJudge_WordsTheWantedTitleHoldsDoNotVeto(t *testing.T) {
hole := Want{Artist: "Hole", Title: "Live Through This", PrimaryType: "Album"}
if m := judge(hole, "Hole - Live Through This (1994) [FLAC]"); m.Veto != "" {
t.Errorf("Live Through This: %s", m.Reason)
}
alive := Want{Artist: "Daft Punk", Title: "Alive 2007", PrimaryType: "Album", SecondaryTypes: []string{"Live"}}
if m := judge(alive, "Daft Punk - Alive 2007 (Live) [FLAC]"); m.Veto != "" {
t.Errorf("a wanted live album: %s", m.Reason)
}
alive.SecondaryTypes = nil
if m := judge(alive, "Daft Punk - Alive 2007 (Live) [FLAC]"); m.Veto != VetoVersion {
t.Errorf("live without the Live type: veto %q", m.Veto)
}
}
func TestJudge_SpellingsOfNames(t *testing.T) {
for _, c := range []struct {
w Want
name string
}{
{Want{Artist: "AC/DC", Title: "Back in Black"}, "ACDC - Back In Black (1980) FLAC"},
{Want{Artist: "AC/DC", Title: "Back in Black"}, "AC-DC - Back in Black [MP3 320]"},
{Want{Artist: "Sigur Rós", Title: "Ágætis byrjun"}, "Sigur Ros - Agaetis Byrjun (1999) [FLAC]"},
{Want{Artist: "Simon & Garfunkel", Title: "Bookends"}, "Simon and Garfunkel - Bookends"},
{Want{Artist: "The Beatles", Title: "Abbey Road"}, "Beatles - Abbey Road (Remastered 2009)"},
{Want{Artist: "Florence + the Machine", Title: "Lungs"}, "Florence and The Machine - Lungs (2009)"},
{Want{Artist: "Guns N' Roses", Title: "Appetite for Destruction"}, "Guns N Roses - Appetite For Destruction"},
} {
if m := judge(c.w, c.name); m.Veto != "" {
t.Errorf("%q for %s / %s: %s", c.name, c.w.Artist, c.w.Title, m.Reason)
}
}
// One word is never matched run together: Muse is not inside Museum.
if m := judge(Want{Artist: "Muse", Title: "Absolution"}, "Museum - Absolution"); m.Veto != VetoArtist {
t.Errorf("Museum read as Muse: %q", m.Veto)
}
}
func TestJudge_ASelfTitledAlbumNeedsMoreThanTheName(t *testing.T) {
weezer := Want{Artist: "Weezer", Title: "Weezer", Year: 1994, PrimaryType: "Album"}
if m := judge(weezer, "Weezer - Weezer (Blue Album) [FLAC]"); m.Doubt != "" || m.Veto != "" {
t.Errorf("named twice: %+v", m)
}
if m := judge(weezer, "Weezer (1994) [FLAC]"); m.Doubt != "" {
t.Errorf("the year agrees: %+v", m)
}
if m := judge(weezer, "Weezer (Green Album) FLAC"); m.Doubt == "" {
t.Error("a self-titled name with nothing else was taken as sure")
}
}
func TestTierOf_TheLadder(t *testing.T) {
for _, c := range []struct {
r Release
cat int
want Tier
}{
{Release{Format: FormatMP3, Bitrate: 256}, 0, TierLossyStandard},
{Release{Format: FormatMP3, Bitrate: 128}, 0, TierLossyPoor},
{Release{Format: FormatMP3, Bitrate: 96}, 0, TierLossyTrash},
{Release{Format: FormatMP3, VBR: "V2"}, 0, TierLossyStandard},
{Release{Format: FormatMP3}, 0, TierLossyStandard},
{Release{Format: FormatAAC, Bitrate: 192}, 0, TierLossyStandard},
{Release{Format: FormatALAC, SampleRate: 96}, 0, TierLosslessHiRes},
{Release{Format: FormatDSD}, 0, TierLosslessHiRes},
{Release{}, 3040, TierLossless},
{Release{}, 3010, TierUnknown},
} {
if got := TierOf(c.r, c.cat); got != c.want {
t.Errorf("TierOf(%+v, %d) = %s, want %s", c.r, c.cat, got, c.want)
}
}
if Rank(TierLossless) <= Rank(TierLossyHigh) || Rank(TierUnknown) >= Rank(TierLossyTrash) {
t.Error("the ladder is out of order")
}
if !NewProfile([]string{"lossless"}).Allows(TierLossless) || NewProfile([]string{"lossless"}).Allows(TierUnknown) {
t.Error("profile")
}
}
func files(paths ...string) []File {
out := make([]File, len(paths))
for i, p := range paths {
out[i] = File{Index: i, Path: p, Size: 1 << 20}
}
return out
}
func numbered(n int, ext string) []string {
var out []string
for i := 1; i <= n; i++ {
out = append(out, fmt.Sprintf("Album/%02d - Track.%s", i, ext))
}
return out
}
func TestJudgeFiles_WhatTheTorrentHolds(t *testing.T) {
w := Want{MinTracks: 10, MaxTracks: 13}
if veto, _, _ := JudgeFiles(w, files(numbered(13, "flac")...)); veto != "" {
t.Errorf("a whole album: %s", veto)
}
if veto, reason, _ := JudgeFiles(w, files(numbered(3, "flac")...)); veto != VetoIncomplete || !strings.Contains(reason, "3 tracks") {
t.Errorf("three tracks: %s %s", veto, reason)
}
if veto, _, _ := JudgeFiles(w, files("Concert/Concert.mkv", "Concert/cover.jpg")); veto != VetoVideo {
t.Errorf("a video: %s", veto)
}
if veto, _, _ := JudgeFiles(w, files("Album.rar", "Album.r00")); veto != VetoNoAudio {
t.Errorf("an archive: %s", veto)
}
if _, _, doubt := JudgeFiles(w, files(numbered(60, "mp3")...)); doubt == "" {
t.Error("sixty tracks for a thirteen-track album raised no doubt")
}
if c := Inspect(files(append(numbered(12, "flac"), "Album/cover.jpg", "Album/album.cue")...)); c.Format != FormatFLAC || c.Audio != 12 {
t.Errorf("inspect = %+v", c)
}
}
// bencode writes the few shapes a test torrent needs.
func bencode(v any) string {
switch x := v.(type) {
case string:
return fmt.Sprintf("%d:%s", len(x), x)
case int:
return fmt.Sprintf("i%de", x)
case []any:
var b strings.Builder
b.WriteString("l")
for _, e := range x {
b.WriteString(bencode(e))
}
return b.String() + "e"
case [][2]any: // an ordered dictionary
var b strings.Builder
b.WriteString("d")
for _, kv := range x {
b.WriteString(bencode(kv[0]) + bencode(kv[1]))
}
return b.String() + "e"
}
panic(fmt.Sprintf("bencode %T", v))
}
func TestParseTorrent_FilesAndInfohash(t *testing.T) {
info := bencode([][2]any{
{"files", []any{
[][2]any{{"length", 300}, {"path", []any{"01 - One.flac"}}},
[][2]any{{"attr", "p"}, {"length", 12}, {"path", []any{".pad", "12"}}},
[][2]any{{"length", 200}, {"path", []any{"Scans", "front.jpg"}}},
}},
{"name", "Artist - Album"},
{"piece length", 16384},
{"pieces", strings.Repeat("x", 20)},
})
body := "d" + bencode("announce") + bencode("http://tracker/") + bencode("info") + info + "e"
meta, err := ParseTorrent([]byte(body))
if err != nil {
t.Fatal(err)
}
sum := sha1.Sum([]byte(info))
if meta.InfoHash != hex.EncodeToString(sum[:]) {
t.Errorf("infohash = %s", meta.InfoHash)
}
if len(meta.Files) != 2 || meta.Files[0].Path != "Artist - Album/01 - One.flac" || meta.Files[1].Path != "Artist - Album/Scans/front.jpg" {
t.Errorf("files = %+v", meta.Files)
}
if _, err := ParseTorrent([]byte("<html>not a torrent</html>")); err == nil {
t.Error("HTML parsed as a torrent")
}
}