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

- 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:
2026-10-10 08:56:47 -04:00
co-authored by Claude Opus 5.5
parent 1a1646a920
commit 89b3b44779
40 changed files with 5332 additions and 49 deletions
+67
View File
@@ -217,6 +217,16 @@ type AcquisitionService struct {
UpdatedAt pgtype.Timestamptz
}
type AcquisitionSetting struct {
ID bool
AutoGrab bool
SearchIntervalHours int32
AllowedQualities []string
LastPassAt pgtype.Timestamptz
LastPass []byte
UpdatedAt pgtype.Timestamptz
}
type Album struct {
ID pgtype.UUID
Title string
@@ -420,6 +430,26 @@ type GeneralLikesArtist struct {
LikedAt pgtype.Timestamptz
}
type Grab struct {
ID pgtype.UUID
WantID pgtype.UUID
InfoHash string
Name string
Key string
Indexer string
Source string
Client string
SavePath string
Quality string
Origin string
Forced bool
GrabbedBy pgtype.UUID
GrabbedAt pgtype.Timestamptz
EndedAt pgtype.Timestamptz
EndReason string
EndDetail string
}
type LibraryChange struct {
ID int64
EntityType string
@@ -652,6 +682,16 @@ type RegistrationSetting struct {
Mode string
}
type ReleaseBlock struct {
InfoHash string
Name string
Reason string
Detail string
WantID pgtype.UUID
BlockedBy pgtype.UUID
BlockedAt pgtype.Timestamptz
}
type ReleaseTypeProfile struct {
ID pgtype.UUID
Name string
@@ -976,6 +1016,33 @@ type Want struct {
FulfilledAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
SearchedAt pgtype.Timestamptz
SearchOutcome string
SearchConsidered int32
BestRelease string
BestVerdict string
SearchError string
}
type WantCandidate struct {
WantID pgtype.UUID
Key string
Name string
InfoHash string
Indexer string
Source string
SizeBytes int64
Seeders int32
FilesCount int32
Format string
Quality string
Year int32
Verdict string
Reason string
Reasons []byte
FoldedFrom []string
Rank int32
SearchedAt pgtype.Timestamptz
}
type YouMightLikeAlbum struct {
+7 -1
View File
@@ -212,7 +212,7 @@ ON CONFLICT (release_group_mbid) DO UPDATE
fulfilled_at = NULL,
updated_at = now()
WHERE wants.fulfilled_at IS NOT NULL
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error
`
type WantMissingAlbumParams struct {
@@ -249,6 +249,12 @@ func (q *Queries) WantMissingAlbum(ctx context.Context, arg WantMissingAlbumPara
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SearchedAt,
&i.SearchOutcome,
&i.SearchConsidered,
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
)
return i, err
}
+585
View File
@@ -0,0 +1,585 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: search.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const blockRelease = `-- name: BlockRelease :exec
INSERT INTO release_blocks (info_hash, name, reason, detail, want_id, blocked_by)
VALUES ($1, $2, $3, $4,
$5, $6)
ON CONFLICT (info_hash) DO UPDATE
SET reason = EXCLUDED.reason, detail = EXCLUDED.detail, blocked_at = now()
`
type BlockReleaseParams struct {
InfoHash string
Name string
Reason string
Detail string
WantID pgtype.UUID
BlockedBy pgtype.UUID
}
func (q *Queries) BlockRelease(ctx context.Context, arg BlockReleaseParams) error {
_, err := q.db.Exec(ctx, blockRelease,
arg.InfoHash,
arg.Name,
arg.Reason,
arg.Detail,
arg.WantID,
arg.BlockedBy,
)
return err
}
const deleteWantCandidates = `-- name: DeleteWantCandidates :exec
DELETE FROM want_candidates WHERE want_id = $1
`
func (q *Queries) DeleteWantCandidates(ctx context.Context, wantID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWantCandidates, wantID)
return err
}
const editionTrackSpan = `-- name: EditionTrackSpan :one
SELECT COALESCE(MIN(track_count) FILTER (WHERE status = 'Official'), MIN(track_count), 0)::int AS min_tracks,
COALESCE(MAX(track_count), 0)::int AS max_tracks
FROM mb_releases
WHERE release_group_mbid = $1 AND track_count > 0
`
type EditionTrackSpanRow struct {
MinTracks int32
MaxTracks int32
}
// The fewest tracks an official edition of the group has (any edition when
// none is official), and the most any has. Zero when none is catalogued.
func (q *Queries) EditionTrackSpan(ctx context.Context, releaseGroupMbid string) (EditionTrackSpanRow, error) {
row := q.db.QueryRow(ctx, editionTrackSpan, releaseGroupMbid)
var i EditionTrackSpanRow
err := row.Scan(&i.MinTracks, &i.MaxTracks)
return i, err
}
const endGrab = `-- name: EndGrab :exec
UPDATE grabs
SET ended_at = now(), end_reason = $1, end_detail = $2
WHERE id = $3 AND ended_at IS NULL
`
type EndGrabParams struct {
EndReason string
EndDetail string
ID pgtype.UUID
}
func (q *Queries) EndGrab(ctx context.Context, arg EndGrabParams) error {
_, err := q.db.Exec(ctx, endGrab, arg.EndReason, arg.EndDetail, arg.ID)
return err
}
const getAcquisitionSettings = `-- name: GetAcquisitionSettings :one
SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at FROM acquisition_settings WHERE id = true
`
// Searching for wants and grabbing releases (M502 #5521).
func (q *Queries) GetAcquisitionSettings(ctx context.Context) (AcquisitionSetting, error) {
row := q.db.QueryRow(ctx, getAcquisitionSettings)
var i AcquisitionSetting
err := row.Scan(
&i.ID,
&i.AutoGrab,
&i.SearchIntervalHours,
&i.AllowedQualities,
&i.LastPassAt,
&i.LastPass,
&i.UpdatedAt,
)
return i, err
}
const getLiveGrab = `-- name: GetLiveGrab :one
SELECT id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail FROM grabs WHERE want_id = $1 AND ended_at IS NULL
`
func (q *Queries) GetLiveGrab(ctx context.Context, wantID pgtype.UUID) (Grab, error) {
row := q.db.QueryRow(ctx, getLiveGrab, wantID)
var i Grab
err := row.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
)
return i, err
}
const getWantCandidate = `-- name: GetWantCandidate :one
SELECT want_id, key, name, info_hash, indexer, source, size_bytes, seeders, files_count, format, quality, year, verdict, reason, reasons, folded_from, rank, searched_at FROM want_candidates WHERE want_id = $1 AND key = $2
`
type GetWantCandidateParams struct {
WantID pgtype.UUID
Key string
}
func (q *Queries) GetWantCandidate(ctx context.Context, arg GetWantCandidateParams) (WantCandidate, error) {
row := q.db.QueryRow(ctx, getWantCandidate, arg.WantID, arg.Key)
var i WantCandidate
err := row.Scan(
&i.WantID,
&i.Key,
&i.Name,
&i.InfoHash,
&i.Indexer,
&i.Source,
&i.SizeBytes,
&i.Seeders,
&i.FilesCount,
&i.Format,
&i.Quality,
&i.Year,
&i.Verdict,
&i.Reason,
&i.Reasons,
&i.FoldedFrom,
&i.Rank,
&i.SearchedAt,
)
return i, err
}
const getWantForSearch = `-- name: GetWantForSearch :one
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason, w.fulfilled_at,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date
FROM wants w
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE w.id = $1
`
type GetWantForSearchRow struct {
ID pgtype.UUID
ReleaseGroupMbid string
ArtistName string
Title string
Reason string
FulfilledAt pgtype.Timestamptz
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
}
func (q *Queries) GetWantForSearch(ctx context.Context, id pgtype.UUID) (GetWantForSearchRow, error) {
row := q.db.QueryRow(ctx, getWantForSearch, id)
var i GetWantForSearchRow
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistName,
&i.Title,
&i.Reason,
&i.FulfilledAt,
&i.PrimaryType,
&i.SecondaryTypes,
&i.FirstReleaseDate,
)
return i, err
}
const insertGrab = `-- name: InsertGrab :one
INSERT INTO grabs (want_id, info_hash, name, key, indexer, source, client, save_path, quality,
origin, forced, grabbed_by)
VALUES ($1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12)
RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail
`
type InsertGrabParams struct {
WantID pgtype.UUID
InfoHash string
Name string
Key string
Indexer string
Source string
Client string
SavePath string
Quality string
Origin string
Forced bool
GrabbedBy pgtype.UUID
}
func (q *Queries) InsertGrab(ctx context.Context, arg InsertGrabParams) (Grab, error) {
row := q.db.QueryRow(ctx, insertGrab,
arg.WantID,
arg.InfoHash,
arg.Name,
arg.Key,
arg.Indexer,
arg.Source,
arg.Client,
arg.SavePath,
arg.Quality,
arg.Origin,
arg.Forced,
arg.GrabbedBy,
)
var i Grab
err := row.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
)
return i, err
}
const insertWantCandidate = `-- name: InsertWantCandidate :exec
INSERT INTO want_candidates (want_id, key, name, info_hash, indexer, source, size_bytes, seeders,
files_count, format, quality, year, verdict, reason, reasons, folded_from, rank)
VALUES ($1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14, $15,
$16, $17)
ON CONFLICT (want_id, key) DO NOTHING
`
type InsertWantCandidateParams struct {
WantID pgtype.UUID
Key string
Name string
InfoHash string
Indexer string
Source string
SizeBytes int64
Seeders int32
FilesCount int32
Format string
Quality string
Year int32
Verdict string
Reason string
Reasons []byte
FoldedFrom []string
Rank int32
}
func (q *Queries) InsertWantCandidate(ctx context.Context, arg InsertWantCandidateParams) error {
_, err := q.db.Exec(ctx, insertWantCandidate,
arg.WantID,
arg.Key,
arg.Name,
arg.InfoHash,
arg.Indexer,
arg.Source,
arg.SizeBytes,
arg.Seeders,
arg.FilesCount,
arg.Format,
arg.Quality,
arg.Year,
arg.Verdict,
arg.Reason,
arg.Reasons,
arg.FoldedFrom,
arg.Rank,
)
return err
}
const listReleaseBlocks = `-- name: ListReleaseBlocks :many
SELECT info_hash, name, reason, detail, want_id, blocked_by, blocked_at FROM release_blocks ORDER BY blocked_at DESC
`
func (q *Queries) ListReleaseBlocks(ctx context.Context) ([]ReleaseBlock, error) {
rows, err := q.db.Query(ctx, listReleaseBlocks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ReleaseBlock
for rows.Next() {
var i ReleaseBlock
if err := rows.Scan(
&i.InfoHash,
&i.Name,
&i.Reason,
&i.Detail,
&i.WantID,
&i.BlockedBy,
&i.BlockedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWantCandidates = `-- name: ListWantCandidates :many
SELECT want_id, key, name, info_hash, indexer, source, size_bytes, seeders, files_count, format, quality, year, verdict, reason, reasons, folded_from, rank, searched_at FROM want_candidates WHERE want_id = $1 ORDER BY rank, key
`
func (q *Queries) ListWantCandidates(ctx context.Context, wantID pgtype.UUID) ([]WantCandidate, error) {
rows, err := q.db.Query(ctx, listWantCandidates, wantID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []WantCandidate
for rows.Next() {
var i WantCandidate
if err := rows.Scan(
&i.WantID,
&i.Key,
&i.Name,
&i.InfoHash,
&i.Indexer,
&i.Source,
&i.SizeBytes,
&i.Seeders,
&i.FilesCount,
&i.Format,
&i.Quality,
&i.Year,
&i.Verdict,
&i.Reason,
&i.Reasons,
&i.FoldedFrom,
&i.Rank,
&i.SearchedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWantsDueForSearch = `-- name: ListWantsDueForSearch :many
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date
FROM wants w
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE w.fulfilled_at IS NULL
AND NOT EXISTS (SELECT 1 FROM grabs gr WHERE gr.want_id = w.id AND gr.ended_at IS NULL)
AND (w.searched_at IS NULL OR w.searched_at < now() - make_interval(hours => $1::int))
ORDER BY w.searched_at IS NOT NULL,
CASE w.reason WHEN 'request' THEN 0 WHEN 'missing' THEN 1 ELSE 2 END,
w.searched_at NULLS FIRST, w.wanted_at, w.id
LIMIT $2
`
type ListWantsDueForSearchParams struct {
Hours int32
MaxRows int32
}
type ListWantsDueForSearchRow struct {
ID pgtype.UUID
ReleaseGroupMbid string
ArtistName string
Title string
Reason string
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
}
// Open wants with no download running that were never searched or not since
// @hours ago: never-searched first, then requests, then missing files, then
// followed artists' releases.
func (q *Queries) ListWantsDueForSearch(ctx context.Context, arg ListWantsDueForSearchParams) ([]ListWantsDueForSearchRow, error) {
rows, err := q.db.Query(ctx, listWantsDueForSearch, arg.Hours, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListWantsDueForSearchRow
for rows.Next() {
var i ListWantsDueForSearchRow
if err := rows.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistName,
&i.Title,
&i.Reason,
&i.PrimaryType,
&i.SecondaryTypes,
&i.FirstReleaseDate,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markCandidatesBlocked = `-- name: MarkCandidatesBlocked :exec
UPDATE want_candidates
SET verdict = 'blocked', reason = $1,
reasons = jsonb_build_array(jsonb_build_object('kind', 'hard', 'code', 'blocked', 'text', $1::text))
WHERE info_hash = $2
`
type MarkCandidatesBlockedParams struct {
Reason string
InfoHash string
}
func (q *Queries) MarkCandidatesBlocked(ctx context.Context, arg MarkCandidatesBlockedParams) error {
_, err := q.db.Exec(ctx, markCandidatesBlocked, arg.Reason, arg.InfoHash)
return err
}
const markCandidatesUnblocked = `-- name: MarkCandidatesUnblocked :exec
UPDATE want_candidates
SET verdict = 'review', reason = $1,
reasons = jsonb_build_array(jsonb_build_object('kind', 'soft', 'code', 'unblocked', 'text', $1::text))
WHERE info_hash = $2 AND verdict = 'blocked'
`
type MarkCandidatesUnblockedParams struct {
Reason string
InfoHash string
}
func (q *Queries) MarkCandidatesUnblocked(ctx context.Context, arg MarkCandidatesUnblockedParams) error {
_, err := q.db.Exec(ctx, markCandidatesUnblocked, arg.Reason, arg.InfoHash)
return err
}
const recordSearchPass = `-- name: RecordSearchPass :exec
UPDATE acquisition_settings SET last_pass_at = now(), last_pass = $1 WHERE id = true
`
func (q *Queries) RecordSearchPass(ctx context.Context, report []byte) error {
_, err := q.db.Exec(ctx, recordSearchPass, report)
return err
}
const recordWantSearch = `-- name: RecordWantSearch :exec
UPDATE wants
SET searched_at = now(),
search_outcome = $1,
search_considered = $2,
best_release = $3,
best_verdict = $4,
search_error = $5,
updated_at = now()
WHERE id = $6
`
type RecordWantSearchParams struct {
Outcome string
Considered int32
BestRelease string
BestVerdict string
SearchError string
ID pgtype.UUID
}
func (q *Queries) RecordWantSearch(ctx context.Context, arg RecordWantSearchParams) error {
_, err := q.db.Exec(ctx, recordWantSearch,
arg.Outcome,
arg.Considered,
arg.BestRelease,
arg.BestVerdict,
arg.SearchError,
arg.ID,
)
return err
}
const unblockRelease = `-- name: UnblockRelease :execrows
DELETE FROM release_blocks WHERE info_hash = $1
`
func (q *Queries) UnblockRelease(ctx context.Context, infoHash string) (int64, error) {
result, err := q.db.Exec(ctx, unblockRelease, infoHash)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateAcquisitionSettings = `-- name: UpdateAcquisitionSettings :one
UPDATE acquisition_settings
SET auto_grab = $1,
search_interval_hours = $2,
allowed_qualities = $3,
updated_at = now()
WHERE id = true
RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at
`
type UpdateAcquisitionSettingsParams struct {
AutoGrab bool
SearchIntervalHours int32
AllowedQualities []string
}
func (q *Queries) UpdateAcquisitionSettings(ctx context.Context, arg UpdateAcquisitionSettingsParams) (AcquisitionSetting, error) {
row := q.db.QueryRow(ctx, updateAcquisitionSettings, arg.AutoGrab, arg.SearchIntervalHours, arg.AllowedQualities)
var i AcquisitionSetting
err := row.Scan(
&i.ID,
&i.AutoGrab,
&i.SearchIntervalHours,
&i.AllowedQualities,
&i.LastPassAt,
&i.LastPass,
&i.UpdatedAt,
)
return i, err
}
+46 -4
View File
@@ -206,7 +206,7 @@ func (q *Queries) GetReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (Re
}
const getWantByReleaseGroup = `-- name: GetWantByReleaseGroup :one
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at FROM wants WHERE release_group_mbid = $1
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants WHERE release_group_mbid = $1
`
func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid string) (Want, error) {
@@ -227,6 +227,12 @@ func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid st
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SearchedAt,
&i.SearchOutcome,
&i.SearchConsidered,
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
)
return i, err
}
@@ -406,7 +412,7 @@ func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams)
}
const listOpenWants = `-- name: ListOpenWants :many
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at FROM wants
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants
WHERE fulfilled_at IS NULL
ORDER BY wanted_at, id
LIMIT $1
@@ -436,6 +442,12 @@ func (q *Queries) ListOpenWants(ctx context.Context, limit int32) ([]Want, error
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SearchedAt,
&i.SearchOutcome,
&i.SearchConsidered,
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
); err != nil {
return nil, err
}
@@ -505,10 +517,14 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
u.username AS requested_by,
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id,
-- The search schedule's last look (#5521), and the download running.
w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error,
gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin, gr.quality AS grab_quality
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
LEFT JOIN grabs gr ON gr.want_id = w.id AND gr.ended_at IS NULL
WHERE CASE $1::text
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
@@ -539,6 +555,16 @@ type ListWantsRow struct {
FirstReleaseDate string
RequestedBy *string
LibraryArtistID pgtype.UUID
SearchedAt pgtype.Timestamptz
SearchOutcome string
SearchConsidered int32
BestRelease string
BestVerdict string
SearchError string
GrabName *string
GrabbedAt pgtype.Timestamptz
GrabOrigin *string
GrabQuality *string
}
// The Wanted page: open wants (all but missing ones, or only missing ones),
@@ -567,6 +593,16 @@ func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWan
&i.FirstReleaseDate,
&i.RequestedBy,
&i.LibraryArtistID,
&i.SearchedAt,
&i.SearchOutcome,
&i.SearchConsidered,
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
&i.GrabName,
&i.GrabbedAt,
&i.GrabOrigin,
&i.GrabQuality,
); err != nil {
return nil, err
}
@@ -791,7 +827,7 @@ INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_
VALUES ($1, $2, $3, $4,
$5, $6, $7, $8)
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error
`
type WantReleaseGroupParams struct {
@@ -834,6 +870,12 @@ func (q *Queries) WantReleaseGroup(ctx context.Context, arg WantReleaseGroupPara
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SearchedAt,
&i.SearchOutcome,
&i.SearchConsidered,
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
)
return i, err
}
@@ -0,0 +1,12 @@
DROP TABLE release_blocks;
DROP TABLE grabs;
DROP TABLE want_candidates;
DROP INDEX wants_search_due_idx;
ALTER TABLE wants
DROP COLUMN searched_at,
DROP COLUMN search_outcome,
DROP COLUMN search_considered,
DROP COLUMN best_release,
DROP COLUMN best_verdict,
DROP COLUMN search_error;
DROP TABLE acquisition_settings;
@@ -0,0 +1,102 @@
-- 0082_search_and_grab.up.sql — searching for wants and grabbing releases
-- (Scribe milestone #502, #5521).
--
-- acquisition_settings one row: whether the schedule takes the best release
-- by itself, how often a want is searched again, which
-- quality tiers are taken, and the last pass's report.
-- wants (columns) when each want was last searched and what came of it.
-- want_candidates every release the last search found for a want, with
-- its verdict and the reasons behind it.
-- grabs downloads started for wants; at most one live per want.
-- release_blocks releases never to take again, by infohash.
CREATE TABLE acquisition_settings (
id boolean PRIMARY KEY DEFAULT true CONSTRAINT acquisition_settings_one_row CHECK (id),
auto_grab boolean NOT NULL DEFAULT true,
-- 0 switches the schedule off.
search_interval_hours integer NOT NULL DEFAULT 24
CONSTRAINT acquisition_settings_interval_check CHECK (search_interval_hours BETWEEN 0 AND 720),
-- Lidarr's "Any" profile (note #5527): trash lossy to lossless; unknown off.
allowed_qualities text[] NOT NULL DEFAULT ARRAY['lossless_hires', 'lossless', 'lossy_high',
'lossy_standard', 'lossy_low', 'lossy_poor', 'lossy_trash'],
last_pass_at timestamptz,
last_pass jsonb NOT NULL DEFAULT '{}',
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO acquisition_settings DEFAULT VALUES;
ALTER TABLE wants
ADD COLUMN searched_at timestamptz,
ADD COLUMN search_outcome text NOT NULL DEFAULT ''
CONSTRAINT wants_search_outcome_check CHECK (search_outcome IN ('', 'grabbed', 'waiting', 'nothing', 'error')),
ADD COLUMN search_considered integer NOT NULL DEFAULT 0,
ADD COLUMN best_release text NOT NULL DEFAULT '',
ADD COLUMN best_verdict text NOT NULL DEFAULT '',
ADD COLUMN search_error text NOT NULL DEFAULT '';
CREATE INDEX wants_search_due_idx ON wants (searched_at NULLS FIRST) WHERE fulfilled_at IS NULL;
CREATE TABLE want_candidates (
want_id uuid NOT NULL REFERENCES wants (id) ON DELETE CASCADE,
-- The infohash, or the name when the source gave none.
key text NOT NULL,
name text NOT NULL,
info_hash text NOT NULL DEFAULT '',
indexer text NOT NULL DEFAULT '',
source text NOT NULL DEFAULT '',
size_bytes bigint NOT NULL DEFAULT 0,
seeders integer NOT NULL DEFAULT 0,
files_count integer NOT NULL DEFAULT 0,
format text NOT NULL DEFAULT '',
quality text NOT NULL DEFAULT '',
year integer NOT NULL DEFAULT 0,
verdict text NOT NULL
CONSTRAINT want_candidates_verdict_check CHECK (verdict IN ('grab', 'review', 'refused', 'rejected', 'blocked')),
reason text NOT NULL DEFAULT '',
reasons jsonb NOT NULL DEFAULT '[]',
folded_from text[] NOT NULL DEFAULT '{}',
-- Position in the search's order, best first.
rank integer NOT NULL DEFAULT 0,
searched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (want_id, key)
);
CREATE INDEX want_candidates_info_hash_idx ON want_candidates (info_hash) WHERE info_hash <> '';
CREATE TABLE grabs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
want_id uuid REFERENCES wants (id) ON DELETE SET NULL,
info_hash text NOT NULL DEFAULT '',
name text NOT NULL,
key text NOT NULL,
indexer text NOT NULL DEFAULT '',
source text NOT NULL DEFAULT '',
-- The acquisition_services row of the download client it went to.
client text NOT NULL,
save_path text NOT NULL,
quality text NOT NULL DEFAULT '',
origin text NOT NULL CONSTRAINT grabs_origin_check CHECK (origin IN ('schedule', 'operator')),
forced boolean NOT NULL DEFAULT false,
grabbed_by uuid REFERENCES users (id) ON DELETE SET NULL,
grabbed_at timestamptz NOT NULL DEFAULT now(),
ended_at timestamptz,
end_reason text NOT NULL DEFAULT ''
CONSTRAINT grabs_end_reason_check CHECK (end_reason IN ('', 'superseded')),
end_detail text NOT NULL DEFAULT ''
);
-- One live grab per want; the service decides which, this is the backstop.
CREATE UNIQUE INDEX grabs_one_live_per_want ON grabs (want_id) WHERE ended_at IS NULL AND want_id IS NOT NULL;
CREATE INDEX grabs_info_hash_idx ON grabs (info_hash) WHERE info_hash <> '';
CREATE TABLE release_blocks (
info_hash text PRIMARY KEY,
name text NOT NULL DEFAULT '',
-- operator: "never this one"; files: its .torrent held the wrong thing.
reason text NOT NULL CONSTRAINT release_blocks_reason_check CHECK (reason IN ('operator', 'files')),
detail text NOT NULL DEFAULT '',
want_id uuid REFERENCES wants (id) ON DELETE SET NULL,
blocked_by uuid REFERENCES users (id) ON DELETE SET NULL,
blocked_at timestamptz NOT NULL DEFAULT now()
);
+121
View File
@@ -0,0 +1,121 @@
-- Searching for wants and grabbing releases (M502 #5521).
-- name: GetAcquisitionSettings :one
SELECT * FROM acquisition_settings WHERE id = true;
-- name: UpdateAcquisitionSettings :one
UPDATE acquisition_settings
SET auto_grab = sqlc.arg(auto_grab),
search_interval_hours = sqlc.arg(search_interval_hours),
allowed_qualities = sqlc.arg(allowed_qualities),
updated_at = now()
WHERE id = true
RETURNING *;
-- name: RecordSearchPass :exec
UPDATE acquisition_settings SET last_pass_at = now(), last_pass = sqlc.arg(report) WHERE id = true;
-- name: ListWantsDueForSearch :many
-- Open wants with no download running that were never searched or not since
-- @hours ago: never-searched first, then requests, then missing files, then
-- followed artists' releases.
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date
FROM wants w
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE w.fulfilled_at IS NULL
AND NOT EXISTS (SELECT 1 FROM grabs gr WHERE gr.want_id = w.id AND gr.ended_at IS NULL)
AND (w.searched_at IS NULL OR w.searched_at < now() - make_interval(hours => sqlc.arg(hours)::int))
ORDER BY w.searched_at IS NOT NULL,
CASE w.reason WHEN 'request' THEN 0 WHEN 'missing' THEN 1 ELSE 2 END,
w.searched_at NULLS FIRST, w.wanted_at, w.id
LIMIT sqlc.arg(max_rows);
-- name: GetWantForSearch :one
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason, w.fulfilled_at,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date
FROM wants w
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE w.id = $1;
-- name: EditionTrackSpan :one
-- The fewest tracks an official edition of the group has (any edition when
-- none is official), and the most any has. Zero when none is catalogued.
SELECT COALESCE(MIN(track_count) FILTER (WHERE status = 'Official'), MIN(track_count), 0)::int AS min_tracks,
COALESCE(MAX(track_count), 0)::int AS max_tracks
FROM mb_releases
WHERE release_group_mbid = $1 AND track_count > 0;
-- name: RecordWantSearch :exec
UPDATE wants
SET searched_at = now(),
search_outcome = sqlc.arg(outcome),
search_considered = sqlc.arg(considered),
best_release = sqlc.arg(best_release),
best_verdict = sqlc.arg(best_verdict),
search_error = sqlc.arg(search_error),
updated_at = now()
WHERE id = sqlc.arg(id);
-- name: DeleteWantCandidates :exec
DELETE FROM want_candidates WHERE want_id = $1;
-- name: InsertWantCandidate :exec
INSERT INTO want_candidates (want_id, key, name, info_hash, indexer, source, size_bytes, seeders,
files_count, format, quality, year, verdict, reason, reasons, folded_from, rank)
VALUES (sqlc.arg(want_id), sqlc.arg(key), sqlc.arg(name), sqlc.arg(info_hash), sqlc.arg(indexer),
sqlc.arg(source), sqlc.arg(size_bytes), sqlc.arg(seeders), sqlc.arg(files_count), sqlc.arg(format),
sqlc.arg(quality), sqlc.arg(year), sqlc.arg(verdict), sqlc.arg(reason), sqlc.arg(reasons),
sqlc.arg(folded_from), sqlc.arg(rank))
ON CONFLICT (want_id, key) DO NOTHING;
-- name: ListWantCandidates :many
SELECT * FROM want_candidates WHERE want_id = $1 ORDER BY rank, key;
-- name: GetWantCandidate :one
SELECT * FROM want_candidates WHERE want_id = $1 AND key = $2;
-- name: MarkCandidatesBlocked :exec
UPDATE want_candidates
SET verdict = 'blocked', reason = sqlc.arg(reason),
reasons = jsonb_build_array(jsonb_build_object('kind', 'hard', 'code', 'blocked', 'text', sqlc.arg(reason)::text))
WHERE info_hash = sqlc.arg(info_hash);
-- name: MarkCandidatesUnblocked :exec
UPDATE want_candidates
SET verdict = 'review', reason = sqlc.arg(reason),
reasons = jsonb_build_array(jsonb_build_object('kind', 'soft', 'code', 'unblocked', 'text', sqlc.arg(reason)::text))
WHERE info_hash = sqlc.arg(info_hash) AND verdict = 'blocked';
-- name: GetLiveGrab :one
SELECT * FROM grabs WHERE want_id = $1 AND ended_at IS NULL;
-- name: InsertGrab :one
INSERT INTO grabs (want_id, info_hash, name, key, indexer, source, client, save_path, quality,
origin, forced, grabbed_by)
VALUES (sqlc.narg(want_id), sqlc.arg(info_hash), sqlc.arg(name), sqlc.arg(key), sqlc.arg(indexer),
sqlc.arg(source), sqlc.arg(client), sqlc.arg(save_path), sqlc.arg(quality),
sqlc.arg(origin), sqlc.arg(forced), sqlc.narg(grabbed_by))
RETURNING *;
-- name: EndGrab :exec
UPDATE grabs
SET ended_at = now(), end_reason = sqlc.arg(end_reason), end_detail = sqlc.arg(end_detail)
WHERE id = sqlc.arg(id) AND ended_at IS NULL;
-- name: ListReleaseBlocks :many
SELECT * FROM release_blocks ORDER BY blocked_at DESC;
-- name: BlockRelease :exec
INSERT INTO release_blocks (info_hash, name, reason, detail, want_id, blocked_by)
VALUES (sqlc.arg(info_hash), sqlc.arg(name), sqlc.arg(reason), sqlc.arg(detail),
sqlc.narg(want_id), sqlc.narg(blocked_by))
ON CONFLICT (info_hash) DO UPDATE
SET reason = EXCLUDED.reason, detail = EXCLUDED.detail, blocked_at = now();
-- name: UnblockRelease :execrows
DELETE FROM release_blocks WHERE info_hash = $1;
+5 -1
View File
@@ -186,10 +186,14 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
u.username AS requested_by,
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id,
-- The search schedule's last look (#5521), and the download running.
w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error,
gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin, gr.quality AS grab_quality
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
LEFT JOIN grabs gr ON gr.want_id = w.id AND gr.ended_at IS NULL
WHERE CASE sqlc.arg(state)::text
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'