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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute a Plan against Spotify + the export folder, writing state back after every successful
|
|
3
|
+
* remote operation so a crash mid-way leaves a consistent (re-plannable) state. See DESIGN.md §6.
|
|
4
|
+
*/
|
|
5
|
+
import type { Config } from "../config.ts";
|
|
6
|
+
import type { SpotifyApi } from "../spotify/api.ts";
|
|
7
|
+
import { MANAGED_DESCRIPTION } from "../spotify/types.ts";
|
|
8
|
+
import type { Repo } from "../state/repo.ts";
|
|
9
|
+
import { chunk } from "../util/retry.ts";
|
|
10
|
+
import { log } from "../util/log.ts";
|
|
11
|
+
import { exportTrack } from "./export.ts";
|
|
12
|
+
import type { ExportPlan, Plan, PlaylistPlan } from "./plan.ts";
|
|
13
|
+
|
|
14
|
+
export interface ApplyDeps {
|
|
15
|
+
api: SpotifyApi;
|
|
16
|
+
repo: Repo;
|
|
17
|
+
cfg: Config;
|
|
18
|
+
prune: boolean;
|
|
19
|
+
now: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ApplySummary {
|
|
23
|
+
created: number;
|
|
24
|
+
renamed: number;
|
|
25
|
+
added: number;
|
|
26
|
+
pruned: number;
|
|
27
|
+
moved: number;
|
|
28
|
+
replaced: number;
|
|
29
|
+
liked: number;
|
|
30
|
+
unliked: number;
|
|
31
|
+
exported: number;
|
|
32
|
+
exportErrors: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Replace the whole playlist only when it saves real calls: more than this many moves AND more than a third of the list. */
|
|
36
|
+
const REPLACE_MIN_MOVES = 5;
|
|
37
|
+
const REPLACE_MOVE_RATIO = 1 / 3;
|
|
38
|
+
|
|
39
|
+
/** Applies playlist and library changes. Exports run separately (`applyExports`) before planning. */
|
|
40
|
+
export async function applyPlan(plan: Plan, deps: ApplyDeps): Promise<ApplySummary> {
|
|
41
|
+
const s: ApplySummary = { created: 0, renamed: 0, added: 0, pruned: 0, moved: 0, replaced: 0, liked: 0, unliked: 0, exported: 0, exportErrors: 0 };
|
|
42
|
+
|
|
43
|
+
for (const p of plan.playlists) await applyPlaylist(p, deps, s);
|
|
44
|
+
|
|
45
|
+
if (plan.likes.add.length > 0) {
|
|
46
|
+
await deps.api.saveTracks(plan.likes.add);
|
|
47
|
+
deps.repo.addLiked(plan.likes.add, deps.now);
|
|
48
|
+
s.liked = plan.likes.add.length;
|
|
49
|
+
}
|
|
50
|
+
if (deps.prune && plan.likes.prune.length > 0) {
|
|
51
|
+
await deps.api.removeSavedTracks(plan.likes.prune);
|
|
52
|
+
deps.repo.removeLiked(plan.likes.prune);
|
|
53
|
+
s.unliked = plan.likes.prune.length;
|
|
54
|
+
}
|
|
55
|
+
return s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Run the ffmpeg export step alone; also used by `spotifify export`. `uriChanged` counts re-exports whose `spotify:local:` identity differs from the previous export (the pasted entries for those are now stale). */
|
|
59
|
+
export async function applyExports(plans: ExportPlan[], deps: { repo: Repo; cfg: Config; now: number }): Promise<{ exported: number; uriChanged: number; errors: number }> {
|
|
60
|
+
const out = { exported: 0, uriChanged: 0, errors: 0 };
|
|
61
|
+
for (const e of plans) {
|
|
62
|
+
const track = deps.repo.representativeTracks([e.canonicalKey]).get(e.canonicalKey);
|
|
63
|
+
if (!track?.file) continue;
|
|
64
|
+
try {
|
|
65
|
+
const previous = deps.repo.getExport(e.canonicalKey);
|
|
66
|
+
const r = await exportTrack(e, track, deps.cfg.export);
|
|
67
|
+
deps.repo.setExport({ canonicalKey: e.canonicalKey, exportPath: r.exportPath, localUri: r.localUri, contentHash: track.file.contentHash, exportedAt: deps.now });
|
|
68
|
+
out.exported++;
|
|
69
|
+
if (previous && previous.localUri !== r.localUri) out.uriChanged++;
|
|
70
|
+
log.info("exported", { path: r.exportPath, uri: r.localUri });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
out.errors++;
|
|
73
|
+
log.error("export failed", { path: e.sourcePath, error: err instanceof Error ? err.message : String(err) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function applyPlaylist(p: PlaylistPlan, deps: ApplyDeps, s: ApplySummary): Promise<void> {
|
|
80
|
+
const { api, repo, now } = deps;
|
|
81
|
+
let spotifyId = p.spotifyId;
|
|
82
|
+
let name = p.rename?.from ?? p.sourceName;
|
|
83
|
+
|
|
84
|
+
if (p.create) {
|
|
85
|
+
const created = await api.createPlaylist(p.create.name, MANAGED_DESCRIPTION);
|
|
86
|
+
spotifyId = created.id;
|
|
87
|
+
name = created.name;
|
|
88
|
+
repo.setSpotifyPlaylist({ sourcePlaylistId: p.sourcePlaylistId, spotifyId, name, snapshotId: created.snapshot_id, lastSyncedAt: null });
|
|
89
|
+
s.created++;
|
|
90
|
+
log.info("created playlist", { name, id: spotifyId });
|
|
91
|
+
}
|
|
92
|
+
if (spotifyId === null) throw new Error(`playlist plan for ${p.sourceName} has neither spotifyId nor create`);
|
|
93
|
+
|
|
94
|
+
if (p.rename) {
|
|
95
|
+
await api.renamePlaylist(spotifyId, p.rename.to);
|
|
96
|
+
name = p.rename.to;
|
|
97
|
+
s.renamed++;
|
|
98
|
+
log.info("renamed playlist", p.rename);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Replace-all fast path: only when no local items exist and moving would cost noticeably more calls.
|
|
102
|
+
const useReplace = p.replaceAllowed && p.moves.length > REPLACE_MIN_MOVES && p.moves.length > p.targetOrder.length * REPLACE_MOVE_RATIO;
|
|
103
|
+
let snapshot: string | null = null;
|
|
104
|
+
|
|
105
|
+
if (useReplace) {
|
|
106
|
+
// Prune not requested → pruned items were kept in targetOrder by the planner, so nothing is lost here.
|
|
107
|
+
snapshot = await api.replacePlaylistItems(spotifyId, p.targetOrder);
|
|
108
|
+
repo.addManaged(spotifyId, p.adds, now);
|
|
109
|
+
if (deps.prune) repo.removeManaged(spotifyId, p.prune.map((x) => x.uri));
|
|
110
|
+
s.added += p.adds.length;
|
|
111
|
+
s.pruned += deps.prune ? p.prune.length : 0;
|
|
112
|
+
s.replaced++;
|
|
113
|
+
log.info("replaced playlist contents", { name, items: p.targetOrder.length });
|
|
114
|
+
} else {
|
|
115
|
+
for (const uris of chunk(p.adds, 100)) {
|
|
116
|
+
snapshot = await api.addPlaylistItems(spotifyId, uris);
|
|
117
|
+
repo.addManaged(spotifyId, uris, now);
|
|
118
|
+
s.added += uris.length;
|
|
119
|
+
}
|
|
120
|
+
if (deps.prune && p.prune.length > 0) {
|
|
121
|
+
snapshot ??= (await api.getPlaylist(spotifyId))?.snapshot_id ?? null;
|
|
122
|
+
if (snapshot === null) throw new Error(`playlist ${spotifyId} vanished during apply`);
|
|
123
|
+
const items = p.prune.map((x) => (x.uri.startsWith("spotify:local:") ? { uri: x.uri, positions: x.positions } : { uri: x.uri }));
|
|
124
|
+
snapshot = await api.removePlaylistItems(spotifyId, items, snapshot);
|
|
125
|
+
repo.removeManaged(spotifyId, p.prune.map((x) => x.uri));
|
|
126
|
+
s.pruned += p.prune.length;
|
|
127
|
+
}
|
|
128
|
+
if (p.moves.length > 0) {
|
|
129
|
+
snapshot ??= (await api.getPlaylist(spotifyId))?.snapshot_id ?? null;
|
|
130
|
+
if (snapshot === null) throw new Error(`playlist ${spotifyId} vanished during apply`);
|
|
131
|
+
for (const m of p.moves) {
|
|
132
|
+
snapshot = await api.reorderPlaylistItems(spotifyId, m.rangeStart, m.insertBefore, snapshot);
|
|
133
|
+
s.moved++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (p.adds.length > 0 || p.moves.length > 0 || p.prune.length > 0) {
|
|
139
|
+
log.info("synced playlist", { name, added: p.adds.length, pruned: deps.prune ? p.prune.length : 0, moves: p.moves.length, awaiting: p.awaiting.length });
|
|
140
|
+
}
|
|
141
|
+
repo.setSpotifyPlaylist({ sourcePlaylistId: p.sourcePlaylistId, spotifyId, name, snapshotId: snapshot, lastSyncedAt: now });
|
|
142
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-second duration exactly as the Spotify desktop client computes it for its local-file index —
|
|
3
|
+
* the value it writes into the last `spotify:local:` segment. Verified against the client's index for
|
|
4
|
+
* 81 CBR mp3 files (ffmpeg-written Info frame): floor((xingFrames + 1) * samplesPerFrame / sampleRate),
|
|
5
|
+
* which for CBR equals floor(audioBytes * 8 / bitrate). ffprobe's duration (gapless-trimmed, Info frame
|
|
6
|
+
* excluded) is up to ~0.05 s shorter and floors differently for 4 of those 81 files, so it cannot be used.
|
|
7
|
+
* VBR mp3 ("Xing" rather than "Info") is not verified; `exportTrack` re-encodes such files to CBR.
|
|
8
|
+
* m4a: floor(mvhd duration / timescale) [unverified against the client; no m4a in the sample].
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Mp3Probe {
|
|
12
|
+
durationSec: number;
|
|
13
|
+
/** "Xing" frame present: variable bitrate, duration formula unverified */
|
|
14
|
+
vbr: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// index: [MPEG1, MPEG2, MPEG2.5] × sample-rate index
|
|
18
|
+
const SAMPLE_RATES: Record<number, readonly number[]> = { 3: [44100, 48000, 32000], 2: [22050, 24000, 16000], 0: [11025, 12000, 8000] };
|
|
19
|
+
// kbit/s by layer for MPEG1 and MPEG2/2.5
|
|
20
|
+
const BITRATES_V1: Record<number, readonly number[]> = {
|
|
21
|
+
3: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448],
|
|
22
|
+
2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384],
|
|
23
|
+
1: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
|
|
24
|
+
};
|
|
25
|
+
const BITRATES_V2: Record<number, readonly number[]> = {
|
|
26
|
+
3: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256],
|
|
27
|
+
2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
|
|
28
|
+
1: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
interface FrameHeader {
|
|
32
|
+
sampleRate: number;
|
|
33
|
+
samplesPerFrame: number;
|
|
34
|
+
bitrateKbps: number;
|
|
35
|
+
/** offset of the Xing/Info tag inside the frame (after side info) */
|
|
36
|
+
xingOffset: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseFrameHeader(b: Uint8Array, at: number): FrameHeader | null {
|
|
40
|
+
if (at + 4 > b.length) return null;
|
|
41
|
+
const b1 = b[at + 1]!;
|
|
42
|
+
const b2 = b[at + 2]!;
|
|
43
|
+
const b3 = b[at + 3]!;
|
|
44
|
+
if (b[at] !== 0xff || (b1 & 0xe0) !== 0xe0) return null;
|
|
45
|
+
const version = (b1 >> 3) & 3; // 3 MPEG1, 2 MPEG2, 0 MPEG2.5, 1 reserved
|
|
46
|
+
const layer = (b1 >> 1) & 3; // 3 Layer I, 2 Layer II, 1 Layer III, 0 reserved
|
|
47
|
+
const bitrateIdx = b2 >> 4;
|
|
48
|
+
const srIdx = (b2 >> 2) & 3;
|
|
49
|
+
if (version === 1 || layer === 0 || bitrateIdx === 0 || bitrateIdx === 15 || srIdx === 3) return null;
|
|
50
|
+
const sampleRate = SAMPLE_RATES[version]![srIdx]!;
|
|
51
|
+
const bitrateKbps = (version === 3 ? BITRATES_V1 : BITRATES_V2)[layer]![bitrateIdx]!;
|
|
52
|
+
const samplesPerFrame = layer === 3 ? 384 : layer === 2 || version === 3 ? 1152 : 576;
|
|
53
|
+
const mono = (b3 >> 6) === 3;
|
|
54
|
+
const xingOffset = 4 + (version === 3 ? (mono ? 17 : 32) : mono ? 9 : 17);
|
|
55
|
+
return { sampleRate, samplesPerFrame, bitrateKbps, xingOffset };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function id3v2End(b: Uint8Array): number {
|
|
59
|
+
if (b.length < 10 || b[0] !== 0x49 || b[1] !== 0x44 || b[2] !== 0x33) return 0;
|
|
60
|
+
const size = ((b[6]! & 0x7f) << 21) | ((b[7]! & 0x7f) << 14) | ((b[8]! & 0x7f) << 7) | (b[9]! & 0x7f);
|
|
61
|
+
return 10 + size + (b[5]! & 0x10 ? 10 : 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ascii = (b: Uint8Array, at: number, n: number) => String.fromCharCode(...b.subarray(at, at + n));
|
|
65
|
+
|
|
66
|
+
export function probeMp3(b: Uint8Array): Mp3Probe | null {
|
|
67
|
+
let at = id3v2End(b);
|
|
68
|
+
let hdr: FrameHeader | null = null;
|
|
69
|
+
for (; at + 4 <= b.length; at++) {
|
|
70
|
+
hdr = parseFrameHeader(b, at);
|
|
71
|
+
if (hdr) break;
|
|
72
|
+
}
|
|
73
|
+
if (!hdr) return null;
|
|
74
|
+
const tag = ascii(b, at + hdr.xingOffset, 4);
|
|
75
|
+
const vbr = tag === "Xing";
|
|
76
|
+
if (vbr || tag === "Info") {
|
|
77
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
78
|
+
const flags = view.getUint32(at + hdr.xingOffset + 4);
|
|
79
|
+
if (flags & 1) {
|
|
80
|
+
const frames = view.getUint32(at + hdr.xingOffset + 8);
|
|
81
|
+
return { durationSec: Math.floor(((frames + 1) * hdr.samplesPerFrame) / hdr.sampleRate), vbr };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const tail = b.length >= 128 && ascii(b, b.length - 128, 3) === "TAG" ? 128 : 0;
|
|
85
|
+
const audioBytes = b.length - at - tail;
|
|
86
|
+
return { durationSec: Math.floor((audioBytes * 8) / (hdr.bitrateKbps * 1000)), vbr };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** floor(mvhd.duration / mvhd.timescale); null when no movie header is found. */
|
|
90
|
+
export function probeMp4DurationSec(b: Uint8Array): number | null {
|
|
91
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
92
|
+
const walk = (start: number, end: number): number | null => {
|
|
93
|
+
let at = start;
|
|
94
|
+
while (at + 8 <= end) {
|
|
95
|
+
let size = view.getUint32(at);
|
|
96
|
+
const type = ascii(b, at + 4, 4);
|
|
97
|
+
let header = 8;
|
|
98
|
+
if (size === 1) {
|
|
99
|
+
size = Number(view.getBigUint64(at + 8));
|
|
100
|
+
header = 16;
|
|
101
|
+
} else if (size === 0) size = end - at;
|
|
102
|
+
if (size < header) return null;
|
|
103
|
+
if (type === "moov") return walk(at + header, at + size);
|
|
104
|
+
if (type === "mvhd") {
|
|
105
|
+
const version = b[at + header]!;
|
|
106
|
+
const timescale = view.getUint32(at + header + (version === 1 ? 20 : 12));
|
|
107
|
+
const duration = version === 1 ? Number(view.getBigUint64(at + header + 24)) : view.getUint32(at + header + 16);
|
|
108
|
+
return timescale > 0 ? Math.floor(duration / timescale) : null;
|
|
109
|
+
}
|
|
110
|
+
at += size;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
return walk(0, b.length);
|
|
115
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export one unmatched local track into the Spotify desktop "Local Files" folder.
|
|
3
|
+
* Every file goes through ffmpeg so tags are canonical and the resulting `spotify:local:` URI is
|
|
4
|
+
* predictable. mp3/m4a keep their codec; everything else is transcoded to mp3. See DESIGN.md §6.5.
|
|
5
|
+
*/
|
|
6
|
+
import { link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { extname, join } from "node:path";
|
|
9
|
+
import type { Config } from "../config.ts";
|
|
10
|
+
import { decryptNcm } from "../sources/local/ncm.ts";
|
|
11
|
+
import type { SourceTrackRow } from "../state/repo.ts";
|
|
12
|
+
import { buildLocalUri } from "../spotify/localUri.ts";
|
|
13
|
+
import { probeMp3, probeMp4DurationSec } from "./duration.ts";
|
|
14
|
+
import { log } from "../util/log.ts";
|
|
15
|
+
import type { ExportPlan } from "./plan.ts";
|
|
16
|
+
import { RetryableError, withRetry } from "../util/retry.ts";
|
|
17
|
+
|
|
18
|
+
export interface ExportResult {
|
|
19
|
+
exportPath: string;
|
|
20
|
+
localUri: string;
|
|
21
|
+
/** tag values actually written */
|
|
22
|
+
tags: { artist: string; album: string; title: string };
|
|
23
|
+
/** whole seconds as the desktop client will index them (last uri segment) */
|
|
24
|
+
durationSec: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const COPY_EXT: Record<string, "mp3" | "m4a"> = { mp3: "mp3", m4a: "m4a", mp4: "m4a", aac: "m4a" };
|
|
28
|
+
|
|
29
|
+
export class ExportError extends Error {}
|
|
30
|
+
|
|
31
|
+
export async function exportTrack(plan: ExportPlan, track: SourceTrackRow, cfg: Config["export"]): Promise<ExportResult> {
|
|
32
|
+
const tmpBase = join(tmpdir(), `spotifify-${track.file?.contentHash.slice(0, 16) ?? Date.now()}`);
|
|
33
|
+
const cleanup: string[] = [];
|
|
34
|
+
try {
|
|
35
|
+
let input = plan.sourcePath;
|
|
36
|
+
let inputExt = extname(plan.sourcePath).slice(1).toLowerCase();
|
|
37
|
+
let coverPath: string | null = null;
|
|
38
|
+
let tags = { artist: track.artists.join(", "), album: track.album ?? "", title: track.title };
|
|
39
|
+
|
|
40
|
+
if (plan.decryptNcm) {
|
|
41
|
+
const { meta, cover } = await decryptNcm(plan.sourcePath, `${tmpBase}.${"audio"}`);
|
|
42
|
+
input = `${tmpBase}.audio`;
|
|
43
|
+
cleanup.push(input);
|
|
44
|
+
inputExt = meta.format.toLowerCase();
|
|
45
|
+
tags = { artist: meta.artist.map(([name]) => name).join(", "), album: meta.album, title: meta.musicName };
|
|
46
|
+
if (cover && cover.length > 0) {
|
|
47
|
+
coverPath = `${tmpBase}.cover`;
|
|
48
|
+
await writeFile(coverPath, cover);
|
|
49
|
+
cleanup.push(coverPath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const outExt = COPY_EXT[inputExt] ?? "mp3";
|
|
53
|
+
const mode: "copy" | "mp3" = COPY_EXT[inputExt] ? "copy" : "mp3";
|
|
54
|
+
const exportPath = join(cfg.dir, `${plan.baseName}.${outExt}`);
|
|
55
|
+
// The desktop client watches export.dir: it parses a path once when the entry is created and drops it
|
|
56
|
+
// when the entry disappears, never re-reading in between. So ffmpeg must not write the final name
|
|
57
|
+
// (a half-written file is indexed with an unknown duration and never plays); the finished file is
|
|
58
|
+
// produced under a non-audio extension and then placed as a new, complete entry (see placeExport).
|
|
59
|
+
const partPath = `${exportPath}.part`;
|
|
60
|
+
cleanup.push(partPath);
|
|
61
|
+
await mkdir(cfg.dir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
const encode = ["-c:a", "libmp3lame", "-b:a", cfg.bitrate];
|
|
64
|
+
const codec = mode === "copy" ? ["-c:a", "copy"] : encode;
|
|
65
|
+
let withCover = await runFfmpeg(cfg.ffmpeg, input, inputExt, coverPath, tags, codec, outExt, partPath);
|
|
66
|
+
if (!withCover.ok) {
|
|
67
|
+
// Cover muxing is the fragile part (odd picture streams, mp4 attached_pic quirks): retry audio-only.
|
|
68
|
+
const audioOnly = await runFfmpeg(cfg.ffmpeg, input, inputExt, null, tags, codec, outExt, partPath, false);
|
|
69
|
+
if (!audioOnly.ok) throw new ExportError(`ffmpeg failed for ${plan.sourcePath}: ${audioOnly.stderr}`);
|
|
70
|
+
log.warn("exported without cover art", { path: exportPath });
|
|
71
|
+
}
|
|
72
|
+
let durationSec = await probeDuration(partPath, outExt);
|
|
73
|
+
if (durationSec === null && outExt === "mp3" && mode === "copy") {
|
|
74
|
+
// A copied VBR stream: the client's duration for "Xing" files is unverified, so re-encode to CBR.
|
|
75
|
+
withCover = await runFfmpeg(cfg.ffmpeg, input, inputExt, coverPath, tags, encode, outExt, partPath);
|
|
76
|
+
if (!withCover.ok) throw new ExportError(`ffmpeg re-encode failed for ${plan.sourcePath}: ${withCover.stderr}`);
|
|
77
|
+
durationSec = await probeDuration(partPath, outExt);
|
|
78
|
+
}
|
|
79
|
+
if (durationSec === null) throw new ExportError(`cannot determine the client duration of ${exportPath}`);
|
|
80
|
+
await placeExport(partPath, exportPath);
|
|
81
|
+
|
|
82
|
+
return { exportPath, localUri: buildLocalUri({ ...tags, durationSec }), tags, durationSec };
|
|
83
|
+
} finally {
|
|
84
|
+
await Promise.all(cleanup.map((p) => rm(p, { force: true })));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Make the finished file appear at `exportPath` as a freshly created, complete entry. A hard link to the
|
|
90
|
+
* `.part` file is atomic and raises the watcher's "added" event with the whole content already there;
|
|
91
|
+
* a rename is only the fallback for filesystems without hard links (the client then sees the file at its
|
|
92
|
+
* next restart or folder toggle). An existing file is removed first so the client drops its old entry;
|
|
93
|
+
* that fails with EPERM/EBUSY while the client has it open (playing), which clears within seconds.
|
|
94
|
+
*/
|
|
95
|
+
async function placeExport(partPath: string, exportPath: string): Promise<void> {
|
|
96
|
+
await withRetry(
|
|
97
|
+
async () => {
|
|
98
|
+
try {
|
|
99
|
+
await rm(exportPath, { force: true });
|
|
100
|
+
} catch (e) {
|
|
101
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
102
|
+
if (code === "EPERM" || code === "EBUSY") throw new RetryableError(`${code} replacing ${exportPath}`);
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{ attempts: 6, baseMs: 500 },
|
|
107
|
+
);
|
|
108
|
+
try {
|
|
109
|
+
await link(partPath, exportPath);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
112
|
+
if (code !== "EXDEV" && code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP") throw e;
|
|
113
|
+
log.warn("hard link unavailable, renaming instead; the desktop client will only index the file after a restart", { path: exportPath });
|
|
114
|
+
await rename(partPath, exportPath);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** null: unparsable, or a VBR mp3 whose client duration is not predictable. */
|
|
119
|
+
async function probeDuration(path: string, ext: "mp3" | "m4a"): Promise<number | null> {
|
|
120
|
+
const buf = await readFile(path);
|
|
121
|
+
if (ext === "m4a") return probeMp4DurationSec(buf);
|
|
122
|
+
const p = probeMp3(buf);
|
|
123
|
+
return p === null || p.vbr ? null : p.durationSec;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runFfmpeg(
|
|
127
|
+
ffmpeg: string,
|
|
128
|
+
input: string,
|
|
129
|
+
inputExt: string,
|
|
130
|
+
coverPath: string | null,
|
|
131
|
+
tags: ExportResult["tags"],
|
|
132
|
+
codec: string[],
|
|
133
|
+
outExt: string,
|
|
134
|
+
outPath: string,
|
|
135
|
+
includeCover = true,
|
|
136
|
+
): Promise<{ ok: boolean; stderr: string }> {
|
|
137
|
+
const args = ["-y", "-hide_banner", "-loglevel", "error"];
|
|
138
|
+
// Raw decrypted ncm audio has no extension; tell ffmpeg the container explicitly.
|
|
139
|
+
if (input.endsWith(".audio")) args.push("-f", inputExt === "mp3" ? "mp3" : inputExt);
|
|
140
|
+
args.push("-i", input);
|
|
141
|
+
if (coverPath) args.push("-i", coverPath);
|
|
142
|
+
args.push("-map", "0:a:0");
|
|
143
|
+
if (includeCover) {
|
|
144
|
+
if (coverPath) args.push("-map", "1:v:0");
|
|
145
|
+
else args.push("-map", "0:v:0?");
|
|
146
|
+
args.push("-c:v", "copy", "-disposition:v:0", "attached_pic");
|
|
147
|
+
}
|
|
148
|
+
args.push("-map_metadata", "-1", "-map_chapters", "-1");
|
|
149
|
+
args.push("-metadata", `title=${tags.title}`, "-metadata", `artist=${tags.artist}`, "-metadata", `album=${tags.album}`);
|
|
150
|
+
args.push(...codec);
|
|
151
|
+
if (outExt === "mp3") args.push("-id3v2_version", "3", "-write_id3v1", "1");
|
|
152
|
+
if (outExt === "m4a") args.push("-movflags", "+faststart");
|
|
153
|
+
args.push("-f", outExt === "m4a" ? "mp4" : "mp3", outPath);
|
|
154
|
+
|
|
155
|
+
const proc = Bun.spawn([ffmpeg, ...args], { stdout: "ignore", stderr: "pipe", stdin: "ignore" });
|
|
156
|
+
const stderr = await new Response(proc.stderr).text();
|
|
157
|
+
const code = await proc.exited;
|
|
158
|
+
return { ok: code === 0, stderr: stderr.trim() };
|
|
159
|
+
}
|
package/src/sync/plan.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan = pure data describing the difference between desired state (sources + match decisions)
|
|
3
|
+
* and the remote Spotify state. Computed by `computePlaylistPlan` (pure) / `buildPlan` (run.ts),
|
|
4
|
+
* printed by `--dry-run`, executed by `apply`. See DESIGN.md §6.
|
|
5
|
+
*/
|
|
6
|
+
import type { LocalExportRow } from "../state/repo.ts";
|
|
7
|
+
import { buildLocalUri, parseLocalUri } from "../spotify/localUri.ts";
|
|
8
|
+
import { planMoves } from "./reorder.ts";
|
|
9
|
+
|
|
10
|
+
export interface DesiredItem {
|
|
11
|
+
uri: string;
|
|
12
|
+
kind: "spotify" | "local";
|
|
13
|
+
canonicalKey: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Move {
|
|
17
|
+
/** index of the item to move in the *current* order at the time this move is applied */
|
|
18
|
+
rangeStart: number;
|
|
19
|
+
/** target index (Spotify `insert_before` semantics) */
|
|
20
|
+
insertBefore: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PlaylistPlan {
|
|
24
|
+
sourcePlaylistId: number;
|
|
25
|
+
sourceName: string;
|
|
26
|
+
/** null when the playlist must be created first */
|
|
27
|
+
spotifyId: string | null;
|
|
28
|
+
create: { name: string } | null;
|
|
29
|
+
rename: { from: string; to: string } | null;
|
|
30
|
+
/** spotify:track URIs to POST, in desired order */
|
|
31
|
+
adds: string[];
|
|
32
|
+
/** local items that must be pasted into the desktop client by the user */
|
|
33
|
+
awaiting: DesiredItem[];
|
|
34
|
+
/** tool-managed remote items no longer in the source; removed only with --prune */
|
|
35
|
+
prune: Array<{ uri: string; positions: number[] }>;
|
|
36
|
+
/** remote items not managed by this tool; never removed, kept at the tail */
|
|
37
|
+
foreign: string[];
|
|
38
|
+
/** minimal move sequence to reach desired order (after adds, and prune when enabled) */
|
|
39
|
+
moves: Move[];
|
|
40
|
+
/** full target order (after adds/prune) — used by the replace-all fast path */
|
|
41
|
+
targetOrder: string[];
|
|
42
|
+
/** when true, apply may replace the whole playlist instead of moving (no local items involved) */
|
|
43
|
+
replaceAllowed: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LikePlan {
|
|
47
|
+
/** spotify track ids to PUT /me/tracks */
|
|
48
|
+
add: string[];
|
|
49
|
+
/** tool-liked ids no longer desired; removed only with --prune */
|
|
50
|
+
prune: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ExportPlan {
|
|
54
|
+
canonicalKey: string;
|
|
55
|
+
sourcePath: string;
|
|
56
|
+
/** sanitized file name without extension, unique within export.dir */
|
|
57
|
+
baseName: string;
|
|
58
|
+
decryptNcm: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Plan {
|
|
62
|
+
playlists: PlaylistPlan[];
|
|
63
|
+
likes: LikePlan;
|
|
64
|
+
exports: ExportPlan[];
|
|
65
|
+
/** canonical keys needing human review */
|
|
66
|
+
reviewPending: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RemoteItem {
|
|
70
|
+
uri: string;
|
|
71
|
+
isLocal: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* A local entry that names one of our exports but with an identity the client will never resolve
|
|
74
|
+
* (different duration segment, tags from an earlier export). Removed with --prune so a correct paste can replace it.
|
|
75
|
+
*/
|
|
76
|
+
stale: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PlaylistPlanInput {
|
|
80
|
+
sourcePlaylistId: number;
|
|
81
|
+
sourceName: string;
|
|
82
|
+
targetName: string;
|
|
83
|
+
/** existing remote playlist (already verified to exist), or null */
|
|
84
|
+
spotify: { id: string; name: string } | null;
|
|
85
|
+
/** ordered, deduped by uri */
|
|
86
|
+
desired: DesiredItem[];
|
|
87
|
+
/** current remote order; local uris already canonicalized via `resolveRemoteLocalUri` */
|
|
88
|
+
remote: RemoteItem[];
|
|
89
|
+
/** uris this tool added to the remote playlist */
|
|
90
|
+
managed: Set<string>;
|
|
91
|
+
pruneEnabled: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Exported titles once carried this marker; remote entries created from them are stale. */
|
|
95
|
+
const LEGACY_TITLE_SUFFIX = / \(local\)$/;
|
|
96
|
+
|
|
97
|
+
export interface ResolvedRemoteLocal {
|
|
98
|
+
uri: string;
|
|
99
|
+
stale: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Map a remote local-file uri onto our export identities. Same artist/album/title/duration as an export
|
|
104
|
+
* → the export's `local_uri`. Same artist/album/title but a different identity (wrong or missing
|
|
105
|
+
* duration segment, legacy title suffix) → stale. Anything else is left untouched (a foreign local file).
|
|
106
|
+
*/
|
|
107
|
+
export function resolveRemoteLocalUri(remoteUri: string, exports: readonly LocalExportRow[]): ResolvedRemoteLocal {
|
|
108
|
+
const parts = parseLocalUri(remoteUri);
|
|
109
|
+
if (!parts) return { uri: remoteUri, stale: false };
|
|
110
|
+
const fold = (s: string) => s.replace(LEGACY_TITLE_SUFFIX, "").trim().toLowerCase();
|
|
111
|
+
for (const e of exports) {
|
|
112
|
+
const p = parseLocalUri(e.localUri);
|
|
113
|
+
if (!p) continue;
|
|
114
|
+
if (fold(p.artist) !== fold(parts.artist) || fold(p.album) !== fold(parts.album) || fold(p.title) !== fold(parts.title)) continue;
|
|
115
|
+
const exact = parts.durationSec === p.durationSec && parts.title === p.title;
|
|
116
|
+
// stale entries keep the API's exact uri: removal of local items needs it verbatim alongside positions
|
|
117
|
+
return exact ? { uri: e.localUri, stale: false } : { uri: remoteUri, stale: true };
|
|
118
|
+
}
|
|
119
|
+
return { uri: buildLocalUri(parts), stale: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function computePlaylistPlan(input: PlaylistPlanInput): PlaylistPlan {
|
|
123
|
+
const { desired, remote, managed, pruneEnabled } = input;
|
|
124
|
+
const desiredSet = new Set(desired.map((d) => d.uri));
|
|
125
|
+
|
|
126
|
+
if (input.spotify === null) {
|
|
127
|
+
return {
|
|
128
|
+
sourcePlaylistId: input.sourcePlaylistId,
|
|
129
|
+
sourceName: input.sourceName,
|
|
130
|
+
spotifyId: null,
|
|
131
|
+
create: { name: input.targetName },
|
|
132
|
+
rename: null,
|
|
133
|
+
adds: desired.filter((d) => d.kind === "spotify").map((d) => d.uri),
|
|
134
|
+
awaiting: desired.filter((d) => d.kind === "local"),
|
|
135
|
+
prune: [],
|
|
136
|
+
foreign: [],
|
|
137
|
+
moves: [],
|
|
138
|
+
targetOrder: desired.filter((d) => d.kind === "spotify").map((d) => d.uri),
|
|
139
|
+
replaceAllowed: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const remoteSet = new Set(remote.map((r) => r.uri));
|
|
144
|
+
const adds = desired.filter((d) => d.kind === "spotify" && !remoteSet.has(d.uri)).map((d) => d.uri);
|
|
145
|
+
const awaiting = desired.filter((d) => d.kind === "local" && !remoteSet.has(d.uri));
|
|
146
|
+
|
|
147
|
+
const prune: PlaylistPlan["prune"] = [];
|
|
148
|
+
const pruneByUri = new Map<string, number[]>();
|
|
149
|
+
const foreign: string[] = [];
|
|
150
|
+
remote.forEach((r, i) => {
|
|
151
|
+
if (desiredSet.has(r.uri)) return;
|
|
152
|
+
if (managed.has(r.uri) || r.stale) {
|
|
153
|
+
let positions = pruneByUri.get(r.uri);
|
|
154
|
+
if (!positions) {
|
|
155
|
+
positions = [];
|
|
156
|
+
pruneByUri.set(r.uri, positions);
|
|
157
|
+
prune.push({ uri: r.uri, positions });
|
|
158
|
+
}
|
|
159
|
+
positions.push(i);
|
|
160
|
+
} else if (!foreign.includes(r.uri)) {
|
|
161
|
+
foreign.push(r.uri);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Predict the order after adds (appended) and prune (when enabled), tokenizing duplicates so
|
|
166
|
+
// the reorder planner sees a permutation.
|
|
167
|
+
const pruneSet = pruneEnabled ? new Set(pruneByUri.keys()) : new Set<string>();
|
|
168
|
+
const occurrence = new Map<string, number>();
|
|
169
|
+
const token = (uri: string) => {
|
|
170
|
+
const n = occurrence.get(uri) ?? 0;
|
|
171
|
+
occurrence.set(uri, n + 1);
|
|
172
|
+
return `${uri}#${n}`;
|
|
173
|
+
};
|
|
174
|
+
const current: string[] = [];
|
|
175
|
+
for (const r of remote) if (!pruneSet.has(r.uri)) current.push(token(r.uri));
|
|
176
|
+
for (const uri of adds) current.push(token(uri));
|
|
177
|
+
|
|
178
|
+
const currentSet = new Set(current);
|
|
179
|
+
const target: string[] = [];
|
|
180
|
+
for (const d of desired) {
|
|
181
|
+
const t = `${d.uri}#0`;
|
|
182
|
+
if (currentSet.has(t)) target.push(t);
|
|
183
|
+
}
|
|
184
|
+
const targetSet = new Set(target);
|
|
185
|
+
for (const t of current) if (!targetSet.has(t)) target.push(t);
|
|
186
|
+
|
|
187
|
+
const moves = planMoves(current, target);
|
|
188
|
+
const targetOrder = target.map((t) => t.slice(0, t.lastIndexOf("#")));
|
|
189
|
+
const anyLocal = remote.some((r) => r.isLocal) || targetOrder.some((u) => u.startsWith("spotify:local:"));
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
sourcePlaylistId: input.sourcePlaylistId,
|
|
193
|
+
sourceName: input.sourceName,
|
|
194
|
+
spotifyId: input.spotify.id,
|
|
195
|
+
create: null,
|
|
196
|
+
rename: input.spotify.name === input.targetName ? null : { from: input.spotify.name, to: input.targetName },
|
|
197
|
+
adds,
|
|
198
|
+
awaiting,
|
|
199
|
+
prune,
|
|
200
|
+
foreign,
|
|
201
|
+
moves,
|
|
202
|
+
targetOrder,
|
|
203
|
+
replaceAllowed: !anyLocal,
|
|
204
|
+
};
|
|
205
|
+
}
|