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>
179 lines
4.5 KiB
Go
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
|
|
}
|