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,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