release / govulncheck (push) Successful in 17s
release / web (push) Failing after 17s
release / go (push) Successful in 1m11s
release / integration (push) Successful in 4m50s
release / android (push) Successful in 4m54s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Ported from Fabled Librarian's System 104. A new lifecycle poller, every ten minutes: - a download the client fails is given up at once; - one nobody shares, or still fetching its file list, past the stall setting (6 h) is asked for again three times, then given up; - giving up removes the download with its files, holds the release back for a week (release_blocks.expires_at), ends the grab and searches the album again, with no limit per album; - a running download the client stops listing for half an hour ends and is searched again, not held back; an empty list is not taken as gone; - an imported download seeds to ratio 2.0 or 14 days, then is removed; - a download the client would not remove is asked again every pass. Every condition and act is one open row per (client, download, condition) in download_interventions. Activity shows what is wrong with a download and how often it was retried, why one ended, its seeding, and a banner for a client that does not answer. A running download can be given up by hand (set aside for good). The seed ratio, seed days and stall hours are on the search settings card. The download client lookup moves to acquire.DownloadClientFor, shared by the importer and the lifecycle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
461 lines
15 KiB
Go
461 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
|
)
|
|
|
|
// The wanted list's admin surface (M502 #5520): wants, follows and
|
|
// release-type profiles.
|
|
|
|
// wantView is one row of GET /api/admin/wanted.
|
|
type wantView struct {
|
|
ID string `json:"id"`
|
|
ReleaseGroupMBID string `json:"release_group_mbid"`
|
|
Title string `json:"title"`
|
|
ArtistMBID string `json:"artist_mbid"`
|
|
ArtistName string `json:"artist_name"`
|
|
ArtistID string `json:"artist_id,omitempty"`
|
|
PrimaryType string `json:"primary_type"`
|
|
SecondaryTypes []string `json:"secondary_types"`
|
|
Year string `json:"year"`
|
|
Reason string `json:"reason"`
|
|
RequestedBy string `json:"requested_by,omitempty"`
|
|
AlbumID string `json:"album_id,omitempty"`
|
|
WantedAt string `json:"wanted_at"`
|
|
HeldAt *string `json:"held_at"`
|
|
ImageURL string `json:"image_url"`
|
|
// Search is the schedule's last look; Grab the download running (#5521).
|
|
Search *wantSearchView `json:"search"`
|
|
Grab *wantGrabView `json:"grab"`
|
|
}
|
|
|
|
type wantSearchView struct {
|
|
SearchedAt string `json:"searched_at"`
|
|
Outcome string `json:"outcome"`
|
|
Considered int32 `json:"considered"`
|
|
BestRelease string `json:"best_release"`
|
|
BestVerdict string `json:"best_verdict"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// wantGrabView is the want's latest download, running or ended, and what
|
|
// its import made of it (#5522).
|
|
type wantGrabView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
GrabbedAt string `json:"grabbed_at"`
|
|
Origin string `json:"origin"`
|
|
Quality string `json:"quality"`
|
|
Live bool `json:"live"`
|
|
EndReason string `json:"end_reason"`
|
|
Progress float32 `json:"progress"`
|
|
ImportState string `json:"import_state"`
|
|
ImportReason string `json:"import_reason"`
|
|
EndDetail string `json:"end_detail"`
|
|
ImportedAt *string `json:"imported_at"`
|
|
ImportedFiles int32 `json:"imported_files"`
|
|
}
|
|
|
|
// wantCounts sizes each tab of the Wanted page.
|
|
type wantCounts struct {
|
|
Wanted int64 `json:"wanted"`
|
|
Missing int64 `json:"missing"`
|
|
Held int64 `json:"held"`
|
|
Following int64 `json:"following"`
|
|
// Activity: downloads running; Attention: of those, the ones waiting on
|
|
// a person; Blocked: releases set aside (#5522).
|
|
Activity int64 `json:"activity"`
|
|
Attention int64 `json:"attention"`
|
|
Blocked int64 `json:"blocked"`
|
|
}
|
|
|
|
type wantsPage struct {
|
|
Items []wantView `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
Counts wantCounts `json:"counts"`
|
|
}
|
|
|
|
const wantsPageSize = 50
|
|
|
|
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&offset=.
|
|
// wanted is everything open but missing files; held is newest first.
|
|
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
|
state := r.URL.Query().Get("state")
|
|
if state == "" {
|
|
state = "wanted"
|
|
}
|
|
if state != "wanted" && state != "missing" && state != "held" {
|
|
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing or held"))
|
|
return
|
|
}
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
q := dbq.New(h.pool)
|
|
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Skip: int32(offset), MaxRows: wantsPageSize})
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
|
|
return
|
|
}
|
|
counts, err := q.CountWants(r.Context())
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
|
|
return
|
|
}
|
|
out := wantsPage{
|
|
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
|
|
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
|
|
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
|
|
}
|
|
switch state {
|
|
case "missing":
|
|
out.Total = counts.Missing
|
|
case "held":
|
|
out.Total = counts.Held
|
|
default:
|
|
out.Total = counts.Wanted
|
|
}
|
|
for _, row := range rows {
|
|
v := wantView{
|
|
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
|
|
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
|
|
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
|
|
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
|
|
WantedAt: formatTimestamp(row.WantedAt),
|
|
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
|
|
}
|
|
if row.LibraryArtistID.Valid {
|
|
v.ArtistID = uuidToString(row.LibraryArtistID)
|
|
}
|
|
if row.AlbumID.Valid {
|
|
v.AlbumID = uuidToString(row.AlbumID)
|
|
}
|
|
if row.RequestedBy != nil {
|
|
v.RequestedBy = *row.RequestedBy
|
|
}
|
|
if row.FulfilledAt.Valid {
|
|
held := formatTimestamp(row.FulfilledAt)
|
|
v.HeldAt = &held
|
|
}
|
|
if v.SecondaryTypes == nil {
|
|
v.SecondaryTypes = []string{}
|
|
}
|
|
if row.SearchedAt.Valid {
|
|
v.Search = &wantSearchView{
|
|
SearchedAt: formatTimestamp(row.SearchedAt), Outcome: row.SearchOutcome,
|
|
Considered: row.SearchConsidered, BestRelease: row.BestRelease,
|
|
BestVerdict: row.BestVerdict, Error: row.SearchError,
|
|
}
|
|
}
|
|
if row.GrabName != nil {
|
|
v.Grab = &wantGrabView{ID: uuidToString(row.GrabID), Name: *row.GrabName,
|
|
GrabbedAt: formatTimestamp(row.GrabbedAt), Live: !row.GrabEndedAt.Valid,
|
|
Origin: deref(row.GrabOrigin), Quality: deref(row.GrabQuality), EndReason: deref(row.GrabEndReason),
|
|
ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason),
|
|
EndDetail: deref(row.GrabEndDetail)}
|
|
if row.GrabProgress != nil {
|
|
v.Grab.Progress = *row.GrabProgress
|
|
}
|
|
if row.GrabImportedFiles != nil {
|
|
v.Grab.ImportedFiles = *row.GrabImportedFiles
|
|
}
|
|
if row.GrabImportedAt.Valid {
|
|
at := formatTimestamp(row.GrabImportedAt)
|
|
v.Grab.ImportedAt = &at
|
|
}
|
|
}
|
|
out.Items = append(out.Items, v)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func first4(date string) string {
|
|
if len(date) < 4 {
|
|
return ""
|
|
}
|
|
return date[:4]
|
|
}
|
|
|
|
// handleRemoveWant implements DELETE /api/admin/wanted/{id}.
|
|
func (h *handlers) handleRemoveWant(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
removed, err := h.wanted.RemoveWant(r.Context(), id)
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "admin wanted: remove", apierror.Internal(err))
|
|
return
|
|
}
|
|
if !removed {
|
|
writeErr(w, apierror.NotFound("want"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// followView is a followed artist.
|
|
type followView struct {
|
|
ArtistMBID string `json:"artist_mbid"`
|
|
ArtistName string `json:"artist_name"`
|
|
ArtistID string `json:"artist_id,omitempty"`
|
|
ProfileID string `json:"profile_id"`
|
|
ProfileName string `json:"profile_name"`
|
|
FollowedAt string `json:"followed_at"`
|
|
CheckedAt *string `json:"checked_at"`
|
|
OpenWants int64 `json:"open_wants"`
|
|
ImageURL string `json:"image_url"`
|
|
}
|
|
|
|
// handleListFollows implements GET /api/admin/follows.
|
|
func (h *handlers) handleListFollows(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := dbq.New(h.pool).ListFollows(r.Context())
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "admin follows: list", apierror.Internal(err))
|
|
return
|
|
}
|
|
out := make([]followView, 0, len(rows))
|
|
for _, f := range rows {
|
|
v := followView{
|
|
ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName,
|
|
ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
|
|
FollowedAt: formatTimestamp(f.FollowedAt), OpenWants: f.OpenWants,
|
|
ImageURL: artistArtURL(f.ArtistMbid, f.ArtistName),
|
|
}
|
|
if f.ArtistID.Valid {
|
|
v.ArtistID = uuidToString(f.ArtistID)
|
|
}
|
|
if f.CheckedAt.Valid {
|
|
checked := formatTimestamp(f.CheckedAt)
|
|
v.CheckedAt = &checked
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// followStateView answers whether one artist is followed, for the control on
|
|
// an artist page.
|
|
type followStateView struct {
|
|
Following bool `json:"following"`
|
|
ProfileID string `json:"profile_id,omitempty"`
|
|
ProfileName string `json:"profile_name,omitempty"`
|
|
}
|
|
|
|
// handleGetFollow implements GET /api/admin/follows/{mbid}.
|
|
func (h *handlers) handleGetFollow(w http.ResponseWriter, r *http.Request) {
|
|
f, err := dbq.New(h.pool).GetFollow(r.Context(), chi.URLParam(r, "mbid"))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeJSON(w, http.StatusOK, followStateView{})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "admin follows: get", apierror.Internal(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, followStateView{
|
|
Following: true, ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
|
|
})
|
|
}
|
|
|
|
type putFollowBody struct {
|
|
ArtistName string `json:"artist_name"`
|
|
ProfileID string `json:"profile_id"`
|
|
WantExisting bool `json:"want_existing"`
|
|
}
|
|
|
|
// handlePutFollow implements PUT /api/admin/follows/{mbid}: follow an artist,
|
|
// or change the profile it is followed on. want_existing also wants what the
|
|
// artist has already released that the profile passes and isn't held.
|
|
func (h *handlers) handlePutFollow(w http.ResponseWriter, r *http.Request) {
|
|
admin, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body putFollowBody
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
var profile pgtype.UUID
|
|
if body.ProfileID != "" {
|
|
if profile, ok = parseUUID(body.ProfileID); !ok {
|
|
writeErr(w, apierror.BadRequest("bad_profile", "profile_id is not an id"))
|
|
return
|
|
}
|
|
}
|
|
mbid := chi.URLParam(r, "mbid")
|
|
if _, err := h.wanted.Follow(r.Context(), wanted.FollowParams{
|
|
ArtistMBID: mbid, ArtistName: body.ArtistName, ProfileID: profile,
|
|
By: admin.ID, WantExisting: body.WantExisting,
|
|
}); err != nil {
|
|
switch {
|
|
case errors.Is(err, wanted.ErrNotFollowable):
|
|
writeErr(w, apierror.BadRequest("not_followable", "Various Artists and MusicBrainz placeholders can't be followed"))
|
|
case errors.Is(err, wanted.ErrProfileNotFound):
|
|
writeErr(w, apierror.BadRequest("profile_not_found", "no such profile"))
|
|
default:
|
|
writeErrWithLog(w, h.logger, "admin follows: put", apierror.Internal(err))
|
|
}
|
|
return
|
|
}
|
|
h.handleGetFollow(w, r)
|
|
}
|
|
|
|
// handleDeleteFollow implements DELETE /api/admin/follows/{mbid}. What the
|
|
// follow already wanted stays on the list.
|
|
func (h *handlers) handleDeleteFollow(w http.ResponseWriter, r *http.Request) {
|
|
removed, err := h.wanted.Unfollow(r.Context(), chi.URLParam(r, "mbid"))
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "admin follows: delete", apierror.Internal(err))
|
|
return
|
|
}
|
|
if !removed {
|
|
writeErr(w, apierror.NotFound("follow"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// releaseProfileView is a release-type profile.
|
|
type releaseProfileView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
PrimaryTypes []string `json:"primary_types"`
|
|
SecondaryTypes []string `json:"secondary_types"`
|
|
IsDefault bool `json:"is_default"`
|
|
FollowCount int64 `json:"follow_count"`
|
|
}
|
|
|
|
// releaseProfilesResponse also carries MusicBrainz's types, so the editor
|
|
// offers exactly what a profile may hold.
|
|
type releaseProfilesResponse struct {
|
|
Profiles []releaseProfileView `json:"profiles"`
|
|
PrimaryTypes []string `json:"primary_types"`
|
|
SecondaryTypes []string `json:"secondary_types"`
|
|
}
|
|
|
|
// handleListReleaseProfiles implements GET /api/admin/release-profiles.
|
|
func (h *handlers) handleListReleaseProfiles(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := dbq.New(h.pool).ListReleaseTypeProfiles(r.Context())
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "admin release profiles: list", apierror.Internal(err))
|
|
return
|
|
}
|
|
out := releaseProfilesResponse{
|
|
Profiles: make([]releaseProfileView, 0, len(rows)),
|
|
PrimaryTypes: wanted.PrimaryTypes, SecondaryTypes: wanted.SecondaryTypes,
|
|
}
|
|
for _, p := range rows {
|
|
out.Profiles = append(out.Profiles, releaseProfileView{
|
|
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
|
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault, FollowCount: p.FollowCount,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
type releaseProfileBody struct {
|
|
Name string `json:"name"`
|
|
PrimaryTypes []string `json:"primary_types"`
|
|
SecondaryTypes []string `json:"secondary_types"`
|
|
IsDefault bool `json:"is_default"`
|
|
}
|
|
|
|
func (b releaseProfileBody) input() wanted.ProfileInput {
|
|
return wanted.ProfileInput{
|
|
Name: b.Name, PrimaryTypes: b.PrimaryTypes, SecondaryTypes: b.SecondaryTypes, IsDefault: b.IsDefault,
|
|
}
|
|
}
|
|
|
|
// handleCreateReleaseProfile implements POST /api/admin/release-profiles.
|
|
func (h *handlers) handleCreateReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
|
var body releaseProfileBody
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
p, err := h.wanted.CreateProfile(r.Context(), body.input())
|
|
if err != nil {
|
|
h.writeProfileErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, releaseProfileView{
|
|
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
|
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
|
|
})
|
|
}
|
|
|
|
// handleUpdateReleaseProfile implements PUT /api/admin/release-profiles/{id}.
|
|
// A follow on the profile judges new releases by the new types from its next
|
|
// check; releases it has already judged are not judged again.
|
|
func (h *handlers) handleUpdateReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var body releaseProfileBody
|
|
if !decodeBody(w, r, &body) {
|
|
return
|
|
}
|
|
p, err := h.wanted.UpdateProfile(r.Context(), id, body.input())
|
|
if err != nil {
|
|
h.writeProfileErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, releaseProfileView{
|
|
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
|
|
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
|
|
})
|
|
}
|
|
|
|
// handleDeleteReleaseProfile implements DELETE /api/admin/release-profiles/{id}.
|
|
func (h *handlers) handleDeleteReleaseProfile(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := requireURLUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.wanted.DeleteProfile(r.Context(), id); err != nil {
|
|
h.writeProfileErr(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *handlers) writeProfileErr(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, wanted.ErrProfileInvalid):
|
|
writeErr(w, apierror.BadRequest("profile_invalid", err.Error()))
|
|
case errors.Is(err, wanted.ErrProfileNotFound):
|
|
writeErr(w, apierror.NotFound("profile"))
|
|
case errors.Is(err, wanted.ErrProfileNameUsed):
|
|
writeErr(w, apierror.Conflict("profile_name_taken", "a profile already has that name"))
|
|
case errors.Is(err, wanted.ErrProfileInUse):
|
|
writeErr(w, apierror.Conflict("profile_in_use", "artists are followed on this profile; move them first"))
|
|
case errors.Is(err, wanted.ErrProfileDefault):
|
|
writeErr(w, apierror.Conflict("profile_is_default", "the default profile can't be deleted"))
|
|
default:
|
|
writeErrWithLog(w, h.logger, "admin release profiles", apierror.Internal(err))
|
|
}
|
|
}
|
|
|
|
// deref is a nullable column's text, or "".
|
|
func deref(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|