@coflyn/mori 0.0.0-stage → 1.0.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/CHANGELOG.md +21 -0
- package/LICENSE +674 -0
- package/README.md +146 -2
- package/bundle.js +11 -0
- package/engine.js +1201 -0
- package/index.js +1545 -0
- package/package.json +47 -4
- package/scrapers-version.json +7 -0
package/index.js
ADDED
|
@@ -0,0 +1,1545 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import {
|
|
9
|
+
detectPlatform,
|
|
10
|
+
PLATFORM_SERVERS,
|
|
11
|
+
scrapeUrl,
|
|
12
|
+
resolveUrl,
|
|
13
|
+
sanitizeFilename,
|
|
14
|
+
downloadMediaFile,
|
|
15
|
+
checkForOtaUpdate,
|
|
16
|
+
getActiveScraperVersion,
|
|
17
|
+
getSavedConfig,
|
|
18
|
+
saveConfig,
|
|
19
|
+
openDirectory,
|
|
20
|
+
extractUrls,
|
|
21
|
+
getHistory,
|
|
22
|
+
addToHistory,
|
|
23
|
+
clearHistory,
|
|
24
|
+
deleteHistoryItem,
|
|
25
|
+
readClipboard,
|
|
26
|
+
getNonCollidingPath,
|
|
27
|
+
resetMori,
|
|
28
|
+
getStats,
|
|
29
|
+
DEFAULT_DOWNLOAD_DIR,
|
|
30
|
+
CONFIG_PATH,
|
|
31
|
+
openFile,
|
|
32
|
+
pingNetwork,
|
|
33
|
+
compileImagesToPdf,
|
|
34
|
+
CHROME_UA,
|
|
35
|
+
} from "./engine.js";
|
|
36
|
+
|
|
37
|
+
const APP_VERSION = "1.0.0";
|
|
38
|
+
|
|
39
|
+
function isNewerVersion(latest, current) {
|
|
40
|
+
if (!latest || !current) return false;
|
|
41
|
+
const l = latest.replace(/^v/, "").split(".").map(Number);
|
|
42
|
+
const c = current.replace(/^v/, "").split(".").map(Number);
|
|
43
|
+
for (let i = 0; i < 3; i++) {
|
|
44
|
+
const lPart = l[i] || 0;
|
|
45
|
+
const cPart = c[i] || 0;
|
|
46
|
+
if (lPart > cPart) return true;
|
|
47
|
+
if (lPart < cPart) return false;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function checkCliUpdate() {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch("https://registry.npmjs.org/@coflyn/mori/latest", {
|
|
55
|
+
signal: AbortSignal.timeout(2000),
|
|
56
|
+
headers: { Accept: "application/json" },
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) return null;
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
if (data?.version && isNewerVersion(data.version, APP_VERSION)) {
|
|
61
|
+
return data.version;
|
|
62
|
+
}
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatRelativeTime(ms) {
|
|
68
|
+
if (!ms) return "";
|
|
69
|
+
const sec = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
|
70
|
+
if (sec < 60) return "just now";
|
|
71
|
+
const min = Math.floor(sec / 60);
|
|
72
|
+
if (min < 60) return `${min}m ago`;
|
|
73
|
+
const hr = Math.floor(min / 60);
|
|
74
|
+
if (hr < 24) return `${hr}h ago`;
|
|
75
|
+
const day = Math.floor(hr / 24);
|
|
76
|
+
return `${day}d ago`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatBytes(bytes) {
|
|
80
|
+
if (!bytes || bytes === 0) return "0 B";
|
|
81
|
+
const k = 1024;
|
|
82
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
83
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
84
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderProgressBar(percent, width = 22) {
|
|
88
|
+
const pVal = Math.max(0, Math.min(100, percent));
|
|
89
|
+
const filled = Math.round((pVal / 100) * width);
|
|
90
|
+
const empty = width - filled;
|
|
91
|
+
return pc.white("█".repeat(filled)) + pc.dim("░".repeat(empty));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function printBanner(coreVer) {
|
|
95
|
+
const brand =
|
|
96
|
+
pc.bold(pc.inverse(" MORI ")) +
|
|
97
|
+
" " +
|
|
98
|
+
pc.bold(`v${APP_VERSION}`) +
|
|
99
|
+
" " +
|
|
100
|
+
pc.dim(`(core v${coreVer})`);
|
|
101
|
+
const sub = pc.dim(
|
|
102
|
+
"Save anything, from anywhere. No watermarks. No tracking.",
|
|
103
|
+
);
|
|
104
|
+
p.intro(`${brand}\n ${sub}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function printHelp() {
|
|
108
|
+
console.log(`
|
|
109
|
+
${pc.bold(pc.inverse(" MORI CLI "))} ${pc.bold(`v${APP_VERSION}`)}
|
|
110
|
+
|
|
111
|
+
${pc.bold("USAGE:")}
|
|
112
|
+
${pc.white("mori")} ${pc.dim("[URL]")} ${pc.dim("[OPTIONS]")}
|
|
113
|
+
${pc.white("npx @coflyn/mori")} ${pc.dim("[URL]")} ${pc.dim("[OPTIONS]")}
|
|
114
|
+
|
|
115
|
+
${pc.bold("OPTIONS:")}
|
|
116
|
+
${pc.white("-o, --output <dir>")} Destination folder (default: ${pc.dim("~/Downloads/Mori")})
|
|
117
|
+
${pc.white("-s, --server <1|2>")} Choose scraper server (TikTok, IG, YouTube, X, Spotify)
|
|
118
|
+
${pc.white("-a, --audio")} Download audio-only stream (MP3/M4A)
|
|
119
|
+
${pc.white("-p, --pdf")} Compile photo album directly into a single PDF
|
|
120
|
+
${pc.white("-c, --clip")} Auto-fetch link from system clipboard
|
|
121
|
+
${pc.white("-y, --yes")} Download best/first available stream automatically
|
|
122
|
+
${pc.white("-i, --info")} Inspect and display media details only
|
|
123
|
+
${pc.white("-u, --update")} Check and pull latest OTA scraper patch
|
|
124
|
+
${pc.white("-h, --help")} Show this documentation
|
|
125
|
+
${pc.white("-v, --version")} Print version number
|
|
126
|
+
|
|
127
|
+
${pc.bold("SUPPORTED PLATFORMS (16):")}
|
|
128
|
+
${pc.dim("TikTok • Instagram • YouTube • Twitter/X • Spotify • Pinterest • Facebook")}
|
|
129
|
+
${pc.dim("Bilibili • Reddit • RedNote • Threads • Pixiv • Bandcamp • Apple Music • Douyin • TeraBox")}
|
|
130
|
+
|
|
131
|
+
${pc.bold("EXAMPLES:")}
|
|
132
|
+
${pc.dim("# Interactive")}
|
|
133
|
+
mori
|
|
134
|
+
|
|
135
|
+
# Download link directly from clipboard
|
|
136
|
+
mori --clip
|
|
137
|
+
|
|
138
|
+
# Download audio-only (MP3)
|
|
139
|
+
mori https://youtu.be/dQw4w9WgXcQ -a
|
|
140
|
+
|
|
141
|
+
# Direct link with Server 2
|
|
142
|
+
mori https://www.tiktok.com/@user/video/123456789 -s 2
|
|
143
|
+
|
|
144
|
+
# Check OTA scraper updates
|
|
145
|
+
mori --update
|
|
146
|
+
`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runConcurrentPool(items, concurrency, fn) {
|
|
150
|
+
const results = new Array(items.length);
|
|
151
|
+
let nextIndex = 0;
|
|
152
|
+
const workers = Array.from(
|
|
153
|
+
{ length: Math.min(concurrency, items.length) },
|
|
154
|
+
async () => {
|
|
155
|
+
while (nextIndex < items.length) {
|
|
156
|
+
const idx = nextIndex++;
|
|
157
|
+
results[idx] = await fn(items[idx], idx);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
await Promise.all(workers);
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function processDownloadItem({ item, meta, outputDir, index, total }) {
|
|
166
|
+
const ext =
|
|
167
|
+
item.type?.toLowerCase().includes("mp3") ||
|
|
168
|
+
item.type?.toLowerCase().includes("audio")
|
|
169
|
+
? "mp3"
|
|
170
|
+
: item.type?.toLowerCase().includes("photo") ||
|
|
171
|
+
item.type?.toLowerCase().includes("image")
|
|
172
|
+
? "jpg"
|
|
173
|
+
: "mp4";
|
|
174
|
+
|
|
175
|
+
let baseTitle = meta.title || "Media";
|
|
176
|
+
if (total > 1) {
|
|
177
|
+
baseTitle = `${baseTitle}_${index + 1}`;
|
|
178
|
+
}
|
|
179
|
+
const filename = sanitizeFilename(baseTitle, ext);
|
|
180
|
+
const destPath = getNonCollidingPath(outputDir, filename);
|
|
181
|
+
|
|
182
|
+
const spin = p.spinner();
|
|
183
|
+
spin.start(pc.dim(`Preparing ${pc.bold(filename)}...`));
|
|
184
|
+
|
|
185
|
+
let directUrl;
|
|
186
|
+
try {
|
|
187
|
+
directUrl = await resolveUrl(item.url, (msg) => {
|
|
188
|
+
spin.message(pc.dim(msg));
|
|
189
|
+
});
|
|
190
|
+
} catch (err) {
|
|
191
|
+
spin.stop(pc.bold(`✕ Resolve failed: ${err.message}`));
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
spin.stop(pc.dim(`Downloading ${pc.bold(filename)}...`));
|
|
196
|
+
|
|
197
|
+
let finalBytes = 0;
|
|
198
|
+
try {
|
|
199
|
+
await downloadMediaFile(directUrl, destPath, (prog) => {
|
|
200
|
+
finalBytes = prog.downloadedBytes;
|
|
201
|
+
const bar = renderProgressBar(prog.percent, 20);
|
|
202
|
+
const pct = `${prog.percent.toFixed(0)}%`.padStart(4);
|
|
203
|
+
const dl = formatBytes(prog.downloadedBytes);
|
|
204
|
+
const tot = prog.totalBytes > 0 ? formatBytes(prog.totalBytes) : "?";
|
|
205
|
+
const spd = `${formatBytes(prog.speedBps)}/s`;
|
|
206
|
+
|
|
207
|
+
const line = ` ${bar} ${pc.bold(pct)} ${pc.dim(`(${dl}/${tot} • ${spd})`)}`;
|
|
208
|
+
process.stdout.write(`\r${line}\x1b[K`);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
process.stdout.write("\r\x1b[2K");
|
|
212
|
+
p.log.message(
|
|
213
|
+
`${pc.bold("● Saved:")} ${pc.bold(path.basename(destPath))} ${pc.dim(`(${path.relative(process.cwd(), destPath)})`)}`,
|
|
214
|
+
);
|
|
215
|
+
addToHistory({
|
|
216
|
+
url: meta.url || "",
|
|
217
|
+
title: meta.title || "Media",
|
|
218
|
+
platform: meta.platform || "unknown",
|
|
219
|
+
filename: path.basename(destPath),
|
|
220
|
+
path: destPath,
|
|
221
|
+
bytes: finalBytes,
|
|
222
|
+
});
|
|
223
|
+
return true;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
process.stdout.write("\r\x1b[2K");
|
|
226
|
+
p.log.error(`Failed to download ${filename}: ${err.message}`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function handleScrapeAndDownload(targetUrl, options) {
|
|
232
|
+
const outputDir = path.resolve(options.output || DEFAULT_DOWNLOAD_DIR);
|
|
233
|
+
if (!fs.existsSync(outputDir)) {
|
|
234
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const platform = detectPlatform(targetUrl);
|
|
238
|
+
if (!platform) {
|
|
239
|
+
p.log.error(`Unsupported URL: ${targetUrl}`);
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const savedConfig = getSavedConfig();
|
|
244
|
+
// Dual Server Selection prompt for supported platforms
|
|
245
|
+
let serverChoice = options.server || savedConfig.defaultServer;
|
|
246
|
+
const serverList = PLATFORM_SERVERS[platform];
|
|
247
|
+
const isInteractive = Boolean(process.stdin.isTTY);
|
|
248
|
+
if (
|
|
249
|
+
(!serverChoice || serverChoice === "auto") &&
|
|
250
|
+
serverList &&
|
|
251
|
+
!options.yes &&
|
|
252
|
+
!options.info &&
|
|
253
|
+
isInteractive
|
|
254
|
+
) {
|
|
255
|
+
const chosen = await p.select({
|
|
256
|
+
message: "Select Server:",
|
|
257
|
+
options: serverList.map((s) => ({
|
|
258
|
+
value: s.id,
|
|
259
|
+
label: s.name,
|
|
260
|
+
})),
|
|
261
|
+
initialValue: "auto",
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (p.isCancel(chosen)) {
|
|
265
|
+
p.cancel("Cancelled.");
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
serverChoice = chosen;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const s = p.spinner();
|
|
272
|
+
s.start(pc.dim("Analyzing link..."));
|
|
273
|
+
|
|
274
|
+
let media;
|
|
275
|
+
try {
|
|
276
|
+
media = await scrapeUrl(targetUrl, serverChoice || "auto");
|
|
277
|
+
media.url = targetUrl;
|
|
278
|
+
s.stop(pc.dim("Link analyzed."));
|
|
279
|
+
} catch (err) {
|
|
280
|
+
s.stop(pc.bold(`✕ Extraction failed: ${err.message}`));
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Metadata card with Platform name as title (e.g. TIKTOK, INSTAGRAM)
|
|
285
|
+
const infoLines = [
|
|
286
|
+
`${pc.dim("Title :")} ${media.title || "Untitled"}`,
|
|
287
|
+
`${pc.dim("Creator :")} ${media.author || "Creator"}`,
|
|
288
|
+
`${pc.dim("Streams :")} ${media.downloads?.length || 0} option(s)`,
|
|
289
|
+
];
|
|
290
|
+
p.note(infoLines.join("\n"), pc.bold(media.platform.toUpperCase()));
|
|
291
|
+
|
|
292
|
+
if (options.info) {
|
|
293
|
+
if (media.downloads?.length) {
|
|
294
|
+
const streamLines = media.downloads.map(
|
|
295
|
+
(d, idx) =>
|
|
296
|
+
` ${pc.bold(`[${idx + 1}]`)} ${pc.white(d.type || "Stream")} ${d.quality ? pc.dim(`(${d.quality})`) : ""} ${d.isMirror ? pc.dim("[Mirror]") : ""}`,
|
|
297
|
+
);
|
|
298
|
+
p.note(streamLines.join("\n"), pc.dim("Available Streams"));
|
|
299
|
+
}
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const downloads = media.downloads || [];
|
|
304
|
+
if (downloads.length === 0) {
|
|
305
|
+
p.log.warn("No active download streams found for this URL.");
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (options.audio) {
|
|
310
|
+
const audioItem =
|
|
311
|
+
downloads.find(
|
|
312
|
+
(d) =>
|
|
313
|
+
d.type?.toLowerCase().includes("mp3") ||
|
|
314
|
+
d.type?.toLowerCase().includes("audio") ||
|
|
315
|
+
d.type?.toLowerCase().includes("music"),
|
|
316
|
+
) || downloads[0];
|
|
317
|
+
|
|
318
|
+
p.log.message(
|
|
319
|
+
`${pc.bold("● Audio Stream:")} ${pc.white(audioItem.type || "MP3")}`,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
await processDownloadItem({
|
|
323
|
+
item: audioItem,
|
|
324
|
+
meta: media,
|
|
325
|
+
outputDir,
|
|
326
|
+
index: 0,
|
|
327
|
+
total: 1,
|
|
328
|
+
});
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (options.yes) {
|
|
333
|
+
await processDownloadItem({
|
|
334
|
+
item: downloads[0],
|
|
335
|
+
meta: media,
|
|
336
|
+
outputDir,
|
|
337
|
+
index: 0,
|
|
338
|
+
total: 1,
|
|
339
|
+
});
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const photoDownloads = downloads.filter(
|
|
344
|
+
(d) =>
|
|
345
|
+
d.type?.toLowerCase().includes("photo") ||
|
|
346
|
+
d.type?.toLowerCase().includes("image") ||
|
|
347
|
+
d.type?.toLowerCase().includes("page"),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const audioDownloads = downloads.filter(
|
|
351
|
+
(d) =>
|
|
352
|
+
d.type?.toLowerCase().includes("audio") ||
|
|
353
|
+
d.type?.toLowerCase().includes("music") ||
|
|
354
|
+
d.type?.toLowerCase().includes("mp3"),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const isPhotoGallery = photoDownloads.length > 1;
|
|
358
|
+
const isSingleImage = photoDownloads.length === 1 && downloads.length === 1;
|
|
359
|
+
|
|
360
|
+
if (options.pdf && isSingleImage) {
|
|
361
|
+
const item = photoDownloads[0];
|
|
362
|
+
const s = p.spinner();
|
|
363
|
+
s.start(pc.dim("Downloading photo for PDF album..."));
|
|
364
|
+
try {
|
|
365
|
+
const res = await fetch(item.url, {
|
|
366
|
+
headers: { "User-Agent": CHROME_UA, Accept: "*/*" },
|
|
367
|
+
});
|
|
368
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
369
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
370
|
+
const rawPdfName = sanitizeFilename(media.title || "Photo", "pdf");
|
|
371
|
+
const outPdfPath = getNonCollidingPath(outputDir, rawPdfName);
|
|
372
|
+
const { bytes } = compileImagesToPdf([buf], outPdfPath);
|
|
373
|
+
s.stop(
|
|
374
|
+
pc.bold("✔ Compiled 1-page PDF:") +
|
|
375
|
+
` ${path.basename(outPdfPath)} (${formatBytes(bytes)})`,
|
|
376
|
+
);
|
|
377
|
+
p.log.step(`${pc.bold("Saved to:")} ${pc.dim(outPdfPath)}`);
|
|
378
|
+
addToHistory({
|
|
379
|
+
url: targetUrl || media.url || "",
|
|
380
|
+
title: media.title || "Photo",
|
|
381
|
+
platform: media.platform || "Photo",
|
|
382
|
+
filename: path.basename(outPdfPath),
|
|
383
|
+
path: outPdfPath,
|
|
384
|
+
bytes,
|
|
385
|
+
});
|
|
386
|
+
return true;
|
|
387
|
+
} catch (err) {
|
|
388
|
+
s.stop(pc.bold(`✕ Failed to download photo: ${err.message}`));
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (isPhotoGallery) {
|
|
394
|
+
let albumFormat = options.pdf ? "pdf" : null;
|
|
395
|
+
let itemsToDownload = photoDownloads;
|
|
396
|
+
let downloadAudioAlso = false;
|
|
397
|
+
|
|
398
|
+
if (!albumFormat) {
|
|
399
|
+
const albumOptions = [
|
|
400
|
+
{
|
|
401
|
+
value: "all",
|
|
402
|
+
label: `Download All (${photoDownloads.length} individual images)`,
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
value: "pdf",
|
|
406
|
+
label: `Download as PDF Album (${photoDownloads.length} photos in 1 file)`,
|
|
407
|
+
},
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
if (audioDownloads.length > 0) {
|
|
411
|
+
albumOptions.push({
|
|
412
|
+
value: "all_plus_audio",
|
|
413
|
+
label: `Download All + Audio (${photoDownloads.length} photos + MP3)`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
albumOptions.push({
|
|
418
|
+
value: "custom",
|
|
419
|
+
label: "Select specific photos...",
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
if (audioDownloads.length > 0) {
|
|
423
|
+
albumOptions.push({
|
|
424
|
+
value: "audio_only",
|
|
425
|
+
label: "Download Audio Only (MP3)",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
albumOptions.push({
|
|
430
|
+
value: "cancel",
|
|
431
|
+
label: pc.dim("Cancel"),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const choice = await p.select({
|
|
435
|
+
message: `Photo album detected (${photoDownloads.length} photos${audioDownloads.length > 0 ? " + audio" : ""}):`,
|
|
436
|
+
options: albumOptions,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
if (p.isCancel(choice) || choice === "cancel") {
|
|
440
|
+
p.cancel("Cancelled.");
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (choice === "audio_only") {
|
|
445
|
+
await processDownloadItem({
|
|
446
|
+
item: audioDownloads[0],
|
|
447
|
+
meta: media,
|
|
448
|
+
outputDir,
|
|
449
|
+
index: 0,
|
|
450
|
+
total: 1,
|
|
451
|
+
});
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (choice === "all_plus_audio") {
|
|
456
|
+
albumFormat = "all";
|
|
457
|
+
downloadAudioAlso = true;
|
|
458
|
+
} else if (choice === "pdf") {
|
|
459
|
+
albumFormat = "pdf";
|
|
460
|
+
} else if (choice === "custom") {
|
|
461
|
+
const selectedIndices = await p.multiselect({
|
|
462
|
+
message: "Select photos to download:",
|
|
463
|
+
options: photoDownloads.map((d, i) => ({
|
|
464
|
+
value: i,
|
|
465
|
+
label: `Photo #${i + 1}`,
|
|
466
|
+
})),
|
|
467
|
+
required: true,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (p.isCancel(selectedIndices)) {
|
|
471
|
+
p.cancel("Cancelled.");
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
itemsToDownload = selectedIndices.map((idx) => photoDownloads[idx]);
|
|
476
|
+
|
|
477
|
+
const formatChoice = await p.select({
|
|
478
|
+
message: "Save selected photos as:",
|
|
479
|
+
options: [
|
|
480
|
+
{
|
|
481
|
+
value: "all",
|
|
482
|
+
label: `Individual image files (${itemsToDownload.length} files)`,
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
value: "pdf",
|
|
486
|
+
label: `Single PDF album (${itemsToDownload.length} photos)`,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
if (p.isCancel(formatChoice)) {
|
|
492
|
+
p.cancel("Cancelled.");
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
albumFormat = formatChoice;
|
|
496
|
+
} else {
|
|
497
|
+
albumFormat = "all";
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (albumFormat === "pdf") {
|
|
502
|
+
const s = p.spinner();
|
|
503
|
+
const total = itemsToDownload.length;
|
|
504
|
+
let completed = 0;
|
|
505
|
+
let totalBytes = 0;
|
|
506
|
+
|
|
507
|
+
s.start(pc.dim(`[0/${total}] Fetching photos in parallel...`));
|
|
508
|
+
|
|
509
|
+
const rawBuffers = await runConcurrentPool(
|
|
510
|
+
itemsToDownload,
|
|
511
|
+
5,
|
|
512
|
+
async (item, i) => {
|
|
513
|
+
try {
|
|
514
|
+
const directUrl = await resolveUrl(item.url);
|
|
515
|
+
const res = await fetch(directUrl, {
|
|
516
|
+
headers: {
|
|
517
|
+
"User-Agent": CHROME_UA,
|
|
518
|
+
Accept: "*/*",
|
|
519
|
+
...(item.headers || {}),
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
523
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
524
|
+
completed++;
|
|
525
|
+
totalBytes += buf.length;
|
|
526
|
+
s.message(
|
|
527
|
+
pc.dim(
|
|
528
|
+
`[${completed}/${total}] Fetching photos... (${formatBytes(totalBytes)})`,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
return buf;
|
|
532
|
+
} catch (err) {
|
|
533
|
+
completed++;
|
|
534
|
+
s.message(
|
|
535
|
+
pc.dim(
|
|
536
|
+
`[${completed}/${total}] Fetching photos... (${formatBytes(totalBytes)})`,
|
|
537
|
+
),
|
|
538
|
+
);
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const buffers = rawBuffers.filter(Boolean);
|
|
545
|
+
|
|
546
|
+
if (buffers.length === 0) {
|
|
547
|
+
s.stop(pc.bold("✕ Failed to download photos for PDF album."));
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
s.stop(
|
|
552
|
+
pc.bold(`✔ [${buffers.length}/${total}] Photos fetched`) +
|
|
553
|
+
` (${formatBytes(totalBytes)})`,
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
s.start(pc.dim(`Compiling ${buffers.length} photos into PDF album...`));
|
|
557
|
+
const rawPdfName = sanitizeFilename(media.title || "Photo_Album", "pdf");
|
|
558
|
+
const outPdfPath = getNonCollidingPath(outputDir, rawPdfName);
|
|
559
|
+
|
|
560
|
+
const { bytes, pages } = compileImagesToPdf(buffers, outPdfPath);
|
|
561
|
+
s.stop(
|
|
562
|
+
pc.bold("✔ PDF Album Created:") +
|
|
563
|
+
` ${path.basename(outPdfPath)} (${pages} pages, ${formatBytes(bytes)})`,
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
addToHistory({
|
|
567
|
+
url: targetUrl || media.url || "",
|
|
568
|
+
title: media.title || "Photo Album",
|
|
569
|
+
platform: media.platform || "Album",
|
|
570
|
+
filename: path.basename(outPdfPath),
|
|
571
|
+
path: outPdfPath,
|
|
572
|
+
bytes,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
p.log.step(`${pc.bold("Saved to:")} ${pc.dim(outPdfPath)}`);
|
|
576
|
+
|
|
577
|
+
if (downloadAudioAlso && audioDownloads.length > 0) {
|
|
578
|
+
await processDownloadItem({
|
|
579
|
+
item: audioDownloads[0],
|
|
580
|
+
meta: media,
|
|
581
|
+
outputDir,
|
|
582
|
+
index: 0,
|
|
583
|
+
total: 1,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const s = p.spinner();
|
|
591
|
+
const total = itemsToDownload.length;
|
|
592
|
+
let completed = 0;
|
|
593
|
+
let totalBytes = 0;
|
|
594
|
+
let successCount = 0;
|
|
595
|
+
|
|
596
|
+
s.start(pc.dim(`[0/${total}] Downloading photos in parallel...`));
|
|
597
|
+
|
|
598
|
+
await runConcurrentPool(itemsToDownload, 5, async (item, i) => {
|
|
599
|
+
const ext =
|
|
600
|
+
item.type?.toLowerCase().includes("mp3") ||
|
|
601
|
+
item.type?.toLowerCase().includes("audio")
|
|
602
|
+
? "mp3"
|
|
603
|
+
: "jpg";
|
|
604
|
+
let baseTitle = media.title || "Photo";
|
|
605
|
+
if (total > 1) {
|
|
606
|
+
baseTitle = `${baseTitle}_${i + 1}`;
|
|
607
|
+
}
|
|
608
|
+
const filename = sanitizeFilename(baseTitle, ext);
|
|
609
|
+
const destPath = getNonCollidingPath(outputDir, filename);
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
const directUrl = await resolveUrl(item.url);
|
|
613
|
+
let fileBytes = 0;
|
|
614
|
+
await downloadMediaFile(directUrl, destPath, (prog) => {
|
|
615
|
+
fileBytes = prog.downloadedBytes;
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
completed++;
|
|
619
|
+
totalBytes += fileBytes;
|
|
620
|
+
successCount++;
|
|
621
|
+
s.message(
|
|
622
|
+
pc.dim(
|
|
623
|
+
`[${completed}/${total}] Downloading photos... (${formatBytes(totalBytes)})`,
|
|
624
|
+
),
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
addToHistory({
|
|
628
|
+
url: targetUrl || media.url || "",
|
|
629
|
+
title: `${media.title || "Photo"} #${i + 1}`,
|
|
630
|
+
platform: media.platform || "Photo",
|
|
631
|
+
filename: path.basename(destPath),
|
|
632
|
+
path: destPath,
|
|
633
|
+
bytes: fileBytes,
|
|
634
|
+
});
|
|
635
|
+
} catch (err) {
|
|
636
|
+
completed++;
|
|
637
|
+
s.message(
|
|
638
|
+
pc.dim(
|
|
639
|
+
`[${completed}/${total}] Downloading photos... (${formatBytes(totalBytes)})`,
|
|
640
|
+
),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
s.stop(
|
|
646
|
+
pc.bold(`✔ [${successCount}/${total}] Photos downloaded:`) +
|
|
647
|
+
` Saved to ${pc.dim(outputDir)} (${formatBytes(totalBytes)})`,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
if (downloadAudioAlso && audioDownloads.length > 0) {
|
|
651
|
+
await processDownloadItem({
|
|
652
|
+
item: audioDownloads[0],
|
|
653
|
+
meta: media,
|
|
654
|
+
outputDir,
|
|
655
|
+
index: itemsToDownload.length,
|
|
656
|
+
total: itemsToDownload.length + 1,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return true;
|
|
661
|
+
} else {
|
|
662
|
+
let selectedItem = downloads[0];
|
|
663
|
+
|
|
664
|
+
if (downloads.length > 1) {
|
|
665
|
+
const formatChoice = await p.select({
|
|
666
|
+
message: "Select stream:",
|
|
667
|
+
options: downloads.map((d, i) => ({
|
|
668
|
+
value: i,
|
|
669
|
+
label: pc.bold(d.type || `Stream ${i + 1}`),
|
|
670
|
+
hint: d.quality || (d.isMirror ? "Mirror" : ""),
|
|
671
|
+
})),
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
if (p.isCancel(formatChoice)) {
|
|
675
|
+
p.cancel("Cancelled.");
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
selectedItem = downloads[formatChoice];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
await processDownloadItem({
|
|
682
|
+
item: selectedItem,
|
|
683
|
+
meta: media,
|
|
684
|
+
outputDir,
|
|
685
|
+
index: 0,
|
|
686
|
+
total: 1,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function main() {
|
|
694
|
+
const { values, positionals } = parseArgs({
|
|
695
|
+
args: process.argv.slice(2),
|
|
696
|
+
options: {
|
|
697
|
+
output: { type: "string", short: "o" },
|
|
698
|
+
server: { type: "string", short: "s" },
|
|
699
|
+
audio: { type: "boolean", short: "a", default: false },
|
|
700
|
+
pdf: { type: "boolean", short: "p", default: false },
|
|
701
|
+
clip: { type: "boolean", short: "c", default: false },
|
|
702
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
703
|
+
info: { type: "boolean", short: "i", default: false },
|
|
704
|
+
update: { type: "boolean", short: "u", default: false },
|
|
705
|
+
help: { type: "boolean", short: "h", default: false },
|
|
706
|
+
version: { type: "boolean", short: "v", default: false },
|
|
707
|
+
},
|
|
708
|
+
allowPositionals: true,
|
|
709
|
+
strict: false,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
if (values.help) {
|
|
713
|
+
printHelp();
|
|
714
|
+
process.exit(0);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (values.version) {
|
|
718
|
+
console.log(
|
|
719
|
+
`Mori CLI v${APP_VERSION} (core v${getActiveScraperVersion()})`,
|
|
720
|
+
);
|
|
721
|
+
process.exit(0);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Handle manual OTA update flag
|
|
725
|
+
if (values.update) {
|
|
726
|
+
const s = p.spinner();
|
|
727
|
+
s.start(pc.dim("Checking remote scraper patch..."));
|
|
728
|
+
try {
|
|
729
|
+
const res = await checkForOtaUpdate(true);
|
|
730
|
+
if (res.updated) {
|
|
731
|
+
s.stop(
|
|
732
|
+
pc.bold(`✔ Scrapers updated to v${res.newVersion}`) +
|
|
733
|
+
(res.changelog ? pc.dim(` — ${res.changelog}`) : ""),
|
|
734
|
+
);
|
|
735
|
+
} else {
|
|
736
|
+
s.stop(pc.dim(`Scrapers already up to date (v${res.currentVersion}).`));
|
|
737
|
+
}
|
|
738
|
+
} catch (err) {
|
|
739
|
+
s.stop(pc.bold(`✕ OTA check failed: ${err.message}`));
|
|
740
|
+
}
|
|
741
|
+
process.exit(0);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const coreVer = getActiveScraperVersion();
|
|
745
|
+
printBanner(coreVer);
|
|
746
|
+
|
|
747
|
+
// Background non-blocking silent OTA check (timeout 2s)
|
|
748
|
+
checkForOtaUpdate(false)
|
|
749
|
+
.then((ota) => {
|
|
750
|
+
if (ota?.updated) {
|
|
751
|
+
p.log.message(
|
|
752
|
+
pc.dim(
|
|
753
|
+
`● New scraper patch installed in background: v${ota.newVersion}`,
|
|
754
|
+
),
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
})
|
|
758
|
+
.catch(() => {});
|
|
759
|
+
|
|
760
|
+
const cliUpdatePromise = checkCliUpdate();
|
|
761
|
+
|
|
762
|
+
const savedConfig = getSavedConfig();
|
|
763
|
+
const defaultDir =
|
|
764
|
+
values.output || savedConfig.outputDir || DEFAULT_DOWNLOAD_DIR;
|
|
765
|
+
let currentOutputDir = path.resolve(defaultDir);
|
|
766
|
+
|
|
767
|
+
if (values.clip) {
|
|
768
|
+
const s = p.spinner();
|
|
769
|
+
s.start(pc.dim("Reading clipboard..."));
|
|
770
|
+
const clipText = await readClipboard();
|
|
771
|
+
const urls = extractUrls(clipText);
|
|
772
|
+
const valid = urls.find((u) => Boolean(detectPlatform(u)));
|
|
773
|
+
if (valid) {
|
|
774
|
+
s.stop(
|
|
775
|
+
`${pc.bold("Clipboard link:")} ${pc.inverse(` ${detectPlatform(valid)?.toUpperCase()} `)} ${pc.dim(valid)}`,
|
|
776
|
+
);
|
|
777
|
+
positionals[0] = valid;
|
|
778
|
+
} else {
|
|
779
|
+
s.stop(pc.bold("✕ No supported media URL found in clipboard."));
|
|
780
|
+
process.exit(1);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
let targetUrl = positionals[0];
|
|
785
|
+
let lastUrl = null;
|
|
786
|
+
|
|
787
|
+
if (targetUrl) {
|
|
788
|
+
const platform = detectPlatform(targetUrl);
|
|
789
|
+
if (!platform) {
|
|
790
|
+
p.log.error(`Unsupported URL: ${targetUrl}`);
|
|
791
|
+
p.outro(pc.dim("Run with --help to view supported platforms."));
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
lastUrl = targetUrl;
|
|
796
|
+
await handleScrapeAndDownload(targetUrl, {
|
|
797
|
+
...values,
|
|
798
|
+
output: currentOutputDir,
|
|
799
|
+
});
|
|
800
|
+
const newVer = await cliUpdatePromise;
|
|
801
|
+
if (newVer) {
|
|
802
|
+
p.log.message(
|
|
803
|
+
pc.yellow(
|
|
804
|
+
`● Update available: v${APP_VERSION} → v${newVer} (run: npm i -g @coflyn/mori)`,
|
|
805
|
+
),
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
p.outro(pc.dim("Complete."));
|
|
809
|
+
} else {
|
|
810
|
+
const newVer = await cliUpdatePromise;
|
|
811
|
+
if (newVer) {
|
|
812
|
+
p.note(
|
|
813
|
+
`${pc.white("New version available:")} ${pc.dim(`v${APP_VERSION}`)} → ${pc.green(`v${newVer}`)}\n` +
|
|
814
|
+
`${pc.dim("Run:")} ${pc.cyan("npm i -g @coflyn/mori")} ${pc.dim("to update")}`,
|
|
815
|
+
pc.yellow("Update Available"),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
let keepGoing = true;
|
|
820
|
+
|
|
821
|
+
while (keepGoing) {
|
|
822
|
+
const urlInput = await p.text({
|
|
823
|
+
message: "Paste link:",
|
|
824
|
+
placeholder: "https://... or type /help",
|
|
825
|
+
validate: (val) => {
|
|
826
|
+
if (!val || val.trim().length === 0)
|
|
827
|
+
return "Please enter a URL or /command.";
|
|
828
|
+
const clean = val.trim().toLowerCase();
|
|
829
|
+
if (clean === "exit" || clean === "quit" || clean === "q")
|
|
830
|
+
return undefined;
|
|
831
|
+
if (clean.startsWith("/")) return undefined;
|
|
832
|
+
if (!detectPlatform(val.trim())) {
|
|
833
|
+
return "Platform not supported. Type /platform to view supported platforms.";
|
|
834
|
+
}
|
|
835
|
+
return undefined;
|
|
836
|
+
},
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
if (p.isCancel(urlInput)) {
|
|
840
|
+
p.cancel("Exited.");
|
|
841
|
+
process.exit(0);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const rawInput = urlInput.trim();
|
|
845
|
+
const lower = rawInput.toLowerCase();
|
|
846
|
+
|
|
847
|
+
// Forgiving exit without leading slash
|
|
848
|
+
if (lower === "exit" || lower === "quit" || lower === "q") {
|
|
849
|
+
p.outro(pc.dim("Exited."));
|
|
850
|
+
process.exit(0);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Slash command interceptor
|
|
854
|
+
if (rawInput.startsWith("/")) {
|
|
855
|
+
const [cmd, ...args] = rawInput.slice(1).split(/\s+/);
|
|
856
|
+
const command = cmd.toLowerCase();
|
|
857
|
+
|
|
858
|
+
switch (command) {
|
|
859
|
+
case "platform":
|
|
860
|
+
case "platforms": {
|
|
861
|
+
const platformList = [
|
|
862
|
+
`${pc.bold("Video :")} TikTok, Douyin, YouTube, Instagram, Facebook, Bilibili, Reddit, Threads, Twitter/X`,
|
|
863
|
+
`${pc.bold("Audio :")} Spotify, Apple Music, Bandcamp`,
|
|
864
|
+
`${pc.bold("Photo :")} Pinterest, Pixiv, RedNote (Xiaohongshu)`,
|
|
865
|
+
`${pc.bold("Cloud :")} TeraBox (1024tera, nephobox, etc.)`,
|
|
866
|
+
];
|
|
867
|
+
p.note(platformList.join("\n"), pc.dim("Supported Platforms (16)"));
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
case "play": {
|
|
872
|
+
const items = getHistory(15);
|
|
873
|
+
let targetItem = null;
|
|
874
|
+
let targetNum = 1;
|
|
875
|
+
|
|
876
|
+
if (args[0]) {
|
|
877
|
+
const parsed = parseInt(args[0], 10);
|
|
878
|
+
if (!isNaN(parsed)) {
|
|
879
|
+
targetNum = parsed;
|
|
880
|
+
if (targetNum < 1 || targetNum > items.length) {
|
|
881
|
+
p.log.warn(
|
|
882
|
+
`Invalid history index #${targetNum}. Available: 1 to ${items.length}.`,
|
|
883
|
+
);
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
targetItem = items[targetNum - 1];
|
|
887
|
+
} else {
|
|
888
|
+
const customPath = path.resolve(args.join(" "));
|
|
889
|
+
if (fs.existsSync(customPath)) {
|
|
890
|
+
const ext = path.extname(customPath).toLowerCase();
|
|
891
|
+
if (ext !== ".mp4" && ext !== ".mp3" && ext !== ".m4a") {
|
|
892
|
+
p.log.warn(
|
|
893
|
+
`Cannot play ${ext ? ext.toUpperCase() : "this"} file. /play is only for MP4 and MP3 (use /open to view files).`,
|
|
894
|
+
);
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
await openFile(customPath);
|
|
899
|
+
p.log.step(`${pc.bold("Playing:")} ${pc.dim(customPath)}`);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
p.log.error(`Failed to play media: ${err.message}`);
|
|
902
|
+
}
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
} else {
|
|
907
|
+
if (items.length > 0) {
|
|
908
|
+
const playIdx = items.findIndex((it) => {
|
|
909
|
+
const ext = path
|
|
910
|
+
.extname(it.path || it.filename || "")
|
|
911
|
+
.toLowerCase();
|
|
912
|
+
return ext === ".mp4" || ext === ".mp3" || ext === ".m4a";
|
|
913
|
+
});
|
|
914
|
+
if (playIdx !== -1) {
|
|
915
|
+
targetItem = items[playIdx];
|
|
916
|
+
targetNum = playIdx + 1;
|
|
917
|
+
} else {
|
|
918
|
+
targetItem = items[0];
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (!targetItem) {
|
|
924
|
+
p.log.warn("No downloaded media found in history to play.");
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!targetItem.path || !fs.existsSync(targetItem.path)) {
|
|
929
|
+
p.log.error(
|
|
930
|
+
`File no longer exists: ${targetItem.path || targetItem.filename}`,
|
|
931
|
+
);
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const fileExt = path
|
|
936
|
+
.extname(targetItem.path || targetItem.filename || "")
|
|
937
|
+
.toLowerCase();
|
|
938
|
+
if (
|
|
939
|
+
fileExt !== ".mp4" &&
|
|
940
|
+
fileExt !== ".mp3" &&
|
|
941
|
+
fileExt !== ".m4a"
|
|
942
|
+
) {
|
|
943
|
+
p.log.warn(
|
|
944
|
+
`Item #${targetNum} is a ${fileExt ? fileExt.toUpperCase() : "non-audio/video"} file. /play is only for MP4 and MP3 (type /open to view files).`,
|
|
945
|
+
);
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
try {
|
|
950
|
+
await openFile(targetItem.path);
|
|
951
|
+
p.log.step(
|
|
952
|
+
`${pc.bold("Playing:")} ${pc.white(`[#${targetNum}]`)} ${pc.bold(targetItem.filename || targetItem.title)} ${pc.dim(`(${targetItem.path})`)}`,
|
|
953
|
+
);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
p.log.error(`Failed to launch media player: ${err.message}`);
|
|
956
|
+
}
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
case "delete":
|
|
961
|
+
case "del":
|
|
962
|
+
case "rm": {
|
|
963
|
+
const items = getHistory(20);
|
|
964
|
+
if (items.length === 0) {
|
|
965
|
+
p.log.warn("No download history to delete.");
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
let targetIndex = -1;
|
|
970
|
+
|
|
971
|
+
if (args[0]) {
|
|
972
|
+
const numArg = parseInt(args[0], 10);
|
|
973
|
+
if (isNaN(numArg) || numArg < 1 || numArg > items.length) {
|
|
974
|
+
p.log.warn(
|
|
975
|
+
`Invalid index. Available range: 1 to ${items.length}.`,
|
|
976
|
+
);
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
targetIndex = numArg - 1;
|
|
980
|
+
} else {
|
|
981
|
+
const selectChoice = await p.select({
|
|
982
|
+
message: "Select item to delete:",
|
|
983
|
+
options: [
|
|
984
|
+
...items.map((it, idx) => ({
|
|
985
|
+
value: idx,
|
|
986
|
+
label: `[#${idx + 1}] ${it.filename || it.title || "Media"}`,
|
|
987
|
+
hint: it.platform || "",
|
|
988
|
+
})),
|
|
989
|
+
{ value: -1, label: pc.dim("Cancel") },
|
|
990
|
+
],
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
if (p.isCancel(selectChoice) || selectChoice === -1) {
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
targetIndex = selectChoice;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const itemToDelete = items[targetIndex];
|
|
1000
|
+
const confirm = await p.confirm({
|
|
1001
|
+
message: `Delete "${itemToDelete.filename || itemToDelete.title}" from disk and history?`,
|
|
1002
|
+
initialValue: false,
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
1006
|
+
p.log.step(pc.dim("Deletion cancelled."));
|
|
1007
|
+
break;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const res = deleteHistoryItem(targetIndex, true);
|
|
1011
|
+
if (res.success) {
|
|
1012
|
+
const fileNote = res.fileDeleted
|
|
1013
|
+
? pc.dim("(deleted from disk & removed from history)")
|
|
1014
|
+
: pc.dim("(removed from history; file was not on disk)");
|
|
1015
|
+
p.log.step(
|
|
1016
|
+
`${pc.bold("✔ Deleted:")} ${pc.white(itemToDelete.filename || itemToDelete.title)} ${fileNote}`,
|
|
1017
|
+
);
|
|
1018
|
+
} else {
|
|
1019
|
+
p.log.error(`Failed to delete item: ${res.error}`);
|
|
1020
|
+
}
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
case "ping": {
|
|
1025
|
+
const host = args[0] || "1.1.1.1";
|
|
1026
|
+
const s = p.spinner();
|
|
1027
|
+
s.start(pc.dim(`Pinging ${host}...`));
|
|
1028
|
+
const res = await pingNetwork(host);
|
|
1029
|
+
|
|
1030
|
+
if (!res.ok || res.avg < 0) {
|
|
1031
|
+
s.stop(
|
|
1032
|
+
pc.bold(`✕ Ping to ${host} failed`) +
|
|
1033
|
+
pc.dim(` (${res.loss} loss)`),
|
|
1034
|
+
);
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
s.stop(
|
|
1039
|
+
`${pc.bold("Pong!")} ${pc.white(`${res.avg}ms`)} ` +
|
|
1040
|
+
pc.dim(
|
|
1041
|
+
`(min ${res.min}ms • max ${res.max}ms • ${res.loss} loss • ${res.host})`,
|
|
1042
|
+
),
|
|
1043
|
+
);
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
case "pdf": {
|
|
1048
|
+
let link = args.find((a) => a.startsWith("http"));
|
|
1049
|
+
if (!link) {
|
|
1050
|
+
const promptRes = await p.text({
|
|
1051
|
+
message: "Paste link to download as PDF album:",
|
|
1052
|
+
placeholder: "https://...",
|
|
1053
|
+
validate: (val) => {
|
|
1054
|
+
if (!val || !val.trim()) return "Please enter a URL.";
|
|
1055
|
+
if (!detectPlatform(val.trim()))
|
|
1056
|
+
return "Platform not supported.";
|
|
1057
|
+
return undefined;
|
|
1058
|
+
},
|
|
1059
|
+
});
|
|
1060
|
+
if (p.isCancel(promptRes)) break;
|
|
1061
|
+
link = promptRes.trim();
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
lastUrl = link;
|
|
1065
|
+
await handleScrapeAndDownload(link, {
|
|
1066
|
+
...values,
|
|
1067
|
+
pdf: true,
|
|
1068
|
+
output: currentOutputDir,
|
|
1069
|
+
});
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
case "batch": {
|
|
1074
|
+
const batchInput = await p.text({
|
|
1075
|
+
message: "Paste multiple links (separated by space or newline):",
|
|
1076
|
+
placeholder: "https://... https://...",
|
|
1077
|
+
validate: (val) => {
|
|
1078
|
+
if (!val || val.trim().length === 0)
|
|
1079
|
+
return "Please paste at least one link.";
|
|
1080
|
+
const urls = extractUrls(val);
|
|
1081
|
+
if (urls.length === 0) return "No valid URLs found.";
|
|
1082
|
+
return undefined;
|
|
1083
|
+
},
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
if (p.isCancel(batchInput)) break;
|
|
1087
|
+
|
|
1088
|
+
const urls = extractUrls(batchInput);
|
|
1089
|
+
const supported = urls.filter((u) => Boolean(detectPlatform(u)));
|
|
1090
|
+
if (supported.length === 0) {
|
|
1091
|
+
p.log.error(
|
|
1092
|
+
"None of the provided links match supported platforms.",
|
|
1093
|
+
);
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
p.log.message(
|
|
1098
|
+
`${pc.bold("● Batch Queue:")} ${pc.white(supported.length)} link(s) queued` +
|
|
1099
|
+
(urls.length > supported.length
|
|
1100
|
+
? pc.dim(
|
|
1101
|
+
` (${urls.length - supported.length} unsupported skipped)`,
|
|
1102
|
+
)
|
|
1103
|
+
: ""),
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
for (let i = 0; i < supported.length; i++) {
|
|
1107
|
+
const u = supported[i];
|
|
1108
|
+
const plat = detectPlatform(u);
|
|
1109
|
+
p.log.step(
|
|
1110
|
+
`${pc.dim(`[${i + 1}/${supported.length}]`)} ${pc.inverse(` ${plat?.toUpperCase()} `)} ${pc.dim(u)}`,
|
|
1111
|
+
);
|
|
1112
|
+
await handleScrapeAndDownload(u, {
|
|
1113
|
+
...values,
|
|
1114
|
+
yes: true,
|
|
1115
|
+
output: currentOutputDir,
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
p.log.message(
|
|
1119
|
+
pc.bold(`✔ Batch download finished (${supported.length} items).`),
|
|
1120
|
+
);
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
case "open": {
|
|
1125
|
+
try {
|
|
1126
|
+
if (!fs.existsSync(currentOutputDir)) {
|
|
1127
|
+
fs.mkdirSync(currentOutputDir, { recursive: true });
|
|
1128
|
+
}
|
|
1129
|
+
await openDirectory(currentOutputDir);
|
|
1130
|
+
p.log.step(
|
|
1131
|
+
`${pc.bold("Opened folder:")} ${pc.dim(currentOutputDir)}`,
|
|
1132
|
+
);
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
p.log.error(`Failed to open directory: ${err.message}`);
|
|
1135
|
+
}
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
case "server": {
|
|
1140
|
+
const currentPref = getSavedConfig().defaultServer || "auto";
|
|
1141
|
+
const choice = await p.select({
|
|
1142
|
+
message: "Select default scraper server:",
|
|
1143
|
+
options: [
|
|
1144
|
+
{
|
|
1145
|
+
value: "auto",
|
|
1146
|
+
label: "Auto",
|
|
1147
|
+
hint: "Best server per platform (default)",
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
value: "1",
|
|
1151
|
+
label: "Server 1",
|
|
1152
|
+
hint: "Primary (TikWM, SnapInsta, Cobalt, etc.)",
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
value: "2",
|
|
1156
|
+
label: "Server 2",
|
|
1157
|
+
hint: "Backup (LoveTik, FastDL, Viddly, etc.)",
|
|
1158
|
+
},
|
|
1159
|
+
],
|
|
1160
|
+
initialValue: currentPref,
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
if (p.isCancel(choice)) break;
|
|
1164
|
+
|
|
1165
|
+
saveConfig({ defaultServer: choice });
|
|
1166
|
+
p.log.message(
|
|
1167
|
+
`${pc.bold("● Default server set:")} ${pc.white(choice)}`,
|
|
1168
|
+
);
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
case "clip":
|
|
1173
|
+
case "paste": {
|
|
1174
|
+
const s = p.spinner();
|
|
1175
|
+
s.start(pc.dim("Reading clipboard..."));
|
|
1176
|
+
const clipText = await readClipboard();
|
|
1177
|
+
if (!clipText) {
|
|
1178
|
+
s.stop(pc.dim("Clipboard is empty."));
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const urls = extractUrls(clipText);
|
|
1183
|
+
const valid = urls.find((u) => Boolean(detectPlatform(u)));
|
|
1184
|
+
if (!valid) {
|
|
1185
|
+
s.stop(pc.bold("✕ No supported media URL found in clipboard."));
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const plat = detectPlatform(valid);
|
|
1190
|
+
s.stop(
|
|
1191
|
+
`${pc.bold("Link from clipboard:")} ${pc.inverse(` ${plat?.toUpperCase()} `)} ${pc.dim(valid)}`,
|
|
1192
|
+
);
|
|
1193
|
+
|
|
1194
|
+
const isAudio =
|
|
1195
|
+
args.includes("-a") ||
|
|
1196
|
+
args.includes("--audio") ||
|
|
1197
|
+
args.includes("audio") ||
|
|
1198
|
+
args.includes("mp3");
|
|
1199
|
+
|
|
1200
|
+
lastUrl = valid;
|
|
1201
|
+
await handleScrapeAndDownload(valid, {
|
|
1202
|
+
...values,
|
|
1203
|
+
audio: isAudio || values.audio,
|
|
1204
|
+
output: currentOutputDir,
|
|
1205
|
+
});
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
case "audio":
|
|
1210
|
+
case "mp3": {
|
|
1211
|
+
let link = args.find((a) => a.startsWith("http"));
|
|
1212
|
+
if (!link) {
|
|
1213
|
+
const promptRes = await p.text({
|
|
1214
|
+
message: "Paste link for Audio-Only (MP3):",
|
|
1215
|
+
placeholder: "https://...",
|
|
1216
|
+
validate: (val) => {
|
|
1217
|
+
if (!val || !val.trim()) return "Please enter a URL.";
|
|
1218
|
+
if (!detectPlatform(val.trim()))
|
|
1219
|
+
return "Platform not supported.";
|
|
1220
|
+
return undefined;
|
|
1221
|
+
},
|
|
1222
|
+
});
|
|
1223
|
+
if (p.isCancel(promptRes)) break;
|
|
1224
|
+
link = promptRes.trim();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
await handleScrapeAndDownload(link, {
|
|
1228
|
+
...values,
|
|
1229
|
+
audio: true,
|
|
1230
|
+
output: currentOutputDir,
|
|
1231
|
+
});
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
case "history": {
|
|
1236
|
+
if (
|
|
1237
|
+
args[0] === "clear" ||
|
|
1238
|
+
args[0] === "clean" ||
|
|
1239
|
+
args[0] === "reset"
|
|
1240
|
+
) {
|
|
1241
|
+
clearHistory();
|
|
1242
|
+
p.log.message(pc.dim("Download history cleared."));
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const items = getHistory(15);
|
|
1247
|
+
if (items.length === 0) {
|
|
1248
|
+
p.log.message(pc.dim("No download history yet."));
|
|
1249
|
+
break;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Check if user specified an iteration number, e.g. /history 1
|
|
1253
|
+
const numArg = parseInt(args[0], 10);
|
|
1254
|
+
if (!isNaN(numArg)) {
|
|
1255
|
+
if (numArg < 1 || numArg > items.length) {
|
|
1256
|
+
p.log.warn(
|
|
1257
|
+
`Invalid history index #${numArg}. Available: 1 to ${items.length}.`,
|
|
1258
|
+
);
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
const selected = items[numArg - 1];
|
|
1262
|
+
if (!selected.url) {
|
|
1263
|
+
p.log.warn(
|
|
1264
|
+
`Item #${numArg} has no recorded source URL (downloaded prior to URL tracking).`,
|
|
1265
|
+
);
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
1268
|
+
p.log.message(
|
|
1269
|
+
`${pc.bold("● Re-downloading from history:")} ${pc.inverse(` ${selected.platform?.toUpperCase()} `)} ${pc.dim(selected.url)}`,
|
|
1270
|
+
);
|
|
1271
|
+
lastUrl = selected.url;
|
|
1272
|
+
await handleScrapeAndDownload(selected.url, {
|
|
1273
|
+
...values,
|
|
1274
|
+
output: currentOutputDir,
|
|
1275
|
+
});
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const historyLines = items.map((it, idx) => {
|
|
1280
|
+
const num = pc.bold(`[${idx + 1}]`);
|
|
1281
|
+
const badge = pc.inverse(` ${it.platform.toUpperCase()} `);
|
|
1282
|
+
const name = pc.bold(it.filename || it.title || "Media");
|
|
1283
|
+
const rel = it.timestamp
|
|
1284
|
+
? pc.dim(`(${formatRelativeTime(it.timestamp)})`)
|
|
1285
|
+
: "";
|
|
1286
|
+
return `${num} ${badge} ${name} ${rel}`;
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
p.note(
|
|
1290
|
+
historyLines.join("\n"),
|
|
1291
|
+
pc.dim(
|
|
1292
|
+
`Recent Downloads (${items.length}) • /history <num> to redownload • /history clear to reset`,
|
|
1293
|
+
),
|
|
1294
|
+
);
|
|
1295
|
+
break;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
case "config": {
|
|
1299
|
+
const cfg = getSavedConfig();
|
|
1300
|
+
const cfgLines = [
|
|
1301
|
+
`${pc.bold("Download Dir :")} ${pc.white(currentOutputDir)}`,
|
|
1302
|
+
`${pc.bold("Default Server:")} ${pc.white(cfg.defaultServer || "auto")}`,
|
|
1303
|
+
`${pc.bold("CLI Version :")} ${pc.white(`v${APP_VERSION}`)}`,
|
|
1304
|
+
`${pc.bold("Core Version :")} ${pc.white(`v${getActiveScraperVersion()}`)}`,
|
|
1305
|
+
`${pc.bold("Config Path :")} ${pc.dim(CONFIG_PATH)}`,
|
|
1306
|
+
];
|
|
1307
|
+
p.note(cfgLines.join("\n"), pc.dim("Active Configuration"));
|
|
1308
|
+
break;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
case "info":
|
|
1312
|
+
case "inspect": {
|
|
1313
|
+
let link = args.find((a) => a.startsWith("http"));
|
|
1314
|
+
if (!link) {
|
|
1315
|
+
const promptRes = await p.text({
|
|
1316
|
+
message: "Paste link to inspect:",
|
|
1317
|
+
placeholder: "https://...",
|
|
1318
|
+
validate: (val) => {
|
|
1319
|
+
if (!val || !val.trim()) return "Please enter a URL.";
|
|
1320
|
+
if (!detectPlatform(val.trim()))
|
|
1321
|
+
return "Platform not supported.";
|
|
1322
|
+
return undefined;
|
|
1323
|
+
},
|
|
1324
|
+
});
|
|
1325
|
+
if (p.isCancel(promptRes)) break;
|
|
1326
|
+
link = promptRes.trim();
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
lastUrl = link;
|
|
1330
|
+
await handleScrapeAndDownload(link, {
|
|
1331
|
+
...values,
|
|
1332
|
+
info: true,
|
|
1333
|
+
output: currentOutputDir,
|
|
1334
|
+
});
|
|
1335
|
+
break;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
case "retry":
|
|
1339
|
+
case "again": {
|
|
1340
|
+
if (!lastUrl) {
|
|
1341
|
+
p.log.warn("No recent URL to retry.");
|
|
1342
|
+
break;
|
|
1343
|
+
}
|
|
1344
|
+
p.log.message(`${pc.bold("● Retrying link:")} ${pc.dim(lastUrl)}`);
|
|
1345
|
+
await handleScrapeAndDownload(lastUrl, {
|
|
1346
|
+
...values,
|
|
1347
|
+
output: currentOutputDir,
|
|
1348
|
+
});
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
case "stats": {
|
|
1353
|
+
const stats = getStats();
|
|
1354
|
+
if (stats.totalDownloads === 0) {
|
|
1355
|
+
p.log.message(
|
|
1356
|
+
pc.dim("No statistics yet. Download some media first!"),
|
|
1357
|
+
);
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const breakdown = Object.entries(stats.platformCounts)
|
|
1362
|
+
.map(
|
|
1363
|
+
([plat, count]) =>
|
|
1364
|
+
` ${pc.bold(plat.toUpperCase().padEnd(12, " "))} : ${pc.white(count)} download(s)`,
|
|
1365
|
+
)
|
|
1366
|
+
.join("\n");
|
|
1367
|
+
|
|
1368
|
+
const statLines = [
|
|
1369
|
+
`${pc.bold("Total Downloads :")} ${pc.white(stats.totalDownloads)} file(s)`,
|
|
1370
|
+
`${pc.bold("Total Storage :")} ${pc.white(formatBytes(stats.totalBytes))}`,
|
|
1371
|
+
`${pc.bold("Top Platform :")} ${pc.white(stats.topPlatform.toUpperCase())}`,
|
|
1372
|
+
"",
|
|
1373
|
+
pc.bold("Platform Breakdown:"),
|
|
1374
|
+
breakdown,
|
|
1375
|
+
];
|
|
1376
|
+
p.note(statLines.join("\n"), pc.dim("Mori Download Statistics"));
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
case "reset": {
|
|
1381
|
+
const confirm = await p.confirm({
|
|
1382
|
+
message:
|
|
1383
|
+
"Reset Mori config, cached scrapers, and history to factory defaults?",
|
|
1384
|
+
initialValue: false,
|
|
1385
|
+
});
|
|
1386
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
1387
|
+
p.log.message(pc.dim("Reset cancelled."));
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
resetMori();
|
|
1392
|
+
currentOutputDir = DEFAULT_DOWNLOAD_DIR;
|
|
1393
|
+
p.log.message(
|
|
1394
|
+
pc.bold(
|
|
1395
|
+
"✔ Mori reset to default state (config, OTA cache, history cleared).",
|
|
1396
|
+
),
|
|
1397
|
+
);
|
|
1398
|
+
break;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
case "update":
|
|
1402
|
+
case "u": {
|
|
1403
|
+
const s = p.spinner();
|
|
1404
|
+
s.start(pc.dim("Checking for updates..."));
|
|
1405
|
+
try {
|
|
1406
|
+
const cliVer = await checkCliUpdate();
|
|
1407
|
+
const ota = await checkForOtaUpdate(true);
|
|
1408
|
+
|
|
1409
|
+
s.stop(pc.bold("Update Check:"));
|
|
1410
|
+
|
|
1411
|
+
if (cliVer) {
|
|
1412
|
+
p.note(
|
|
1413
|
+
`${pc.white("New CLI release:")} ${pc.dim(`v${APP_VERSION}`)} → ${pc.green(`v${cliVer}`)}\n` +
|
|
1414
|
+
`${pc.dim("Run:")} ${pc.cyan("npm i -g @coflyn/mori")}`,
|
|
1415
|
+
pc.yellow("CLI Update Available"),
|
|
1416
|
+
);
|
|
1417
|
+
} else {
|
|
1418
|
+
p.log.step(
|
|
1419
|
+
pc.dim(`CLI package is up to date (v${APP_VERSION}).`),
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (ota.updated) {
|
|
1424
|
+
p.log.step(
|
|
1425
|
+
pc.bold(`✔ Scrapers updated to v${ota.newVersion}`) +
|
|
1426
|
+
(ota.changelog ? pc.dim(` — ${ota.changelog}`) : ""),
|
|
1427
|
+
);
|
|
1428
|
+
} else {
|
|
1429
|
+
p.log.step(
|
|
1430
|
+
pc.dim(
|
|
1431
|
+
`Scraper engine is up to date (v${ota.currentVersion}).`,
|
|
1432
|
+
),
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
s.stop(pc.bold(`✕ Update check failed: ${err.message}`));
|
|
1437
|
+
}
|
|
1438
|
+
break;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
case "dir": {
|
|
1442
|
+
const targetDir = args.join(" ").trim();
|
|
1443
|
+
if (targetDir) {
|
|
1444
|
+
currentOutputDir = path.resolve(targetDir);
|
|
1445
|
+
if (!fs.existsSync(currentOutputDir))
|
|
1446
|
+
fs.mkdirSync(currentOutputDir, { recursive: true });
|
|
1447
|
+
saveConfig({ outputDir: currentOutputDir });
|
|
1448
|
+
p.log.message(
|
|
1449
|
+
`${pc.bold("● Saved output dir (persistent):")} ${pc.dim(currentOutputDir)}`,
|
|
1450
|
+
);
|
|
1451
|
+
} else {
|
|
1452
|
+
const newDir = await p.text({
|
|
1453
|
+
message: "Download directory (saved permanently):",
|
|
1454
|
+
initialValue: currentOutputDir,
|
|
1455
|
+
});
|
|
1456
|
+
if (!p.isCancel(newDir) && newDir.trim()) {
|
|
1457
|
+
currentOutputDir = path.resolve(newDir.trim());
|
|
1458
|
+
if (!fs.existsSync(currentOutputDir))
|
|
1459
|
+
fs.mkdirSync(currentOutputDir, { recursive: true });
|
|
1460
|
+
saveConfig({ outputDir: currentOutputDir });
|
|
1461
|
+
p.log.message(
|
|
1462
|
+
`${pc.bold("● Saved output dir (persistent):")} ${pc.dim(currentOutputDir)}`,
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
case "clear":
|
|
1470
|
+
case "cls": {
|
|
1471
|
+
console.clear();
|
|
1472
|
+
printBanner(getActiveScraperVersion());
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
case "help":
|
|
1477
|
+
case "?": {
|
|
1478
|
+
const cmds = [
|
|
1479
|
+
`${pc.bold("/clip")} Download link currently in clipboard`,
|
|
1480
|
+
`${pc.bold("/audio [url]")} Download audio stream only (MP3/M4A)`,
|
|
1481
|
+
`${pc.bold("/pdf [url]")} Download photo album compiled directly into 1 PDF`,
|
|
1482
|
+
`${pc.bold("/play [num]")} Play downloaded media in default system player`,
|
|
1483
|
+
`${pc.bold("/ping [host]")} Check user network latency (default: 1.1.1.1)`,
|
|
1484
|
+
`${pc.bold("/info [url]")} Inspect media details without downloading`,
|
|
1485
|
+
`${pc.bold("/retry")} Re-download the last link processed`,
|
|
1486
|
+
`${pc.bold("/batch")} Bulk download multiple links at once`,
|
|
1487
|
+
`${pc.bold("/platform")} List all 16 supported media platforms`,
|
|
1488
|
+
`${pc.bold("/dir")} View or set download directory (persistent)`,
|
|
1489
|
+
`${pc.bold("/open")} Open download folder in file manager`,
|
|
1490
|
+
`${pc.bold("/server")} Set default scraper server (auto/1/2)`,
|
|
1491
|
+
`${pc.bold("/history [num]")} View or redownload history item (/history 1, /history clear)`,
|
|
1492
|
+
`${pc.bold("/delete [num]")} Delete downloaded file from disk and history`,
|
|
1493
|
+
`${pc.bold("/stats")} View total downloads & storage statistics`,
|
|
1494
|
+
`${pc.bold("/config")} Show active Mori configuration`,
|
|
1495
|
+
`${pc.bold("/update")} Check and apply remote OTA scraper patch`,
|
|
1496
|
+
`${pc.bold("/reset")} Factory reset Mori config, cache, & history`,
|
|
1497
|
+
`${pc.bold("/clear")} Clear screen and reprint header`,
|
|
1498
|
+
`${pc.bold("/exit, /q")} Exit Mori CLI`,
|
|
1499
|
+
];
|
|
1500
|
+
p.note(cmds.join("\n"), pc.dim("Available Commands"));
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
case "exit":
|
|
1505
|
+
case "quit":
|
|
1506
|
+
case "q": {
|
|
1507
|
+
p.outro(pc.dim("Exited."));
|
|
1508
|
+
process.exit(0);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
default:
|
|
1512
|
+
p.log.warn(
|
|
1513
|
+
`Unknown command: /${command}. Type /help for available commands.`,
|
|
1514
|
+
);
|
|
1515
|
+
break;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Return to prompt without asking "Continue?"
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
lastUrl = rawInput;
|
|
1523
|
+
await handleScrapeAndDownload(rawInput, {
|
|
1524
|
+
...values,
|
|
1525
|
+
output: currentOutputDir,
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
const next = await p.confirm({
|
|
1529
|
+
message: "Continue?",
|
|
1530
|
+
initialValue: true,
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
if (p.isCancel(next) || !next) {
|
|
1534
|
+
keepGoing = false;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
p.outro(pc.dim("Complete."));
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
main().catch((err) => {
|
|
1543
|
+
p.log.error(`Error: ${err.message}`);
|
|
1544
|
+
process.exit(1);
|
|
1545
|
+
});
|