feat: indexers and download clients you add and configure (M502 #5518)
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>
This commit is contained in:
2026-10-09 20:34:51 -04:00
co-authored by Claude Opus 5.5
parent 5306a261c1
commit a2e1890d4c
35 changed files with 4966 additions and 2 deletions
+362
View File
@@ -0,0 +1,362 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: acquisition.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createAcquisitionService = `-- name: CreateAcquisitionService :one
INSERT INTO acquisition_services
(code, kind, protocol, label, category, timeout_ms, searches_per_hour)
VALUES
($1, $2, $3, $4,
$5, $6, $7)
ON CONFLICT (code) DO NOTHING
RETURNING code, kind, protocol, label, enabled, base_url, api_key, username, password, category, timeout_ms, serves_search, serves_downloads, serves_enrichment, local_path, remote_path, searches_per_hour, created_at, updated_at
`
type CreateAcquisitionServiceParams struct {
Code string
Kind string
Protocol string
Label string
Category string
TimeoutMs int32
SearchesPerHour int32
}
// ON CONFLICT so the caller can try the next code when this one is taken.
func (q *Queries) CreateAcquisitionService(ctx context.Context, arg CreateAcquisitionServiceParams) (AcquisitionService, error) {
row := q.db.QueryRow(ctx, createAcquisitionService,
arg.Code,
arg.Kind,
arg.Protocol,
arg.Label,
arg.Category,
arg.TimeoutMs,
arg.SearchesPerHour,
)
var i AcquisitionService
err := row.Scan(
&i.Code,
&i.Kind,
&i.Protocol,
&i.Label,
&i.Enabled,
&i.BaseUrl,
&i.ApiKey,
&i.Username,
&i.Password,
&i.Category,
&i.TimeoutMs,
&i.ServesSearch,
&i.ServesDownloads,
&i.ServesEnrichment,
&i.LocalPath,
&i.RemotePath,
&i.SearchesPerHour,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteAcquisitionService = `-- name: DeleteAcquisitionService :execrows
DELETE FROM acquisition_services WHERE code = $1
`
func (q *Queries) DeleteAcquisitionService(ctx context.Context, code string) (int64, error) {
result, err := q.db.Exec(ctx, deleteAcquisitionService, code)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const getAcquisitionService = `-- name: GetAcquisitionService :one
SELECT code, kind, protocol, label, enabled, base_url, api_key, username, password, category, timeout_ms, serves_search, serves_downloads, serves_enrichment, local_path, remote_path, searches_per_hour, created_at, updated_at FROM acquisition_services WHERE code = $1
`
// The full row, credentials included. For building a client, never for a
// response.
func (q *Queries) GetAcquisitionService(ctx context.Context, code string) (AcquisitionService, error) {
row := q.db.QueryRow(ctx, getAcquisitionService, code)
var i AcquisitionService
err := row.Scan(
&i.Code,
&i.Kind,
&i.Protocol,
&i.Label,
&i.Enabled,
&i.BaseUrl,
&i.ApiKey,
&i.Username,
&i.Password,
&i.Category,
&i.TimeoutMs,
&i.ServesSearch,
&i.ServesDownloads,
&i.ServesEnrichment,
&i.LocalPath,
&i.RemotePath,
&i.SearchesPerHour,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listAcquisitionServices = `-- name: ListAcquisitionServices :many
SELECT code, kind, protocol, label, enabled, base_url,
(api_key <> '')::boolean AS has_api_key,
(password <> '')::boolean AS has_password,
username, category, timeout_ms,
serves_search, serves_downloads, serves_enrichment,
local_path, remote_path, searches_per_hour,
created_at, updated_at
FROM acquisition_services
ORDER BY kind, label, code
`
type ListAcquisitionServicesRow struct {
Code string
Kind string
Protocol string
Label string
Enabled bool
BaseUrl string
HasApiKey bool
HasPassword bool
Username string
Category string
TimeoutMs int32
ServesSearch bool
ServesDownloads bool
ServesEnrichment bool
LocalPath string
RemotePath string
SearchesPerHour int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
// Indexers and download clients (M502 #5518).
// Returns no secret, only whether one is set: this feeds the settings page.
func (q *Queries) ListAcquisitionServices(ctx context.Context) ([]ListAcquisitionServicesRow, error) {
rows, err := q.db.Query(ctx, listAcquisitionServices)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAcquisitionServicesRow
for rows.Next() {
var i ListAcquisitionServicesRow
if err := rows.Scan(
&i.Code,
&i.Kind,
&i.Protocol,
&i.Label,
&i.Enabled,
&i.BaseUrl,
&i.HasApiKey,
&i.HasPassword,
&i.Username,
&i.Category,
&i.TimeoutMs,
&i.ServesSearch,
&i.ServesDownloads,
&i.ServesEnrichment,
&i.LocalPath,
&i.RemotePath,
&i.SearchesPerHour,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listUsableAcquisitionServices = `-- name: ListUsableAcquisitionServices :many
SELECT code, kind, protocol, label, enabled, base_url, api_key, username, password, category, timeout_ms, serves_search, serves_downloads, serves_enrichment, local_path, remote_path, searches_per_hour, created_at, updated_at FROM acquisition_services
WHERE enabled AND base_url <> ''
AND CASE WHEN protocol = 'exchange' THEN
CASE $1::text
WHEN 'search' THEN serves_search
WHEN 'download' THEN serves_downloads
WHEN 'enrichment' THEN serves_enrichment
ELSE false
END
ELSE kind = $1::text
END
ORDER BY code
`
// Every enabled, addressed service doing one job. An exchange row does a job
// when its switch for it is on; any other row does the job its kind names.
// Ordered by code so a fan-out is deterministic.
func (q *Queries) ListUsableAcquisitionServices(ctx context.Context, job string) ([]AcquisitionService, error) {
rows, err := q.db.Query(ctx, listUsableAcquisitionServices, job)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AcquisitionService
for rows.Next() {
var i AcquisitionService
if err := rows.Scan(
&i.Code,
&i.Kind,
&i.Protocol,
&i.Label,
&i.Enabled,
&i.BaseUrl,
&i.ApiKey,
&i.Username,
&i.Password,
&i.Category,
&i.TimeoutMs,
&i.ServesSearch,
&i.ServesDownloads,
&i.ServesEnrichment,
&i.LocalPath,
&i.RemotePath,
&i.SearchesPerHour,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAcquisitionServiceAPIKey = `-- name: SetAcquisitionServiceAPIKey :execrows
UPDATE acquisition_services SET api_key = $1, updated_at = now()
WHERE code = $2
`
type SetAcquisitionServiceAPIKeyParams struct {
ApiKey string
Code string
}
func (q *Queries) SetAcquisitionServiceAPIKey(ctx context.Context, arg SetAcquisitionServiceAPIKeyParams) (int64, error) {
result, err := q.db.Exec(ctx, setAcquisitionServiceAPIKey, arg.ApiKey, arg.Code)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setAcquisitionServicePassword = `-- name: SetAcquisitionServicePassword :execrows
UPDATE acquisition_services SET password = $1, updated_at = now()
WHERE code = $2
`
type SetAcquisitionServicePasswordParams struct {
Password string
Code string
}
func (q *Queries) SetAcquisitionServicePassword(ctx context.Context, arg SetAcquisitionServicePasswordParams) (int64, error) {
result, err := q.db.Exec(ctx, setAcquisitionServicePassword, arg.Password, arg.Code)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateAcquisitionService = `-- name: UpdateAcquisitionService :one
UPDATE acquisition_services SET
label = $1,
enabled = $2,
base_url = $3,
username = $4,
category = $5,
timeout_ms = $6,
serves_search = $7,
serves_downloads = $8,
serves_enrichment = $9,
local_path = $10,
remote_path = $11,
searches_per_hour = $12,
updated_at = now()
WHERE code = $13
RETURNING code, kind, protocol, label, enabled, base_url, api_key, username, password, category, timeout_ms, serves_search, serves_downloads, serves_enrichment, local_path, remote_path, searches_per_hour, created_at, updated_at
`
type UpdateAcquisitionServiceParams struct {
Label string
Enabled bool
BaseUrl string
Username string
Category string
TimeoutMs int32
ServesSearch bool
ServesDownloads bool
ServesEnrichment bool
LocalPath string
RemotePath string
SearchesPerHour int32
Code string
}
// Everything on the settings form except the credentials, which have their
// own write-only calls.
func (q *Queries) UpdateAcquisitionService(ctx context.Context, arg UpdateAcquisitionServiceParams) (AcquisitionService, error) {
row := q.db.QueryRow(ctx, updateAcquisitionService,
arg.Label,
arg.Enabled,
arg.BaseUrl,
arg.Username,
arg.Category,
arg.TimeoutMs,
arg.ServesSearch,
arg.ServesDownloads,
arg.ServesEnrichment,
arg.LocalPath,
arg.RemotePath,
arg.SearchesPerHour,
arg.Code,
)
var i AcquisitionService
err := row.Scan(
&i.Code,
&i.Kind,
&i.Protocol,
&i.Label,
&i.Enabled,
&i.BaseUrl,
&i.ApiKey,
&i.Username,
&i.Password,
&i.Category,
&i.TimeoutMs,
&i.ServesSearch,
&i.ServesDownloads,
&i.ServesEnrichment,
&i.LocalPath,
&i.RemotePath,
&i.SearchesPerHour,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+22
View File
@@ -195,6 +195,28 @@ type AcoustidSetting struct {
UpdatedAt pgtype.Timestamptz
}
type AcquisitionService struct {
Code string
Kind string
Protocol string
Label string
Enabled bool
BaseUrl string
ApiKey string
Username string
Password string
Category string
TimeoutMs int32
ServesSearch bool
ServesDownloads bool
ServesEnrichment bool
LocalPath string
RemotePath string
SearchesPerHour int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type Album struct {
ID pgtype.UUID
Title string
@@ -0,0 +1 @@
DROP TABLE IF EXISTS acquisition_services;
@@ -0,0 +1,63 @@
-- 0079_acquisition_services.up.sql — the indexers and download clients the
-- operator adds in Admin → Integrations (Scribe milestone #502, #5518).
--
-- Minstrel replaces Lidarr, so it searches and downloads itself. Nothing is
-- seeded and nothing is copied from Lidarr: a connection is a row the operator
-- adds, from a list of what Minstrel can speak. Ported from Fabled
-- Librarian's acquisition_services (its migrations 0044, 0055, 0062, 0064,
-- 0070), which settled the shape:
--
-- kind which list the row appears in: search (Indexers) or download
-- (Download clients).
-- protocol which client is built: prowlarr (its native /api/v1/search,
-- because Prowlarr has no aggregate Torznab feed), torznab (any
-- single feed), qbittorrent (the WebUI v2), exchange.
--
-- FabledExchange is ONE row under Download clients with a switch per job:
-- downloading, searching its DHT index, and describing a torrent's files and
-- swarm before a grab. The serves_* switches mean something on an exchange
-- row only; every other row does the one job its kind names.
--
-- Secrets (api_key, password) are write-only: no query the API answers with
-- returns them, only whether one is set.
CREATE TABLE acquisition_services (
-- code is the stable identity: the protocol for the first of its kind,
-- then protocol-2, protocol-3. Legible in a log, never typed.
code text PRIMARY KEY,
kind text NOT NULL
CONSTRAINT acquisition_services_kind_check CHECK (kind IN ('search', 'download')),
protocol text NOT NULL
CONSTRAINT acquisition_services_protocol_check
CHECK (protocol IN ('prowlarr', 'torznab', 'qbittorrent', 'exchange')),
label text NOT NULL,
-- A new row is off: it has no address yet.
enabled boolean NOT NULL DEFAULT false,
-- The address as the operator's browser opens it. For a Torznab feed it
-- is the whole feed URL up to and including /api.
base_url text NOT NULL DEFAULT '',
api_key text NOT NULL DEFAULT '',
-- qBittorrent only. Both may be empty: a WebUI with auth bypassed for
-- local addresses takes neither.
username text NOT NULL DEFAULT '',
password text NOT NULL DEFAULT '',
-- Download clients: everything Minstrel adds is filed under it.
category text NOT NULL DEFAULT '',
timeout_ms integer NOT NULL DEFAULT 30000
CONSTRAINT acquisition_services_timeout_check CHECK (timeout_ms BETWEEN 1000 AND 300000),
serves_search boolean NOT NULL DEFAULT true,
serves_downloads boolean NOT NULL DEFAULT true,
serves_enrichment boolean NOT NULL DEFAULT true,
-- One downloads folder, two names: where Minstrel has it mounted, and
-- where the client sees it (empty = the same path, nothing translated).
local_path text NOT NULL DEFAULT '/downloads'
CONSTRAINT acquisition_services_local_path_check CHECK (local_path LIKE '/%'),
remote_path text NOT NULL DEFAULT ''
CONSTRAINT acquisition_services_remote_path_check CHECK (remote_path = '' OR remote_path LIKE '/%'),
-- The most searches the schedule sends this source an hour; 0 = unpaced.
-- Prowlarr's indexers answer 429 when asked too often; an Exchange
-- searches its own database and starts unpaced.
searches_per_hour integer NOT NULL DEFAULT 120
CONSTRAINT acquisition_services_searches_per_hour_check CHECK (searches_per_hour BETWEEN 0 AND 3600),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
+76
View File
@@ -0,0 +1,76 @@
-- Indexers and download clients (M502 #5518).
-- name: ListAcquisitionServices :many
-- Returns no secret, only whether one is set: this feeds the settings page.
SELECT code, kind, protocol, label, enabled, base_url,
(api_key <> '')::boolean AS has_api_key,
(password <> '')::boolean AS has_password,
username, category, timeout_ms,
serves_search, serves_downloads, serves_enrichment,
local_path, remote_path, searches_per_hour,
created_at, updated_at
FROM acquisition_services
ORDER BY kind, label, code;
-- name: GetAcquisitionService :one
-- The full row, credentials included. For building a client, never for a
-- response.
SELECT * FROM acquisition_services WHERE code = sqlc.arg(code);
-- name: ListUsableAcquisitionServices :many
-- Every enabled, addressed service doing one job. An exchange row does a job
-- when its switch for it is on; any other row does the job its kind names.
-- Ordered by code so a fan-out is deterministic.
SELECT * FROM acquisition_services
WHERE enabled AND base_url <> ''
AND CASE WHEN protocol = 'exchange' THEN
CASE sqlc.arg(job)::text
WHEN 'search' THEN serves_search
WHEN 'download' THEN serves_downloads
WHEN 'enrichment' THEN serves_enrichment
ELSE false
END
ELSE kind = sqlc.arg(job)::text
END
ORDER BY code;
-- name: CreateAcquisitionService :one
-- ON CONFLICT so the caller can try the next code when this one is taken.
INSERT INTO acquisition_services
(code, kind, protocol, label, category, timeout_ms, searches_per_hour)
VALUES
(sqlc.arg(code), sqlc.arg(kind), sqlc.arg(protocol), sqlc.arg(label),
sqlc.arg(category), sqlc.arg(timeout_ms), sqlc.arg(searches_per_hour))
ON CONFLICT (code) DO NOTHING
RETURNING *;
-- name: UpdateAcquisitionService :one
-- Everything on the settings form except the credentials, which have their
-- own write-only calls.
UPDATE acquisition_services SET
label = sqlc.arg(label),
enabled = sqlc.arg(enabled),
base_url = sqlc.arg(base_url),
username = sqlc.arg(username),
category = sqlc.arg(category),
timeout_ms = sqlc.arg(timeout_ms),
serves_search = sqlc.arg(serves_search),
serves_downloads = sqlc.arg(serves_downloads),
serves_enrichment = sqlc.arg(serves_enrichment),
local_path = sqlc.arg(local_path),
remote_path = sqlc.arg(remote_path),
searches_per_hour = sqlc.arg(searches_per_hour),
updated_at = now()
WHERE code = sqlc.arg(code)
RETURNING *;
-- name: DeleteAcquisitionService :execrows
DELETE FROM acquisition_services WHERE code = sqlc.arg(code);
-- name: SetAcquisitionServiceAPIKey :execrows
UPDATE acquisition_services SET api_key = sqlc.arg(api_key), updated_at = now()
WHERE code = sqlc.arg(code);
-- name: SetAcquisitionServicePassword :execrows
UPDATE acquisition_services SET password = sqlc.arg(password), updated_at = now()
WHERE code = sqlc.arg(code);