Files
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

179 lines
4.5 KiB
Go

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
}