spotifify 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -0
  4. package/README.zh-CN.md +131 -0
  5. package/config.example.toml +61 -0
  6. package/package.json +66 -0
  7. package/scripts/register-task.ps1 +43 -0
  8. package/src/cli.ts +504 -0
  9. package/src/config.ts +215 -0
  10. package/src/env.d.ts +15 -0
  11. package/src/match/aliases.ts +76 -0
  12. package/src/match/fingerprint.ts +104 -0
  13. package/src/match/matcher.ts +182 -0
  14. package/src/match/normalize.ts +107 -0
  15. package/src/match/score.ts +90 -0
  16. package/src/match/search.ts +97 -0
  17. package/src/match/types.ts +45 -0
  18. package/src/sources/local/ncm.ts +198 -0
  19. package/src/sources/local/scan.ts +55 -0
  20. package/src/sources/local/source.ts +105 -0
  21. package/src/sources/local/tags.ts +67 -0
  22. package/src/sources/netease/auth.ts +91 -0
  23. package/src/sources/netease/client.ts +188 -0
  24. package/src/sources/netease/lib.ts +38 -0
  25. package/src/sources/netease/source.ts +91 -0
  26. package/src/sources/types.ts +49 -0
  27. package/src/spotify/api.ts +155 -0
  28. package/src/spotify/auth.ts +121 -0
  29. package/src/spotify/client.ts +120 -0
  30. package/src/spotify/localUri.ts +48 -0
  31. package/src/spotify/types.ts +61 -0
  32. package/src/state/db.ts +42 -0
  33. package/src/state/repo.ts +480 -0
  34. package/src/state/schema.sql +115 -0
  35. package/src/sync/apply.ts +142 -0
  36. package/src/sync/duration.ts +115 -0
  37. package/src/sync/export.ts +159 -0
  38. package/src/sync/plan.ts +205 -0
  39. package/src/sync/reorder.ts +72 -0
  40. package/src/sync/run.ts +404 -0
  41. package/src/tui/App.tsx +420 -0
  42. package/src/tui/CandidatePane.tsx +158 -0
  43. package/src/tui/ReviewList.tsx +56 -0
  44. package/src/tui/SearchInput.tsx +37 -0
  45. package/src/tui/index.ts +32 -0
  46. package/src/tui/model.ts +54 -0
  47. package/src/util/bin.ts +12 -0
  48. package/src/util/clipboard.ts +13 -0
  49. package/src/util/fs.ts +18 -0
  50. package/src/util/lock.ts +38 -0
  51. package/src/util/log.ts +31 -0
  52. package/src/util/open.ts +22 -0
  53. package/src/util/retry.ts +49 -0
