Files
minstrel/internal/api/admin_search.go
T
bvandeusenandClaude Opus 5.5 edcb3a57ef
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
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: the library's folders are set in Admin › Library, and changing a folder moves its tracks with it, keeping their plays, likes and playlists (#6129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:37:03 -04:00

341 lines
12 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Searching for wants and grabbing releases (M502 #5521): the schedule's
// settings, each want's candidates, and a person's Search, Grab and "never
// this one".
// searchTimeout bounds a search a person waits on: a Prowlarr fanning out to
// many trackers can take most of a minute.
const searchTimeout = 2 * time.Minute
type searchSettingsView struct {
AutoGrab bool `json:"auto_grab"`
SearchIntervalHours int32 `json:"search_interval_hours"`
AllowedQualities []string `json:"allowed_qualities"`
Tiers []release.TierInfo `json:"tiers"`
LastPassAt *string `json:"last_pass_at"`
LastPass json.RawMessage `json:"last_pass"`
// ImportRoot is the library folder new music is filed into (#5522); ""
// is the first of ScanPaths, the library's folders.
ImportRoot string `json:"import_root"`
ScanPaths []string `json:"scan_paths"`
// The download lifecycle (#5523): seed an imported download to this
// ratio or for this many days; give up on one nobody shares after this
// many hours. 0 is: remove once imported, no time limit, never.
SeedRatio float32 `json:"seed_ratio"`
SeedDays int32 `json:"seed_days"`
StallHours int32 `json:"stall_hours"`
// UpgradeUntil is the tier a held album is upgraded to (#5524); "" is
// no quality upgrades.
UpgradeUntil string `json:"upgrade_until"`
}
func searchSettings(s dbq.AcquisitionSetting, roots []string) searchSettingsView {
v := searchSettingsView{
AutoGrab: s.AutoGrab, SearchIntervalHours: s.SearchIntervalHours,
AllowedQualities: s.AllowedQualities, Tiers: release.Tiers, LastPass: s.LastPass,
ImportRoot: s.ImportRoot, ScanPaths: roots,
SeedRatio: s.SeedRatio, SeedDays: s.SeedDays, StallHours: s.StallHours,
UpgradeUntil: s.UpgradeUntil,
}
if v.ScanPaths == nil {
v.ScanPaths = []string{}
}
if v.AllowedQualities == nil {
v.AllowedQualities = []string{}
}
if len(v.LastPass) == 0 {
v.LastPass = json.RawMessage("{}")
}
if s.LastPassAt.Valid {
at := formatTimestamp(s.LastPassAt)
v.LastPassAt = &at
}
return v
}
// handleGetSearchSettings implements GET /api/admin/acquisition/settings.
func (h *handlers) handleGetSearchSettings(w http.ResponseWriter, r *http.Request) {
s, err := dbq.New(h.pool).GetAcquisitionSettings(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin search settings: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
}
type putSearchSettingsBody struct {
AutoGrab bool `json:"auto_grab"`
SearchIntervalHours int32 `json:"search_interval_hours"`
AllowedQualities []string `json:"allowed_qualities"`
ImportRoot string `json:"import_root"`
SeedRatio float32 `json:"seed_ratio"`
SeedDays int32 `json:"seed_days"`
StallHours int32 `json:"stall_hours"`
UpgradeUntil string `json:"upgrade_until"`
}
// handlePutSearchSettings implements PUT /api/admin/acquisition/settings.
func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Request) {
var body putSearchSettingsBody
if !decodeBody(w, r, &body) {
return
}
if body.SearchIntervalHours < 0 || body.SearchIntervalHours > 720 {
writeErr(w, apierror.BadRequest("bad_interval", "search_interval_hours must be between 0 and 720"))
return
}
if len(body.AllowedQualities) == 0 {
writeErr(w, apierror.BadRequest("bad_quality", "allow at least one quality"))
return
}
for _, t := range body.AllowedQualities {
if !release.KnownTier(t) {
writeErr(w, apierror.BadRequest("bad_quality", "unknown quality "+t))
return
}
}
if body.UpgradeUntil != "" && !release.KnownTier(body.UpgradeUntil) {
writeErr(w, apierror.BadRequest("bad_upgrade_until", "unknown quality "+body.UpgradeUntil))
return
}
if body.ImportRoot != "" && !slices.Contains(h.importer.Roots(), body.ImportRoot) {
writeErr(w, apierror.BadRequest("bad_import_root", "file new music into one of the library's folders"))
return
}
if body.SeedRatio < 0 || body.SeedRatio > 100 || body.SeedDays < 0 || body.SeedDays > 365 ||
body.StallHours < 0 || body.StallHours > 720 {
writeErr(w, apierror.BadRequest("bad_lifecycle",
"seed ratio must be 0 to 100, seed days 0 to 365 and stall hours 0 to 720"))
return
}
s, err := dbq.New(h.pool).UpdateAcquisitionSettings(r.Context(), dbq.UpdateAcquisitionSettingsParams{
AutoGrab: body.AutoGrab, SearchIntervalHours: body.SearchIntervalHours, AllowedQualities: body.AllowedQualities,
ImportRoot: body.ImportRoot, SeedRatio: body.SeedRatio, SeedDays: body.SeedDays, StallHours: body.StallHours,
UpgradeUntil: body.UpgradeUntil,
})
if err != nil {
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
}
// candidateView is one release a want's last search found.
type candidateView struct {
Key string `json:"key"`
Name string `json:"name"`
InfoHash string `json:"info_hash"`
Indexer string `json:"indexer"`
SizeBytes int64 `json:"size_bytes"`
Seeders int32 `json:"seeders"`
FilesCount int32 `json:"files_count"`
Format string `json:"format"`
Quality string `json:"quality"`
Year int32 `json:"year"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
Reasons json.RawMessage `json:"reasons"`
FoldedFrom []string `json:"folded_from"`
SearchedAt string `json:"searched_at"`
Downloading bool `json:"downloading"`
}
// handleListCandidates implements GET /api/admin/wanted/{id}/candidates.
func (h *handlers) handleListCandidates(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
q := dbq.New(h.pool)
rows, err := q.ListWantCandidates(r.Context(), id)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: candidates", apierror.Internal(err))
return
}
live, err := q.GetLiveGrab(r.Context(), id)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
writeErrWithLog(w, h.logger, "admin wanted: live grab", apierror.Internal(err))
return
}
out := make([]candidateView, 0, len(rows))
for _, c := range rows {
v := candidateView{
Key: c.Key, Name: c.Name, InfoHash: c.InfoHash, Indexer: c.Indexer, SizeBytes: c.SizeBytes,
Seeders: c.Seeders, FilesCount: c.FilesCount, Format: c.Format, Quality: c.Quality, Year: c.Year,
Verdict: c.Verdict, Reason: c.Reason, Reasons: c.Reasons, FoldedFrom: c.FoldedFrom,
SearchedAt: formatTimestamp(c.SearchedAt),
Downloading: live.ID.Valid && live.Key == c.Key,
}
if v.FoldedFrom == nil {
v.FoldedFrom = []string{}
}
if len(v.Reasons) == 0 {
v.Reasons = json.RawMessage("[]")
}
out = append(out, v)
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
// handleSearchWant implements POST /api/admin/wanted/{id}/search: search
// now, as the schedule would, taking the best release when auto-grab is on.
func (h *handlers) handleSearchWant(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
defer cancel()
res, err := h.searcher.SearchNow(ctx, id)
if h.writeSearchErr(w, err) {
return
}
writeJSON(w, http.StatusOK, res)
}
type grabBody struct {
Key string `json:"key"`
Force bool `json:"force"`
}
// grabView is what came of a person's grab.
type grabView struct {
Added bool `json:"added"`
Refused bool `json:"refused"`
Gone bool `json:"gone"`
Unconfigured bool `json:"unconfigured"`
Missing string `json:"missing,omitempty"`
Kept bool `json:"kept"`
Replaced bool `json:"replaced"`
Verdict string `json:"verdict,omitempty"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
}
// handleGrabCandidate implements POST /api/admin/wanted/{id}/grab: take the
// release named by key. The search runs again first, so the indexer's link
// never leaves the server; force takes what the verdict would not.
func (h *handlers) handleGrabCandidate(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body grabBody
if !decodeBody(w, r, &body) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
defer cancel()
took, err := h.searcher.Take(ctx, id, body.Key, body.Force, admin.ID)
if h.writeSearchErr(w, err) {
return
}
v := grabView{
Added: took.Added, Refused: took.Refused, Gone: took.Gone, Unconfigured: took.Unconfigured,
Missing: took.Missing, Error: took.Error,
}
if took.Chosen != nil {
v.Verdict, v.Reason = string(took.Chosen.Verdict), took.Chosen.Reason
}
if took.Contest != nil {
v.Kept, v.Replaced = took.Contest.Kept, took.Contest.Replaced
if took.Contest.Kept {
v.Reason = took.Contest.Reason
}
}
writeJSON(w, http.StatusOK, v)
}
type blockBody struct {
Key string `json:"key"`
}
// handleBlockCandidate implements POST /api/admin/wanted/{id}/block: never
// take this release again, for any want.
func (h *handlers) handleBlockCandidate(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body blockBody
if !decodeBody(w, r, &body) {
return
}
c, err := dbq.New(h.pool).GetWantCandidate(r.Context(), dbq.GetWantCandidateParams{WantID: id, Key: body.Key})
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("candidate"))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: block", apierror.Internal(err))
return
}
if c.InfoHash == "" {
writeErr(w, apierror.BadRequest("no_infohash", "this release has no infohash to set aside by"))
return
}
if err := acquire.NewMemory(h.pool).Block(r.Context(), dbq.BlockReleaseParams{
InfoHash: c.InfoHash, Name: c.Name, Reason: acquire.BlockOperator, WantID: id, BlockedBy: admin.ID,
}); err != nil {
writeErrWithLog(w, h.logger, "admin wanted: block", apierror.Internal(err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnblockRelease implements DELETE /api/admin/release-blocks/{hash}.
func (h *handlers) handleUnblockRelease(w http.ResponseWriter, r *http.Request) {
removed, err := acquire.NewMemory(h.pool).Unblock(r.Context(), chi.URLParam(r, "hash"))
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: unblock", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("block"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeSearchErr answers a Searcher error, and says whether there was one.
func (h *handlers) writeSearchErr(w http.ResponseWriter, err error) bool {
switch {
case err == nil:
return false
case errors.Is(err, pgx.ErrNoRows):
writeErr(w, apierror.NotFound("want"))
case errors.Is(err, wanted.ErrWantHeld):
writeErr(w, apierror.Conflict("want_held", "the library already holds this album, and it isn't wanted for a better copy"))
default:
writeErrWithLog(w, h.logger, "admin wanted: search", apierror.Internal(err))
}
return true
}