Files
minstrel/internal/api/admin_import.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

286 lines
9.7 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
)
// Downloads and their imports: the Wanted view's Activity tab,
// a person's "import anyway", "refuse" and "retry", and the Blocked tab.
// downloadView is one grab and what its import made of it.
type downloadView struct {
ID string `json:"id"`
WantID *string `json:"want_id"`
WantTitle string `json:"want_title"`
WantArtist string `json:"want_artist"`
Name string `json:"name"`
Indexer string `json:"indexer"`
Client string `json:"client"`
Quality string `json:"quality"`
Origin string `json:"origin"`
GrabbedAt string `json:"grabbed_at"`
EndedAt *string `json:"ended_at"`
EndReason string `json:"end_reason"`
Progress float32 `json:"progress"`
ImportState string `json:"import_state"`
ImportReason string `json:"import_reason"`
ImportedAt *string `json:"imported_at"`
ImportedFiles int32 `json:"imported_files"`
Destination string `json:"destination"`
Edition string `json:"edition,omitempty"`
Evidence *importer.Evidence `json:"evidence,omitempty"`
Files []string `json:"files"`
Skipped int `json:"skipped"`
RehomedFrom string `json:"rehomed_from,omitempty"`
// EndDetail says why an ended download ended.
EndDetail string `json:"end_detail"`
// Seeding: imported and still sharing; Ratio its share ratio as last
// read; SeedReleasedAt when it was removed once it had shared enough.
Seeding bool `json:"seeding"`
Ratio float32 `json:"ratio"`
SeedReleasedAt *string `json:"seed_released_at"`
// Condition is what is wrong with it now, if anything.
Condition *conditionView `json:"condition"`
}
// conditionView is an open row of the intervention ledger: what was seen,
// since when, and how many times it was retried.
type conditionView struct {
Trigger string `json:"trigger"`
Detail string `json:"detail"`
Retries int32 `json:"retries"`
Since string `json:"since"`
}
func downloadFrom(g dbq.ListGrabsRow) downloadView {
v := downloadView{
ID: uuidToString(g.ID), Name: g.Name, Indexer: g.Indexer, Client: g.Client, Quality: g.Quality,
Origin: g.Origin, GrabbedAt: formatTimestamp(g.GrabbedAt), EndReason: g.EndReason,
Progress: g.Progress, ImportState: g.ImportState, ImportReason: g.ImportReason,
ImportedFiles: g.ImportedFiles, Destination: g.Destination, Files: []string{},
EndDetail: g.EndDetail, Seeding: g.Seeding, Ratio: g.Ratio,
}
if g.SeedReleasedAt.Valid {
at := formatTimestamp(g.SeedReleasedAt)
v.SeedReleasedAt = &at
}
if g.Condition != nil {
v.Condition = &conditionView{Trigger: *g.Condition, Detail: deref(g.ConditionDetail),
Since: formatTimestamp(g.ConditionSince)}
if g.ConditionRetries != nil {
v.Condition.Retries = *g.ConditionRetries
}
}
if g.WantID.Valid {
id := uuidToString(g.WantID)
v.WantID = &id
}
if g.WantTitle != nil {
v.WantTitle = *g.WantTitle
}
if g.WantArtist != nil {
v.WantArtist = *g.WantArtist
}
if g.EndedAt.Valid {
at := formatTimestamp(g.EndedAt)
v.EndedAt = &at
}
if g.ImportedAt.Valid {
at := formatTimestamp(g.ImportedAt)
v.ImportedAt = &at
}
var det importer.Detail
if len(g.ImportDetail) > 0 && json.Unmarshal(g.ImportDetail, &det) == nil && det.Verdict.Outcome != "" {
ev := det.Verdict.Evidence
v.Evidence, v.Edition, v.Skipped, v.RehomedFrom = &ev, det.Edition, det.Skipped, det.RehomedFrom
if det.Files != nil {
v.Files = det.Files
}
}
return v
}
type downloadsPage struct {
Items []downloadView `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
// ClientsDown are the download clients not answering now.
ClientsDown []clientDownView `json:"clients_down"`
}
type clientDownView struct {
Client string `json:"client"`
Detail string `json:"detail"`
Since string `json:"since"`
}
// handleListDownloads implements GET /api/admin/downloads?offset=: running
// downloads first, then the rest newest first.
func (h *handlers) handleListDownloads(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeErr(w, apierror.BadRequest("invalid_paging", "limit and offset must be numbers"))
return
}
rows, err := dbq.New(h.pool).ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: int32(limit), Skip: int32(offset)})
if err != nil {
writeErrWithLog(w, h.logger, "admin downloads: list", apierror.Internal(err))
return
}
down, err := dbq.New(h.pool).ListClientsDown(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin downloads: clients down", apierror.Internal(err))
return
}
out := downloadsPage{Items: make([]downloadView, 0, len(rows)), Limit: limit, Offset: offset,
ClientsDown: make([]clientDownView, 0, len(down))}
for _, g := range rows {
out.Items = append(out.Items, downloadFrom(g))
}
for _, c := range down {
out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail,
Since: formatTimestamp(c.FirstSeen)})
}
writeJSON(w, http.StatusOK, out)
}
// handleAbandonDownload implements POST /api/admin/downloads/{id}/abandon: a
// running download is given up, its release set aside, the download removed
// and the album searched again.
func (h *handlers) handleAbandonDownload(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := h.lifecycle.Abandon(r.Context(), id, admin.ID); err != nil {
switch {
case errors.Is(err, pgx.ErrNoRows), errors.Is(err, lifecycle.ErrNotAbandonable):
writeErr(w, apierror.Conflict("not_running", "that download is no longer running"))
default:
writeErrWithLog(w, h.logger, "admin downloads: abandon", apierror.Internal(err))
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleImportAnyway implements POST /api/admin/downloads/{id}/import: a
// download the check held for a person (or one that failed) is imported on
// the next pass whatever the check says.
func (h *handlers) handleImportAnyway(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := dbq.New(h.pool).ForceGrabImport(r.Context(), id); err != nil {
h.writeImportErr(w, "import anyway", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRetryImport implements POST /api/admin/downloads/{id}/retry.
func (h *handlers) handleRetryImport(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := dbq.New(h.pool).RetryGrabImport(r.Context(), id); err != nil {
h.writeImportErr(w, "retry", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRefuseDownload implements POST /api/admin/downloads/{id}/refuse: the
// release is blocked, the download removed and the album searched again.
func (h *handlers) handleRefuseDownload(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := h.importer.Refuse(r.Context(), id, admin.ID); err != nil {
h.writeImportErr(w, "refuse", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handlers) writeImportErr(w http.ResponseWriter, what string, err error) {
switch {
case errors.Is(err, pgx.ErrNoRows), errors.Is(err, importer.ErrNotDecidable):
writeErr(w, apierror.Conflict("not_decidable", "that download is not waiting on a decision"))
default:
writeErrWithLog(w, h.logger, "admin downloads: "+what, apierror.Internal(err))
}
}
type releaseBlockView struct {
InfoHash string `json:"info_hash"`
Name string `json:"name"`
Reason string `json:"reason"`
Detail string `json:"detail"`
BlockedAt string `json:"blocked_at"`
// ExpiresAt is when a release given up on may be taken again; null is
// for good.
ExpiresAt *string `json:"expires_at"`
WantTitle string `json:"want_title"`
WantArtist string `json:"want_artist"`
}
type releaseBlocksPage struct {
Items []releaseBlockView `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// handleListReleaseBlocks implements GET /api/admin/release-blocks?offset=:
// every release set aside, by a person or by a check, newest first.
func (h *handlers) handleListReleaseBlocks(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeErr(w, apierror.BadRequest("invalid_paging", "limit and offset must be numbers"))
return
}
rows, err := dbq.New(h.pool).ListReleaseBlocksPage(r.Context(), dbq.ListReleaseBlocksPageParams{
MaxRows: int32(limit), Skip: int32(offset)})
if err != nil {
writeErrWithLog(w, h.logger, "admin release blocks: list", apierror.Internal(err))
return
}
out := releaseBlocksPage{Items: make([]releaseBlockView, 0, len(rows)), Limit: limit, Offset: offset}
for _, b := range rows {
v := releaseBlockView{InfoHash: b.InfoHash, Name: b.Name, Reason: b.Reason, Detail: b.Detail,
BlockedAt: formatTimestamp(b.BlockedAt)}
if b.ExpiresAt.Valid {
at := formatTimestamp(b.ExpiresAt)
v.ExpiresAt = &at
}
if b.WantTitle != nil {
v.WantTitle = *b.WantTitle
}
if b.WantArtist != nil {
v.WantArtist = *b.WantArtist
}
out.Items = append(out.Items, v)
}
writeJSON(w, http.StatusOK, out)
}