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
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:
@@ -0,0 +1,76 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// File is one file inside a torrent.
|
||||
type File struct {
|
||||
Index int
|
||||
Path string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Contents is what a torrent's file list holds.
|
||||
type Contents struct {
|
||||
Audio int
|
||||
Video int
|
||||
// Format is the format most of the audio files are in, "" when none.
|
||||
Format string
|
||||
}
|
||||
|
||||
var videoExt = map[string]bool{
|
||||
"mkv": true, "mp4": true, "avi": true, "m2ts": true, "vob": true,
|
||||
"mov": true, "webm": true, "wmv": true, "m4v": true, "ts": true,
|
||||
}
|
||||
|
||||
// Inspect counts a file list's audio and video files.
|
||||
func Inspect(files []File) Contents {
|
||||
var c Contents
|
||||
formats := map[string]int{}
|
||||
for _, f := range files {
|
||||
ext := strings.TrimPrefix(strings.ToLower(path.Ext(f.Path)), ".")
|
||||
if fm := formatOf(ext); fm != "" {
|
||||
c.Audio++
|
||||
formats[fm]++
|
||||
} else if videoExt[ext] {
|
||||
c.Video++
|
||||
}
|
||||
}
|
||||
best := 0
|
||||
for fm, n := range formats {
|
||||
if n > best || (n == best && formatRank[fm] > formatRank[c.Format]) {
|
||||
c.Format, best = fm, n
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// JudgeFiles says what a torrent's files say about whether it is the album:
|
||||
// a veto (code and sentence), or a doubt for a person, or nothing.
|
||||
func JudgeFiles(w Want, files []File) (veto, reason, doubt string) {
|
||||
if len(files) == 0 {
|
||||
return "", "", ""
|
||||
}
|
||||
c := Inspect(files)
|
||||
switch {
|
||||
case c.Audio == 0 && c.Video > 0:
|
||||
return VetoVideo, "It holds video files and no audio.", ""
|
||||
case c.Audio == 0:
|
||||
return VetoNoAudio, "It holds no audio files Minstrel can read.", ""
|
||||
case w.MinTracks > 0 && c.Audio < w.MinTracks:
|
||||
return VetoIncomplete, "It holds " + tracks(c.Audio) + "; the shortest edition has " + tracks(w.MinTracks) + ".", ""
|
||||
case w.MaxTracks > 0 && c.Audio > 2*w.MaxTracks+4:
|
||||
return "", "", "It holds " + tracks(c.Audio) + ", more than any edition of the album has: probably more than one album."
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
func tracks(n int) string {
|
||||
if n == 1 {
|
||||
return "1 track"
|
||||
}
|
||||
return strconv.Itoa(n) + " tracks"
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package release
|
||||
|
||||
import "strings"
|
||||
|
||||
// Want is the album a search is for, as the catalogue describes it.
|
||||
type Want struct {
|
||||
Artist string
|
||||
Title string
|
||||
Year int
|
||||
PrimaryType string // Album, EP, Single, Broadcast, Other
|
||||
SecondaryTypes []string
|
||||
// MinTracks and MaxTracks span the group's editions' track counts; zero
|
||||
// when the catalogue has not read them.
|
||||
MinTracks int
|
||||
MaxTracks int
|
||||
}
|
||||
|
||||
// Veto codes: why a name is not the album. Kept on a candidate as its
|
||||
// reason's code.
|
||||
const (
|
||||
VetoArtist = "wrong_artist"
|
||||
VetoTitle = "wrong_title"
|
||||
VetoVideo = "video"
|
||||
VetoDiscography = "discography"
|
||||
VetoVersion = "other_version"
|
||||
VetoType = "wrong_type"
|
||||
VetoNoAudio = "no_audio"
|
||||
VetoIncomplete = "incomplete"
|
||||
)
|
||||
|
||||
// Match is what a name says about whether it is the album wanted.
|
||||
type Match struct {
|
||||
// Veto is a code from above when the name is not the album, with Reason
|
||||
// saying why in a sentence.
|
||||
Veto string
|
||||
Reason string
|
||||
// Doubt is set when the name may be the album and cannot say for sure: a
|
||||
// person decides. It is a sentence.
|
||||
Doubt string
|
||||
// YearAgrees: the name states the album's year, give or take one.
|
||||
YearAgrees bool
|
||||
}
|
||||
|
||||
// versions are words that make a release a different recording of the
|
||||
// album, with what to call it. A word the wanted title or artist holds does
|
||||
// not count ("Live Through This", "Instrumental Core").
|
||||
var versions = []struct {
|
||||
words []string
|
||||
what string
|
||||
// typ is the MusicBrainz secondary type that wants this version.
|
||||
typ string
|
||||
}{
|
||||
{[]string{"instrumental", "instrumentals"}, "an instrumental version", ""},
|
||||
{[]string{"drumless"}, "a drumless edition", ""},
|
||||
{[]string{"karaoke"}, "a karaoke version", ""},
|
||||
{[]string{"acapella", "acappella", "accapella", "acapellas"}, "an a cappella version", ""},
|
||||
{[]string{"remix", "remixes", "remixed"}, "a remix release", "Remix"},
|
||||
{[]string{"live", "bootleg"}, "a live recording", "Live"},
|
||||
{[]string{"demo", "demos"}, "demo recordings", "Demo"},
|
||||
// Not "covers": in a release name that is usually the artwork scans.
|
||||
{[]string{"tribute", "lullaby", "lullabies"}, "a tribute release", ""},
|
||||
{[]string{"nightcore", "slowed", "sped"}, "a slowed or sped-up edit", ""},
|
||||
{[]string{"commentary", "interview", "interviews"}, "commentary or interviews", "Interview"},
|
||||
}
|
||||
|
||||
// collections are words naming more than one album.
|
||||
var collections = []string{"collection", "anthology", "albums", "boxset", "discography"}
|
||||
|
||||
// Judge says whether a release name is the album wanted. It looks only at
|
||||
// the name; JudgeFiles looks at what a torrent holds.
|
||||
func Judge(w Want, r Release) Match {
|
||||
var m Match
|
||||
m.YearAgrees = r.Year != 0 && w.Year != 0 && abs(r.Year-w.Year) <= 1
|
||||
if r.Video != "" {
|
||||
m.Veto, m.Reason = VetoVideo, "A video, not audio (“"+r.Video+"”)."
|
||||
return m
|
||||
}
|
||||
artist := Tokens(w.Artist)
|
||||
title := Tokens(w.Title)
|
||||
if len(artist) > 0 && !contains(r.Tokens, artist) && !contains(r.Tokens, withoutThe(artist)) {
|
||||
m.Veto, m.Reason = VetoArtist, "The name doesn’t mention "+w.Artist+"."
|
||||
return m
|
||||
}
|
||||
if len(title) > 0 && !contains(r.Tokens, title) && !contains(r.Tokens, bare(w.Title)) {
|
||||
m.Veto, m.Reason = VetoTitle, "The name doesn’t contain the title “"+w.Title+"”."
|
||||
return m
|
||||
}
|
||||
own := map[string]bool{}
|
||||
for _, t := range append(append([]string{}, title...), artist...) {
|
||||
own[t] = true
|
||||
}
|
||||
if r.Discography != "" && !own[r.Discography] {
|
||||
m.Veto, m.Reason = VetoDiscography, "A discography or collection ("+r.Discography+"), and one album is wanted."
|
||||
return m
|
||||
}
|
||||
for _, c := range collections {
|
||||
if !own[c] && has(r.Tokens, c) {
|
||||
m.Veto, m.Reason = VetoDiscography, "A collection of albums (“"+c+"”), and one album is wanted."
|
||||
return m
|
||||
}
|
||||
}
|
||||
for _, v := range versions {
|
||||
if v.typ != "" && hasType(w.SecondaryTypes, v.typ) {
|
||||
continue
|
||||
}
|
||||
for _, word := range v.words {
|
||||
if !own[word] && has(r.Tokens, word) {
|
||||
m.Veto, m.Reason = VetoVersion, "This is "+v.what+" (“"+word+"”)."
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.PrimaryType == "Album" || w.PrimaryType == "EP" {
|
||||
if !own["single"] && has(r.Tokens, "single") {
|
||||
m.Veto, m.Reason = VetoType, "A single, and the "+strings.ToLower(w.PrimaryType)+" is wanted."
|
||||
return m
|
||||
}
|
||||
}
|
||||
if w.PrimaryType == "Album" && !own["ep"] && has(r.Tokens, "ep") {
|
||||
m.Veto, m.Reason = VetoType, "An EP, and the album is wanted."
|
||||
return m
|
||||
}
|
||||
// A self-titled album: every release of the artist names it once. Two
|
||||
// mentions, or its year, say it is this one.
|
||||
if len(title) > 0 && equal(title, artist) && count(r.Tokens, title) < 2 && !m.YearAgrees {
|
||||
m.Doubt = "The album shares the artist’s name, and nothing else in the name says it is this album."
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func withoutThe(t []string) []string {
|
||||
if len(t) > 1 && t[0] == "the" {
|
||||
return t[1:]
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// bare is a title without a trailing bracketed part ("Album (Remastered)"),
|
||||
// for names that leave it off.
|
||||
func bare(title string) []string {
|
||||
if i := strings.IndexAny(title, "(["); i > 0 {
|
||||
return Tokens(title[:i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contains says whether want's words appear in name in order and together,
|
||||
// or, for two or more words, run together ("ACDC" for AC/DC). A single word
|
||||
// is never looked for run together: "muse" is inside "museum".
|
||||
func contains(name, want []string) bool {
|
||||
if len(want) == 0 {
|
||||
return false
|
||||
}
|
||||
if count(name, want) > 0 {
|
||||
return true
|
||||
}
|
||||
joined := strings.Join(want, "")
|
||||
if len(want) < 2 || len(joined) < 4 {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.Join(name, ""), joined)
|
||||
}
|
||||
|
||||
// count is how often want's words appear in name in order and together.
|
||||
func count(name, want []string) int {
|
||||
n := 0
|
||||
for i := 0; i+len(want) <= len(name); i++ {
|
||||
if equal(name[i:i+len(want)], want) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func has(tokens []string, word string) bool {
|
||||
for _, t := range tokens {
|
||||
if t == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasType(types []string, t string) bool {
|
||||
for _, x := range types {
|
||||
if strings.EqualFold(x, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Package release reads music release names and judges them against a
|
||||
// wanted album (M502 #5521): what a name says it is (format, bitrate, bit
|
||||
// depth, source, year, edition), whether it is the album wanted, and which
|
||||
// quality tier it sits in. Nothing here touches the network or the database,
|
||||
// so every rule is testable on a string.
|
||||
package release
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Formats a name can state.
|
||||
const (
|
||||
FormatFLAC = "FLAC"
|
||||
FormatALAC = "ALAC"
|
||||
FormatAPE = "APE"
|
||||
FormatWV = "WavPack"
|
||||
FormatWAV = "WAV"
|
||||
FormatAIFF = "AIFF"
|
||||
FormatDSD = "DSD"
|
||||
FormatMP3 = "MP3"
|
||||
FormatAAC = "AAC"
|
||||
FormatVorbis = "Vorbis"
|
||||
FormatOpus = "Opus"
|
||||
)
|
||||
|
||||
// Sources a name can state.
|
||||
const (
|
||||
SourceWEB = "WEB"
|
||||
SourceCD = "CD"
|
||||
SourceVinyl = "Vinyl"
|
||||
SourceSACD = "SACD"
|
||||
SourceCassette = "Cassette"
|
||||
)
|
||||
|
||||
// Release is what a release name says about itself. Zero values mean the
|
||||
// name does not say.
|
||||
type Release struct {
|
||||
Name string
|
||||
Year int
|
||||
Format string
|
||||
// Bitrate is a constant bitrate in kbps; VBR is a LAME preset (V0, V1, V2).
|
||||
Bitrate int
|
||||
VBR string
|
||||
// BitDepth (16, 24) and SampleRate (kHz) for lossless.
|
||||
BitDepth int
|
||||
SampleRate float64
|
||||
Source string
|
||||
// Video is the marker that says this is a video ("x264", "1080p"), or "".
|
||||
Video string
|
||||
// Discography: a year range or a word naming many albums.
|
||||
Discography string
|
||||
// Tokens are the name's words, normalised for matching.
|
||||
Tokens []string
|
||||
}
|
||||
|
||||
var (
|
||||
formatRe = regexp.MustCompile(`\b(flac|alac|ape|wavpack|wv|wav|aiff|dsd|dsf|dff|mp3|aac|m4a|ogg|vorbis|opus)\b`)
|
||||
// "24-96", "24/192", "16-44 1" (44.1 after dots become spaces).
|
||||
depthRateRe = regexp.MustCompile(`\b(16|24|32) ?[-/ ] ?(44 1|44|48|88 2|88|96|176 4|176|192|352 8|384)\b`)
|
||||
depthRe = regexp.MustCompile(`\b(16|24|32) ?-?bits?\b`)
|
||||
rateRe = regexp.MustCompile(`\b(44 1|48|88 2|96|176 4|192) ?khz\b`)
|
||||
hiResRe = regexp.MustCompile(`\bhi ?-?res\b`)
|
||||
codecRateRe = regexp.MustCompile(`\b(?:mp3|aac|m4a|cbr|ogg|opus)[ \-@]*(320|256|224|192|160|128|112|96|64)\b`)
|
||||
kbpsRe = regexp.MustCompile(`\b(320|256|224|192|160|128|112|96|64) ?(?:kbps|kbit|kb/s|k)\b`)
|
||||
vbrRe = regexp.MustCompile(`\bv([0-2])\b`)
|
||||
webRe = regexp.MustCompile(`\bweb(?: ?-?dl)?\b`)
|
||||
cdRe = regexp.MustCompile(`\bcd ?(?:rip)?\b`)
|
||||
vinylRe = regexp.MustCompile(`\b(?:vinyl|vinylrip|lp ?rip|needle ?drop)\b`)
|
||||
sacdRe = regexp.MustCompile(`\bsacd\b`)
|
||||
cassetteRe = regexp.MustCompile(`\b(?:cassette|tape ?rip)\b`)
|
||||
yearRe = regexp.MustCompile(`\b(19[3-9]\d|20[0-4]\d)\b`)
|
||||
bracketYear = regexp.MustCompile(`[(\[{] ?(19[3-9]\d|20[0-4]\d) ?[)\]}]`)
|
||||
yearRangeRe = regexp.MustCompile(`\b(19[3-9]\d|20[0-4]\d) ?(?:-|–|to) ?(19[3-9]\d|20[0-4]\d)\b`)
|
||||
videoRe = regexp.MustCompile(`\b(x264|x265|h ?264|h ?265|hevc|xvid|divx|1080p|1080i|720p|2160p|bluray|blu ?ray|bdrip|brrip|bdremux|dvdrip|dvd9|dvd5|webrip|hdtv|mkv|avi|m2ts)\b`)
|
||||
discogRe = regexp.MustCompile(`\b(discography|discografia|discographie|diskografie|discografie)\b`)
|
||||
)
|
||||
|
||||
// Parse reads what a release name says.
|
||||
func Parse(name string) Release {
|
||||
r := Release{Name: name}
|
||||
s := spaced(name)
|
||||
|
||||
r.Format = bestFormat(formatRe.FindAllStringSubmatch(s, -1))
|
||||
|
||||
if m := depthRateRe.FindStringSubmatch(s); m != nil {
|
||||
r.BitDepth, _ = strconv.Atoi(m[1])
|
||||
r.SampleRate = rate(m[2])
|
||||
}
|
||||
if r.BitDepth == 0 {
|
||||
if m := depthRe.FindStringSubmatch(s); m != nil {
|
||||
r.BitDepth, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
}
|
||||
if r.SampleRate == 0 {
|
||||
if m := rateRe.FindStringSubmatch(s); m != nil {
|
||||
r.SampleRate = rate(m[1])
|
||||
}
|
||||
}
|
||||
if r.BitDepth == 0 && hiResRe.MatchString(s) {
|
||||
r.BitDepth = 24
|
||||
}
|
||||
|
||||
if m := codecRateRe.FindStringSubmatch(s); m != nil {
|
||||
r.Bitrate, _ = strconv.Atoi(m[1])
|
||||
} else if m := kbpsRe.FindStringSubmatch(s); m != nil {
|
||||
r.Bitrate, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
// A bare "V0" is a LAME preset only beside MP3 or VBR; elsewhere it can
|
||||
// be part of a title.
|
||||
if m := vbrRe.FindStringSubmatch(s); m != nil && (r.Format == FormatMP3 || strings.Contains(s, "vbr")) {
|
||||
r.VBR = "V" + m[1]
|
||||
}
|
||||
if r.Format == "" && (r.Bitrate > 0 || r.VBR != "") {
|
||||
r.Format = FormatMP3
|
||||
}
|
||||
|
||||
switch {
|
||||
case sacdRe.MatchString(s):
|
||||
r.Source = SourceSACD
|
||||
case vinylRe.MatchString(s):
|
||||
r.Source = SourceVinyl
|
||||
case webRe.MatchString(s):
|
||||
r.Source = SourceWEB
|
||||
case cassetteRe.MatchString(s):
|
||||
r.Source = SourceCassette
|
||||
case cdRe.MatchString(s):
|
||||
r.Source = SourceCD
|
||||
}
|
||||
|
||||
if m := videoRe.FindStringSubmatch(s); m != nil {
|
||||
r.Video = m[1]
|
||||
}
|
||||
if m := discogRe.FindStringSubmatch(s); m != nil {
|
||||
r.Discography = m[1]
|
||||
} else if m := yearRangeRe.FindStringSubmatch(s); m != nil && m[2] > m[1] {
|
||||
r.Discography = m[1] + "–" + m[2]
|
||||
}
|
||||
r.Year = year(s)
|
||||
r.Tokens = Tokens(name)
|
||||
return r
|
||||
}
|
||||
|
||||
// spaced lowercases a name and turns its separators into spaces, keeping
|
||||
// the slash in "24/96" and "kb/s".
|
||||
func spaced(name string) string {
|
||||
var b strings.Builder
|
||||
for _, c := range strings.ToLower(name) {
|
||||
switch c {
|
||||
case '_', '.', ',':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rate reads a sample rate in kHz; "44", "88" and "176" are the short
|
||||
// spellings of 44.1, 88.2 and 176.4.
|
||||
func rate(s string) float64 {
|
||||
switch s {
|
||||
case "44":
|
||||
return 44.1
|
||||
case "88":
|
||||
return 88.2
|
||||
case "176":
|
||||
return 176.4
|
||||
}
|
||||
f, _ := strconv.ParseFloat(strings.ReplaceAll(s, " ", "."), 64)
|
||||
return f
|
||||
}
|
||||
|
||||
// formatRank orders formats a name might state together ("FLAC + MP3"):
|
||||
// the best is the one it is described by.
|
||||
var formatRank = map[string]int{
|
||||
FormatDSD: 11, FormatFLAC: 10, FormatALAC: 9, FormatWV: 8, FormatAPE: 7, FormatWAV: 6, FormatAIFF: 5,
|
||||
FormatOpus: 4, FormatAAC: 3, FormatVorbis: 2, FormatMP3: 1,
|
||||
}
|
||||
|
||||
func bestFormat(found [][]string) string {
|
||||
best := ""
|
||||
for _, m := range found {
|
||||
f := formatOf(m[1])
|
||||
if formatRank[f] > formatRank[best] {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// formatOf names the format a word or file extension stands for.
|
||||
func formatOf(word string) string {
|
||||
switch word {
|
||||
case "flac":
|
||||
return FormatFLAC
|
||||
case "alac":
|
||||
return FormatALAC
|
||||
case "ape":
|
||||
return FormatAPE
|
||||
case "wavpack", "wv":
|
||||
return FormatWV
|
||||
case "wav":
|
||||
return FormatWAV
|
||||
case "aiff", "aif":
|
||||
return FormatAIFF
|
||||
case "dsd", "dsf", "dff":
|
||||
return FormatDSD
|
||||
case "mp3":
|
||||
return FormatMP3
|
||||
case "aac", "m4a":
|
||||
return FormatAAC
|
||||
case "ogg", "vorbis":
|
||||
return FormatVorbis
|
||||
case "opus":
|
||||
return FormatOpus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// year is the release year a name states: one in brackets when there is
|
||||
// one (so the title "1989" is not read as its year), else the last.
|
||||
func year(s string) int {
|
||||
if m := bracketYear.FindStringSubmatch(s); m != nil {
|
||||
y, _ := strconv.Atoi(m[1])
|
||||
return y
|
||||
}
|
||||
all := yearRe.FindAllString(s, -1)
|
||||
if len(all) == 0 {
|
||||
return 0
|
||||
}
|
||||
y, _ := strconv.Atoi(all[len(all)-1])
|
||||
return y
|
||||
}
|
||||
|
||||
// fold maps the accented letters music names carry to plain ones.
|
||||
var fold = map[rune]string{
|
||||
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'ā': "a", 'ą': "a",
|
||||
'æ': "ae", 'ç': "c", 'ć': "c", 'č': "c",
|
||||
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ē': "e", 'ė': "e", 'ę': "e", 'ě': "e",
|
||||
'ì': "i", 'í': "i", 'î': "i", 'ï': "i", 'ī': "i", 'ı': "i",
|
||||
'ñ': "n", 'ń': "n", 'ň': "n",
|
||||
'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o", 'ō': "o", 'ő': "o",
|
||||
'œ': "oe", 'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ū': "u", 'ů': "u", 'ű': "u",
|
||||
'ý': "y", 'ÿ': "y", 'ß': "ss", 'š': "s", 'ś': "s", 'ş': "s",
|
||||
'ž': "z", 'ź': "z", 'ż': "z", 'ł': "l", 'đ': "d", 'ð': "d", 'þ': "th", 'ř': "r",
|
||||
}
|
||||
|
||||
// abbreviations are spelled out on both sides before comparing.
|
||||
var abbreviations = map[string]string{"vol": "volume", "pt": "part", "n": "and"}
|
||||
|
||||
// Tokens are a name's words for matching: lowercase, accents folded, "&"
|
||||
// and "+" read as "and", apostrophes dropped (so "Don't" is "dont"), every
|
||||
// other mark a word break.
|
||||
func Tokens(s string) []string {
|
||||
var b strings.Builder
|
||||
for _, c := range strings.ToLower(s) {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
||||
b.WriteRune(c)
|
||||
case c == '\'' || c == '’' || c == '`' || c == '´':
|
||||
case c == '&' || c == '+':
|
||||
b.WriteString(" and ")
|
||||
default:
|
||||
if f, ok := fold[c]; ok {
|
||||
b.WriteString(f)
|
||||
} else if c > 127 && isLetter(c) {
|
||||
b.WriteRune(c)
|
||||
} else {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
words := strings.Fields(b.String())
|
||||
for i, w := range words {
|
||||
if full, ok := abbreviations[w]; ok {
|
||||
words[i] = full
|
||||
}
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// isLetter keeps letters of scripts the fold table does not cover (Cyrillic,
|
||||
// CJK) as letters rather than breaks.
|
||||
func isLetter(c rune) bool {
|
||||
return (c >= 0x0400 && c <= 0x04FF) || (c >= 0x0370 && c <= 0x03FF) || c >= 0x3040
|
||||
}
|
||||
@@ -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] }
|
||||
@@ -0,0 +1,251 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Reading a .torrent file, ported from Fabled Librarian (#4463). A .torrent
|
||||
// is the metadata: its info dictionary names every file, and its SHA-1 is the
|
||||
// infohash. So a release offered as a .torrent link can be judged on what it
|
||||
// holds before any of it is downloaded. Bencode is four shapes; walking them
|
||||
// here costs less than a dependency.
|
||||
|
||||
// TorrentMeta is what a .torrent says about itself.
|
||||
type TorrentMeta struct {
|
||||
// InfoHash is the v1 infohash: the SHA-1 of the info dictionary's own
|
||||
// bytes, lowercase hex.
|
||||
InfoHash string
|
||||
Name string
|
||||
Files []File
|
||||
}
|
||||
|
||||
// ErrNotTorrent is bytes that are not a .torrent.
|
||||
var ErrNotTorrent = errors.New("not a .torrent file")
|
||||
|
||||
// maxTorrentBytes bounds what is read, as Exchange bounds what it accepts.
|
||||
const maxTorrentBytes = 10 << 20
|
||||
|
||||
// ParseTorrent reads a .torrent's info dictionary.
|
||||
func ParseTorrent(b []byte) (TorrentMeta, error) {
|
||||
if len(b) == 0 || len(b) > maxTorrentBytes || b[0] != 'd' {
|
||||
return TorrentMeta{}, ErrNotTorrent
|
||||
}
|
||||
d := &bdecoder{b: b}
|
||||
root, err := d.value(0)
|
||||
if err != nil {
|
||||
return TorrentMeta{}, fmt.Errorf("%w: %v", ErrNotTorrent, err)
|
||||
}
|
||||
top, ok := root.(map[string]any)
|
||||
if !ok || d.infoEnd == 0 {
|
||||
return TorrentMeta{}, fmt.Errorf("%w: no info dictionary", ErrNotTorrent)
|
||||
}
|
||||
info, ok := top["info"].(map[string]any)
|
||||
if !ok {
|
||||
return TorrentMeta{}, fmt.Errorf("%w: no info dictionary", ErrNotTorrent)
|
||||
}
|
||||
sum := sha1.Sum(b[d.infoStart:d.infoEnd])
|
||||
out := TorrentMeta{InfoHash: hex.EncodeToString(sum[:])}
|
||||
out.Name, _ = info["name"].(string)
|
||||
|
||||
files, multi := info["files"].([]any)
|
||||
if !multi {
|
||||
// A single-file torrent: the name IS the file.
|
||||
length, _ := info["length"].(int64)
|
||||
out.Files = []File{{Path: out.Name, Size: length}}
|
||||
return out, nil
|
||||
}
|
||||
for index, f := range files {
|
||||
entry, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts, _ := entry["path"].([]any)
|
||||
segs := []string{out.Name}
|
||||
for _, p := range parts {
|
||||
if s, ok := p.(string); ok {
|
||||
segs = append(segs, s)
|
||||
}
|
||||
}
|
||||
length, _ := entry["length"].(int64)
|
||||
// BEP 47 padding files carry no content.
|
||||
if attr, _ := entry["attr"].(string); strings.Contains(attr, "p") {
|
||||
continue
|
||||
}
|
||||
out.Files = append(out.Files, File{Index: index, Path: strings.Join(segs, "/"), Size: length})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bdecoder walks bencode, and remembers where the top-level info dictionary
|
||||
// sits so its raw bytes can be hashed exactly as published.
|
||||
type bdecoder struct {
|
||||
b []byte
|
||||
i int
|
||||
infoStart, infoEnd int
|
||||
}
|
||||
|
||||
// maxDepth: a real torrent nests four deep. Anything past this is hostile.
|
||||
const maxDepth = 32
|
||||
|
||||
func (d *bdecoder) value(depth int) (any, error) {
|
||||
if depth > maxDepth {
|
||||
return nil, errors.New("nested too deep")
|
||||
}
|
||||
if d.i >= len(d.b) {
|
||||
return nil, errors.New("truncated")
|
||||
}
|
||||
switch c := d.b[d.i]; {
|
||||
case c == 'i':
|
||||
end := bytes.IndexByte(d.b[d.i:], 'e')
|
||||
if end < 0 {
|
||||
return nil, errors.New("unterminated integer")
|
||||
}
|
||||
n, err := strconv.ParseInt(string(d.b[d.i+1:d.i+end]), 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.i += end + 1
|
||||
return n, nil
|
||||
case c == 'l':
|
||||
d.i++
|
||||
var out []any
|
||||
for d.i < len(d.b) && d.b[d.i] != 'e' {
|
||||
v, err := d.value(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if d.i >= len(d.b) {
|
||||
return nil, errors.New("unterminated list")
|
||||
}
|
||||
d.i++
|
||||
return out, nil
|
||||
case c == 'd':
|
||||
d.i++
|
||||
out := map[string]any{}
|
||||
for d.i < len(d.b) && d.b[d.i] != 'e' {
|
||||
k, err := d.str()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := d.i
|
||||
v, err := d.value(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if depth == 0 && k == "info" {
|
||||
d.infoStart, d.infoEnd = start, d.i
|
||||
}
|
||||
// Pieces are 20 bytes a piece and never read: not kept.
|
||||
if k != "pieces" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if d.i >= len(d.b) {
|
||||
return nil, errors.New("unterminated dictionary")
|
||||
}
|
||||
d.i++
|
||||
return out, nil
|
||||
case c >= '0' && c <= '9':
|
||||
return d.str()
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *bdecoder) str() (string, error) {
|
||||
colon := bytes.IndexByte(d.b[d.i:], ':')
|
||||
if colon < 0 {
|
||||
return "", errors.New("unterminated string length")
|
||||
}
|
||||
n, err := strconv.Atoi(string(d.b[d.i : d.i+colon]))
|
||||
if err != nil || n < 0 {
|
||||
return "", errors.New("bad string length")
|
||||
}
|
||||
start := d.i + colon + 1
|
||||
if start+n > len(d.b) {
|
||||
return "", errors.New("string runs past the end")
|
||||
}
|
||||
d.i = start + n
|
||||
return string(d.b[start : start+n]), nil
|
||||
}
|
||||
Reference in New Issue
Block a user