feat: one Missing files page under Collection: folders gone, each with its album's want, last search or download, and Search, Releases and Remove; an album held before its files went is no longer shown as held again (M504 #5626)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 6m2s
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 18s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 6m2s
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
@@ -31,10 +32,13 @@ type missingTrackView struct {
|
||||
// reacquisitionStateView is what the wanted list says about one album
|
||||
// (milestone #290, M502 #5520): when it was wanted back, and when the library
|
||||
// held it again. A missing album with no want yet is still inside the grace
|
||||
// window, or MusicBrainz cannot name it.
|
||||
// window, or MusicBrainz cannot name it. Want is that want as the wanted list
|
||||
// sends it, so the Missing page can show its search and download and act on
|
||||
// it (M504).
|
||||
type reacquisitionStateView struct {
|
||||
WantedAt *string `json:"wanted_at"`
|
||||
HeldAt *string `json:"held_at"`
|
||||
WantedAt *string `json:"wanted_at"`
|
||||
HeldAt *string `json:"held_at"`
|
||||
Want *wantView `json:"want"`
|
||||
}
|
||||
|
||||
// missingGroupView is a directory's worth of missing tracks.
|
||||
@@ -174,7 +178,13 @@ func (h *handlers) attachReacquisitionState(
|
||||
// groupMissingByDirectory folded them in.
|
||||
dirAlbum := make(map[string]pgtype.UUID, len(groups))
|
||||
ids := make([]pgtype.UUID, 0, len(groups))
|
||||
// The latest loss in each folder: a want held before it is the album
|
||||
// as it was before its files went, not the library holding it again.
|
||||
dirLost := make(map[string]time.Time, len(groups))
|
||||
for _, row := range rows {
|
||||
if row.MissingSince.Valid && row.MissingSince.Time.After(dirLost[row.Directory]) {
|
||||
dirLost[row.Directory] = row.MissingSince.Time
|
||||
}
|
||||
if _, seen := dirAlbum[row.Directory]; seen {
|
||||
continue
|
||||
}
|
||||
@@ -194,6 +204,7 @@ func (h *handlers) attachReacquisitionState(
|
||||
byAlbum[uuidToString(w.AlbumID)] = w
|
||||
}
|
||||
}
|
||||
full := h.wantViewsByID(r, q, byAlbum)
|
||||
for i := range groups {
|
||||
albumID, ok := dirAlbum[groups[i].Directory]
|
||||
if !ok {
|
||||
@@ -203,6 +214,12 @@ func (h *handlers) attachReacquisitionState(
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Held before its files went (every library album is on the list,
|
||||
// held, since #5567): not wanted back yet, which the sweep does once
|
||||
// the grace window has passed.
|
||||
if w.FulfilledAt.Valid && !w.FulfilledAt.Time.After(dirLost[groups[i].Directory]) {
|
||||
continue
|
||||
}
|
||||
view := &reacquisitionStateView{}
|
||||
wantedAt := formatTimestamp(w.WantedAt)
|
||||
view.WantedAt = &wantedAt
|
||||
@@ -210,6 +227,36 @@ func (h *handlers) attachReacquisitionState(
|
||||
heldAt := formatTimestamp(w.FulfilledAt)
|
||||
view.HeldAt = &heldAt
|
||||
}
|
||||
if v, ok := full[uuidToString(w.WantID)]; ok {
|
||||
view.Want = &v
|
||||
}
|
||||
groups[i].Reacquisition = view
|
||||
}
|
||||
}
|
||||
|
||||
// wantViewsByID reads each album's current want in full, keyed by want id.
|
||||
// Best-effort like the state it decorates: a failure leaves the groups with
|
||||
// their dates and no want.
|
||||
func (h *handlers) wantViewsByID(
|
||||
r *http.Request,
|
||||
q *dbq.Queries,
|
||||
byAlbum map[string]dbq.GetWantsForAlbumsRow,
|
||||
) map[string]wantView {
|
||||
ids := make([]pgtype.UUID, 0, len(byAlbum))
|
||||
for _, w := range byAlbum {
|
||||
ids = append(ids, w.WantID)
|
||||
}
|
||||
out := make(map[string]wantView, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: "all", WantIds: ids, MaxRows: int32(len(ids))})
|
||||
if err != nil {
|
||||
h.logger.Warn("admin missing: want detail lookup failed", "err", err)
|
||||
return out
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[uuidToString(row.ID)] = wantViewOf(row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// seedMissingWant marks a track's file missing for two days and puts its
|
||||
// album on the wanted list, held at heldDaysAgo (0 for an open want) and
|
||||
// last searched with nothing found.
|
||||
func seedMissingWant(t *testing.T, h *handlers, trackID, albumID pgtype.UUID, group string, heldDaysAgo int) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := h.pool.Exec(ctx,
|
||||
`UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1`, trackID); err != nil {
|
||||
t.Fatalf("mark missing: %v", err)
|
||||
}
|
||||
if _, err := h.pool.Exec(ctx, `DELETE FROM wants WHERE release_group_mbid = $1`, group); err != nil {
|
||||
t.Fatalf("clear want: %v", err)
|
||||
}
|
||||
var id pgtype.UUID
|
||||
if err := h.pool.QueryRow(ctx,
|
||||
`INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id,
|
||||
wanted_at, fulfilled_at, searched_at, search_outcome)
|
||||
VALUES ($1, 'artist-'||$1, 'Artist', 'Album '||$1, 'library', $2,
|
||||
now() - interval '30 days',
|
||||
CASE WHEN $3::int > 0 THEN now() - make_interval(days => $3::int) END,
|
||||
now() - interval '1 hour', 'nothing')
|
||||
RETURNING id`, group, albumID, heldDaysAgo).Scan(&id); err != nil {
|
||||
t.Fatalf("insert want: %v", err)
|
||||
}
|
||||
return uuidToString(id)
|
||||
}
|
||||
|
||||
// A folder's album that was held before its files went is not wanted back
|
||||
// yet (every library album is on the list, held, since #5567); an open want
|
||||
// comes with its search, so the Missing page can show it and act on it (M504).
|
||||
func TestListMissingTracks_WantState(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
admin := seedUser(t, pool, "admin", "pw", true)
|
||||
|
||||
heldTrack, _ := seedTrackOnDisk(t, h, "missheld")
|
||||
openTrack, _ := seedTrackOnDisk(t, h, "missopen")
|
||||
seedMissingWant(t, h, heldTrack.ID, heldTrack.AlbumID, "rg-missing-held", 10)
|
||||
openID := seedMissingWant(t, h, openTrack.ID, openTrack.AlbumID, "rg-missing-open", 0)
|
||||
|
||||
req := withUser(httptest.NewRequest(http.MethodGet, "/api/admin/library/missing", nil), admin)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleListMissingTracks(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp adminMissingResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Groups) != 2 {
|
||||
t.Fatalf("groups = %d, want 2", len(resp.Groups))
|
||||
}
|
||||
byTitle := map[string]missingGroupView{}
|
||||
for _, g := range resp.Groups {
|
||||
byTitle[g.Tracks[0].Title] = g
|
||||
}
|
||||
|
||||
if r := byTitle["AdmQ Track missheld"].Reacquisition; r != nil {
|
||||
t.Errorf("held before the loss: reacquisition = %+v, want none", r)
|
||||
}
|
||||
r := byTitle["AdmQ Track missopen"].Reacquisition
|
||||
if r == nil || r.WantedAt == nil || r.HeldAt != nil {
|
||||
t.Fatalf("open want: reacquisition = %+v, want wanted and not held", r)
|
||||
}
|
||||
if r.Want == nil || r.Want.ID != openID {
|
||||
t.Fatalf("open want: want = %+v, want id %s", r.Want, openID)
|
||||
}
|
||||
if r.Want.Search == nil || r.Want.Search.Outcome != "nothing" {
|
||||
t.Errorf("open want: search = %+v, want outcome nothing", r.Want.Search)
|
||||
}
|
||||
}
|
||||
@@ -270,59 +270,65 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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)
|
||||
out.Items = append(out.Items, wantViewOf(row))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// wantViewOf is one ListWants row as the wanted list and the Missing page
|
||||
// send it.
|
||||
func wantViewOf(row dbq.ListWantsRow) wantView {
|
||||
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
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user