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"` // 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"` 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, 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|held|all&artist=&q=&offset=. // wanted is everything open but missing files. 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 != "held" && state != "all" { writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, 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), 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": ""}: 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) } }