@@ -0,0 +1,420 @@
1
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import type { Candidate, MatchRow } from "../match/types.ts";
4
+ import type { SourceTrackRow } from "../state/repo.ts";
5
+ import { openExternal } from "../util/open.ts";
6
+ import { CandidatePane, MAX_SHOWN_CANDIDATES } from "./CandidatePane.tsx";
7
+ import { SearchInput, type InputMode } from "./SearchInput.tsx";
8
+ import { ReviewList } from "./ReviewList.tsx";
9
+ import { TABS, type Queues, type ReviewItem, type Tab } from "./model.ts";
10
+
11
+ /** The subset of the matcher the TUI drives; see src/match/matcher.ts. */
12
+ export interface ReviewMatcher {
13
+ candidatesFor(track: SourceTrackRow, query?: string): Promise<Candidate[]>;
14
+ candidateFromUri(track: SourceTrackRow, uriOrUrl: string): Promise<Candidate | null>;
15
+ }
16
+
17
+ export interface ReviewRepo {
18
+ upsertMatch(m: MatchRow): void;
19
+ }
20
+
21
+ export interface AppProps {
22
+ repo: ReviewRepo;
23
+ matcher: ReviewMatcher;
24
+ market: string;
25
+ initialQueues: Queues;
26
+ /** Called with the decision count right before the app unmounts. */
27
+ onExit: (decided: number) => void;
28
+ }
29
+
30
+ interface Status {
31
+ text: string;
32
+ kind: "info" | "error";
33
+ }
34
+
35
+ interface UndoEntry {
36
+ tab: Tab;
37
+ index: number;
38
+ item: ReviewItem;
39
+ }
40
+
41
+ interface Location {
42
+ tab: Tab;
43
+ index: number;
44
+ item: ReviewItem;
45
+ }
46
+
47
+ interface UiState {
48
+ queues: Queues;
49
+ tab: Tab;
50
+ cursor: Record<Tab, number>;
51
+ /** Candidate index for the item under the cursor. */
52
+ selected: number;
53
+ mode: { kind: "normal" } | { kind: "input"; input: InputMode; value: string };
54
+ /** Canonical keys with a matcher call in flight. */
55
+ busy: Set<string>;
56
+ status: Status;
57
+ decided: number;
58
+ undo: UndoEntry[];
59
+ showHelp: boolean;
60
+ }
61
+
62
+ const HELP =
63
+ "j/k ↑/↓ move Tab switch tab 1-9 select candidate Enter confirm o open candidate O open source l mark local s skip / search p paste Spotify link u undo ? help q quit";
64
+
65
+ const SPINNER = ["-", "\\", "|", "/"];
66
+
67
+ /** Where the source track can be inspected: the netease song page, or the local file itself. */
68
+ function sourceLink(track: SourceTrackRow): string | null {
69
+ if (track.neteaseId !== undefined) return `https://music.163.com/#/song?id=${track.neteaseId}`;
70
+ return track.file?.path ?? null;
71
+ }
72
+
73
+ function findItem(queues: Queues, key: string): Location | undefined {
74
+ for (const tab of TABS) {
75
+ const index = queues[tab].findIndex((it) => it.match.canonicalKey === key);
76
+ if (index >= 0) return { tab, index, item: queues[tab][index]! };
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function withoutKey(set: Set<string>, key: string): Set<string> {
82
+ const next = new Set(set);
83
+ next.delete(key);
84
+ return next;
85
+ }
86
+
87
+ function useTerminalSize(): { columns: number; rows: number } {
88
+ const { stdout } = useStdout();
89
+ const [size, setSize] = useState({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
90
+ useEffect(() => {
91
+ const onResize = () => setSize({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
92
+ stdout.on("resize", onResize);
93
+ return () => {
94
+ stdout.off("resize", onResize);
95
+ };
96
+ }, [stdout]);
97
+ return size;
98
+ }
99
+
100
+ export function App({ repo, matcher, market, initialQueues, onExit }: AppProps) {
101
+ const { exit } = useApp();
102
+ const { rows } = useTerminalSize();
103
+ const [state, setState] = useState<UiState>(() => ({
104
+ queues: initialQueues,
105
+ tab: initialQueues.review.length > 0 || initialQueues.local.length === 0 ? "review" : "local",
106
+ cursor: { review: 0, local: 0 },
107
+ selected: 0,
108
+ mode: { kind: "normal" },
109
+ busy: new Set(),
110
+ status: { text: "Enter confirms the highlighted candidate · ? for help", kind: "info" },
111
+ decided: 0,
112
+ undo: [],
113
+ showHelp: false,
114
+ }));
115
+ // Mirror of `state` that handlers read so that a decision and a subsequent async result never see stale data.
116
+ const ref = useRef(state);
117
+ const commit = (next: UiState) => {
118
+ ref.current = next;
119
+ setState(next);
120
+ };
121
+
122
+ const [tick, setTick] = useState(0);
123
+ const spinning = state.busy.size > 0;
124
+ useEffect(() => {
125
+ if (!spinning) return;
126
+ const timer = setInterval(() => setTick((t) => t + 1), 120);
127
+ return () => clearInterval(timer);
128
+ }, [spinning]);
129
+
130
+ const quit = (s: UiState) => {
131
+ onExit(s.decided);
132
+ exit();
133
+ };
134
+
135
+ const move = (s: UiState, delta: number) => {
136
+ const n = s.queues[s.tab].length;
137
+ if (n === 0) return;
138
+ const c = Math.max(0, Math.min(n - 1, s.cursor[s.tab] + delta));
139
+ commit({ ...s, cursor: { ...s.cursor, [s.tab]: c }, selected: 0 });
140
+ };
141
+
142
+ /** Persist `next` for `loc.item`, drop it from its queue, remember it for undo. */
143
+ const decide = (s: UiState, loc: Location, next: MatchRow, text: string) => {
144
+ repo.upsertMatch(next);
145
+ const queue = s.queues[loc.tab].filter((_, i) => i !== loc.index);
146
+ const c = s.cursor[loc.tab];
147
+ const cursor = Math.max(0, Math.min(queue.length - 1, c > loc.index ? c - 1 : c));
148
+ commit({
149
+ ...s,
150
+ queues: { ...s.queues, [loc.tab]: queue },
151
+ cursor: { ...s.cursor, [loc.tab]: cursor },
152
+ selected: loc.tab === s.tab && loc.index === c ? 0 : s.selected,
153
+ decided: s.decided + 1,
154
+ undo: [...s.undo, { tab: loc.tab, index: loc.index, item: loc.item }],
155
+ status: { text, kind: "info" },
156
+ });
157
+ };
158
+
159
+ const pick = (s: UiState, loc: Location, c: Candidate, candidates: Candidate[]) => {
160
+ decide(
161
+ s,
162
+ loc,
163
+ {
164
+ ...loc.item.match,
165
+ status: "matched",
166
+ spotifyId: c.id,
167
+ spotifyUri: c.uri,
168
+ score: c.score,
169
+ decidedBy: "user",
170
+ decidedAt: Date.now(),
171
+ candidates,
172
+ },
173
+ `matched: ${loc.item.track.title} → ${c.title} — ${c.artists.join(", ")} (${c.score.toFixed(2)})`,
174
+ );
175
+ };
176
+
177
+ const unmatched = (s: UiState, loc: Location, status: "local" | "skipped") => {
178
+ decide(
179
+ s,
180
+ loc,
181
+ {
182
+ ...loc.item.match,
183
+ status,
184
+ spotifyId: null,
185
+ spotifyUri: null,
186
+ score: null,
187
+ decidedBy: "user",
188
+ decidedAt: Date.now(),
189
+ },
190
+ `${status === "local" ? "marked local" : "skipped"}: ${loc.item.track.title}`,
191
+ );
192
+ };
193
+
194
+ const undo = (s: UiState) => {
195
+ const entry = s.undo[s.undo.length - 1];
196
+ if (!entry) {
197
+ commit({ ...s, status: { text: "nothing to undo", kind: "error" } });
198
+ return;
199
+ }
200
+ repo.upsertMatch(entry.item.match);
201
+ const list = s.queues[entry.tab];
202
+ const index = Math.min(entry.index, list.length);
203
+ const queue = [...list.slice(0, index), entry.item, ...list.slice(index)];
204
+ commit({
205
+ ...s,
206
+ queues: { ...s.queues, [entry.tab]: queue },
207
+ tab: entry.tab,
208
+ cursor: { ...s.cursor, [entry.tab]: index },
209
+ selected: 0,
210
+ decided: s.decided - 1,
211
+ undo: s.undo.slice(0, -1),
212
+ status: { text: `undone: ${entry.item.track.title} is back in ${entry.tab}`, kind: "info" },
213
+ });
214
+ };
215
+
216
+ const settle = (key: string, apply: (s: UiState, loc: Location | undefined) => void) => {
217
+ const s = ref.current;
218
+ apply({ ...s, busy: withoutKey(s.busy, key) }, findItem(s.queues, key));
219
+ };
220
+
221
+ const search = (s: UiState, item: ReviewItem, query: string) => {
222
+ const key = item.match.canonicalKey;
223
+ commit({ ...s, mode: { kind: "normal" }, busy: new Set(s.busy).add(key), status: { text: `searching "${query}"`, kind: "info" } });
224
+ matcher.candidatesFor(item.track, query).then(
225
+ (candidates) =>
226
+ settle(key, (s2, loc) => {
227
+ if (!loc) {
228
+ commit(s2);
229
+ return;
230
+ }
231
+ const next = { ...loc.item.match, candidates };
232
+ repo.upsertMatch(next);
233
+ const queue = s2.queues[loc.tab].with(loc.index, { ...loc.item, match: next });
234
+ const isCurrent = loc.tab === s2.tab && loc.index === s2.cursor[loc.tab];
235
+ commit({
236
+ ...s2,
237
+ queues: { ...s2.queues, [loc.tab]: queue },
238
+ selected: isCurrent ? 0 : s2.selected,
239
+ status: { text: `${candidates.length} candidate${candidates.length === 1 ? "" : "s"} for "${query}"`, kind: "info" },
240
+ });
241
+ }),
242
+ (err: unknown) =>
243
+ settle(key, (s2) => commit({ ...s2, status: { text: `search failed: ${err instanceof Error ? err.message : String(err)}`, kind: "error" } })),
244
+ );
245
+ };
246
+
247
+ const pasteUri = (s: UiState, item: ReviewItem, value: string) => {
248
+ const key = item.match.canonicalKey;
249
+ commit({ ...s, mode: { kind: "normal" }, busy: new Set(s.busy).add(key), status: { text: `resolving ${value}`, kind: "info" } });
250
+ matcher.candidateFromUri(item.track, value).then(
251
+ (c) =>
252
+ settle(key, (s2, loc) => {
253
+ if (!loc) {
254
+ commit(s2);
255
+ return;
256
+ }
257
+ if (!c) {
258
+ commit({ ...s2, status: { text: `no track found for ${value}`, kind: "error" } });
259
+ return;
260
+ }
261
+ pick(s2, loc, c, [c, ...loc.item.match.candidates.filter((x) => x.id !== c.id)]);
262
+ }),
263
+ (err: unknown) =>
264
+ settle(key, (s2) => commit({ ...s2, status: { text: `lookup failed: ${err instanceof Error ? err.message : String(err)}`, kind: "error" } })),
265
+ );
266
+ };
267
+
268
+ const finished = state.queues.review.length === 0 && state.queues.local.length === 0 && state.undo.length === 0;
269
+
270
+ useInput(
271
+ (input, key) => {
272
+ const s = ref.current;
273
+ if (finished || input === "q" || key.escape) {
274
+ quit(s);
275
+ return;
276
+ }
277
+ if (input === "?") {
278
+ commit({ ...s, showHelp: !s.showHelp });
279
+ return;
280
+ }
281
+ if (key.tab) {
282
+ commit({ ...s, tab: s.tab === "review" ? "local" : "review", selected: 0 });
283
+ return;
284
+ }
285
+ if (input === "j" || key.downArrow) {
286
+ move(s, 1);
287
+ return;
288
+ }
289
+ if (input === "k" || key.upArrow) {
290
+ move(s, -1);
291
+ return;
292
+ }
293
+ if (input === "u") {
294
+ undo(s);
295
+ return;
296
+ }
297
+ const index = s.cursor[s.tab];
298
+ const item = s.queues[s.tab][index];
299
+ if (!item) return;
300
+ const loc: Location = { tab: s.tab, index, item };
301
+ if (input >= "1" && input <= "9" && input.length === 1) {
302
+ const n = Number(input) - 1;
303
+ if (n < Math.min(item.match.candidates.length, MAX_SHOWN_CANDIDATES)) commit({ ...s, selected: n });
304
+ return;
305
+ }
306
+ if (input === "o") {
307
+ const c = item.match.candidates[s.selected];
308
+ if (!c) {
309
+ commit({ ...s, status: { text: "no candidate to open", kind: "error" } });
310
+ return;
311
+ }
312
+ const url = `https://open.spotify.com/track/${c.id}`;
313
+ commit({ ...s, status: { text: openExternal(url) ? `opened ${url}` : `could not open ${url}`, kind: "info" } });
314
+ return;
315
+ }
316
+ if (input === "O") {
317
+ const target = sourceLink(item.track);
318
+ if (target === null) {
319
+ commit({ ...s, status: { text: "source has no link to open", kind: "error" } });
320
+ return;
321
+ }
322
+ commit({ ...s, status: { text: openExternal(target) ? `opened ${target}` : `could not open ${target}`, kind: "info" } });
323
+ return;
324
+ }
325
+ if (s.busy.has(item.match.canonicalKey)) {
326
+ commit({ ...s, status: { text: "search in progress for this item; wait or move on", kind: "error" } });
327
+ return;
328
+ }
329
+ if (key.return) {
330
+ const c = item.match.candidates[s.selected];
331
+ if (c) pick(s, loc, c, item.match.candidates);
332
+ else commit({ ...s, status: { text: "no candidate to confirm; / to search, p to paste a link, l for local, s to skip", kind: "error" } });
333
+ return;
334
+ }
335
+ if (input === "l") {
336
+ unmatched(s, loc, "local");
337
+ return;
338
+ }
339
+ if (input === "s") {
340
+ unmatched(s, loc, "skipped");
341
+ return;
342
+ }
343
+ if (input === "/") {
344
+ commit({ ...s, mode: { kind: "input", input: "search", value: `${item.track.title} ${item.track.artists[0] ?? ""}`.trim() } });
345
+ return;
346
+ }
347
+ if (input === "p") {
348
+ commit({ ...s, mode: { kind: "input", input: "uri", value: "" } });
349
+ }
350
+ },
351
+ { isActive: state.mode.kind === "normal" },
352
+ );
353
+
354
+ if (finished) {
355
+ return (
356
+ <Box flexDirection="column">
357
+ <Text>Nothing to review. Press any key to exit.</Text>
358
+ </Box>
359
+ );
360
+ }
361
+
362
+ const queue = state.queues[state.tab];
363
+ const cursor = state.cursor[state.tab];
364
+ const item = queue[cursor];
365
+ const headerRows = state.showHelp ? 2 : 1;
366
+ const bodyHeight = Math.max(3, rows - headerRows - 2);
367
+
368
+ return (
369
+ <Box flexDirection="column" height={rows - 1}>
370
+ <Box flexDirection="row">
371
+ {TABS.map((tab) => (
372
+ <Text key={tab} bold={tab === state.tab} inverse={tab === state.tab}>
373
+ {` ${tab} ${tab === state.tab ? `${queue.length === 0 ? 0 : cursor + 1}/${queue.length}` : state.queues[tab].length} `}
374
+ </Text>
375
+ ))}
376
+ <Text>{` decided ${state.decided} market ${market}`}</Text>
377
+ <Text dimColor>{" ? help q quit"}</Text>
378
+ </Box>
379
+ {state.showHelp ? (
380
+ <Text color="cyan" wrap="truncate-end">
381
+ {HELP}
382
+ </Text>
383
+ ) : null}
384
+ <Box flexDirection="row" height={bodyHeight} overflow="hidden">
385
+ <Box width="40%" flexShrink={0} borderStyle="single" borderRight borderTop={false} borderBottom={false} borderLeft={false} paddingRight={1}>
386
+ <ReviewList items={queue} cursor={cursor} height={bodyHeight} busy={state.busy} />
387
+ </Box>
388
+ <CandidatePane item={item} selected={state.selected} busy={item ? state.busy.has(item.match.canonicalKey) : false} />
389
+ </Box>
390
+ <Box flexDirection="row">
391
+ {state.mode.kind === "input" && item ? (
392
+ <SearchInput
393
+ mode={state.mode.input}
394
+ value={state.mode.value}
395
+ onChange={(value) => {
396
+ const s = ref.current;
397
+ if (s.mode.kind === "input") commit({ ...s, mode: { ...s.mode, value } });
398
+ }}
399
+ onSubmit={(value) => {
400
+ const s = ref.current;
401
+ const trimmed = value.trim();
402
+ if (trimmed.length === 0) {
403
+ commit({ ...s, mode: { kind: "normal" } });
404
+ return;
405
+ }
406
+ if (s.mode.kind === "input" && s.mode.input === "uri") pasteUri(s, item, trimmed);
407
+ else search(s, item, trimmed);
408
+ }}
409
+ onCancel={() => commit({ ...ref.current, mode: { kind: "normal" } })}
410
+ />
411
+ ) : (
412
+ <Text color={state.status.kind === "error" ? "red" : undefined} wrap="truncate-end">
413
+ {spinning ? `${SPINNER[tick % SPINNER.length]} ` : ""}
414
+ {state.status.text}
415
+ </Text>
416
+ )}
417
+ </Box>
418
+ </Box>
419
+ );
420
+ }
@@ -0,0 +1,158 @@
1
+ import { Box, Text } from "ink";
2
+ import type { Candidate } from "../match/types.ts";
3
+ import { fmtDelta, fmtDuration, scoreColor, sourceOrigin, type ReviewItem } from "./model.ts";
4
+
5
+ /** Only 1-9 are addressable from the keyboard; the stored list may be longer. */
6
+ export const MAX_SHOWN_CANDIDATES = 9;
7
+
8
+ interface Props {
9
+ item: ReviewItem | undefined;
10
+ selected: number;
11
+ busy: boolean;
12
+ }
13
+
14
+ interface Cells {
15
+ idx: string;
16
+ title: string;
17
+ artists: string;
18
+ album: string;
19
+ duration: string;
20
+ score: string;
21
+ flag: string;
22
+ }
23
+
24
+ interface RowStyle {
25
+ color?: string;
26
+ scoreColor?: string;
27
+ flagColor?: string;
28
+ bold?: boolean;
29
+ inverse?: boolean;
30
+ dim?: boolean;
31
+ }
32
+
33
+ function Row({ cells, style }: { cells: Cells; style: RowStyle }) {
34
+ const text = { color: style.color, bold: style.bold, inverse: style.inverse, dimColor: style.dim } as const;
35
+ return (
36
+ <Box flexDirection="row">
37
+ <Box width={3} flexShrink={0}>
38
+ <Text {...text}>{cells.idx}</Text>
39
+ </Box>
40
+ <Box flexGrow={3} flexBasis={0} overflow="hidden" marginRight={1}>
41
+ <Text {...text} wrap="truncate-end">
42
+ {cells.title}
43
+ </Text>
44
+ </Box>
45
+ <Box flexGrow={2} flexBasis={0} overflow="hidden" marginRight={1}>
46
+ <Text {...text} wrap="truncate-end">
47
+ {cells.artists}
48
+ </Text>
49
+ </Box>
50
+ <Box flexGrow={2} flexBasis={0} overflow="hidden" marginRight={1}>
51
+ <Text {...text} wrap="truncate-end">
52
+ {cells.album}
53
+ </Text>
54
+ </Box>
55
+ <Box width={11} flexShrink={0}>
56
+ <Text {...text}>{cells.duration}</Text>
57
+ </Box>
58
+ <Box width={5} flexShrink={0}>
59
+ <Text {...text} color={style.scoreColor ?? style.color}>
60
+ {cells.score}
61
+ </Text>
62
+ </Box>
63
+ <Box width={12} flexShrink={0}>
64
+ <Text {...text} color={style.flagColor ?? style.color}>
65
+ {cells.flag}
66
+ </Text>
67
+ </Box>
68
+ </Box>
69
+ );
70
+ }
71
+
72
+ const HEADER: Cells = { idx: "#", title: "title", artists: "artists", album: "album", duration: "duration", score: "score", flag: "" };
73
+
74
+ function candidateCells(c: Candidate, i: number, sourceMs: number | undefined): Cells {
75
+ return {
76
+ idx: String(i + 1),
77
+ title: c.title,
78
+ artists: c.artists.join(", "),
79
+ album: c.album,
80
+ duration: `${fmtDuration(c.durationMs)} ${fmtDelta(c.durationMs, sourceMs)}`,
81
+ score: c.score.toFixed(2),
82
+ flag: c.isPlayable ? "playable" : "unavailable",
83
+ };
84
+ }
85
+
86
+ export function CandidatePane({ item, selected, busy }: Props) {
87
+ if (!item) {
88
+ return (
89
+ <Box flexDirection="column" flexGrow={1} paddingLeft={1}>
90
+ <Text dimColor>Nothing selected.</Text>
91
+ </Box>
92
+ );
93
+ }
94
+ const { track, match } = item;
95
+ const shown = match.candidates.slice(0, MAX_SHOWN_CANDIDATES);
96
+ const current = shown[selected];
97
+ const sourceCells: Cells = {
98
+ idx: "S",
99
+ title: track.title,
100
+ artists: track.artists.join(", "),
101
+ album: track.album ?? "",
102
+ duration: fmtDuration(track.durationMs),
103
+ score: "",
104
+ flag: "source",
105
+ };
106
+
107
+ return (
108
+ <Box flexDirection="column" flexGrow={1} paddingLeft={1} overflow="hidden">
109
+ <Text>
110
+ <Text bold color="magenta">
111
+ {track.title}
112
+ </Text>
113
+ <Text> — {track.artists.join(", ")}</Text>
114
+ </Text>
115
+ <Text dimColor wrap="truncate-end">
116
+ {sourceOrigin(track)}
117
+ {track.isrc ? ` isrc ${track.isrc}` : ""}
118
+ {track.aliases.length > 0 ? ` aka ${track.aliases.join(" / ")}` : ""}
119
+ </Text>
120
+ <Text dimColor wrap="truncate-end">
121
+ {item.playlists.length > 0 ? `in: ${item.playlists.join(", ")}` : "in: (no playlist)"}
122
+ </Text>
123
+ <Box marginTop={1} flexDirection="column">
124
+ <Row cells={HEADER} style={{ dim: true }} />
125
+ <Row cells={sourceCells} style={{ color: "magenta" }} />
126
+ {busy ? (
127
+ <Text color="yellow">searching…</Text>
128
+ ) : shown.length === 0 ? (
129
+ <Text dimColor>No candidates. Press / to search or p to paste a Spotify link.</Text>
130
+ ) : (
131
+ shown.map((c, i) => (
132
+ <Row
133
+ key={c.id}
134
+ cells={candidateCells(c, i, track.durationMs)}
135
+ style={{
136
+ bold: i === selected,
137
+ inverse: i === selected,
138
+ dim: !c.isPlayable,
139
+ scoreColor: scoreColor(c.score),
140
+ flagColor: c.isPlayable ? "green" : "red",
141
+ }}
142
+ />
143
+ ))
144
+ )}
145
+ {!busy && match.candidates.length > shown.length ? (
146
+ <Text dimColor>{`+${match.candidates.length - shown.length} more not shown`}</Text>
147
+ ) : null}
148
+ </Box>
149
+ {current && !busy ? (
150
+ <Box marginTop={1}>
151
+ <Text dimColor wrap="truncate-end">
152
+ {`[${selected + 1}] title ${current.parts.title.toFixed(2)} artist ${current.parts.artist.toFixed(2)} album ${current.parts.album.toFixed(2)} duration ${current.parts.duration.toFixed(2)} version tags ${current.parts.versionTagsAgree ? "agree" : "DIFFER"} ${current.uri}`}
153
+ </Text>
154
+ </Box>
155
+ ) : null}
156
+ </Box>
157
+ );
158
+ }
@@ -0,0 +1,56 @@
1
+ import { Box, Text } from "ink";
2
+ import { scoreColor, type ReviewItem } from "./model.ts";
3
+
4
+ interface Props {
5
+ items: ReviewItem[];
6
+ cursor: number;
7
+ /** Rows available for item lines. */
8
+ height: number;
9
+ /** Keys with an async search in flight. */
10
+ busy: ReadonlySet<string>;
11
+ }
12
+
13
+ export function ReviewList({ items, cursor, height, busy }: Props) {
14
+ const scrolls = items.length > height;
15
+ const visible = Math.max(1, scrolls ? height - 1 : height);
16
+ const maxTop = Math.max(0, items.length - visible);
17
+ const top = Math.min(maxTop, Math.max(0, cursor - Math.floor(visible / 2)));
18
+ const rows = items.slice(top, top + visible);
19
+
20
+ return (
21
+ <Box flexDirection="column" flexGrow={1} overflow="hidden">
22
+ {items.length === 0 ? (
23
+ <Text dimColor>(queue empty)</Text>
24
+ ) : (
25
+ rows.map((item, i) => {
26
+ const index = top + i;
27
+ const active = index === cursor;
28
+ const best = item.match.candidates[0];
29
+ const label = `${item.track.title} — ${item.track.artists.join(", ")}`;
30
+ return (
31
+ <Box key={item.match.canonicalKey} flexDirection="row">
32
+ <Box width={2} flexShrink={0}>
33
+ <Text color="cyan">{active ? ">" : busy.has(item.match.canonicalKey) ? "…" : " "}</Text>
34
+ </Box>
35
+ <Box flexGrow={1} flexShrink={1} overflow="hidden">
36
+ <Text bold={active} inverse={active} wrap="truncate-end">
37
+ {label}
38
+ </Text>
39
+ </Box>
40
+ <Box width={5} flexShrink={0} justifyContent="flex-end">
41
+ {best ? (
42
+ <Text color={scoreColor(best.score)}>{best.score.toFixed(2)}</Text>
43
+ ) : (
44
+ <Text dimColor>none</Text>
45
+ )}
46
+ </Box>
47
+ </Box>
48
+ );
49
+ })
50
+ )}
51
+ {scrolls ? (
52
+ <Text dimColor>{`${top + 1}-${Math.min(items.length, top + visible)} of ${items.length}`}</Text>
53
+ ) : null}
54
+ </Box>
55
+ );
56
+ }
@@ -0,0 +1,37 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import TextInput from "ink-text-input";
3
+
4
+ export type InputMode = "search" | "uri";
5
+
6
+ const PROMPT: Record<InputMode, { label: string; placeholder: string }> = {
7
+ search: { label: "search:", placeholder: "custom query, e.g. title artist" },
8
+ uri: { label: "spotify:", placeholder: "spotify:track:ID or https://open.spotify.com/track/ID" },
9
+ };
10
+
11
+ interface Props {
12
+ mode: InputMode;
13
+ value: string;
14
+ onChange: (value: string) => void;
15
+ onSubmit: (value: string) => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ /** One-line prompt for `/` (custom query) and `p` (paste URI). Esc cancels; Enter submits. */
20
+ export function SearchInput({ mode, value, onChange, onSubmit, onCancel }: Props) {
21
+ useInput(
22
+ (_input, key) => {
23
+ if (key.escape) onCancel();
24
+ },
25
+ { isActive: true },
26
+ );
27
+ const prompt = PROMPT[mode];
28
+ return (
29
+ <Box flexDirection="row">
30
+ <Text color="cyan" bold>
31
+ {prompt.label}{" "}
32
+ </Text>
33
+ <TextInput value={value} onChange={onChange} onSubmit={onSubmit} placeholder={prompt.placeholder} />
34
+ <Text dimColor>{" (Enter submit · Esc cancel)"}</Text>
35
+ </Box>
36
+ );
37
+ }