Jump the Review list to an artist by letter

An A–Z/# bar above the Songs and Albums lists jumps to the first artist
starting with that letter. Songs come from a new /letters endpoint giving
each leading character's offset; the song list now sorts case-insensitively
like the album list so each letter stays together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-01 14:28:27 -07:00
1 parent 095e294f4b
commit 00147aaa9e
5 files changed
+160 -16

No files matched your search

+12
View File
@@ -93,6 +93,7 @@ func (s *Server) Handler() http.Handler {
r.Get("/job", s.handleJob) r.Get("/job", s.handleJob)
r.Post("/job/cancel", s.handleCancelJob) r.Post("/job/cancel", s.handleCancelJob)
r.Get("/matches", s.handleMatches) r.Get("/matches", s.handleMatches)
r.Get("/letters", s.handleLetters)
r.Get("/export.csv", s.handleExport) r.Get("/export.csv", s.handleExport)
r.Get("/groups", s.handleGroups) r.Get("/groups", s.handleGroups)
r.Post("/bulk/preview", s.handleBulkPreview) r.Post("/bulk/preview", s.handleBulkPreview)
@@ -474,6 +475,17 @@ func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"items": ms, "total": total}) writeJSON(w, 200, map[string]any{"items": ms, "total": total})
} }
func (s *Server) handleLetters(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
q := r.URL.Query()
ls, err := s.st.MatchLetters(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q")})
if err != nil {
writeErr(w, 500, err)
return
}
writeJSON(w, 200, ls)
}
func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r) id, _ := idParam(r)
if !s.preAction(w, r, id, "link identical") { if !s.preAction(w, r, id, "link identical") {
+34 -1
View File
@@ -432,7 +432,7 @@ func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Mat
f.Limit = 100 f.Limit = 100
} }
rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE `+w+ rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE `+w+
` ORDER BY rel_slave LIMIT ? OFFSET ?`, append(args, f.Limit, f.Offset)...) ` ORDER BY rel_slave COLLATE NOCASE, rel_slave LIMIT ? OFFSET ?`, append(args, f.Limit, f.Offset)...)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -448,6 +448,39 @@ func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Mat
return out, total, rows.Err() return out, total, rows.Err()
} }
// LetterStart is where the paths beginning with one (lowercased) character
// start in the Matches listing of a filter.
type LetterStart struct {
Letter string `json:"letter"`
Offset int `json:"offset"`
}
// MatchLetters returns, in listing order, the offset of the first match for
// each leading character of rel_slave, for jumping to an artist by letter.
// Grouping on lower() of the first character keeps each group contiguous
// under the NOCASE order Matches uses.
func (s *Store) MatchLetters(ctx context.Context, pairID int64, f MatchFilter) ([]LetterStart, error) {
w, args := matchWhere(pairID, f)
rows, err := s.DB.QueryContext(ctx, `SELECT lower(substr(rel_slave, 1, 1)) AS l, COUNT(*) FROM matches WHERE `+w+
` GROUP BY l ORDER BY l`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []LetterStart{}
offset := 0
for rows.Next() {
var l string
var n int
if err := rows.Scan(&l, &n); err != nil {
return nil, err
}
out = append(out, LetterStart{Letter: l, Offset: offset})
offset += n
}
return out, rows.Err()
}
// ExportMatches calls fn for every match of the filter, ordered by slave // ExportMatches calls fn for every match of the filter, ordered by slave
// path. Rows are read in pages so a slow download never holds the database // path. Rows are read in pages so a slow download never holds the database
// connection (scans need it) for longer than one page. // connection (scans need it) for longer than one page.
+43 -1
View File
@@ -1,6 +1,11 @@
package store package store
import "testing" import (
"context"
"reflect"
"strings"
"testing"
)
func TestDecodeWarnings(t *testing.T) { func TestDecodeWarnings(t *testing.T) {
old := decodeWarnings(`["Only 3 of 9 folders match."]`) old := decodeWarnings(`["Only 3 of 9 folders match."]`)
@@ -15,3 +20,40 @@ func TestDecodeWarnings(t *testing.T) {
t.Fatalf("empty list should stay an empty (non-nil) list, got %#v", ws) t.Fatalf("empty list should stay an empty (non-nil) list, got %#v", ws)
} }
} }
func TestMatchLetters(t *testing.T) {
ctx := context.Background()
st, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.DB.Close() })
p := Pair{Name: "p", MasterRoot: "/m", SlaveRoot: "/s", RetentionDays: 30}
if err := st.SavePair(ctx, &p); err != nil {
t.Fatal(err)
}
for _, rel := range []string{"beck/a.flac", "ABBA/a.flac", "Air/a.flac", "2Pac/a.flac", "Björk/a.flac", "abba/b.flac"} {
m := Match{PairID: p.ID, RelSlave: rel, Status: StatusUnmatched, Kind: KindNone}
if err := st.UpsertMatch(ctx, &m); err != nil {
t.Fatal(err)
}
}
ls, err := st.MatchLetters(ctx, p.ID, MatchFilter{})
if err != nil {
t.Fatal(err)
}
want := []LetterStart{{"2", 0}, {"a", 1}, {"b", 4}}
if !reflect.DeepEqual(ls, want) {
t.Fatalf("letters = %+v, want %+v", ls, want)
}
// The offsets must agree with the listing order, whatever the case.
ms, _, err := st.Matches(ctx, p.ID, MatchFilter{Limit: 10})
if err != nil {
t.Fatal(err)
}
for _, l := range ls {
if got := strings.ToLower(ms[l.Offset].RelSlave[:1]); got != l.Letter {
t.Fatalf("offset %d starts with %q, want %q (listing %v)", l.Offset, got, l.Letter, ms)
}
}
}
+10 -7
View File
@@ -5,7 +5,7 @@ import { api, type AlbumGroup, type BulkAction, type BulkPreview, type Match, ty
import { useInfo } from "../App"; import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui"; import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { MatchRow } from "./Review"; import { LetterBar, MatchRow, letterOf } from "./Review";
export type Selection = Set<number>; export type Selection = Set<number>;
@@ -52,14 +52,14 @@ export function AlbumView({
sel: Selection; sel: Selection;
setSel: (s: Selection) => void; setSel: (s: Selection) => void;
}) { }) {
const [page, setPage] = useState(0); const [start, setStart] = useState(0);
useEffect(() => setPage(0), [status, query]); useEffect(() => setStart(0), [status, query]);
const groups = useQuery({ const groups = useQuery({
queryKey: ["matches", "groups", pair.id, status, query], queryKey: ["matches", "groups", pair.id, status, query],
queryFn: () => api.get<AlbumGroup[]>(`/pairs/${pair.id}/groups?status=${status}&q=${encodeURIComponent(query)}`), queryFn: () => api.get<AlbumGroup[]>(`/pairs/${pair.id}/groups?status=${status}&q=${encodeURIComponent(query)}`),
}); });
const all = groups.data ?? []; const all = groups.data ?? [];
const shown = all.slice(page * GROUP_PAGE, (page + 1) * GROUP_PAGE); const shown = all.slice(start, start + GROUP_PAGE);
const shownIDs = shown.flatMap((g) => g.ids); const shownIDs = shown.flatMap((g) => g.ids);
const pageSelected = shownIDs.length > 0 && shownIDs.every((id) => sel.has(id)); const pageSelected = shownIDs.length > 0 && shownIDs.every((id) => sel.has(id));
@@ -80,6 +80,9 @@ export function AlbumView({
return ( return (
<> <>
{all.length > GROUP_PAGE && (
<LetterBar starts={all.map((g, i) => ({ letter: g.folder.charAt(0), offset: i }))} current={shown[0] && letterOf(shown[0].folder)} onJump={setStart} />
)}
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted"> <label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<TriCheckbox <TriCheckbox
@@ -102,13 +105,13 @@ export function AlbumView({
{all.length > GROUP_PAGE && ( {all.length > GROUP_PAGE && (
<div className="flex items-center justify-between text-sm text-muted"> <div className="flex items-center justify-between text-sm text-muted">
<span> <span>
Albums {page * GROUP_PAGE + 1}–{Math.min((page + 1) * GROUP_PAGE, all.length)} of {all.length} Albums {start + 1}–{Math.min(start + GROUP_PAGE, all.length)} of {all.length}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}> <Button size="sm" disabled={start === 0} onClick={() => setStart(Math.max(0, start - GROUP_PAGE))}>
Previous Previous
</Button> </Button>
<Button size="sm" disabled={(page + 1) * GROUP_PAGE >= all.length} onClick={() => setPage(page + 1)}> <Button size="sm" disabled={start + GROUP_PAGE >= all.length} onClick={() => setStart(start + GROUP_PAGE)}>
Next Next
</Button> </Button>
</div> </div>
+61 -7
View File
@@ -35,13 +35,58 @@ function hashParam(name: string) {
const PAGE = 50; const PAGE = 50;
const LETTERS = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "#"];
// letterOf buckets a path by its first character: A–Z, or # for anything else.
export function letterOf(path: string) {
const c = path.charAt(0).toUpperCase();
return c >= "A" && c <= "Z" ? c : "#";
}
// LetterBar jumps a sorted list to the first artist starting with a letter.
// starts gives the list offset of each leading character, in list order.
export function LetterBar({ starts, current, onJump }: { starts: { letter: string; offset: number }[]; current?: string; onJump: (offset: number) => void }) {
const first = new Map<string, number>();
for (const s of starts) {
const l = letterOf(s.letter);
if (!first.has(l) || s.offset < first.get(l)!) first.set(l, s.offset);
}
return (
<nav aria-label="Jump to artist by letter" className="flex flex-wrap gap-0.5">
{LETTERS.map((l) => {
const at = first.get(l);
return (
<button
key={l}
disabled={at === undefined}
onClick={() => at !== undefined && onJump(at)}
aria-current={current === l || undefined}
title={at === undefined ? `No artists starting with ${l}` : `Jump to ${l === "#" ? "numbers and symbols" : l}`}
className={cx(
"min-w-7 rounded-md px-1.5 py-1 text-xs font-medium tabular-nums",
at === undefined
? "text-gray-700 cursor-default"
: current === l
? "bg-accent/15 text-white cursor-pointer"
: "text-muted hover:bg-panel-2 hover:text-white cursor-pointer",
)}
>
{l}
</button>
);
})}
</nav>
);
}
export function Review({ pair }: { pair: Pair }) { export function Review({ pair }: { pair: Pair }) {
const qc = useQueryClient(); const qc = useQueryClient();
const toast = useToast(); const toast = useToast();
const [status, setStatus] = useState(() => hashParam("status") ?? (pair.summary.review > 0 ? "review" : "identical")); const [status, setStatus] = useState(() => hashParam("status") ?? (pair.summary.review > 0 ? "review" : "identical"));
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [debounced, setDebounced] = useState(""); const [debounced, setDebounced] = useState("");
const [page, setPage] = useState(0); // The list starts at an offset rather than a page so a letter jump lands at the top.
const [offset, setOffset] = useState(0);
const [confirmBulk, setConfirmBulk] = useState(false); const [confirmBulk, setConfirmBulk] = useState(false);
const [view, setView] = useState<"songs" | "albums">(() => { const [view, setView] = useState<"songs" | "albums">(() => {
try { try {
@@ -64,17 +109,23 @@ export function Review({ pair }: { pair: Pair }) {
const t = setTimeout(() => setDebounced(q), 250); const t = setTimeout(() => setDebounced(q), 250);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [q]); }, [q]);
useEffect(() => setPage(0), [status, debounced]); useEffect(() => setOffset(0), [status, debounced]);
// A selection only makes sense for the list it was made in. // A selection only makes sense for the list it was made in.
useEffect(() => setSel(new Set()), [status, debounced, pair.id]); useEffect(() => setSel(new Set()), [status, debounced, pair.id]);
const list = useQuery({ const list = useQuery({
queryKey: ["matches", pair.id, status, debounced, page], queryKey: ["matches", pair.id, status, debounced, offset],
queryFn: () => queryFn: () =>
api.get<Page<Match>>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${page * PAGE}`), api.get<Page<Match>>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${offset}`),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
enabled: status !== "duplicates", enabled: status !== "duplicates",
}); });
const letters = useQuery({
queryKey: ["matches", "letters", pair.id, status, debounced],
queryFn: () => api.get<{ letter: string; offset: number }[]>(`/pairs/${pair.id}/letters?status=${status}&q=${encodeURIComponent(debounced)}`),
placeholderData: keepPreviousData,
enabled: status !== "duplicates" && view === "songs",
});
const bulk = useMutation({ const bulk = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`), mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`),
@@ -165,6 +216,9 @@ export function Review({ pair }: { pair: Pair }) {
<AlbumView pair={pair} status={status} query={debounced} sel={sel} setSel={setSel} /> <AlbumView pair={pair} status={status} query={debounced} sel={sel} setSel={setSel} />
) : ( ) : (
<> <>
{list.data && list.data.total > PAGE && letters.data && (
<LetterBar starts={letters.data} current={list.data.items[0] && letterOf(list.data.items[0].rel_slave)} onJump={setOffset} />
)}
<Card className="overflow-hidden"> <Card className="overflow-hidden">
{list.data && list.data.items.length > 0 && ( {list.data && list.data.items.length > 0 && (
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted"> <label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
@@ -196,13 +250,13 @@ export function Review({ pair }: { pair: Pair }) {
{list.data && list.data.total > PAGE && ( {list.data && list.data.total > PAGE && (
<div className="flex items-center justify-between text-sm text-muted"> <div className="flex items-center justify-between text-sm text-muted">
<span> <span>
{page * PAGE + 1}–{Math.min((page + 1) * PAGE, list.data.total)} of {list.data.total} {offset + 1}–{Math.min(offset + PAGE, list.data.total)} of {list.data.total}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}> <Button size="sm" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Previous Previous
</Button> </Button>
<Button size="sm" disabled={(page + 1) * PAGE >= list.data.total} onClick={() => setPage(page + 1)}> <Button size="sm" disabled={offset + PAGE >= list.data.total} onClick={() => setOffset(offset + PAGE)}>
Next Next
</Button> </Button>
</div> </div>