Files
minstrel/internal/api/admin_acquisition.go
T
bvandeusenandClaude Opus 5.5 a2e1890d4c
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: indexers and download clients you add and configure (M502 #5518)
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.

- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
  at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
  session) and FabledExchange, one entry with a switch per job:
  downloads (/api/downloads), search (/api/search) and release details
  (/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
  A download client's Test lists its torrents and checks the downloads
  folder is visible and writable from Minstrel; an Exchange's Test
  reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
  client's own name for it); search sources carry searches per hour.

Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:34:51 -04:00

210 lines
7.7 KiB
Go

package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Indexers and download clients, admin side (M502 #5518). Rows the operator
// adds and configures; nothing is seeded. Secrets are write-only: no response
// carries one, only whether it is set.
// acquisitionBodyLimit caps a settings body. The largest is a full form of
// short strings.
const acquisitionBodyLimit = 64 << 10
type acquisitionServiceResp struct {
Code string `json:"code"`
Kind string `json:"kind"`
Protocol string `json:"protocol"`
Label string `json:"label"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
HasAPIKey bool `json:"has_api_key"`
HasPassword bool `json:"has_password"`
Username string `json:"username"`
Category string `json:"category"`
TimeoutMs int32 `json:"timeout_ms"`
ServesSearch bool `json:"serves_search"`
ServesDownloads bool `json:"serves_downloads"`
ServesEnrichment bool `json:"serves_enrichment"`
LocalPath string `json:"local_path"`
RemotePath string `json:"remote_path"`
SearchesPerHour int32 `json:"searches_per_hour"`
// Usable is switched on and addressed: the state the jobs read.
Usable bool `json:"usable"`
}
func acquisitionRespOf(r dbq.AcquisitionService) acquisitionServiceResp {
return acquisitionServiceResp{
Code: r.Code, Kind: r.Kind, Protocol: r.Protocol, Label: r.Label,
Enabled: r.Enabled, BaseURL: r.BaseUrl,
HasAPIKey: r.ApiKey != "", HasPassword: r.Password != "",
Username: r.Username, Category: r.Category, TimeoutMs: r.TimeoutMs,
ServesSearch: r.ServesSearch, ServesDownloads: r.ServesDownloads,
ServesEnrichment: r.ServesEnrichment,
LocalPath: r.LocalPath, RemotePath: r.RemotePath,
SearchesPerHour: r.SearchesPerHour,
Usable: r.Enabled && r.BaseUrl != "",
}
}
// handleListAcquisitionServices implements GET /api/admin/acquisition/services.
func (h *handlers) handleListAcquisitionServices(w http.ResponseWriter, r *http.Request) {
rows, err := h.acquisition.List(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin: list acquisition services", apierror.Internal(err))
return
}
out := make([]acquisitionServiceResp, len(rows))
for i, row := range rows {
out[i] = acquisitionRespOf(dbq.AcquisitionService{
Code: row.Code, Kind: row.Kind, Protocol: row.Protocol, Label: row.Label,
Enabled: row.Enabled, BaseUrl: row.BaseUrl, Username: row.Username,
Category: row.Category, TimeoutMs: row.TimeoutMs,
ServesSearch: row.ServesSearch, ServesDownloads: row.ServesDownloads,
ServesEnrichment: row.ServesEnrichment, LocalPath: row.LocalPath,
RemotePath: row.RemotePath, SearchesPerHour: row.SearchesPerHour,
})
out[i].HasAPIKey, out[i].HasPassword = row.HasApiKey, row.HasPassword
}
writeJSON(w, http.StatusOK, map[string]any{"services": out})
}
// handleCreateAcquisitionService implements POST /api/admin/acquisition/services
// with {"protocol": "prowlarr"|"torznab"|"qbittorrent"|"exchange"}. The row
// starts switched off, with no address.
func (h *handlers) handleCreateAcquisitionService(w http.ResponseWriter, r *http.Request) {
var body struct {
Protocol string `json:"protocol"`
}
if !decodeAcquisitionBody(w, r, &body) {
return
}
row, err := h.acquisition.Create(r.Context(), body.Protocol)
if err != nil {
h.writeAcquisitionErr(w, "admin: create acquisition service", err)
return
}
writeJSON(w, http.StatusCreated, acquisitionRespOf(row))
}
type updateAcquisitionBody struct {
Label string `json:"label"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
Username string `json:"username"`
Category string `json:"category"`
TimeoutMs int32 `json:"timeout_ms"`
ServesSearch bool `json:"serves_search"`
ServesDownloads bool `json:"serves_downloads"`
ServesEnrichment bool `json:"serves_enrichment"`
LocalPath string `json:"local_path"`
RemotePath string `json:"remote_path"`
SearchesPerHour int32 `json:"searches_per_hour"`
}
// handleUpdateAcquisitionService implements PUT
// /api/admin/acquisition/services/{code}: the whole form except credentials.
func (h *handlers) handleUpdateAcquisitionService(w http.ResponseWriter, r *http.Request) {
var body updateAcquisitionBody
if !decodeAcquisitionBody(w, r, &body) {
return
}
row, err := h.acquisition.Save(r.Context(), chi.URLParam(r, "code"), acquire.Update(body))
if err != nil {
h.writeAcquisitionErr(w, "admin: update acquisition service", err)
return
}
writeJSON(w, http.StatusOK, acquisitionRespOf(row))
}
// handleDeleteAcquisitionService implements DELETE
// /api/admin/acquisition/services/{code}.
func (h *handlers) handleDeleteAcquisitionService(w http.ResponseWriter, r *http.Request) {
if err := h.acquisition.Delete(r.Context(), chi.URLParam(r, "code")); err != nil {
h.writeAcquisitionErr(w, "admin: delete acquisition service", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleTestAcquisitionService implements POST
// /api/admin/acquisition/services/{code}/test. POST because it reaches
// another machine, which a prefetch must not trigger. Always 200 with
// {ok, detail}: a failed connection is the answer, not an error.
func (h *handlers) handleTestAcquisitionService(w http.ResponseWriter, r *http.Request) {
row, err := h.acquisition.Get(r.Context(), chi.URLParam(r, "code"))
if err != nil {
h.writeAcquisitionErr(w, "admin: test acquisition service", err)
return
}
writeJSON(w, http.StatusOK, acquire.Test(r.Context(), row))
}
// handleSetAcquisitionSecret implements PUT and DELETE on
// /api/admin/acquisition/services/{code}/api-key and .../password. PUT takes
// {"value": "..."}; DELETE clears it. 204 with no body either way: echoing
// anything here is how a secret ends up in a response.
func (h *handlers) handleSetAcquisitionSecret(field string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
value := ""
if r.Method == http.MethodPut {
var body struct {
Value string `json:"value"`
}
if !decodeAcquisitionBody(w, r, &body) {
return
}
if body.Value == "" {
writeErr(w, apierror.BadRequest("invalid_setting", "Paste a value, or use Remove to clear the one that is set."))
return
}
value = body.Value
}
code := chi.URLParam(r, "code")
var err error
if field == "password" {
err = h.acquisition.SetPassword(r.Context(), code, value)
} else {
err = h.acquisition.SetAPIKey(r.Context(), code, value)
}
if err != nil {
h.writeAcquisitionErr(w, "admin: set acquisition "+field, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func decodeAcquisitionBody(w http.ResponseWriter, r *http.Request, into any) bool {
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, acquisitionBodyLimit)).Decode(into)
if err != nil && !errors.Is(err, io.EOF) {
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
return false
}
return true
}
func (h *handlers) writeAcquisitionErr(w http.ResponseWriter, msg string, err error) {
var invalid *acquire.InvalidError
switch {
case errors.As(err, &invalid):
writeErr(w, apierror.BadRequest("invalid_setting", invalid.Message))
case errors.Is(err, acquire.ErrNotFound):
writeErr(w, apierror.NotFound("service"))
case errors.Is(err, acquire.ErrTooMany):
writeErr(w, apierror.Conflict("too_many_services", "There are already twenty of these. Remove one you no longer use first."))
default:
writeErrWithLog(w, h.logger, msg, apierror.Internal(err))
}
}