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
- 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>
816 lines
28 KiB
Go
816 lines
28 KiB
Go
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)
|
||
}
|
||
}
|