Files
minstrel/internal/acquire/memory.go
T
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

169 lines
4.9 KiB
Go

package acquire
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Block reasons, matching release_blocks_reason_check.
const (
BlockOperator = "operator"
BlockFiles = "files"
)
// Grab origins, matching grabs_origin_check.
const (
OriginSchedule = "schedule"
OriginOperator = "operator"
)
// Memory is what searches and grabs leave behind: every candidate of a
// want's last search, the grabs, and the releases set aside.
type Memory struct {
pool *pgxpool.Pool
}
// NewMemory returns a Memory over pool.
func NewMemory(pool *pgxpool.Pool) *Memory { return &Memory{pool: pool} }
// Keep replaces a want's candidates with this search's, in order.
func (m *Memory) Keep(ctx context.Context, wantID pgtype.UUID, cands []Candidate) error {
return pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
if err := q.DeleteWantCandidates(ctx, wantID); err != nil {
return err
}
for i, c := range cands {
reasons, err := json.Marshal(c.Reasons)
if err != nil {
return err
}
if c.Reasons == nil {
reasons = []byte("[]")
}
folded := c.FoldedFrom
if folded == nil {
folded = []string{}
}
if err := q.InsertWantCandidate(ctx, dbq.InsertWantCandidateParams{
WantID: wantID, Key: Key(c), Name: c.Name, InfoHash: c.InfoHash, Indexer: c.Indexer,
Source: c.Source.Source, SizeBytes: c.SizeBytes, Seeders: int32(c.Seeders),
FilesCount: int32(c.FilesCount), Format: release.Describe(c.Release), Quality: string(c.Tier),
Year: int32(c.Year), Verdict: string(c.Verdict), Reason: c.Reason, Reasons: reasons,
FoldedFrom: folded, Rank: int32(i),
}); err != nil {
return err
}
}
return nil
})
}
// blocked is every release set aside, by infohash, with the sentence a
// candidate shows. A failed read blocks nothing: offering a release set
// aside costs one grab a person can undo, while blocking everything on a
// database blip would hide every result.
func (m *Memory) blocked(ctx context.Context) map[string]string {
if m == nil {
return nil
}
rows, err := dbq.New(m.pool).ListReleaseBlocks(ctx)
if err != nil {
return nil
}
out := make(map[string]string, len(rows))
for _, r := range rows {
out[indexer.NormalizeHash(r.InfoHash)] = BlockSentence(r.Reason, r.Detail)
}
return out
}
// BlockSentence is what a blocked candidate says.
func BlockSentence(reason, detail string) string {
if reason == BlockFiles {
if detail != "" {
return "Set aside for what its files hold: " + detail
}
return "Set aside for what its files hold."
}
return "You set this release aside."
}
// Block sets a release aside and marks every kept candidate of it blocked.
func (m *Memory) Block(ctx context.Context, p dbq.BlockReleaseParams) error {
p.InfoHash = indexer.NormalizeHash(p.InfoHash)
if p.InfoHash == "" {
return errors.New("a release without an infohash cannot be set aside")
}
return pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
if err := q.BlockRelease(ctx, p); err != nil {
return err
}
return q.MarkCandidatesBlocked(ctx, dbq.MarkCandidatesBlockedParams{
InfoHash: p.InfoHash, Reason: BlockSentence(p.Reason, p.Detail),
})
})
}
// Unblock lets a release be taken again. Its kept candidates wait for a
// person until the next search judges them afresh.
func (m *Memory) Unblock(ctx context.Context, hash string) (bool, error) {
hash = indexer.NormalizeHash(hash)
var n int64
err := pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
var err error
if n, err = q.UnblockRelease(ctx, hash); err != nil {
return err
}
return q.MarkCandidatesUnblocked(ctx, dbq.MarkCandidatesUnblockedParams{
InfoHash: hash, Reason: "Allowed again. The next search judges it afresh.",
})
})
return n > 0, err
}
// LiveGrab is the download running for a want, if there is one.
func (m *Memory) LiveGrab(ctx context.Context, wantID pgtype.UUID) (dbq.Grab, bool, error) {
g, err := dbq.New(m.pool).GetLiveGrab(ctx, wantID)
if errors.Is(err, pgx.ErrNoRows) {
return g, false, nil
}
if err != nil {
return g, false, err
}
return g, true, nil
}
// Grabbed records a download started, ending the grab it replaced first so
// the one-live-grab index holds.
func (m *Memory) Grabbed(ctx context.Context, p dbq.InsertGrabParams, replaced pgtype.UUID, why string) (dbq.Grab, error) {
var g dbq.Grab
err := pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
if replaced.Valid {
if err := q.EndGrab(ctx, dbq.EndGrabParams{ID: replaced, EndReason: "superseded", EndDetail: why}); err != nil {
return err
}
}
var err error
g, err = q.InsertGrab(ctx, p)
return err
})
if err != nil {
return g, fmt.Errorf("record grab: %w", err)
}
return g, nil
}