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 }