feat: the Wanted view becomes Collection, shaped like Lidarr: an artist catalogue first, the state sections down a left rail (M502 #5577)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m54s
release / integration (push) Successful in 5m33s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m56s
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
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m54s
release / integration (push) Successful in 5m33s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m56s
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
The section opens on a catalogue of every artist on the list, A–Z with a letter rail, each card showing how many of their albums are held. An artist opens on their page: every album of theirs, held or not, with its status, search, releases and tracks. Wanted, Missing files, Activity, Following and Blocked sit down a left rail as the sections that show state; Held is gone, the catalogue shows it. The section moves from /wanted to /collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+132
-50
@@ -69,8 +69,10 @@ type wantGrabView struct {
|
||||
ImportedFiles int32 `json:"imported_files"`
|
||||
}
|
||||
|
||||
// wantCounts sizes each tab of the Wanted page.
|
||||
// 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"`
|
||||
Wanted int64 `json:"wanted"`
|
||||
Missing int64 `json:"missing"`
|
||||
Held int64 `json:"held"`
|
||||
@@ -90,6 +92,34 @@ type wantsPage struct {
|
||||
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
|
||||
@@ -113,77 +143,129 @@ const unmatchedShown = 200
|
||||
|
||||
const wantsPageSize = 50
|
||||
|
||||
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&q=&offset=.
|
||||
// wantArtistsPageSize fills a few rows of the artist grid a page.
|
||||
const wantArtistsPageSize = 96
|
||||
|
||||
// wantsHeader is what every Wanted page carries: each tab's count, and how
|
||||
// the one-time library listing went. The albums no match was found for are
|
||||
// named only when asked (the main page's first page).
|
||||
func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries, unmatched bool) (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, Unmatched: []unmatchedAlbumView{}}
|
||||
if listing.LibraryListedAt.Valid {
|
||||
at := formatTimestamp(listing.LibraryListedAt)
|
||||
lib.ListedAt = &at
|
||||
}
|
||||
if unmatched && listing.LibraryUnlisted > 0 {
|
||||
rows, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
|
||||
if err != nil {
|
||||
return wantCounts{}, libraryListingView{}, err
|
||||
}
|
||||
for _, u := range rows {
|
||||
lib.Unmatched = append(lib.Unmatched, unmatchedAlbumView{
|
||||
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
return wantCounts{Artists: counts.Artists, 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, offset == 0)
|
||||
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.
|
||||
// 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" {
|
||||
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing or held"))
|
||||
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, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
offset := offsetParam(r)
|
||||
artist := strings.TrimSpace(r.URL.Query().Get("artist"))
|
||||
q := dbq.New(h.pool)
|
||||
filter := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Q: filter, Skip: int32(offset), MaxRows: wantsPageSize})
|
||||
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, err := q.CountWants(r.Context())
|
||||
counts, lib, err := h.wantsHeader(r, q, false)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
|
||||
writeErrWithLog(w, h.logger, "admin wanted: counts", 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},
|
||||
}
|
||||
out.Library.Unmatched = []unmatchedAlbumView{}
|
||||
if listing.LibraryListedAt.Valid {
|
||||
at := formatTimestamp(listing.LibraryListedAt)
|
||||
out.Library.ListedAt = &at
|
||||
}
|
||||
if state == "held" && offset == 0 && listing.LibraryUnlisted > 0 {
|
||||
unmatched, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
|
||||
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: unmatched albums", apierror.Internal(err))
|
||||
writeErrWithLog(w, h.logger, "admin wanted: artist", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
for _, u := range unmatched {
|
||||
out.Library.Unmatched = append(out.Library.Unmatched, unmatchedAlbumView{
|
||||
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
switch state {
|
||||
case "missing":
|
||||
out.Total = counts.Missing
|
||||
case "held":
|
||||
out.Total = counts.Held
|
||||
default:
|
||||
out.Total = counts.Wanted
|
||||
}
|
||||
if filter != "" {
|
||||
out.Total = 0
|
||||
if len(rows) > 0 {
|
||||
out.Total = rows[0].Total
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user