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
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>
210 lines
7.7 KiB
Go
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))
|
|
}
|
|
}
|