Add album view and bulk actions to Review

The Review list can now be grouped by album (disc folders folded in),
showing per-album counts of upgrades, identical files, downgrades and
approximate matches. Whole albums or individual songs can be selected and
linked, promoted, ignored or un-ignored in one action.

Every bulk action is previewed server-side first: how many songs will be
processed, which are skipped and why, and warnings with examples for
downgrades, approximate matches, duration mismatches, folder moves and
renames. Downgrades are excluded unless explicitly included, and
replacing master files requires an acknowledgement. Link and promote run
as a background job that re-plans each song and uses the normal
single-item operation, so all preflight checks still apply. IDs from
another pair are refused and duplicates are ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-23 21:33:44 -07:00
1 parent 0ae4e85bd2
commit 1d31999793
9 files changed
+1209 -7

No files matched your search

+44
View File
@@ -310,6 +310,7 @@ func (s *Store) AllMatches(ctx context.Context, pairID int64) (map[string]Match,
type MatchFilter struct {
Status string // a status, "review" (different+similar), "ignored" or ""
Query string
IDs []int64 // restrict to these matches (at most maxIDFilter)
Limit int
Offset int
}
@@ -335,9 +336,52 @@ func matchWhere(pairID int64, f MatchFilter) (string, []any) {
q := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%"
args = append(args, q, q)
}
if len(f.IDs) > 0 {
ids := f.IDs
if len(ids) > maxIDFilter {
ids = ids[:maxIDFilter]
}
where = append(where, "id IN ("+strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")+")")
for _, id := range ids {
args = append(args, id)
}
}
return strings.Join(where, " AND "), args
}
const maxIDFilter = 500
// MatchesByIDs loads matches by id (any pair; callers check ownership).
func (s *Store) MatchesByIDs(ctx context.Context, ids []int64) ([]Match, error) {
out := make([]Match, 0, len(ids))
for start := 0; start < len(ids); start += maxIDFilter {
chunk := ids[start:min(start+maxIDFilter, len(ids))]
args := make([]any, len(chunk))
for i, id := range chunk {
args[i] = id
}
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE id IN (`+
strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",")+`)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
m, err := scanMatch(rows)
if err != nil {
rows.Close()
return nil, err
}
out = append(out, m)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
return out, nil
}
func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Match, int, error) {
w, args := matchWhere(pairID, f)
var total int