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 (M502 #5522): 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 (#5523). 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 (#5523). 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 (#5523). 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) }