Files
minstrel/internal/api/admin_acquisition.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

211 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. Rows an admin
// 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))
}
}