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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/README.zh-CN.md +131 -0
- package/config.example.toml +61 -0
- package/package.json +66 -0
- package/scripts/register-task.ps1 +43 -0
- package/src/cli.ts +504 -0
- package/src/config.ts +215 -0
- package/src/env.d.ts +15 -0
- package/src/match/aliases.ts +76 -0
- package/src/match/fingerprint.ts +104 -0
- package/src/match/matcher.ts +182 -0
- package/src/match/normalize.ts +107 -0
- package/src/match/score.ts +90 -0
- package/src/match/search.ts +97 -0
- package/src/match/types.ts +45 -0
- package/src/sources/local/ncm.ts +198 -0
- package/src/sources/local/scan.ts +55 -0
- package/src/sources/local/source.ts +105 -0
- package/src/sources/local/tags.ts +67 -0
- package/src/sources/netease/auth.ts +91 -0
- package/src/sources/netease/client.ts +188 -0
- package/src/sources/netease/lib.ts +38 -0
- package/src/sources/netease/source.ts +91 -0
- package/src/sources/types.ts +49 -0
- package/src/spotify/api.ts +155 -0
- package/src/spotify/auth.ts +121 -0
- package/src/spotify/client.ts +120 -0
- package/src/spotify/localUri.ts +48 -0
- package/src/spotify/types.ts +61 -0
- package/src/state/db.ts +42 -0
- package/src/state/repo.ts +480 -0
- package/src/state/schema.sql +115 -0
- package/src/sync/apply.ts +142 -0
- package/src/sync/duration.ts +115 -0
- package/src/sync/export.ts +159 -0
- package/src/sync/plan.ts +205 -0
- package/src/sync/reorder.ts +72 -0
- package/src/sync/run.ts +404 -0
- package/src/tui/App.tsx +420 -0
- package/src/tui/CandidatePane.tsx +158 -0
- package/src/tui/ReviewList.tsx +56 -0
- package/src/tui/SearchInput.tsx +37 -0
- package/src/tui/index.ts +32 -0
- package/src/tui/model.ts +54 -0
- package/src/util/bin.ts +12 -0
- package/src/util/clipboard.ts +13 -0
- package/src/util/fs.ts +18 -0
- package/src/util/lock.ts +38 -0
- package/src/util/log.ts +31 -0
- package/src/util/open.ts +22 -0
- package/src/util/retry.ts +49 -0
package/src/tui/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { render } from "ink";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import type { Matcher } from "../match/matcher.ts";
|
|
4
|
+
import type { Repo } from "../state/repo.ts";
|
|
5
|
+
import { App } from "./App.tsx";
|
|
6
|
+
import { loadQueue } from "./model.ts";
|
|
7
|
+
|
|
8
|
+
export interface ReviewDeps {
|
|
9
|
+
repo: Repo;
|
|
10
|
+
matcher: Matcher;
|
|
11
|
+
market: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Runs the interactive review; resolves when the user quits. Every decision is already persisted by then. */
|
|
15
|
+
export async function runReviewTui(deps: ReviewDeps): Promise<{ decided: number }> {
|
|
16
|
+
const initialQueues = { review: loadQueue(deps.repo, "review"), local: loadQueue(deps.repo, "local") };
|
|
17
|
+
let decided = 0;
|
|
18
|
+
const app = render(
|
|
19
|
+
createElement(App, {
|
|
20
|
+
repo: deps.repo,
|
|
21
|
+
matcher: deps.matcher,
|
|
22
|
+
market: deps.market,
|
|
23
|
+
initialQueues,
|
|
24
|
+
onExit: (n: number) => {
|
|
25
|
+
decided = n;
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
// Ink wires its exit resolver lazily: waitUntilExit() must be pending before exit() runs, i.e. before any key arrives.
|
|
30
|
+
await app.waitUntilExit();
|
|
31
|
+
return { decided };
|
|
32
|
+
}
|
package/src/tui/model.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { MatchRow } from "../match/types.ts";
|
|
2
|
+
import type { Repo, SourceTrackRow } from "../state/repo.ts";
|
|
3
|
+
|
|
4
|
+
/** Which queue is on screen: `review` = needs a human; `local` = auto-decided unmatched, still pickable. */
|
|
5
|
+
export type Tab = "review" | "local";
|
|
6
|
+
|
|
7
|
+
export const TABS: readonly Tab[] = ["review", "local"];
|
|
8
|
+
|
|
9
|
+
export interface ReviewItem {
|
|
10
|
+
match: MatchRow;
|
|
11
|
+
track: SourceTrackRow;
|
|
12
|
+
playlists: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Queues = Record<Tab, ReviewItem[]>;
|
|
16
|
+
|
|
17
|
+
/** Load one tab's queue; keys without a representative source track are dropped. */
|
|
18
|
+
export function loadQueue(repo: Repo, tab: Tab): ReviewItem[] {
|
|
19
|
+
const matches = repo.listMatches(tab);
|
|
20
|
+
const tracks = repo.representativeTracks(matches.map((m) => m.canonicalKey));
|
|
21
|
+
const items: ReviewItem[] = [];
|
|
22
|
+
for (const match of matches) {
|
|
23
|
+
const track = tracks.get(match.canonicalKey);
|
|
24
|
+
if (!track) continue;
|
|
25
|
+
items.push({ match, track, playlists: repo.playlistNamesForKey(match.canonicalKey) });
|
|
26
|
+
}
|
|
27
|
+
return items;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `m:ss`; `--:--` when unknown. */
|
|
31
|
+
export function fmtDuration(ms: number | undefined): string {
|
|
32
|
+
if (ms === undefined) return "--:--";
|
|
33
|
+
const s = Math.round(ms / 1000);
|
|
34
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Signed whole-second delta between candidate and source, e.g. `+3s`; empty when the source duration is unknown. */
|
|
38
|
+
export function fmtDelta(candidateMs: number, sourceMs: number | undefined): string {
|
|
39
|
+
if (sourceMs === undefined) return "";
|
|
40
|
+
const d = Math.round((candidateMs - sourceMs) / 1000);
|
|
41
|
+
if (d === 0) return "±0s";
|
|
42
|
+
return `${d > 0 ? "+" : "-"}${Math.abs(d)}s`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function scoreColor(score: number): "green" | "yellow" | "red" {
|
|
46
|
+
if (score >= 0.85) return "green";
|
|
47
|
+
if (score >= 0.6) return "yellow";
|
|
48
|
+
return "red";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sourceOrigin(track: SourceTrackRow): string {
|
|
52
|
+
if (track.kind === "netease") return `netease id ${track.externalId}`;
|
|
53
|
+
return `local ${track.file?.path ?? track.externalId}`;
|
|
54
|
+
}
|
package/src/util/bin.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Probe an external binary by running it with `args`; returns the first output line or null. */
|
|
2
|
+
export async function probeBinary(cmd: string, args: string[]): Promise<string | null> {
|
|
3
|
+
try {
|
|
4
|
+
const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
5
|
+
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
6
|
+
await proc.exited;
|
|
7
|
+
const line = (out || err).split(/\r?\n/, 1)[0]?.trim();
|
|
8
|
+
return line || `${cmd} (exit ${proc.exitCode})`;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Copies text to the system clipboard; returns false when no clipboard command is available. */
|
|
2
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
3
|
+
const cmd =
|
|
4
|
+
process.platform === "win32" ? ["clip"] : process.platform === "darwin" ? ["pbcopy"] : ["xclip", "-selection", "clipboard"];
|
|
5
|
+
try {
|
|
6
|
+
const proc = Bun.spawn(cmd, { stdin: "pipe", stdout: "ignore", stderr: "ignore" });
|
|
7
|
+
proc.stdin.write(text);
|
|
8
|
+
proc.stdin.end();
|
|
9
|
+
return (await proc.exited) === 0;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/util/fs.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Replace characters Windows/NTFS rejects and trim trailing dots/spaces. */
|
|
4
|
+
export function sanitizeFilename(name: string, max = 150): string {
|
|
5
|
+
const cleaned = name
|
|
6
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
|
|
7
|
+
.replace(/\s+/g, " ")
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/[. ]+$/, "");
|
|
10
|
+
return (cleaned || "untitled").slice(0, max);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** blake2b256 hex digest of a file, streamed. */
|
|
14
|
+
export async function hashFile(path: string): Promise<string> {
|
|
15
|
+
const hasher = new Bun.CryptoHasher("blake2b256");
|
|
16
|
+
for await (const chunk of createReadStream(path, { highWaterMark: 1 << 20 })) hasher.update(chunk as Buffer);
|
|
17
|
+
return hasher.digest("hex");
|
|
18
|
+
}
|
package/src/util/lock.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export class LockHeldError extends Error {
|
|
4
|
+
constructor(readonly pid: number) {
|
|
5
|
+
super(`another sync is running (pid ${pid})`);
|
|
6
|
+
this.name = "LockHeldError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Acquire a pid lock file. A lock whose pid is no longer alive is treated as stale and taken over.
|
|
12
|
+
* Returns a release function; callers must invoke it in `finally`.
|
|
13
|
+
*/
|
|
14
|
+
export function acquireLock(path: string): () => void {
|
|
15
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
16
|
+
try {
|
|
17
|
+
const fd = openSync(path, "wx");
|
|
18
|
+
writeSync(fd, String(process.pid));
|
|
19
|
+
closeSync(fd);
|
|
20
|
+
return () => rmSync(path, { force: true });
|
|
21
|
+
} catch (e) {
|
|
22
|
+
if (!(e instanceof Error) || !("code" in e) || e.code !== "EEXIST") throw e;
|
|
23
|
+
const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
24
|
+
if (Number.isInteger(pid) && pid !== process.pid && isAlive(pid)) throw new LockHeldError(pid);
|
|
25
|
+
rmSync(path, { force: true }); // stale: owner died without cleanup
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`could not acquire lock ${path}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isAlive(pid: number): boolean {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return e instanceof Error && "code" in e && e.code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/util/log.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type Level = "debug" | "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
const ORDER: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
7
|
+
|
|
8
|
+
let threshold: Level = "info";
|
|
9
|
+
let filePath: string | null = null;
|
|
10
|
+
|
|
11
|
+
export function configureLog(opts: { level?: Level; file?: string }): void {
|
|
12
|
+
if (opts.level) threshold = opts.level;
|
|
13
|
+
if (opts.file) {
|
|
14
|
+
mkdirSync(dirname(opts.file), { recursive: true });
|
|
15
|
+
filePath = opts.file;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function emit(level: Level, msg: string, data?: unknown): void {
|
|
20
|
+
if (ORDER[level] < ORDER[threshold]) return;
|
|
21
|
+
const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} ${msg}${data === undefined ? "" : " " + JSON.stringify(data)}`;
|
|
22
|
+
(level === "error" || level === "warn" ? console.error : console.log)(line);
|
|
23
|
+
if (filePath) appendFileSync(filePath, line + "\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const log = {
|
|
27
|
+
debug: (msg: string, data?: unknown) => emit("debug", msg, data),
|
|
28
|
+
info: (msg: string, data?: unknown) => emit("info", msg, data),
|
|
29
|
+
warn: (msg: string, data?: unknown) => emit("warn", msg, data),
|
|
30
|
+
error: (msg: string, data?: unknown) => emit("error", msg, data),
|
|
31
|
+
};
|
package/src/util/open.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { log } from "./log.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Open a URL or file with the OS default handler, detached.
|
|
5
|
+
* Windows: `cmd /c start` would parse `&` in query strings as a command separator and mangle URLs;
|
|
6
|
+
* rundll32's protocol handler passes the argument through verbatim.
|
|
7
|
+
*/
|
|
8
|
+
export function openExternal(target: string): boolean {
|
|
9
|
+
const cmd =
|
|
10
|
+
process.platform === "win32"
|
|
11
|
+
? ["rundll32", "url.dll,FileProtocolHandler", target]
|
|
12
|
+
: process.platform === "darwin"
|
|
13
|
+
? ["open", target]
|
|
14
|
+
: ["xdg-open", target];
|
|
15
|
+
try {
|
|
16
|
+
Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", stdin: "ignore" }).unref();
|
|
17
|
+
return true;
|
|
18
|
+
} catch (e) {
|
|
19
|
+
log.warn("could not open externally", { target, error: String(e) });
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function sleep(ms: number): Promise<void> {
|
|
2
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
3
|
+
setTimeout(resolve, ms);
|
|
4
|
+
return promise;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class RetryableError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
message: string,
|
|
10
|
+
readonly retryAfterMs?: number,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "RetryableError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Retries `fn` on RetryableError with exponential backoff, honoring `retryAfterMs` when given. */
|
|
18
|
+
export async function withRetry<T>(fn: () => Promise<T>, opts: { attempts?: number; baseMs?: number } = {}): Promise<T> {
|
|
19
|
+
const attempts = opts.attempts ?? 4;
|
|
20
|
+
const baseMs = opts.baseMs ?? 500;
|
|
21
|
+
for (let i = 0; ; i++) {
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (!(e instanceof RetryableError) || i + 1 >= attempts) throw e;
|
|
26
|
+
await sleep(e.retryAfterMs ?? baseMs * 2 ** i);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Runs `worker` over `items` with at most `limit` in flight; results keep input order. */
|
|
32
|
+
export async function mapLimit<T, R>(items: readonly T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
33
|
+
const results = new Array<R>(items.length);
|
|
34
|
+
let next = 0;
|
|
35
|
+
const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
36
|
+
while (next < items.length) {
|
|
37
|
+
const i = next++;
|
|
38
|
+
results[i] = await worker(items[i]!, i);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
await Promise.all(lanes);
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function chunk<T>(arr: readonly T[], size: number): T[][] {
|
|
46
|
+
const out: T[][] = [];
|
|
47
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
48
|
+
return out;
|
|
49
|
+
}
|