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>
312 lines
12 KiB
Go
312 lines
12 KiB
Go
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 - Random Access Memories (BBC Sessions) FLAC": VetoVersion,
|
||
"Daft Punk - Random Access Memories Unplugged [MP3]": 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")
|
||
}
|
||
}
|
||
|
||
func TestTierOfFile(t *testing.T) {
|
||
cases := []struct {
|
||
format string
|
||
kbps int
|
||
want Tier
|
||
ok bool
|
||
}{
|
||
{"flac", 900, TierLossless, true},
|
||
{"FLAC", 2800, TierLosslessHiRes, true},
|
||
{"m4a", 750, TierLossless, true},
|
||
{"m4a", 256, TierLossyHigh, true},
|
||
{"mp3", 320, TierLossyHigh, true},
|
||
{"mp3", 245, TierLossyHigh, true}, // a V0, by size
|
||
{"mp3", 192, TierLossyLow, true},
|
||
{"mp3", 128, TierLossyPoor, true},
|
||
{"mp3", 96, TierLossyTrash, true},
|
||
{"opus", 160, TierLossyLow, true},
|
||
{"wma", 192, TierUnknown, false},
|
||
{"mp3", 0, TierLossyStandard, false},
|
||
}
|
||
for _, c := range cases {
|
||
got, ok := TierOfFile(c.format, c.kbps)
|
||
if got != c.want || ok != c.ok {
|
||
t.Errorf("TierOfFile(%q, %d) = %s, %v; want %s, %v", c.format, c.kbps, got, ok, c.want, c.ok)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestFileKbpsAndWords(t *testing.T) {
|
||
// 5 MB over 3:30 of audio is about 190 kbps.
|
||
k := FileKbps(5_000_000, 210_000)
|
||
if k != 190 {
|
||
t.Fatalf("FileKbps = %d, want 190", k)
|
||
}
|
||
if got := HeldWords("mp3", TierLossyLow, 191); got != "MP3, about 192 kbps" {
|
||
t.Errorf("HeldWords = %q", got)
|
||
}
|
||
if got := HeldWords("flac", TierLossless, 900); got != "FLAC" {
|
||
t.Errorf("HeldWords = %q", got)
|
||
}
|
||
if FileKbps(0, 1000) != 0 || FileKbps(1000, 0) != 0 {
|
||
t.Error("FileKbps with nothing to measure is 0")
|
||
}
|
||
}
|
||
|
||
func TestHeldCopy_TakesTheWorstFormat(t *testing.T) {
|
||
h, ok := HeldCopy([]HeldFormat{
|
||
{Format: "flac", Bytes: 300_000_000, Ms: 2_400_000},
|
||
{Format: "mp3", Bytes: 4_800_000, Ms: 200_000}, // 192 kbps
|
||
})
|
||
if !ok || h.Tier != TierLossyLow || h.Words != "MP3, about 192 kbps" {
|
||
t.Fatalf("HeldCopy = %+v, %v", h, ok)
|
||
}
|
||
if _, ok := HeldCopy([]HeldFormat{{Format: "wma", Bytes: 1, Ms: 1}}); ok {
|
||
t.Error("a format the ladder can't place says nothing")
|
||
}
|
||
}
|