release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m31s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
A one-time job in the wanted worker lists every album in the library as a held want, one per release group, with a record that it happened (acquisition_settings.library_listed_at). Albums whose tags name only a release are named on MusicBrainz first, 120 a pass; what nothing names is counted and shown on the Held tab. Re-acquisition now wants back only albums on the list. Taking a held album off the list leaves its files, and it is never fetched back; albums put in the library folder by hand afterwards are not added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
481 lines
16 KiB
Go
481 lines
16 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"`
|
|
// Library is the one-time listing of the library's albums (#5567).
|
|
Library libraryListingView `json:"library"`
|
|
}
|
|
|
|
// libraryListingView: whether the library's albums are all on the list
|
|
// yet, and how many could not be listed (nothing names them to
|
|
// MusicBrainz).
|
|
type libraryListingView struct {
|
|
ListedAt *string `json:"listed_at"`
|
|
Unlisted int32 `json:"unlisted"`
|
|
}
|
|
|
|
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
|
|
}
|
|
listing, err := q.GetLibraryListing(r.Context())
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "admin wanted: library listing", apierror.Internal(err))
|
|
return
|
|
}
|
|
out := wantsPage{
|
|
Library: libraryListingView{Unlisted: listing.LibraryUnlisted},
|
|
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},
|
|
}
|
|
if listing.LibraryListedAt.Valid {
|
|
at := formatTimestamp(listing.LibraryListedAt)
|
|
out.Library.ListedAt = &at
|
|
}
|
|
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
|
|
}
|