Files
minstrel/internal/api/admin_wanted.go
T
bvandeusenandClaude Opus 5.5 c54792c51e
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's
  cutoff, note #5527; "" is off), an "Upgrade until" row under Searching.
- The wanted worker judges held albums hourly: tier per format from size per
  second (the scanner records no bitrate), the album's worst. Below the
  cutoff it is wanted for quality; a video rip among its tracks always.
- The schedule searches upgrades weekly, after open wants; a release no
  better than the copy held is refused ("not_better").
- The import matches each file to the track it would replace (recording,
  then disc/track) and replaces it when it is a rip or the file is a better
  tier: over the same path in one rename, or filed beside it and the old
  track folded in (library.ReplaceTrack: file first, then foldTrackInto).
  Nothing better: the release is set aside.
- Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a
  clean copy was looked for instead of Lidarr's search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:59 -04:00

816 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"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/catalogue"
"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"`
// Upgrade: why a held album is wanted again, quality or rip, and
// HeldQuality what the library's copy is (#5524).
Upgrade string `json:"upgrade,omitempty"`
HeldQuality string `json:"held_quality,omitempty"`
// 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. Artists: how many artists
// the list holds albums of, its main page (#5572).
type wantCounts struct {
Artists int64 `json:"artists"`
// Unmatched: library albums waiting to be matched by hand (#5579).
Unmatched int64 `json:"unmatched"`
Wanted int64 `json:"wanted"`
Missing int64 `json:"missing"`
Held int64 `json:"held"`
// Upgrades: held albums wanted for a better copy (#5524).
Upgrades int64 `json:"upgrades"`
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"`
// Artist heads one artist's page, when the list is narrowed to them.
Artist *wantArtistView `json:"artist,omitempty"`
}
// wantArtistView is one artist on the Wanted view's main page (#5572): how
// many of their albums the list holds, of how many, like Lidarr's artist
// index.
type wantArtistView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
SortName string `json:"sort_name"`
ArtistID string `json:"artist_id,omitempty"`
ImageURL string `json:"image_url"`
Albums int32 `json:"albums"`
Held int32 `json:"held"`
Wanted int32 `json:"wanted"`
Missing int32 `json:"missing"`
Downloading int32 `json:"downloading"`
Following bool `json:"following"`
}
type wantArtistsPage struct {
Items []wantArtistView `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
Library libraryListingView `json:"library"`
}
// libraryListingView: whether the library's albums are all on the list
// yet, and how many could not be matched to MusicBrainz (#5569); those are
// listed in the Collection's Unmatched section (#5579).
type libraryListingView struct {
ListedAt *string `json:"listed_at"`
Unlisted int32 `json:"unlisted"`
}
// unmatchedAlbumView is one album in the Collection's Unmatched section
// (#5579): why no MusicBrainz album matched it, or that a person left it
// off.
type unmatchedAlbumView struct {
AlbumID string `json:"album_id"`
Title string `json:"title"`
Artist string `json:"artist"`
Detail string `json:"detail"`
Tracks int32 `json:"tracks"`
LeftOff bool `json:"left_off"`
DecidedAt string `json:"decided_at"`
}
// unmatchedShown bounds the Unmatched section: a library a listing could
// not match more of than this has a bigger problem than one page shows.
const unmatchedShown = 500
const wantsPageSize = 50
// wantArtistsPageSize fills a few rows of the artist grid a page.
const wantArtistsPageSize = 96
// wantsHeader is what every Collection page carries: each section's count,
// and how the one-time library listing went.
func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries) (wantCounts, libraryListingView, error) {
counts, err := q.CountWants(r.Context())
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
listing, err := q.GetLibraryListing(r.Context())
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
lib := libraryListingView{Unlisted: listing.LibraryUnlisted}
if listing.LibraryListedAt.Valid {
at := formatTimestamp(listing.LibraryListedAt)
lib.ListedAt = &at
}
return wantCounts{Artists: counts.Artists, Unmatched: counts.Unmatched, Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held,
Upgrades: counts.Upgrades, Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
}
func offsetParam(r *http.Request) int {
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
return max(offset, 0)
}
func wantArtistViewOf(row dbq.ListWantArtistsRow) wantArtistView {
v := wantArtistView{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName, SortName: row.SortName,
ImageURL: artistArtURL(row.ArtistMbid, row.ArtistName),
Albums: row.Albums, Held: row.Held, Wanted: row.Wanted, Missing: row.Missing,
Downloading: row.Downloading, Following: row.Following,
}
if row.LibraryArtistID.Valid {
v.ArtistID = uuidToString(row.LibraryArtistID)
}
return v
}
// handleListWantArtists implements GET /api/admin/wanted/artists?q=&offset=:
// the Wanted view's main page, every artist on the list A–Z (#5572).
func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request) {
offset := offsetParam(r)
q := dbq.New(h.pool)
rows, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantArtistsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: artists", apierror.Internal(err))
return
}
counts, lib, err := h.wantsHeader(r, q)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
out := wantArtistsPage{Items: make([]wantArtistView, 0, len(rows)), Limit: wantArtistsPageSize, Offset: offset,
Counts: counts, Library: lib}
for _, row := range rows {
out.Total = row.Total
out.Items = append(out.Items, wantArtistViewOf(row))
}
writeJSON(w, http.StatusOK, out)
}
// handleListWants implements
// GET /api/admin/wanted?state=wanted|missing|upgrade|held|all&artist=&q=&offset=.
// wanted is everything open but missing files; upgrade the held albums
// wanted for a better copy (#5524). Each list runs artist by
// artist, A–Z, like the library (#5572); q narrows it to an artist or
// album, and artist to one artist's page, which it then heads.
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 != "upgrade" && state != "held" && state != "all" {
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, upgrade, held or all"))
return
}
offset := offsetParam(r)
artist := strings.TrimSpace(r.URL.Query().Get("artist"))
q := dbq.New(h.pool)
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, ArtistMbid: artist,
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
return
}
counts, lib, err := h.wantsHeader(r, q)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
out := wantsPage{Library: lib, Counts: counts,
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset}
if artist != "" {
heads, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{ArtistMbid: artist, MaxRows: 1})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: artist", apierror.Internal(err))
return
}
if len(heads) == 0 {
writeErr(w, apierror.NotFound("artist"))
return
}
v := wantArtistViewOf(heads[0])
out.Artist = &v
}
for _, row := range rows {
out.Total = row.Total
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), Upgrade: row.Upgrade, HeldQuality: row.HeldQuality,
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)
}
// wantTracksView is an album's track list for the Wanted view (#5572): the
// library's own tracks for an album it holds or held, else MusicBrainz's
// standard edition of what is wanted.
type wantTracksView struct {
// library, musicbrainz, or none when MusicBrainz lists no edition.
Source string `json:"source"`
// Edition names the MusicBrainz release the list is from.
Edition string `json:"edition,omitempty"`
Tracks []wantTrackView `json:"tracks"`
}
type wantTrackView struct {
Disc int32 `json:"disc"`
Number int32 `json:"number"`
Title string `json:"title"`
LengthMs int32 `json:"length_ms"`
// Missing: the library's file for it is gone.
Missing bool `json:"missing"`
}
// handleWantTracks implements GET /api/admin/wanted/{id}/tracks.
func (h *handlers) handleWantTracks(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
q := dbq.New(h.pool)
want, err := q.GetWantAlbum(r.Context(), id)
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("want"))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: tracks", apierror.Internal(err))
return
}
out := wantTracksView{Tracks: []wantTrackView{}}
if want.AlbumID.Valid {
rows, err := q.ListLibraryAlbumTracks(r.Context(), want.AlbumID)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: library tracks", apierror.Internal(err))
return
}
out.Source = "library"
for _, t := range rows {
v := wantTrackView{Title: t.Title, LengthMs: t.DurationMs, Missing: t.Missing}
if t.DiscNumber != nil {
v.Disc = *t.DiscNumber
}
if t.TrackNumber != nil {
v.Number = *t.TrackNumber
}
out.Tracks = append(out.Tracks, v)
}
writeJSON(w, http.StatusOK, out)
return
}
eds, err := h.catalogue.Releases(r.Context(), want.ReleaseGroupMbid)
switch {
case errors.Is(err, catalogue.ErrNotFound):
out.Source = "none"
writeJSON(w, http.StatusOK, out)
return
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
writeErr(w, &apierror.Error{Status: 503, Code: "musicbrainz_unreachable", Message: "MusicBrainz can't be reached", Cause: err})
return
case err != nil:
writeErrWithLog(w, h.logger, "admin wanted: editions", apierror.Internal(err))
return
}
e, ok := standardEdition(eds.Editions)
if !ok {
out.Source = "none"
writeJSON(w, http.StatusOK, out)
return
}
out.Source = "musicbrainz"
out.Edition = editionLabel(e.Release)
for _, t := range e.Tracks {
out.Tracks = append(out.Tracks, wantTrackView{Disc: t.MediumPosition, Number: t.Position, Title: t.Title, LengthMs: t.LengthMs})
}
writeJSON(w, http.StatusOK, out)
}
// standardEdition is the edition a person would call the album: an official
// one with the fewest tracks, else any with the fewest. The catalogue lists
// official editions first by date, so a tie goes to the earliest.
func standardEdition(eds []catalogue.Edition) (catalogue.Edition, bool) {
best := -1
for i, e := range eds {
if len(e.Tracks) == 0 {
continue
}
if best < 0 {
best = i
continue
}
b := eds[best]
bo, eo := b.Release.Status == "Official", e.Release.Status == "Official"
if (eo && !bo) || (eo == bo && len(e.Tracks) < len(b.Tracks)) {
best = i
}
}
if best < 0 {
return catalogue.Edition{}, false
}
return eds[best], true
}
// editionLabel names a release for a person: "2023 · CD · GB".
func editionLabel(r dbq.MbRelease) string {
var parts []string
for _, p := range []string{first4(r.ReleaseDate), r.Formats, r.Country} {
if p != "" {
parts = append(parts, p)
}
}
return strings.Join(parts, " · ")
}
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
}
// handleListUnmatched implements GET /api/admin/wanted/unmatched: the
// Collection's Unmatched section (#5579), the albums no MusicBrainz album
// matched and, after them, the ones a person left off.
func (h *handlers) handleListUnmatched(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: unmatched", apierror.Internal(err))
return
}
out := struct {
Items []unmatchedAlbumView `json:"items"`
}{Items: make([]unmatchedAlbumView, 0, len(rows))}
for _, u := range rows {
out.Items = append(out.Items, unmatchedAlbumView{
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
Tracks: u.Tracks, LeftOff: u.Outcome == "dismissed", DecidedAt: formatTimestamp(u.DecidedAt),
})
}
writeJSON(w, http.StatusOK, out)
}
type manualMatchView struct {
ReleaseGroupMBID string `json:"release_group_mbid"`
Title string `json:"title"`
Artist string `json:"artist"`
Agree int `json:"agree"`
Tracks int `json:"tracks"`
}
// handleMatchUnmatched implements POST /api/admin/wanted/unmatched/{id}/match
// {"ref": "<MusicBrainz link or id>"}: the album goes on the list as held,
// as that MusicBrainz album.
func (h *handlers) handleMatchUnmatched(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body struct {
Ref string `json:"ref"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "expected {\"ref\": \"…\"}"))
return
}
m, err := h.wanted.MatchAlbumTo(r.Context(), id, body.Ref)
switch {
case errors.Is(err, wanted.ErrMatchRef):
writeErr(w, apierror.BadRequest("bad_ref", "That isn't a MusicBrainz album link or id."))
return
case errors.Is(err, wanted.ErrMatchNoGroup):
writeErr(w, &apierror.Error{Status: http.StatusUnprocessableEntity, Code: "no_such_album",
Message: "MusicBrainz has no album at that link.", Cause: err})
return
case errors.Is(err, wanted.ErrAlbumNotUnmatched):
writeErr(w, apierror.NotFound("unmatched album"))
return
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
writeErr(w, &apierror.Error{Status: http.StatusServiceUnavailable, Code: "musicbrainz_unreachable",
Message: "MusicBrainz can't be reached", Cause: err})
return
case err != nil:
writeErrWithLog(w, h.logger, "admin wanted: match by hand", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, manualMatchView{ReleaseGroupMBID: m.Group, Title: m.Title, Artist: m.Artist,
Agree: m.Agree, Tracks: m.Tracks})
}
// handleLeaveUnmatchedOff implements POST /api/admin/wanted/unmatched/{id}/leave-off,
// and handlePutUnmatchedBack its undoing, POST …/{id}/put-back.
func (h *handlers) handleLeaveUnmatchedOff(w http.ResponseWriter, r *http.Request) {
h.setUnmatched(w, r, h.wanted.LeaveAlbumOff)
}
func (h *handlers) handlePutUnmatchedBack(w http.ResponseWriter, r *http.Request) {
h.setUnmatched(w, r, h.wanted.PutAlbumBack)
}
func (h *handlers) setUnmatched(w http.ResponseWriter, r *http.Request, set func(context.Context, pgtype.UUID) error) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
err := set(r.Context(), id)
switch {
case errors.Is(err, wanted.ErrAlbumNotUnmatched):
writeErr(w, apierror.NotFound("unmatched album"))
case err != nil:
writeErrWithLog(w, h.logger, "admin wanted: unmatched", apierror.Internal(err))
default:
w.WriteHeader(http.StatusNoContent)
}
}