@audiofool/moondog 0.1.0
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/README.md +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
copyFile,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
open,
|
|
9
|
+
readdir,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
readAppleMusicSourceStatus,
|
|
18
|
+
resolveAppleMusicImportsRoot,
|
|
19
|
+
} from "../core/apple-library-source-status.mjs";
|
|
20
|
+
import {
|
|
21
|
+
resolveAppleMusicSubjectId,
|
|
22
|
+
resolveAppleMusicProjectionPath,
|
|
23
|
+
} from "../core/apple-projection-domain-services.mjs";
|
|
24
|
+
import { resolveSpotifyResolutionCachePath } from "../integrations/spotify/resolution-cache.mjs";
|
|
25
|
+
import { resolveListeningHistoryPath } from "./listening-history-store.mjs";
|
|
26
|
+
|
|
27
|
+
export const LOCAL_MUSIC_DATA_EXPORT_VERSION = "moondog-local-music-export/1";
|
|
28
|
+
export const LOCAL_MUSIC_DATA_RESET_VERSION = "moondog-local-music-reset/1";
|
|
29
|
+
export const LOCAL_MUSIC_DATA_SCOPES = Object.freeze([
|
|
30
|
+
"listening",
|
|
31
|
+
"apple",
|
|
32
|
+
"profile",
|
|
33
|
+
]);
|
|
34
|
+
export const LOCAL_MUSIC_DATA_LIMITS = Object.freeze({
|
|
35
|
+
maximum_files: 100_000,
|
|
36
|
+
maximum_bytes: 5 * 1024 * 1024 * 1024,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const excludedState = Object.freeze([
|
|
40
|
+
"lyric_library",
|
|
41
|
+
"conversation_memory",
|
|
42
|
+
"model_settings",
|
|
43
|
+
"oauth_credentials",
|
|
44
|
+
"raw_provider_exports",
|
|
45
|
+
"spotify_client_configuration",
|
|
46
|
+
]);
|
|
47
|
+
const sqliteSidecarSuffixes = ["-journal", "-shm", "-wal"];
|
|
48
|
+
|
|
49
|
+
let databaseConstructorPromise;
|
|
50
|
+
|
|
51
|
+
function databaseConstructor() {
|
|
52
|
+
databaseConstructorPromise ??= import("node:sqlite").then(
|
|
53
|
+
({ DatabaseSync }) => DatabaseSync,
|
|
54
|
+
);
|
|
55
|
+
return databaseConstructorPromise;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fail(code, message, options) {
|
|
59
|
+
const error = new Error(message, options);
|
|
60
|
+
error.name = "MoondogLocalMusicDataError";
|
|
61
|
+
error.code = code;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requireScope(scope) {
|
|
66
|
+
if (!LOCAL_MUSIC_DATA_SCOPES.includes(scope)) {
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
`Local music data scope must be one of: ${LOCAL_MUSIC_DATA_SCOPES.join(", ")}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return scope;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function optionalMetadata(filePath) {
|
|
75
|
+
try {
|
|
76
|
+
return await lstat(filePath);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error?.code === "ENOENT") return null;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function privatePermissions(metadata) {
|
|
84
|
+
return process.platform === "win32" || (metadata.mode & 0o077) === 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function safeTimestamp(value) {
|
|
88
|
+
return new Date(value).toISOString().replaceAll(/[:.]/gu, "-");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function compareFingerprintEntries(left, right) {
|
|
92
|
+
if (left.path < right.path) return -1;
|
|
93
|
+
if (left.path > right.path) return 1;
|
|
94
|
+
if (left.kind < right.kind) return -1;
|
|
95
|
+
if (left.kind > right.kind) return 1;
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function contentFingerprint(entries) {
|
|
100
|
+
const digest = createHash("sha256");
|
|
101
|
+
for (const entry of [...entries].sort(compareFingerprintEntries)) {
|
|
102
|
+
digest.update(JSON.stringify(entry));
|
|
103
|
+
digest.update("\0");
|
|
104
|
+
}
|
|
105
|
+
return digest.digest("hex");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function componentDefinitions(paths, scope) {
|
|
109
|
+
const listening = [
|
|
110
|
+
{
|
|
111
|
+
id: "listening_history",
|
|
112
|
+
kind: "sqlite",
|
|
113
|
+
path: paths.listeningHistoryPath,
|
|
114
|
+
exportPath: "listening/listening-history.sqlite",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "spotify_resolution_cache",
|
|
118
|
+
kind: "sqlite",
|
|
119
|
+
path: paths.spotifyResolutionCachePath,
|
|
120
|
+
exportPath: "listening/spotify-resolution-cache.sqlite",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "tasteprints",
|
|
124
|
+
kind: "directory",
|
|
125
|
+
path: paths.tasteprintsPath,
|
|
126
|
+
exportPath: "listening/tasteprints",
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
const apple = [
|
|
130
|
+
{
|
|
131
|
+
id: "apple_imports",
|
|
132
|
+
kind: "directory",
|
|
133
|
+
path: paths.appleImportsRoot,
|
|
134
|
+
exportPath: "apple/imports",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "apple_projection",
|
|
138
|
+
kind: "sqlite",
|
|
139
|
+
path: paths.appleProjectionPath,
|
|
140
|
+
exportPath: "apple/projection.sqlite",
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
if (scope === "listening") return listening;
|
|
144
|
+
if (scope === "apple") return apple;
|
|
145
|
+
return [...listening, ...apple];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function resolveLocalMusicDataPaths({
|
|
149
|
+
environment = process.env,
|
|
150
|
+
appleImportsRoot = resolveAppleMusicImportsRoot(environment),
|
|
151
|
+
appleProjectionPath = resolveAppleMusicProjectionPath(environment),
|
|
152
|
+
} = {}) {
|
|
153
|
+
const listeningHistoryPath = resolveListeningHistoryPath(environment);
|
|
154
|
+
const spotifyResolutionCachePath = resolveSpotifyResolutionCachePath(environment);
|
|
155
|
+
for (const [label, value] of Object.entries({
|
|
156
|
+
listeningHistoryPath,
|
|
157
|
+
spotifyResolutionCachePath,
|
|
158
|
+
appleImportsRoot,
|
|
159
|
+
appleProjectionPath,
|
|
160
|
+
})) {
|
|
161
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) {
|
|
162
|
+
throw new TypeError(`${label} must be an absolute path`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
listeningHistoryPath: path.resolve(listeningHistoryPath),
|
|
167
|
+
spotifyResolutionCachePath: path.resolve(spotifyResolutionCachePath),
|
|
168
|
+
tasteprintsPath: path.join(
|
|
169
|
+
path.dirname(path.resolve(listeningHistoryPath)),
|
|
170
|
+
"tasteprints",
|
|
171
|
+
),
|
|
172
|
+
appleImportsRoot: path.resolve(appleImportsRoot),
|
|
173
|
+
appleProjectionPath: path.resolve(appleProjectionPath),
|
|
174
|
+
stateRoot: path.dirname(path.resolve(listeningHistoryPath)),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function inspectDirectory(root) {
|
|
179
|
+
const pending = [root];
|
|
180
|
+
const confirmationEntries = [];
|
|
181
|
+
let files = 0;
|
|
182
|
+
let bytes = 0;
|
|
183
|
+
let latestModifiedMs = 0;
|
|
184
|
+
let permissionsPrivate = true;
|
|
185
|
+
while (pending.length > 0) {
|
|
186
|
+
const directory = pending.pop();
|
|
187
|
+
const directoryMetadata = await lstat(directory);
|
|
188
|
+
if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) {
|
|
189
|
+
fail("local_music_data_unsafe", "Local music data contains an unsafe directory.");
|
|
190
|
+
}
|
|
191
|
+
permissionsPrivate &&= privatePermissions(directoryMetadata);
|
|
192
|
+
latestModifiedMs = Math.max(latestModifiedMs, directoryMetadata.mtimeMs);
|
|
193
|
+
confirmationEntries.push({
|
|
194
|
+
kind: "directory",
|
|
195
|
+
path: path.relative(root, directory).split(path.sep).join("/") || ".",
|
|
196
|
+
mode: directoryMetadata.mode & 0o777,
|
|
197
|
+
});
|
|
198
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
const entryPath = path.join(directory, entry.name);
|
|
201
|
+
const metadata = await lstat(entryPath);
|
|
202
|
+
if (metadata.isSymbolicLink()) {
|
|
203
|
+
fail("local_music_data_unsafe", "Local music data contains a symbolic link.");
|
|
204
|
+
}
|
|
205
|
+
if (metadata.isDirectory()) {
|
|
206
|
+
pending.push(entryPath);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!metadata.isFile()) {
|
|
210
|
+
fail("local_music_data_unsafe", "Local music data contains an unsupported entry.");
|
|
211
|
+
}
|
|
212
|
+
files += 1;
|
|
213
|
+
bytes += metadata.size;
|
|
214
|
+
permissionsPrivate &&= privatePermissions(metadata);
|
|
215
|
+
latestModifiedMs = Math.max(latestModifiedMs, metadata.mtimeMs);
|
|
216
|
+
confirmationEntries.push({
|
|
217
|
+
kind: "file",
|
|
218
|
+
path: path.relative(root, entryPath).split(path.sep).join("/"),
|
|
219
|
+
mode: metadata.mode & 0o777,
|
|
220
|
+
bytes: metadata.size,
|
|
221
|
+
sha256: await digestFile(entryPath),
|
|
222
|
+
});
|
|
223
|
+
if (
|
|
224
|
+
files > LOCAL_MUSIC_DATA_LIMITS.maximum_files ||
|
|
225
|
+
bytes > LOCAL_MUSIC_DATA_LIMITS.maximum_bytes
|
|
226
|
+
) {
|
|
227
|
+
fail("local_music_data_too_large", "Local music data exceeds the export limits.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
files,
|
|
233
|
+
bytes,
|
|
234
|
+
latestModifiedMs,
|
|
235
|
+
permissionsPrivate,
|
|
236
|
+
confirmation: {
|
|
237
|
+
files,
|
|
238
|
+
bytes,
|
|
239
|
+
modified_at: new Date(latestModifiedMs).toISOString(),
|
|
240
|
+
sha256: contentFingerprint(confirmationEntries),
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function inspectSqliteFamily(filePath) {
|
|
246
|
+
const metadata = await optionalMetadata(filePath);
|
|
247
|
+
const sidecars = [];
|
|
248
|
+
for (const suffix of sqliteSidecarSuffixes) {
|
|
249
|
+
const sidecarPath = `${filePath}${suffix}`;
|
|
250
|
+
const sidecarMetadata = await optionalMetadata(sidecarPath);
|
|
251
|
+
if (sidecarMetadata) sidecars.push({ path: sidecarPath, metadata: sidecarMetadata });
|
|
252
|
+
}
|
|
253
|
+
if (!metadata) {
|
|
254
|
+
if (sidecars.length > 0) {
|
|
255
|
+
fail(
|
|
256
|
+
"local_music_data_unsafe",
|
|
257
|
+
"Local music data contains an orphaned SQLite sidecar.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
263
|
+
fail("local_music_data_unsafe", "Local music data contains an unsafe SQLite file.");
|
|
264
|
+
}
|
|
265
|
+
let bytes = metadata.size;
|
|
266
|
+
let latestModifiedMs = metadata.mtimeMs;
|
|
267
|
+
let permissionsPrivate = privatePermissions(metadata);
|
|
268
|
+
let confirmationFiles = 1;
|
|
269
|
+
let confirmationBytes = metadata.size;
|
|
270
|
+
let confirmationModifiedMs = metadata.mtimeMs;
|
|
271
|
+
const confirmationEntries = [{
|
|
272
|
+
kind: "file",
|
|
273
|
+
path: "database",
|
|
274
|
+
mode: metadata.mode & 0o777,
|
|
275
|
+
bytes: metadata.size,
|
|
276
|
+
sha256: await digestFile(filePath),
|
|
277
|
+
}];
|
|
278
|
+
for (const sidecar of sidecars) {
|
|
279
|
+
if (!sidecar.metadata.isFile() || sidecar.metadata.isSymbolicLink()) {
|
|
280
|
+
fail("local_music_data_unsafe", "Local music data contains an unsafe SQLite sidecar.");
|
|
281
|
+
}
|
|
282
|
+
bytes += sidecar.metadata.size;
|
|
283
|
+
latestModifiedMs = Math.max(latestModifiedMs, sidecar.metadata.mtimeMs);
|
|
284
|
+
permissionsPrivate &&= privatePermissions(sidecar.metadata);
|
|
285
|
+
if (!sidecar.path.endsWith("-shm") && sidecar.metadata.size > 0) {
|
|
286
|
+
confirmationFiles += 1;
|
|
287
|
+
confirmationBytes += sidecar.metadata.size;
|
|
288
|
+
confirmationModifiedMs = Math.max(
|
|
289
|
+
confirmationModifiedMs,
|
|
290
|
+
sidecar.metadata.mtimeMs,
|
|
291
|
+
);
|
|
292
|
+
confirmationEntries.push({
|
|
293
|
+
kind: "file",
|
|
294
|
+
path: path.basename(sidecar.path).slice(path.basename(filePath).length),
|
|
295
|
+
mode: sidecar.metadata.mode & 0o777,
|
|
296
|
+
bytes: sidecar.metadata.size,
|
|
297
|
+
sha256: await digestFile(sidecar.path),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
files: 1 + sidecars.length,
|
|
303
|
+
bytes,
|
|
304
|
+
latestModifiedMs,
|
|
305
|
+
permissionsPrivate,
|
|
306
|
+
sidecars: sidecars.map((sidecar) => sidecar.path),
|
|
307
|
+
confirmation: {
|
|
308
|
+
files: confirmationFiles,
|
|
309
|
+
bytes: confirmationBytes,
|
|
310
|
+
modified_at: new Date(confirmationModifiedMs).toISOString(),
|
|
311
|
+
sha256: contentFingerprint(confirmationEntries),
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function inspectComponent(definition) {
|
|
317
|
+
const metadata = await optionalMetadata(definition.path);
|
|
318
|
+
if (!metadata) {
|
|
319
|
+
if (definition.kind === "sqlite") await inspectSqliteFamily(definition.path);
|
|
320
|
+
const missingComponent = {
|
|
321
|
+
id: definition.id,
|
|
322
|
+
kind: definition.kind,
|
|
323
|
+
path: definition.path,
|
|
324
|
+
state: "missing",
|
|
325
|
+
files: 0,
|
|
326
|
+
bytes: 0,
|
|
327
|
+
modified_at: null,
|
|
328
|
+
private_permissions: null,
|
|
329
|
+
};
|
|
330
|
+
Object.defineProperty(missingComponent, "confirmation", {
|
|
331
|
+
value: { files: 0, bytes: 0, modified_at: null, sha256: null },
|
|
332
|
+
});
|
|
333
|
+
return missingComponent;
|
|
334
|
+
}
|
|
335
|
+
const details = definition.kind === "directory"
|
|
336
|
+
? await inspectDirectory(definition.path)
|
|
337
|
+
: await inspectSqliteFamily(definition.path);
|
|
338
|
+
const component = {
|
|
339
|
+
id: definition.id,
|
|
340
|
+
kind: definition.kind,
|
|
341
|
+
path: definition.path,
|
|
342
|
+
state: "ready",
|
|
343
|
+
files: details.files,
|
|
344
|
+
bytes: details.bytes,
|
|
345
|
+
modified_at: new Date(details.latestModifiedMs).toISOString(),
|
|
346
|
+
private_permissions: details.permissionsPrivate,
|
|
347
|
+
};
|
|
348
|
+
Object.defineProperty(component, "confirmation", {
|
|
349
|
+
value: details.confirmation ?? {
|
|
350
|
+
files: details.files,
|
|
351
|
+
bytes: details.bytes,
|
|
352
|
+
modified_at: new Date(details.latestModifiedMs).toISOString(),
|
|
353
|
+
sha256: null,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
return component;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function sqliteReadTarget(filePath) {
|
|
360
|
+
for (const suffix of ["-journal", "-wal"]) {
|
|
361
|
+
const metadata = await optionalMetadata(`${filePath}${suffix}`);
|
|
362
|
+
if (metadata?.size > 0) return filePath;
|
|
363
|
+
}
|
|
364
|
+
const target = pathToFileURL(filePath);
|
|
365
|
+
target.searchParams.set("immutable", "1");
|
|
366
|
+
return target;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function listeningSubjectId(databasePath) {
|
|
370
|
+
if (!(await optionalMetadata(databasePath))) return null;
|
|
371
|
+
const DatabaseSync = await databaseConstructor();
|
|
372
|
+
let database;
|
|
373
|
+
try {
|
|
374
|
+
database = new DatabaseSync(await sqliteReadTarget(databasePath), {
|
|
375
|
+
readOnly: true,
|
|
376
|
+
});
|
|
377
|
+
const table = database
|
|
378
|
+
.prepare(
|
|
379
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'local_music_subject'",
|
|
380
|
+
)
|
|
381
|
+
.get();
|
|
382
|
+
if (!table) fail("local_music_data_invalid", "Listening history has no subject registry.");
|
|
383
|
+
const subjectId = database
|
|
384
|
+
.prepare("SELECT subject_id FROM local_music_subject WHERE singleton = 1")
|
|
385
|
+
.get()?.subject_id;
|
|
386
|
+
return typeof subjectId === "string" ? subjectId.toLowerCase() : null;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (error?.code?.startsWith?.("local_music_data_")) throw error;
|
|
389
|
+
fail("local_music_data_invalid", "Listening history could not be inspected.", {
|
|
390
|
+
cause: error,
|
|
391
|
+
});
|
|
392
|
+
} finally {
|
|
393
|
+
database?.close();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function appleSubjectId(importsRoot) {
|
|
398
|
+
if (!(await optionalMetadata(importsRoot))) return null;
|
|
399
|
+
try {
|
|
400
|
+
const status = await readAppleMusicSourceStatus(importsRoot);
|
|
401
|
+
if (status.state === "missing") return null;
|
|
402
|
+
if (status.state === "invalid") {
|
|
403
|
+
fail("local_music_data_invalid", "Apple imports could not be inspected.");
|
|
404
|
+
}
|
|
405
|
+
return await resolveAppleMusicSubjectId({ importsRoot });
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (error?.code?.startsWith?.("local_music_data_")) throw error;
|
|
408
|
+
fail("local_music_data_invalid", "Apple imports could not be inspected.", {
|
|
409
|
+
cause: error,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function confirmationToken(value) {
|
|
415
|
+
const digest = createHash("sha256")
|
|
416
|
+
.update(JSON.stringify({
|
|
417
|
+
scope: value.scope,
|
|
418
|
+
subject_ids: value.subject_ids,
|
|
419
|
+
components: value.components.map((component) => ({
|
|
420
|
+
id: component.id,
|
|
421
|
+
path: component.path,
|
|
422
|
+
state: component.state,
|
|
423
|
+
files: component.confirmation.files,
|
|
424
|
+
bytes: component.confirmation.bytes,
|
|
425
|
+
modified_at: component.confirmation.modified_at,
|
|
426
|
+
sha256: component.confirmation.sha256,
|
|
427
|
+
})),
|
|
428
|
+
}))
|
|
429
|
+
.digest("hex")
|
|
430
|
+
.slice(0, 16);
|
|
431
|
+
return `reset-${value.scope}-${digest}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function inspectLocalMusicData({
|
|
435
|
+
scope,
|
|
436
|
+
paths = resolveLocalMusicDataPaths(),
|
|
437
|
+
} = {}) {
|
|
438
|
+
const normalizedScope = requireScope(scope);
|
|
439
|
+
const definitions = componentDefinitions(paths, normalizedScope);
|
|
440
|
+
const components = [];
|
|
441
|
+
for (const definition of definitions) {
|
|
442
|
+
components.push(await inspectComponent(definition));
|
|
443
|
+
}
|
|
444
|
+
const subjectIds = [];
|
|
445
|
+
if (new Set(["listening", "profile"]).has(normalizedScope)) {
|
|
446
|
+
const subjectId = await listeningSubjectId(paths.listeningHistoryPath);
|
|
447
|
+
if (subjectId) subjectIds.push(subjectId);
|
|
448
|
+
}
|
|
449
|
+
if (new Set(["apple", "profile"]).has(normalizedScope)) {
|
|
450
|
+
const subjectId = await appleSubjectId(paths.appleImportsRoot);
|
|
451
|
+
if (subjectId) subjectIds.push(subjectId);
|
|
452
|
+
}
|
|
453
|
+
const uniqueSubjectIds = [...new Set(subjectIds)].sort();
|
|
454
|
+
const readyComponents = components.filter((component) => component.state === "ready");
|
|
455
|
+
const totals = {
|
|
456
|
+
components: readyComponents.length,
|
|
457
|
+
files: readyComponents.reduce((sum, component) => sum + component.files, 0),
|
|
458
|
+
bytes: readyComponents.reduce((sum, component) => sum + component.bytes, 0),
|
|
459
|
+
};
|
|
460
|
+
if (
|
|
461
|
+
totals.files > LOCAL_MUSIC_DATA_LIMITS.maximum_files ||
|
|
462
|
+
totals.bytes > LOCAL_MUSIC_DATA_LIMITS.maximum_bytes
|
|
463
|
+
) {
|
|
464
|
+
fail("local_music_data_too_large", "Local music data exceeds the export limits.");
|
|
465
|
+
}
|
|
466
|
+
const result = {
|
|
467
|
+
inspection_version: "moondog-local-music-inspection/2",
|
|
468
|
+
scope: normalizedScope,
|
|
469
|
+
state:
|
|
470
|
+
uniqueSubjectIds.length > 1
|
|
471
|
+
? "subject_conflict"
|
|
472
|
+
: readyComponents.length > 0
|
|
473
|
+
? "ready"
|
|
474
|
+
: "empty",
|
|
475
|
+
subject_id: uniqueSubjectIds.length === 1 ? uniqueSubjectIds[0] : null,
|
|
476
|
+
subject_ids: uniqueSubjectIds,
|
|
477
|
+
components,
|
|
478
|
+
totals,
|
|
479
|
+
excluded: [...excludedState],
|
|
480
|
+
writes: "none",
|
|
481
|
+
};
|
|
482
|
+
return { ...result, reset_confirmation: confirmationToken(result) };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function createSqliteSnapshot(sourcePath, destinationPath) {
|
|
486
|
+
await mkdir(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
|
|
487
|
+
const DatabaseSync = await databaseConstructor();
|
|
488
|
+
const database = new DatabaseSync(await sqliteReadTarget(sourcePath), {
|
|
489
|
+
readOnly: true,
|
|
490
|
+
});
|
|
491
|
+
try {
|
|
492
|
+
const escaped = destinationPath.replaceAll("'", "''");
|
|
493
|
+
database.exec(`VACUUM INTO '${escaped}'`);
|
|
494
|
+
} finally {
|
|
495
|
+
database.close();
|
|
496
|
+
}
|
|
497
|
+
if (process.platform !== "win32") await chmod(destinationPath, 0o600);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function copyPrivateDirectory(sourceRoot, destinationRoot) {
|
|
501
|
+
await mkdir(destinationRoot, { mode: 0o700 });
|
|
502
|
+
const entries = await readdir(sourceRoot, { withFileTypes: true });
|
|
503
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
504
|
+
const sourcePath = path.join(sourceRoot, entry.name);
|
|
505
|
+
const destinationPath = path.join(destinationRoot, entry.name);
|
|
506
|
+
const metadata = await lstat(sourcePath);
|
|
507
|
+
if (metadata.isSymbolicLink()) {
|
|
508
|
+
fail("local_music_data_unsafe", "Local music data contains a symbolic link.");
|
|
509
|
+
}
|
|
510
|
+
if (metadata.isDirectory()) {
|
|
511
|
+
await copyPrivateDirectory(sourcePath, destinationPath);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (!metadata.isFile()) {
|
|
515
|
+
fail("local_music_data_unsafe", "Local music data contains an unsupported entry.");
|
|
516
|
+
}
|
|
517
|
+
await copyFile(sourcePath, destinationPath);
|
|
518
|
+
if (process.platform !== "win32") await chmod(destinationPath, 0o600);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function digestFile(filePath) {
|
|
523
|
+
const digest = createHash("sha256");
|
|
524
|
+
for await (const chunk of createReadStream(filePath)) digest.update(chunk);
|
|
525
|
+
return digest.digest("hex");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function exportedFiles(root, directory = root) {
|
|
529
|
+
const files = [];
|
|
530
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
531
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
532
|
+
const entryPath = path.join(directory, entry.name);
|
|
533
|
+
const metadata = await lstat(entryPath);
|
|
534
|
+
if (metadata.isSymbolicLink()) {
|
|
535
|
+
fail("local_music_data_unsafe", "The local music export contains a symbolic link.");
|
|
536
|
+
}
|
|
537
|
+
if (metadata.isDirectory()) {
|
|
538
|
+
files.push(...await exportedFiles(root, entryPath));
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (!metadata.isFile()) {
|
|
542
|
+
fail("local_music_data_unsafe", "The local music export contains an unsupported entry.");
|
|
543
|
+
}
|
|
544
|
+
files.push({
|
|
545
|
+
path: path.relative(root, entryPath).split(path.sep).join("/"),
|
|
546
|
+
bytes: metadata.size,
|
|
547
|
+
sha256: await digestFile(entryPath),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return files;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function writePrivateJson(filePath, value) {
|
|
554
|
+
let handle;
|
|
555
|
+
try {
|
|
556
|
+
handle = await open(filePath, "wx", 0o600);
|
|
557
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
558
|
+
await handle.sync();
|
|
559
|
+
} finally {
|
|
560
|
+
await handle?.close();
|
|
561
|
+
}
|
|
562
|
+
if (process.platform !== "win32") await chmod(filePath, 0o600);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function assertOutputAvailable(outputPath) {
|
|
566
|
+
const parent = path.dirname(outputPath);
|
|
567
|
+
const parentMetadata = await optionalMetadata(parent);
|
|
568
|
+
if (!parentMetadata || !parentMetadata.isDirectory() || parentMetadata.isSymbolicLink()) {
|
|
569
|
+
fail(
|
|
570
|
+
"local_music_export_parent_invalid",
|
|
571
|
+
"The export parent must already exist as a real directory.",
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (await optionalMetadata(outputPath)) {
|
|
575
|
+
fail("local_music_export_exists", "The export destination already exists.");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export async function exportLocalMusicData({
|
|
580
|
+
scope,
|
|
581
|
+
outputPath,
|
|
582
|
+
paths = resolveLocalMusicDataPaths(),
|
|
583
|
+
now = () => new Date(),
|
|
584
|
+
} = {}) {
|
|
585
|
+
const normalizedScope = requireScope(scope);
|
|
586
|
+
if (typeof outputPath !== "string" || !outputPath.trim()) {
|
|
587
|
+
throw new TypeError("Local music export requires an output directory path");
|
|
588
|
+
}
|
|
589
|
+
const resolvedOutput = path.resolve(outputPath);
|
|
590
|
+
await assertOutputAvailable(resolvedOutput);
|
|
591
|
+
const inspection = await inspectLocalMusicData({ scope: normalizedScope, paths });
|
|
592
|
+
if (inspection.state === "subject_conflict") {
|
|
593
|
+
fail("local_music_subject_conflict", "Local music sources use different subjects.");
|
|
594
|
+
}
|
|
595
|
+
if (inspection.state === "empty") {
|
|
596
|
+
fail("local_music_data_empty", "The selected local music data scope is empty.");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const partialPath = `${resolvedOutput}.partial-${randomBytes(6).toString("hex")}`;
|
|
600
|
+
await mkdir(partialPath, { mode: 0o700 });
|
|
601
|
+
try {
|
|
602
|
+
const components = componentDefinitions(paths, normalizedScope);
|
|
603
|
+
for (const component of components) {
|
|
604
|
+
if (!(await optionalMetadata(component.path))) continue;
|
|
605
|
+
const destinationPath = path.join(
|
|
606
|
+
partialPath,
|
|
607
|
+
...component.exportPath.split("/"),
|
|
608
|
+
);
|
|
609
|
+
if (component.kind === "sqlite") {
|
|
610
|
+
await createSqliteSnapshot(component.path, destinationPath);
|
|
611
|
+
} else {
|
|
612
|
+
await mkdir(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
|
|
613
|
+
await copyPrivateDirectory(component.path, destinationPath);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const finalInspection = await inspectLocalMusicData({
|
|
617
|
+
scope: normalizedScope,
|
|
618
|
+
paths,
|
|
619
|
+
});
|
|
620
|
+
if (finalInspection.reset_confirmation !== inspection.reset_confirmation) {
|
|
621
|
+
fail(
|
|
622
|
+
"local_music_export_source_changed",
|
|
623
|
+
"Local music data changed during export. No export was created.",
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const files = await exportedFiles(partialPath);
|
|
627
|
+
const manifest = {
|
|
628
|
+
export_version: LOCAL_MUSIC_DATA_EXPORT_VERSION,
|
|
629
|
+
created_at: now().toISOString(),
|
|
630
|
+
scope: normalizedScope,
|
|
631
|
+
subject_id: inspection.subject_id,
|
|
632
|
+
source_confirmation: inspection.reset_confirmation,
|
|
633
|
+
private: true,
|
|
634
|
+
files,
|
|
635
|
+
totals: {
|
|
636
|
+
files: files.length,
|
|
637
|
+
bytes: files.reduce((sum, file) => sum + file.bytes, 0),
|
|
638
|
+
},
|
|
639
|
+
excluded: [...excludedState],
|
|
640
|
+
restore: "manual_review_required",
|
|
641
|
+
};
|
|
642
|
+
const manifestPath = path.join(partialPath, "manifest.json");
|
|
643
|
+
await writePrivateJson(manifestPath, manifest);
|
|
644
|
+
const manifestSha256 = await digestFile(manifestPath);
|
|
645
|
+
await rename(partialPath, resolvedOutput);
|
|
646
|
+
if (process.platform !== "win32") await chmod(resolvedOutput, 0o700);
|
|
647
|
+
return {
|
|
648
|
+
export_version: LOCAL_MUSIC_DATA_EXPORT_VERSION,
|
|
649
|
+
scope: normalizedScope,
|
|
650
|
+
subject_id: inspection.subject_id,
|
|
651
|
+
path: resolvedOutput,
|
|
652
|
+
manifest_sha256: manifestSha256,
|
|
653
|
+
files: files.length + 1,
|
|
654
|
+
bytes: manifest.totals.bytes + (await lstat(path.join(resolvedOutput, "manifest.json"))).size,
|
|
655
|
+
private: true,
|
|
656
|
+
source_changed: false,
|
|
657
|
+
excluded: [...excludedState],
|
|
658
|
+
};
|
|
659
|
+
} catch (error) {
|
|
660
|
+
await rm(partialPath, { recursive: true, force: true });
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function checkpointSqlite(filePath) {
|
|
666
|
+
if (!(await optionalMetadata(filePath))) return;
|
|
667
|
+
const DatabaseSync = await databaseConstructor();
|
|
668
|
+
let database;
|
|
669
|
+
try {
|
|
670
|
+
database = new DatabaseSync(filePath);
|
|
671
|
+
database.exec("PRAGMA busy_timeout = 1");
|
|
672
|
+
database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
673
|
+
} catch (error) {
|
|
674
|
+
fail(
|
|
675
|
+
"local_music_data_busy",
|
|
676
|
+
"Local music data is busy. Close Moondog Studio and other Moondog processes before reset.",
|
|
677
|
+
{ cause: error },
|
|
678
|
+
);
|
|
679
|
+
} finally {
|
|
680
|
+
database?.close();
|
|
681
|
+
}
|
|
682
|
+
if (await optionalMetadata(`${filePath}-shm`)) {
|
|
683
|
+
fail(
|
|
684
|
+
"local_music_data_busy",
|
|
685
|
+
"Local music data is still open in another process.",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function resetTargets(definitions) {
|
|
691
|
+
const targets = [];
|
|
692
|
+
for (const definition of definitions) {
|
|
693
|
+
if (definition.kind === "sqlite") {
|
|
694
|
+
await checkpointSqlite(definition.path);
|
|
695
|
+
for (const candidate of [
|
|
696
|
+
definition.path,
|
|
697
|
+
...sqliteSidecarSuffixes.map((suffix) => `${definition.path}${suffix}`),
|
|
698
|
+
]) {
|
|
699
|
+
const metadata = await optionalMetadata(candidate);
|
|
700
|
+
if (!metadata) continue;
|
|
701
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
702
|
+
fail("local_music_data_unsafe", "Local music data contains an unsafe SQLite entry.");
|
|
703
|
+
}
|
|
704
|
+
targets.push({ component: definition.id, path: candidate });
|
|
705
|
+
}
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const metadata = await optionalMetadata(definition.path);
|
|
709
|
+
if (!metadata) continue;
|
|
710
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
711
|
+
fail("local_music_data_unsafe", "Local music data contains an unsafe directory.");
|
|
712
|
+
}
|
|
713
|
+
targets.push({ component: definition.id, path: definition.path });
|
|
714
|
+
}
|
|
715
|
+
return targets;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export async function resetLocalMusicData({
|
|
719
|
+
scope,
|
|
720
|
+
confirmation,
|
|
721
|
+
paths = resolveLocalMusicDataPaths(),
|
|
722
|
+
now = () => new Date(),
|
|
723
|
+
} = {}) {
|
|
724
|
+
const normalizedScope = requireScope(scope);
|
|
725
|
+
if (typeof confirmation !== "string" || !confirmation.trim()) {
|
|
726
|
+
throw new TypeError("Local music reset requires the exact inspection confirmation token");
|
|
727
|
+
}
|
|
728
|
+
const inspection = await inspectLocalMusicData({ scope: normalizedScope, paths });
|
|
729
|
+
if (inspection.state === "subject_conflict") {
|
|
730
|
+
fail("local_music_subject_conflict", "Local music sources use different subjects.");
|
|
731
|
+
}
|
|
732
|
+
if (inspection.state === "empty") {
|
|
733
|
+
fail("local_music_data_empty", "The selected local music data scope is empty.");
|
|
734
|
+
}
|
|
735
|
+
if (confirmation !== inspection.reset_confirmation) {
|
|
736
|
+
fail(
|
|
737
|
+
"local_music_reset_confirmation_mismatch",
|
|
738
|
+
"The reset confirmation does not match the current selected state. Inspect again before retrying.",
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const definitions = componentDefinitions(paths, normalizedScope);
|
|
743
|
+
const targets = await resetTargets(definitions);
|
|
744
|
+
const resetAt = now();
|
|
745
|
+
const resetId = `${safeTimestamp(resetAt)}-${randomBytes(4).toString("hex")}`;
|
|
746
|
+
const moves = targets.map((target) => ({
|
|
747
|
+
...target,
|
|
748
|
+
archivePath: `${target.path}.moondog-reset-${resetId}`,
|
|
749
|
+
}));
|
|
750
|
+
for (const move of moves) {
|
|
751
|
+
if (await optionalMetadata(move.archivePath)) {
|
|
752
|
+
fail("local_music_reset_conflict", "A reset archive destination already exists.");
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const completed = [];
|
|
757
|
+
try {
|
|
758
|
+
for (const move of moves) {
|
|
759
|
+
await rename(move.path, move.archivePath);
|
|
760
|
+
completed.push(move);
|
|
761
|
+
}
|
|
762
|
+
const resetDirectory = path.join(paths.stateRoot, "reset-manifests");
|
|
763
|
+
await mkdir(resetDirectory, { recursive: true, mode: 0o700 });
|
|
764
|
+
if (process.platform !== "win32") await chmod(resetDirectory, 0o700);
|
|
765
|
+
const manifestPath = path.join(resetDirectory, `profile-reset-${resetId}.json`);
|
|
766
|
+
await writePrivateJson(manifestPath, {
|
|
767
|
+
reset_version: LOCAL_MUSIC_DATA_RESET_VERSION,
|
|
768
|
+
reset_id: resetId,
|
|
769
|
+
created_at: resetAt.toISOString(),
|
|
770
|
+
scope: normalizedScope,
|
|
771
|
+
subject_id: inspection.subject_id,
|
|
772
|
+
confirmation: inspection.reset_confirmation,
|
|
773
|
+
deletion: "none",
|
|
774
|
+
archives: completed.map((move) => ({
|
|
775
|
+
component: move.component,
|
|
776
|
+
original_path: move.path,
|
|
777
|
+
archive_path: move.archivePath,
|
|
778
|
+
})),
|
|
779
|
+
excluded: [...excludedState],
|
|
780
|
+
restore: "rename_archives_to_original_paths_after_review",
|
|
781
|
+
});
|
|
782
|
+
for (const move of completed) {
|
|
783
|
+
if (await optionalMetadata(move.path)) {
|
|
784
|
+
fail(
|
|
785
|
+
"local_music_data_busy",
|
|
786
|
+
"A local music data path was recreated during reset. The archived copy was preserved.",
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
reset_version: LOCAL_MUSIC_DATA_RESET_VERSION,
|
|
792
|
+
reset_id: resetId,
|
|
793
|
+
scope: normalizedScope,
|
|
794
|
+
subject_id: inspection.subject_id,
|
|
795
|
+
state: "archived",
|
|
796
|
+
deletion: "none",
|
|
797
|
+
manifest_path: manifestPath,
|
|
798
|
+
archives: completed.map((move) => ({
|
|
799
|
+
component: move.component,
|
|
800
|
+
path: move.archivePath,
|
|
801
|
+
})),
|
|
802
|
+
excluded: [...excludedState],
|
|
803
|
+
};
|
|
804
|
+
} catch (error) {
|
|
805
|
+
for (const move of completed.reverse()) {
|
|
806
|
+
if (!(await optionalMetadata(move.path))) {
|
|
807
|
+
await rename(move.archivePath, move.path).catch(() => {});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|