feat(explore): give the album page a primary action that tells the truth

H-13: no Play, no Shuffle, no Add to queue on the album header. The
reason it is not just three buttons is that explore-album-details is a
catalog page — there is no library-side album detail page at all — so
the album shown may be wholly the user's, partly theirs, or not theirs.
A Play button that plays 7 of a 40-track release under a label saying
'Play' is the page lying about what is owned, so the button says which:
'Play' when all of it is owned, 'Play 7 of 12' when some is, and no
play button at all when none is.

albumLibraryStatus() stays as it was — four claims of decreasing
confidence OR'd into one tick, the weakest firing when a single
recording matches. That is a fine answer to 'is any of this mine' and a
useless basis for a button, so ownership() counts the displayed
tracklist instead.

GetFilePathsByRecordingMBIDs is the catalog-side sibling of
GetFilePathsByAlbums: one query, paths only, grouped so the caller
keeps the tracklist's order. It is keyed on recording MBID because that
is how the backend decides a track is inLibrary, and because
MBTrack.LocalID is declared and never written by anything. The local
album id is preferred where there is one — a library-only album has no
MBIDs at all, and keying on them alone queued nothing.

The ticks also get the legend H-13 asks for. They were never unlabelled
— the indicator has carried a title and aria-label all along — but a
sighted user got a column of green circles and no key.
This commit is contained in:
2026-08-12 15:28:22 -04:00
parent 71324b561a
commit f854076d95
9 changed files with 928 additions and 2 deletions
+114
View File
@@ -1204,3 +1204,117 @@ func (l *Library) GetFilePathsByGenres(
return paths, nil
}
// GetFilePathsByRecordingMBIDs returns the file paths of every track
// whose recording MBID is in mbids, grouped by MBID.
//
// This is the catalog side of GetFilePathsByAlbums. An Explore album
// page knows what the user owns as a set of recording MBIDs and nothing
// else: that is exactly how the backend decides a track's InLibrary
// flag (markReleasesInLibrary → CheckMBIDs), and MBTrack.LocalID is a
// declared field that nothing writes, so there is no id to ask by.
//
// Grouped rather than flattened for the same two reasons as its
// siblings — the caller owns the order (the tracklist's, not the
// database's), and one recording can have more than one file, which is
// what this app's duplicate detection exists for.
//
// A library id of 0 means "every library".
func (l *Library) GetFilePathsByRecordingMBIDs(
mbids []string, libraryID int64,
) (map[string][]string, error) {
paths := make(map[string][]string, len(mbids))
if len(mbids) == 0 {
return paths, nil
}
// recordings.mbid is nullable, so sqlc asks for NullStrings. An
// empty MBID would match every untagged recording in the library,
// which is the opposite of the question, so those are dropped here
// rather than passed through as NULL.
keys := make([]sql.NullString, 0, len(mbids))
for _, mbid := range mbids {
if mbid == "" {
continue
}
keys = append(keys, sql.NullString{String: mbid, Valid: true})
}
if len(keys) == 0 {
return paths, nil
}
rows, err := l.filePathRowsByMBID(keys, libraryID)
if err != nil {
return nil, err
}
for _, row := range rows {
if !row.mbid.Valid {
continue
}
paths[row.mbid.String] = append(paths[row.mbid.String], row.path)
}
return paths, nil
}
// filePathRowsByMBID runs the scoped or unscoped query behind
// GetFilePathsByRecordingMBIDs and flattens the two row types into one.
func (l *Library) filePathRowsByMBID(
keys []sql.NullString, libraryID int64,
) ([]mbidFilePath, error) {
if libraryID > 0 {
rows, err := l.db.ReadQueries.GetFilePathsByRecordingMBIDsByLibrary(
l.ctx, sqlcgen.GetFilePathsByRecordingMBIDsByLibraryParams{
Mbids: keys,
LibraryID: libraryID,
},
)
if err != nil {
l.logger.Error(
"could not retrieve recording file paths for library",
"recordings", len(keys),
"libraryID", libraryID,
"error", err,
)
return nil, fmt.Errorf("could not get recording file paths: %w", err)
}
out := make([]mbidFilePath, 0, len(rows))
for _, row := range rows {
out = append(out, mbidFilePath{mbid: row.RecordingMbid, path: row.FilePath})
}
return out, nil
}
rows, err := l.db.ReadQueries.GetFilePathsByRecordingMBIDs(l.ctx, keys)
if err != nil {
l.logger.Error(
"could not retrieve recording file paths",
"recordings", len(keys),
"error", err,
)
return nil, fmt.Errorf("could not get recording file paths: %w", err)
}
out := make([]mbidFilePath, 0, len(rows))
for _, row := range rows {
out = append(out, mbidFilePath{mbid: row.RecordingMbid, path: row.FilePath})
}
return out, nil
}
// mbidFilePath is one row of either GetFilePathsByRecordingMBIDs query.
type mbidFilePath struct {
mbid sql.NullString
path string
}