Files
minstrel/internal/db/dbq/search.sql.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

586 lines
15 KiB
Go

// 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
}