release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's cutoff, note #5527; "" is off), an "Upgrade until" row under Searching. - The wanted worker judges held albums hourly: tier per format from size per second (the scanner records no bitrate), the album's worst. Below the cutoff it is wanted for quality; a video rip among its tracks always. - The schedule searches upgrades weekly, after open wants; a release no better than the copy held is refused ("not_better"). - The import matches each file to the track it would replace (recording, then disc/track) and replaces it when it is a rip or the file is a better tier: over the same path in one rename, or filed beside it and the old track folded in (library.ReplaceTrack: file first, then foldTrackInto). Nothing better: the release is set aside. - Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a clean copy was looked for instead of Lidarr's search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
12 KiB
Go
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
|
|
}
|