open-nami 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/dist/cli.js +3268 -0
  4. package/package.json +62 -0
package/dist/cli.js ADDED
@@ -0,0 +1,3268 @@
1
+ #!/usr/bin/env node
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __commonJS = (cb, mod) => function __require() {
4
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
5
+ };
6
+
7
+ // package.json
8
+ var require_package = __commonJS({
9
+ "package.json"(exports, module) {
10
+ module.exports = {
11
+ name: "open-nami",
12
+ version: "1.0.0",
13
+ description: "Open Nami \u2014 terminal-first bulk social media profile downloader for Instagram, TikTok, Facebook & X.",
14
+ type: "module",
15
+ bin: {
16
+ "open-nami": "dist/cli.js",
17
+ opennami: "dist/cli.js",
18
+ nami: "dist/cli.js",
19
+ on: "dist/cli.js"
20
+ },
21
+ files: [
22
+ "dist/cli.js"
23
+ ],
24
+ engines: {
25
+ node: ">=20"
26
+ },
27
+ scripts: {
28
+ build: "tsup",
29
+ dev: "tsup --watch",
30
+ test: "node scripts/run-tests.js",
31
+ typecheck: "tsc --noEmit",
32
+ start: "node dist/cli.js",
33
+ "build:pypi": "node scripts/build-pypi-bundle.js && python -m build pypi",
34
+ prepublishOnly: "npm test && npm run typecheck && npm run build"
35
+ },
36
+ keywords: [
37
+ "profile",
38
+ "downloader",
39
+ "gallery-dl",
40
+ "yt-dlp",
41
+ "instagram",
42
+ "tiktok",
43
+ "facebook",
44
+ "twitter",
45
+ "cli",
46
+ "tui",
47
+ "ink"
48
+ ],
49
+ license: "MIT",
50
+ author: {
51
+ name: "Igect",
52
+ url: "https://igect.link/"
53
+ },
54
+ repository: {
55
+ type: "git",
56
+ url: "https://github.com/OpenSelena/open-nami.git"
57
+ },
58
+ dependencies: {
59
+ ink: "^7.1.1",
60
+ "ink-select-input": "^6.2.0",
61
+ "ink-spinner": "^5.0.0",
62
+ react: "^19.3.0"
63
+ },
64
+ devDependencies: {
65
+ "@types/node": "^26.6.2",
66
+ "@types/react": "^19.3.0",
67
+ tsup: "^8.5.1",
68
+ tsx: "^4.23.13",
69
+ typescript: "^7.0.2"
70
+ }
71
+ };
72
+ }
73
+ });
74
+
75
+ // src/cli.tsx
76
+ import { createRequire } from "module";
77
+ import { render } from "ink";
78
+
79
+ // src/app.tsx
80
+ import { useState as useState6, useCallback as useCallback2, useRef as useRef3, useEffect as useEffect5, useMemo as useMemo3 } from "react";
81
+ import { Box as Box13, Text as Text15, useApp, useInput as useInput3, useStdout as useStdout3 } from "ink";
82
+ import Spinner2 from "ink-spinner";
83
+
84
+ // src/components/fullscreen.tsx
85
+ import { useEffect, useState } from "react";
86
+ import { Box, useStdout } from "ink";
87
+
88
+ // src/theme.ts
89
+ import React, { createContext, useContext } from "react";
90
+ var THEME_MODES = ["auto", "light", "dark"];
91
+ var BRAND_COLOR = "#C15F3C";
92
+ var themes = {
93
+ auto: {
94
+ mode: "auto",
95
+ // Leaving colors unset is more reliable than trying to detect whether a
96
+ // terminal is light or dark. ANSI defaults already follow its theme.
97
+ primary: void 0,
98
+ gray: void 0,
99
+ dark: void 0,
100
+ background: void 0,
101
+ dimSecondary: true,
102
+ inverseButton: true
103
+ },
104
+ light: {
105
+ mode: "light",
106
+ primary: "#18181b",
107
+ gray: "#52525b",
108
+ dark: "#ffffff",
109
+ background: "#ffffff",
110
+ dimSecondary: false,
111
+ inverseButton: false
112
+ },
113
+ dark: {
114
+ mode: "dark",
115
+ primary: "#ffffff",
116
+ gray: "#a1a1aa",
117
+ dark: "#18181b",
118
+ background: "#18181b",
119
+ dimSecondary: false,
120
+ inverseButton: false
121
+ }
122
+ };
123
+ var ThemeContext = createContext(themes.auto);
124
+ function themeFor(mode) {
125
+ return themes[mode];
126
+ }
127
+ function ThemeProvider({ mode, children }) {
128
+ return React.createElement(ThemeContext.Provider, { value: themeFor(mode) }, children);
129
+ }
130
+ function useTheme() {
131
+ return useContext(ThemeContext);
132
+ }
133
+ function nextThemeMode(mode) {
134
+ return THEME_MODES[(THEME_MODES.indexOf(mode) + 1) % THEME_MODES.length];
135
+ }
136
+
137
+ // src/components/fullscreen.tsx
138
+ import { jsx } from "react/jsx-runtime";
139
+ function FullScreen({ children }) {
140
+ const theme = useTheme();
141
+ const { stdout } = useStdout();
142
+ const dimensions = () => ({
143
+ columns: stdout?.columns && stdout.columns > 0 ? stdout.columns : 80,
144
+ rows: stdout?.rows && stdout.rows > 1 ? stdout.rows : 24
145
+ });
146
+ const [size, setSize] = useState(dimensions);
147
+ useEffect(() => {
148
+ if (!stdout) return;
149
+ const onResize = () => setSize(dimensions());
150
+ stdout.on("resize", onResize);
151
+ return () => {
152
+ stdout.off("resize", onResize);
153
+ };
154
+ }, [stdout]);
155
+ return /* @__PURE__ */ jsx(
156
+ Box,
157
+ {
158
+ width: size.columns,
159
+ height: size.rows - 1,
160
+ flexDirection: "column",
161
+ alignItems: "center",
162
+ justifyContent: "center",
163
+ backgroundColor: theme.background,
164
+ children: /* @__PURE__ */ jsx(Box, { flexDirection: "column", alignItems: "center", flexShrink: 0, children })
165
+ }
166
+ );
167
+ }
168
+
169
+ // src/components/logo.tsx
170
+ import { useEffect as useEffect2, useMemo, useState as useState2 } from "react";
171
+ import { Box as Box2, Text } from "ink";
172
+ import { jsx as jsx2 } from "react/jsx-runtime";
173
+ var ART = [
174
+ "\u2588\u2580\u2588 \u2588\u2580\u2588 \u2588\u2580\u2580 \u2588\u2580\u2584\u2588 \u2588\u2580\u2584\u2588 \u2588\u2580\u2588 \u2588\u2580\u2584\u2580\u2588 \u2580\u2588\u2580",
175
+ "\u2588 \u2593 \u2588\u2580\u2593 \u2588\u2580\u2593 \u2588 \u2593 \u2588 \u2593 \u2588\u2580\u2588 \u2588 \u2593 \u2588 \u2593 ",
176
+ "\u2580\u2580\u2580 \u2580 \u2580\u2580\u2580 \u2580 \u2580 \u2580 \u2580 \u2580 \u2580 \u2580 \u2580 \u2580\u2580\u2580"
177
+ ];
178
+ var GRID = ART.map((line) => [...line]);
179
+ var ROWS = GRID.length;
180
+ var INTRO_MS = 900;
181
+ var INTRO_SPREAD_MS = 550;
182
+ var SWEEP_MS = 1e3;
183
+ var SWEEP_EVERY_MS = 7e3;
184
+ var TILT = 2;
185
+ var HALF = 2.4;
186
+ var LIGHTER = { "\u2588": "\u2592", "\u2593": "\u2591" };
187
+ var HALF_BLOCKS = /* @__PURE__ */ new Set(["\u2580", "\u2584"]);
188
+ var ease = (t) => 1 - Math.pow(1 - t, 3);
189
+ var LOGO_COLOR = BRAND_COLOR;
190
+ function cellAt(ch, row, col, phase, t, delay, theme) {
191
+ if (ch === " " || phase === "idle") return { ch, color: LOGO_COLOR, dim: false };
192
+ if (phase === "intro") {
193
+ const dt = t - delay;
194
+ if (dt < 0) return { ch: " ", color: LOGO_COLOR, dim: false };
195
+ if (dt < 110) return { ch: HALF_BLOCKS.has(ch) ? ch : "\u2591", color: theme.gray, dim: theme.dimSecondary };
196
+ if (dt < 220) return { ch: HALF_BLOCKS.has(ch) ? ch : "\u2592", color: theme.gray, dim: theme.dimSecondary };
197
+ return { ch, color: LOGO_COLOR, dim: false };
198
+ }
199
+ const cols = GRID[0].length;
200
+ const pMin = -TILT * ROWS - HALF;
201
+ const pMax = cols + HALF;
202
+ const p = pMin + ease(t / SWEEP_MS) * (pMax - pMin);
203
+ const d = Math.abs(col - (ROWS - 1 - row) * TILT - p);
204
+ if (d <= HALF && 1 - d / HALF > 0.35) {
205
+ if (HALF_BLOCKS.has(ch)) return { ch, color: theme.gray, dim: theme.dimSecondary };
206
+ return { ch: LIGHTER[ch] ?? ch, color: LOGO_COLOR, dim: false };
207
+ }
208
+ return { ch, color: LOGO_COLOR, dim: false };
209
+ }
210
+ function renderRow(row, phase, t, delays, theme) {
211
+ const segments = [];
212
+ GRID[row].forEach((ch, col) => {
213
+ const cell = cellAt(ch, row, col, phase, t, delays[col], theme);
214
+ const last = segments[segments.length - 1];
215
+ if (last && (last.color === cell.color && last.dim === cell.dim || cell.ch === " ")) last.text += cell.ch;
216
+ else segments.push({ text: cell.ch, color: cell.color, dim: cell.dim });
217
+ });
218
+ return /* @__PURE__ */ jsx2(Text, { children: segments.map((s, i) => /* @__PURE__ */ jsx2(Text, { color: s.color, dimColor: s.dim, children: s.text }, i)) }, row);
219
+ }
220
+ function Logo() {
221
+ const theme = useTheme();
222
+ const animated = Boolean(process.stdout.isTTY);
223
+ const delays = useMemo(
224
+ () => GRID.map((row) => row.map(() => Math.random() * INTRO_SPREAD_MS)),
225
+ []
226
+ );
227
+ const [phase, setPhase] = useState2(animated ? "intro" : "idle");
228
+ const [t, setT] = useState2(0);
229
+ useEffect2(() => {
230
+ if (!animated) return;
231
+ if (phase === "idle") {
232
+ const id2 = setTimeout(() => {
233
+ setT(0);
234
+ setPhase("sweep");
235
+ }, SWEEP_EVERY_MS);
236
+ return () => clearTimeout(id2);
237
+ }
238
+ const duration = phase === "intro" ? INTRO_MS : SWEEP_MS;
239
+ const start = Date.now();
240
+ const id = setInterval(() => {
241
+ const elapsed = Date.now() - start;
242
+ if (elapsed >= duration) {
243
+ setT(0);
244
+ setPhase("idle");
245
+ } else {
246
+ setT(elapsed);
247
+ }
248
+ }, 33);
249
+ return () => clearInterval(id);
250
+ }, [phase, animated]);
251
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", flexShrink: 0, children: GRID.map((_, row) => renderRow(row, phase, t, delays[row], theme)) });
252
+ }
253
+
254
+ // src/components/shortcuts.tsx
255
+ import { Text as Text2 } from "ink";
256
+ import { Fragment, jsx as jsx3, jsxs } from "react/jsx-runtime";
257
+ function Shortcuts({ items, leading }) {
258
+ const theme = useTheme();
259
+ return /* @__PURE__ */ jsxs(Text2, { children: [
260
+ leading ? /* @__PURE__ */ jsxs(Fragment, { children: [
261
+ leading,
262
+ /* @__PURE__ */ jsx3(Text2, { color: theme.gray, dimColor: theme.dimSecondary, children: " \xB7 " })
263
+ ] }) : null,
264
+ items.map(([key, label], index) => /* @__PURE__ */ jsxs(Text2, { children: [
265
+ index > 0 ? /* @__PURE__ */ jsx3(Text2, { color: theme.gray, dimColor: theme.dimSecondary, children: " \xB7 " }) : null,
266
+ /* @__PURE__ */ jsx3(Text2, { color: theme.primary, children: key }),
267
+ /* @__PURE__ */ jsxs(Text2, { color: theme.gray, dimColor: theme.dimSecondary, children: [
268
+ " ",
269
+ label
270
+ ] })
271
+ ] }, `${key}-${label}`))
272
+ ] });
273
+ }
274
+
275
+ // src/components/views/input-view.tsx
276
+ import { Box as Box4, Text as Text5 } from "ink";
277
+
278
+ // src/components/framed-input.tsx
279
+ import { Box as Box3, Text as Text3 } from "ink";
280
+ import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
281
+ var frameButtonWidth = (label) => label.length + 4;
282
+ function FramedInput({
283
+ title,
284
+ width,
285
+ button,
286
+ buttonDim = false,
287
+ children
288
+ }) {
289
+ const theme = useTheme();
290
+ const inner = width - 2;
291
+ const tail = Math.max(0, inner - title.length - 3);
292
+ const buttonW = button ? frameButtonWidth(button) : 0;
293
+ const fillColor = buttonDim ? theme.gray : theme.primary;
294
+ return /* @__PURE__ */ jsxs2(Box3, { width: width + buttonW, children: [
295
+ /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", width, children: [
296
+ /* @__PURE__ */ jsxs2(Text3, { children: [
297
+ /* @__PURE__ */ jsx4(Text3, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u256D\u2500 " }),
298
+ /* @__PURE__ */ jsx4(Text3, { color: theme.primary, children: title }),
299
+ /* @__PURE__ */ jsx4(Text3, { color: theme.gray, dimColor: theme.dimSecondary, children: ` ${"\u2500".repeat(tail)}${button ? "\u2500" : "\u256E"}` })
300
+ ] }),
301
+ /* @__PURE__ */ jsxs2(Box3, { width, height: 1, overflow: "hidden", children: [
302
+ /* @__PURE__ */ jsx4(Text3, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u2502 " }),
303
+ /* @__PURE__ */ jsx4(Text3, { color: theme.primary, children: "\u276F " }),
304
+ /* @__PURE__ */ jsx4(Box3, { flexGrow: 1, height: 1, overflow: "hidden", children }),
305
+ button ? null : /* @__PURE__ */ jsx4(Text3, { color: theme.gray, dimColor: theme.dimSecondary, children: " \u2502" })
306
+ ] }),
307
+ /* @__PURE__ */ jsx4(Text3, { color: theme.gray, dimColor: theme.dimSecondary, children: `\u2570${"\u2500".repeat(inner)}${button ? "\u2500" : "\u256F"}` })
308
+ ] }),
309
+ button ? /* @__PURE__ */ jsxs2(Box3, { flexDirection: "column", width: buttonW, children: [
310
+ /* @__PURE__ */ jsx4(Text3, { bold: true, color: fillColor, dimColor: buttonDim && theme.dimSecondary, children: "\u2584".repeat(buttonW) }),
311
+ /* @__PURE__ */ jsx4(
312
+ Text3,
313
+ {
314
+ backgroundColor: theme.inverseButton ? void 0 : fillColor,
315
+ color: theme.inverseButton ? void 0 : theme.dark,
316
+ inverse: theme.inverseButton && !buttonDim,
317
+ dimColor: buttonDim && theme.dimSecondary,
318
+ bold: true,
319
+ children: ` ${button} `
320
+ }
321
+ ),
322
+ /* @__PURE__ */ jsx4(Text3, { bold: true, color: fillColor, dimColor: buttonDim && theme.dimSecondary, children: "\u2580".repeat(buttonW) })
323
+ ] }) : null
324
+ ] });
325
+ }
326
+
327
+ // src/components/text-input.tsx
328
+ import { useRef as useRef2, useState as useState3 } from "react";
329
+ import { Text as Text4, useInput } from "ink";
330
+
331
+ // src/lib/use-mouse-click.ts
332
+ import { useEffect as useEffect3, useRef } from "react";
333
+ import { useStdin, useStdout as useStdout2 } from "ink";
334
+ var ENABLE = "\x1B[?1000h\x1B[?1006h";
335
+ var DISABLE = "\x1B[?1006l\x1B[?1000l";
336
+ var SGR_PRESS = /\u001B\[<(\d+);(\d+);(\d+)M/g;
337
+ function useMouseClick(onClick, isActive) {
338
+ const handlerRef = useRef(onClick);
339
+ handlerRef.current = onClick;
340
+ const { stdin } = useStdin();
341
+ const { stdout } = useStdout2();
342
+ useEffect3(() => {
343
+ if (!isActive || !stdin || !stdout || !process.stdin.isTTY) return;
344
+ stdout.write(ENABLE);
345
+ const onData = (data) => {
346
+ for (const match of String(data).matchAll(SGR_PRESS)) {
347
+ const [, button, x, y] = match;
348
+ if (button === "0") handlerRef.current(Number(x), Number(y));
349
+ }
350
+ };
351
+ stdin.on("data", onData);
352
+ return () => {
353
+ stdin.off("data", onData);
354
+ stdout.write(DISABLE);
355
+ };
356
+ }, [isActive, stdin, stdout]);
357
+ }
358
+ var stripMouseReports = (value) => value.replace(/\u001B?\[?<\d+;\d+;\d+[Mm]/g, "");
359
+
360
+ // src/components/text-input.tsx
361
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
362
+ var wordLeft = (text, from) => {
363
+ let i = from;
364
+ while (i > 0 && !/\w/.test(text[i - 1])) i--;
365
+ while (i > 0 && /\w/.test(text[i - 1])) i--;
366
+ return i;
367
+ };
368
+ var wordRight = (text, from) => {
369
+ let i = from;
370
+ while (i < text.length && !/\w/.test(text[i])) i++;
371
+ while (i < text.length && /\w/.test(text[i])) i++;
372
+ return i;
373
+ };
374
+ function TextInput({
375
+ value,
376
+ onChange,
377
+ onSubmit,
378
+ placeholder = "",
379
+ width = 40,
380
+ history = [],
381
+ submitOnPaste,
382
+ onTab,
383
+ focus = true,
384
+ onArrowUp,
385
+ onArrowDown
386
+ }) {
387
+ const theme = useTheme();
388
+ const [cursorState, setCursorState] = useState3(value.length);
389
+ const [anchorState, setAnchorState] = useState3(null);
390
+ const [historyPos, setHistoryPos] = useState3(null);
391
+ const draftRef = useRef2("");
392
+ const offsetRef = useRef2(0);
393
+ const cursor = Math.min(cursorState, value.length);
394
+ const anchor = anchorState === null ? null : Math.min(anchorState, value.length);
395
+ const selection = anchor !== null && anchor !== cursor ? [Math.min(anchor, cursor), Math.max(anchor, cursor)] : null;
396
+ const place = (position, selecting = false) => {
397
+ if (selecting) {
398
+ if (anchor === null) setAnchorState(cursor);
399
+ } else {
400
+ setAnchorState(null);
401
+ }
402
+ setCursorState(Math.max(0, Math.min(value.length, position)));
403
+ };
404
+ const edit = (next, position) => {
405
+ setAnchorState(null);
406
+ setCursorState(Math.max(0, Math.min(next.length, position)));
407
+ setHistoryPos(null);
408
+ onChange(next);
409
+ };
410
+ const recall = (text) => {
411
+ setAnchorState(null);
412
+ setCursorState(text.length);
413
+ onChange(text);
414
+ };
415
+ const removeRange = (start, end) => edit(value.slice(0, start) + value.slice(end), start);
416
+ useInput((input, key) => {
417
+ if (key.return) {
418
+ onSubmit?.(value);
419
+ return;
420
+ }
421
+ if (key.tab) {
422
+ onTab?.();
423
+ return;
424
+ }
425
+ if (key.pageUp || key.pageDown) return;
426
+ if (key.escape) {
427
+ setAnchorState(null);
428
+ return;
429
+ }
430
+ if (key.upArrow || key.downArrow) {
431
+ if (history.length === 0) {
432
+ if (key.upArrow) onArrowUp?.();
433
+ if (key.downArrow) onArrowDown?.();
434
+ return;
435
+ }
436
+ if (key.upArrow) {
437
+ if (historyPos === null) draftRef.current = value;
438
+ const next2 = historyPos === null ? 0 : Math.min(historyPos + 1, history.length - 1);
439
+ if (next2 === historyPos) return;
440
+ setHistoryPos(next2);
441
+ recall(history[next2]);
442
+ } else if (historyPos !== null) {
443
+ const next2 = historyPos - 1;
444
+ setHistoryPos(next2 < 0 ? null : next2);
445
+ recall(next2 < 0 ? draftRef.current : history[next2]);
446
+ }
447
+ return;
448
+ }
449
+ if (key.home) return place(0);
450
+ if (key.end) return place(value.length);
451
+ if (key.leftArrow || key.rightArrow) {
452
+ const dir = key.leftArrow ? -1 : 1;
453
+ if (selection && !key.shift) return place(dir < 0 ? selection[0] : selection[1]);
454
+ const byWord = key.meta || key.ctrl;
455
+ const target = byWord ? dir < 0 ? wordLeft(value, cursor) : wordRight(value, cursor) : cursor + dir;
456
+ return place(target, key.shift);
457
+ }
458
+ if (key.backspace) {
459
+ if (selection) return removeRange(selection[0], selection[1]);
460
+ return removeRange(key.meta ? wordLeft(value, cursor) : Math.max(0, cursor - 1), cursor);
461
+ }
462
+ if (key.delete) {
463
+ if (selection) return removeRange(selection[0], selection[1]);
464
+ return removeRange(cursor, key.meta ? wordRight(value, cursor) : Math.min(value.length, cursor + 1));
465
+ }
466
+ if (key.ctrl) {
467
+ if (input === "a") return place(0);
468
+ if (input === "e") return place(value.length);
469
+ if (input === "u") return removeRange(0, selection ? selection[1] : cursor);
470
+ if (input === "k") return removeRange(selection ? selection[0] : cursor, value.length);
471
+ if (input === "w") {
472
+ const end2 = selection ? selection[1] : cursor;
473
+ return removeRange(wordLeft(value, selection ? selection[0] : cursor), end2);
474
+ }
475
+ return;
476
+ }
477
+ if (key.meta) {
478
+ if (input === "b") return place(wordLeft(value, cursor));
479
+ if (input === "f") return place(wordRight(value, cursor));
480
+ return;
481
+ }
482
+ if (!input) return;
483
+ const clean = stripMouseReports(input).replace(/[\x00-\x1f\x7f]/g, "");
484
+ if (!clean) return;
485
+ const [start, end] = selection ?? [cursor, cursor];
486
+ const next = value.slice(0, start) + clean + value.slice(end);
487
+ edit(next, start + clean.length);
488
+ if (clean.length > 1 && value === "" && submitOnPaste?.(next.trim())) onSubmit?.(next);
489
+ }, { isActive: Boolean(process.stdin.isTTY) && focus });
490
+ const span = Math.max(8, width);
491
+ let offset = Math.min(offsetRef.current, Math.max(0, value.length + 1 - span));
492
+ if (cursor < offset) offset = cursor;
493
+ if (cursor > offset + span - 1) offset = cursor - span + 1;
494
+ offsetRef.current = offset;
495
+ if (!focus) {
496
+ if (!value) {
497
+ return /* @__PURE__ */ jsx5(Text4, { color: theme.gray, dimColor: theme.dimSecondary, children: placeholder.slice(0, span - 1) });
498
+ }
499
+ const cells2 = Array.from({ length: Math.min(span, value.length - offset + 1) }, (_, column) => {
500
+ const index = offset + column;
501
+ return /* @__PURE__ */ jsx5(Text4, { color: theme.gray, dimColor: theme.dimSecondary, children: value[index] ?? " " }, index);
502
+ });
503
+ return /* @__PURE__ */ jsx5(Text4, { children: cells2 });
504
+ }
505
+ if (!value) {
506
+ return /* @__PURE__ */ jsxs3(Text4, { children: [
507
+ /* @__PURE__ */ jsx5(Text4, { inverse: true, children: " " }),
508
+ /* @__PURE__ */ jsx5(Text4, { color: theme.gray, dimColor: theme.dimSecondary, children: placeholder.slice(0, span - 1) })
509
+ ] });
510
+ }
511
+ const cells = Array.from({ length: Math.min(span, value.length - offset + 1) }, (_, column) => {
512
+ const index = offset + column;
513
+ const selected = selection !== null && index >= selection[0] && index < selection[1];
514
+ const atCursor = selection === null && index === cursor;
515
+ return /* @__PURE__ */ jsx5(Text4, { color: theme.primary, inverse: selected || atCursor, children: value[index] ?? " " }, index);
516
+ });
517
+ return /* @__PURE__ */ jsx5(Text4, { children: cells });
518
+ }
519
+
520
+ // src/lib/format.ts
521
+ function formatDuration(seconds) {
522
+ if (!Number.isFinite(seconds) || seconds <= 0) return "0:00";
523
+ const s = Math.round(seconds);
524
+ const h = Math.floor(s / 3600);
525
+ const m = Math.floor(s % 3600 / 60);
526
+ const sec = s % 60;
527
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
528
+ const ss = String(sec).padStart(2, "0");
529
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
530
+ }
531
+ function truncate(text, max) {
532
+ if (!text) return "";
533
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
534
+ }
535
+ function shortenPath(filepath, homedir, max = 60) {
536
+ const pretty = filepath.startsWith(homedir) ? `~${filepath.slice(homedir.length)}` : filepath;
537
+ if (pretty.length <= max) return pretty;
538
+ const ext = /\.\w{1,5}$/.exec(pretty)?.[0] ?? "";
539
+ return `${pretty.slice(0, max - ext.length - 1)}\u2026${ext}`;
540
+ }
541
+ function terminalLink(text, url) {
542
+ return `\x1B]8;;${url}\x07${text}\x1B]8;;\x07`;
543
+ }
544
+ var ERROR_RULES = [
545
+ {
546
+ tokens: ["notfounderror", "could not be found", "not found", "user does not exist", "profile not found"],
547
+ category: "not_found",
548
+ message: "profile or user could not be found",
549
+ hint: "verify username spelling, or the profile may be private, deleted, or login-restricted.",
550
+ inlineText: "not found or has no content"
551
+ },
552
+ {
553
+ tokens: ["login", "authrequired", "redirect to login", "checkpoint_required", "challenge_required", "bot verification"],
554
+ category: "auth_required",
555
+ message: "login required to access this media",
556
+ hint: "stories, highlights, and private accounts require active cookies in your cookies folder.",
557
+ inlineText: "login cookies required"
558
+ },
559
+ {
560
+ tokens: ["429", "too many requests", "rate limit", "throttled"],
561
+ category: "rate_limited",
562
+ message: "rate limit reached (HTTP 429)",
563
+ hint: "the platform is temporarily throttling requests. Please wait a minute or use cookies.",
564
+ inlineText: "rate limit reached (HTTP 429)"
565
+ },
566
+ {
567
+ tokens: ["private"],
568
+ category: "private_account",
569
+ message: "this account is private",
570
+ hint: "valid cookies with an account that follows this user are required to download.",
571
+ inlineText: "account is private"
572
+ }
573
+ ];
574
+ function classifyEngineError(raw) {
575
+ const lower = raw.toLowerCase();
576
+ for (const rule of ERROR_RULES) {
577
+ if (rule.tokens.some((t) => lower.includes(t))) {
578
+ return {
579
+ category: rule.category,
580
+ message: rule.message,
581
+ hint: rule.hint,
582
+ inlineText: rule.inlineText
583
+ };
584
+ }
585
+ }
586
+ const clean = raw.replace(/\[[^\]]+\]/g, "").replace(/^[:\s-]+/, "").trim();
587
+ return {
588
+ category: "generic",
589
+ message: clean || "download failed",
590
+ hint: "check your internet connection or verify the profile URL.",
591
+ inlineText: clean || "download failed"
592
+ };
593
+ }
594
+ function formatEngineError(raw) {
595
+ const classified = classifyEngineError(raw);
596
+ return {
597
+ message: classified.message,
598
+ hint: classified.hint
599
+ };
600
+ }
601
+ function cleanInlineError(err) {
602
+ const jobMatch = err.match(/^\[(Photos|Videos|Stories|Highlights)\]\s*(.*)/i);
603
+ const jobPrefix = jobMatch ? `${jobMatch[1].toLowerCase()}: ` : "";
604
+ const rest = jobMatch ? jobMatch[2] : err;
605
+ const classified = classifyEngineError(rest);
606
+ return `${jobPrefix}${classified.inlineText}`;
607
+ }
608
+
609
+ // src/components/views/input-view.tsx
610
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
611
+ function InputView({
612
+ urlInput,
613
+ setUrlInput,
614
+ onSubmit,
615
+ boxWidth,
616
+ history = [],
617
+ clipboardOffered,
618
+ clipboardAccepted,
619
+ clipboardUrl,
620
+ warning,
621
+ version = "1.0.0",
622
+ releaseUrl,
623
+ theme,
624
+ buttonText,
625
+ gapComponent: Gap2
626
+ }) {
627
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", alignItems: "center", children: [
628
+ /* @__PURE__ */ jsx6(FramedInput, { title: "Paste a profile link", width: boxWidth, button: buttonText, children: /* @__PURE__ */ jsx6(
629
+ TextInput,
630
+ {
631
+ value: urlInput,
632
+ onChange: setUrlInput,
633
+ onSubmit,
634
+ placeholder: "https://instagram.com/profile or @profile\u2026",
635
+ width: boxWidth - 6,
636
+ history,
637
+ onTab: () => {
638
+ if (clipboardOffered && clipboardUrl) setUrlInput(clipboardUrl);
639
+ }
640
+ }
641
+ ) }),
642
+ warning ? /* @__PURE__ */ jsxs4(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: [
643
+ "\u2717 ",
644
+ warning
645
+ ] }) : clipboardOffered ? /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: "link in your clipboard \u2014 Tab to paste it" }) : clipboardAccepted ? /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: "from your clipboard \u2014 \u21B5 to download it" }) : null,
646
+ /* @__PURE__ */ jsx6(Gap2, {}),
647
+ /* @__PURE__ */ jsxs4(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: [
648
+ /* @__PURE__ */ jsx6(Text5, { color: BRAND_COLOR, underline: true, children: terminalLink(`v${version}`, releaseUrl) }),
649
+ " \xB7 by ",
650
+ /* @__PURE__ */ jsx6(Text5, { color: BRAND_COLOR, underline: true, children: terminalLink("OpenSelena", "https://github.com/OpenSelena") })
651
+ ] })
652
+ ] });
653
+ }
654
+
655
+ // src/components/views/platform-view.tsx
656
+ import { Box as Box7, Text as Text8 } from "ink";
657
+ import SelectInput2 from "ink-select-input";
658
+
659
+ // src/components/panel.tsx
660
+ import { Box as Box5, Text as Text6 } from "ink";
661
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
662
+ function Panel({ title, width, children }) {
663
+ const theme = useTheme();
664
+ const inner = width - 2;
665
+ const tail = Math.max(0, inner - title.length - 3);
666
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", width, children: [
667
+ /* @__PURE__ */ jsxs5(Text6, { children: [
668
+ /* @__PURE__ */ jsx7(Text6, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u256D\u2500 " }),
669
+ /* @__PURE__ */ jsx7(Text6, { color: theme.primary, children: title }),
670
+ /* @__PURE__ */ jsx7(Text6, { color: theme.gray, dimColor: theme.dimSecondary, children: ` ${"\u2500".repeat(tail)}\u256E` })
671
+ ] }),
672
+ /* @__PURE__ */ jsx7(
673
+ Box5,
674
+ {
675
+ width,
676
+ borderStyle: "round",
677
+ borderColor: theme.gray,
678
+ borderDimColor: theme.dimSecondary,
679
+ borderBackgroundColor: theme.background,
680
+ borderTop: false,
681
+ flexDirection: "column",
682
+ paddingX: 2,
683
+ children
684
+ }
685
+ )
686
+ ] });
687
+ }
688
+
689
+ // src/components/views/picking-view.tsx
690
+ import { Box as Box6, Text as Text7 } from "ink";
691
+ import SelectInput from "ink-select-input";
692
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
693
+ function ChoiceIndicator({ isSelected }) {
694
+ const theme = useTheme();
695
+ return /* @__PURE__ */ jsx8(Box6, { marginRight: 1, children: /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: isSelected ? "\u276F" : " " }) });
696
+ }
697
+ function ChoiceItem({ isSelected, label }) {
698
+ const theme = useTheme();
699
+ return /* @__PURE__ */ jsx8(Text7, { color: theme.primary, bold: isSelected, children: label });
700
+ }
701
+ function getPickingOptions(platform) {
702
+ const items = [
703
+ { label: "Photos", value: "photos" },
704
+ { label: "Videos", value: "videos" }
705
+ ];
706
+ if (platform === "instagram") {
707
+ items.push({ label: "Stories", value: "stories" });
708
+ items.push({ label: "Highlights", value: "highlights" });
709
+ }
710
+ items.push({
711
+ label: "Everything",
712
+ value: "all"
713
+ });
714
+ return items;
715
+ }
716
+ function PickingView({
717
+ contentWidth,
718
+ profile,
719
+ onSelect,
720
+ onHighlight,
721
+ theme,
722
+ gapComponent: Gap2
723
+ }) {
724
+ const platformName = profile.platform.charAt(0).toUpperCase() + profile.platform.slice(1);
725
+ const items = getPickingOptions(profile.platform);
726
+ return /* @__PURE__ */ jsxs6(Box6, { width: contentWidth, children: [
727
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", flexGrow: 1, flexBasis: 0, paddingTop: 1, paddingRight: 3, children: [
728
+ /* @__PURE__ */ jsxs6(Text7, { bold: true, color: theme.primary, children: [
729
+ "@",
730
+ profile.username
731
+ ] }),
732
+ /* @__PURE__ */ jsx8(Gap2, {}),
733
+ /* @__PURE__ */ jsxs6(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: [
734
+ "\u25B8 ",
735
+ platformName,
736
+ profile.cleanUrl ? ` \xB7 ${profile.cleanUrl}` : ""
737
+ ] }),
738
+ /* @__PURE__ */ jsx8(Gap2, {}),
739
+ /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: "Choose what to download from this profile" })
740
+ ] }),
741
+ /* @__PURE__ */ jsx8(Panel, { title: "Download", width: 38, children: /* @__PURE__ */ jsx8(
742
+ SelectInput,
743
+ {
744
+ indicatorComponent: ChoiceIndicator,
745
+ itemComponent: ChoiceItem,
746
+ items: items.map((item, index) => ({
747
+ key: String(index),
748
+ label: item.label,
749
+ value: item.value
750
+ })),
751
+ onSelect: (item) => onSelect(item.value),
752
+ onHighlight: (item) => onHighlight?.(item.value)
753
+ }
754
+ ) })
755
+ ] });
756
+ }
757
+
758
+ // src/components/views/platform-view.tsx
759
+ import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
760
+ var PLATFORM_OPTIONS = [
761
+ { label: "Instagram", value: "instagram" },
762
+ { label: "TikTok", value: "tiktok" },
763
+ { label: "Facebook", value: "facebook" },
764
+ { label: "X (Twitter)", value: "x" }
765
+ ];
766
+ function getPlatformOptions() {
767
+ return PLATFORM_OPTIONS;
768
+ }
769
+ function PlatformView({
770
+ contentWidth,
771
+ username,
772
+ onSelect,
773
+ onHighlight,
774
+ theme,
775
+ gapComponent: Gap2
776
+ }) {
777
+ const items = getPlatformOptions();
778
+ return /* @__PURE__ */ jsxs7(Box7, { width: contentWidth, children: [
779
+ /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", flexGrow: 1, flexBasis: 0, paddingTop: 1, paddingRight: 3, children: [
780
+ /* @__PURE__ */ jsxs7(Text8, { bold: true, color: theme.primary, children: [
781
+ "@",
782
+ username.replace(/^@/, "")
783
+ ] }),
784
+ /* @__PURE__ */ jsx9(Gap2, {}),
785
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u25B8 Choose platform" }),
786
+ /* @__PURE__ */ jsx9(Gap2, {}),
787
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: "Select target platform for this profile" })
788
+ ] }),
789
+ /* @__PURE__ */ jsx9(Panel, { title: "Platform", width: 38, children: /* @__PURE__ */ jsx9(
790
+ SelectInput2,
791
+ {
792
+ indicatorComponent: ChoiceIndicator,
793
+ itemComponent: ChoiceItem,
794
+ items: items.map((item, index) => ({
795
+ key: String(index),
796
+ label: item.label,
797
+ value: item.value
798
+ })),
799
+ onSelect: (item) => onSelect(item.value),
800
+ onHighlight: (item) => onHighlight?.(item.value)
801
+ }
802
+ ) })
803
+ ] });
804
+ }
805
+
806
+ // src/components/views/probing-view.tsx
807
+ import { Box as Box8, Text as Text9 } from "ink";
808
+ import { jsx as jsx10 } from "react/jsx-runtime";
809
+ function ProbingView({
810
+ platformName,
811
+ target,
812
+ boxWidth,
813
+ theme,
814
+ buttonText
815
+ }) {
816
+ const displayTarget = target.length > boxWidth - 8 ? `${target.slice(0, boxWidth - 9)}\u2026` : target;
817
+ return /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", alignItems: "center", children: /* @__PURE__ */ jsx10(FramedInput, { title: platformName || "Paste a profile link", width: boxWidth, button: buttonText, buttonDim: true, children: /* @__PURE__ */ jsx10(Text9, { color: theme.gray, dimColor: theme.dimSecondary, children: displayTarget }) }) });
818
+ }
819
+
820
+ // src/components/views/downloading-view.tsx
821
+ import { useState as useState4, useEffect as useEffect4 } from "react";
822
+ import os from "os";
823
+ import { Box as Box9, Text as Text11 } from "ink";
824
+ import Spinner from "ink-spinner";
825
+
826
+ // src/components/progress-bar.tsx
827
+ import React5 from "react";
828
+ import { Text as Text10 } from "ink";
829
+ import { jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
830
+ function computeScannerTrack(tick, width = 30, blockWidth = 6) {
831
+ const safeWidth = Math.max(1, width);
832
+ const safeBlock = Math.min(safeWidth, Math.max(1, blockWidth));
833
+ const maxPos = safeWidth - safeBlock;
834
+ if (maxPos <= 0) {
835
+ return { before: 0, active: safeWidth, after: 0 };
836
+ }
837
+ const cycle = 2 * maxPos;
838
+ const phase = (tick % cycle + cycle) % cycle;
839
+ const pos = phase <= maxPos ? phase : cycle - phase;
840
+ return {
841
+ before: pos,
842
+ active: safeBlock,
843
+ after: safeWidth - pos - safeBlock
844
+ };
845
+ }
846
+ function getScannerSegments(tick, width = 30, blockWidth = 6) {
847
+ const { before, active, after } = computeScannerTrack(tick, width, blockWidth);
848
+ return {
849
+ beforeStr: "\u2591".repeat(before),
850
+ activeStr: "\u2588".repeat(active),
851
+ afterStr: "\u2591".repeat(after)
852
+ };
853
+ }
854
+ function ProgressBar({
855
+ percent = 0,
856
+ width = 30,
857
+ showPercent = true,
858
+ indeterminate = false,
859
+ tick: externalTick
860
+ }) {
861
+ const theme = useTheme();
862
+ const [internalTick, setInternalTick] = React5.useState(0);
863
+ React5.useEffect(() => {
864
+ if (!indeterminate || externalTick !== void 0) return;
865
+ const timer = setInterval(() => setInternalTick((t) => t + 1), 75);
866
+ return () => clearInterval(timer);
867
+ }, [indeterminate, externalTick]);
868
+ if (indeterminate) {
869
+ const currentTick = externalTick ?? internalTick;
870
+ const { beforeStr, activeStr, afterStr } = getScannerSegments(currentTick, width);
871
+ return /* @__PURE__ */ jsxs8(Text10, { children: [
872
+ /* @__PURE__ */ jsx11(Text10, { color: theme.gray, dimColor: theme.dimSecondary, children: beforeStr }),
873
+ /* @__PURE__ */ jsx11(Text10, { color: theme.primary, children: activeStr }),
874
+ /* @__PURE__ */ jsx11(Text10, { color: theme.gray, dimColor: theme.dimSecondary, children: afterStr })
875
+ ] });
876
+ }
877
+ const normalized = percent > 1 ? percent / 100 : Math.max(0, percent);
878
+ const clamped = Math.min(1, normalized);
879
+ const percentLabel = showPercent ? ` ${`${Math.round(clamped * 100)}%`.padStart(4)}` : "";
880
+ const barWidth = Math.max(1, showPercent ? width - percentLabel.length : width);
881
+ const filled = Math.round(clamped * barWidth);
882
+ return /* @__PURE__ */ jsxs8(Text10, { children: [
883
+ /* @__PURE__ */ jsx11(Text10, { color: theme.primary, children: "\u2588".repeat(filled) }),
884
+ /* @__PURE__ */ jsx11(Text10, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u2591".repeat(barWidth - filled) }),
885
+ percentLabel ? /* @__PURE__ */ jsx11(Text10, { color: theme.primary, children: percentLabel }) : null
886
+ ] });
887
+ }
888
+
889
+ // src/components/views/downloading-view.tsx
890
+ import { jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
891
+ function DownloadingView({
892
+ profile,
893
+ choice,
894
+ progress,
895
+ outputDir,
896
+ theme,
897
+ gapComponent: Gap2
898
+ }) {
899
+ const [elapsed, setElapsed] = useState4(0);
900
+ useEffect4(() => {
901
+ const timer = setInterval(() => setElapsed((s) => s + 1), 1e3);
902
+ return () => clearInterval(timer);
903
+ }, []);
904
+ const job = progress?.job || (choice ? `${choice.charAt(0).toUpperCase() + choice.slice(1)}` : "Initializing");
905
+ const downloaded = progress?.downloadedCount ?? 0;
906
+ const skipped = progress?.skippedCount ?? 0;
907
+ const currentFile = progress?.currentFile ? truncate(progress.currentFile, 42) : "";
908
+ const statusLine = progress?.statusText || (currentFile ? currentFile : "downloading\u2026");
909
+ return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", alignItems: "center", children: [
910
+ /* @__PURE__ */ jsxs9(Text11, { color: theme.gray, dimColor: theme.dimSecondary, children: [
911
+ "@",
912
+ profile.username,
913
+ " \xB7 ",
914
+ job
915
+ ] }),
916
+ /* @__PURE__ */ jsx12(Gap2, {}),
917
+ /* @__PURE__ */ jsx12(ProgressBar, { indeterminate: true, width: 30 }),
918
+ /* @__PURE__ */ jsx12(Gap2, {}),
919
+ /* @__PURE__ */ jsxs9(Text11, { children: [
920
+ /* @__PURE__ */ jsx12(Text11, { color: theme.primary, children: /* @__PURE__ */ jsx12(Spinner, { type: "dots" }) }),
921
+ /* @__PURE__ */ jsx12(Text11, { color: theme.gray, dimColor: theme.dimSecondary, children: ` ${statusLine}` })
922
+ ] }),
923
+ /* @__PURE__ */ jsx12(Gap2, {}),
924
+ /* @__PURE__ */ jsx12(Text11, { color: theme.gray, dimColor: theme.dimSecondary, children: `${downloaded} downloaded \xB7 ${skipped} archived \xB7 ${formatDuration(elapsed)} elapsed` }),
925
+ outputDir ? /* @__PURE__ */ jsxs9(Text11, { color: theme.gray, dimColor: theme.dimSecondary, children: [
926
+ "saving to ",
927
+ shortenPath(outputDir, os.homedir(), 54)
928
+ ] }) : null
929
+ ] });
930
+ }
931
+
932
+ // src/components/views/completion-view.tsx
933
+ import os2 from "os";
934
+ import { Box as Box10, Text as Text12 } from "ink";
935
+ import { Fragment as Fragment2, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
936
+ var DONE_LABEL = "\u21B5 download another";
937
+ function CompletionView({
938
+ profile,
939
+ result,
940
+ theme,
941
+ gapComponent: Gap2
942
+ }) {
943
+ const isZero = result.downloadedCount === 0 && result.skippedCount === 0;
944
+ const plural = result.downloadedCount === 1 ? "" : "s";
945
+ const skipText = result.skippedCount > 0 ? ` (${result.skippedCount} archived)` : "";
946
+ return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", alignItems: "center", children: [
947
+ /* @__PURE__ */ jsx13(Text12, { children: isZero ? /* @__PURE__ */ jsxs10(Fragment2, { children: [
948
+ /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "\u2713 profile scanned " }),
949
+ /* @__PURE__ */ jsx13(Text12, { color: theme.primary, children: "\u2014 0 media items found" })
950
+ ] }) : /* @__PURE__ */ jsxs10(Fragment2, { children: [
951
+ /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "\u2713 profile downloaded! " }),
952
+ /* @__PURE__ */ jsx13(Text12, { color: theme.primary, children: "saved to:" })
953
+ ] }) }),
954
+ /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: shortenPath(result.outputDir, os2.homedir(), 60) }),
955
+ /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: isZero ? "no photos or videos available" : `${result.downloadedCount} media item${plural} downloaded${skipText}` }),
956
+ /* @__PURE__ */ jsx13(Box10, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: [
957
+ /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "[o]" }),
958
+ " reveal in folder"
959
+ ] }) }),
960
+ /* @__PURE__ */ jsx13(Gap2, {}),
961
+ /* @__PURE__ */ jsx13(
962
+ Box10,
963
+ {
964
+ borderStyle: "round",
965
+ borderColor: theme.gray,
966
+ borderDimColor: theme.dimSecondary,
967
+ borderBackgroundColor: theme.background,
968
+ paddingX: 3,
969
+ children: /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: DONE_LABEL })
970
+ }
971
+ ),
972
+ result.errors.length > 0 ? /* @__PURE__ */ jsx13(Box10, { flexDirection: "column", marginTop: 1, alignItems: "center", children: result.errors.slice(0, 3).map((err, i) => /* @__PURE__ */ jsxs10(Text12, { color: "red", dimColor: true, wrap: "wrap", children: [
973
+ "\u2022 ",
974
+ cleanInlineError(err)
975
+ ] }, i)) }) : null
976
+ ] });
977
+ }
978
+
979
+ // src/components/views/error-view.tsx
980
+ import { Box as Box11, Text as Text13 } from "ink";
981
+ import { jsx as jsx14, jsxs as jsxs11 } from "react/jsx-runtime";
982
+ function ErrorView({ message, hint, columns, theme }) {
983
+ const width = Math.max(10, Math.min(columns - 6, 72));
984
+ return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", alignItems: "center", width, children: [
985
+ /* @__PURE__ */ jsxs11(Text13, { bold: true, color: theme.primary, children: [
986
+ "\u2717 ",
987
+ message
988
+ ] }),
989
+ hint ? /* @__PURE__ */ jsx14(Box11, { marginTop: 1, children: /* @__PURE__ */ jsxs11(Text13, { color: theme.gray, dimColor: theme.dimSecondary, children: [
990
+ "Hint: ",
991
+ hint
992
+ ] }) }) : null
993
+ ] });
994
+ }
995
+
996
+ // src/components/views/settings-view.tsx
997
+ import { useState as useState5, useMemo as useMemo2, useCallback } from "react";
998
+ import { Box as Box12, Text as Text14, useInput as useInput2 } from "ink";
999
+ import fs3 from "fs";
1000
+ import path3 from "path";
1001
+ import os5 from "os";
1002
+
1003
+ // src/lib/config.ts
1004
+ import fs from "fs";
1005
+ import path from "path";
1006
+ import os3 from "os";
1007
+ function getDefaultConfigPath() {
1008
+ if (process.env.OPEN_NAMI_CONFIG && process.env.OPEN_NAMI_CONFIG.trim()) {
1009
+ return path.resolve(process.env.OPEN_NAMI_CONFIG.trim());
1010
+ }
1011
+ return path.join(os3.homedir(), ".open-nami", "config.json");
1012
+ }
1013
+ function loadConfig(configPath = getDefaultConfigPath()) {
1014
+ try {
1015
+ if (!fs.existsSync(configPath)) return {};
1016
+ const raw = fs.readFileSync(configPath, "utf8");
1017
+ const parsed = JSON.parse(raw);
1018
+ if (parsed && typeof parsed === "object") {
1019
+ return {
1020
+ theme: typeof parsed.theme === "string" ? parsed.theme : void 0,
1021
+ cookiesDir: typeof parsed.cookiesDir === "string" ? parsed.cookiesDir.trim() : void 0,
1022
+ downloadDir: typeof parsed.downloadDir === "string" ? parsed.downloadDir.trim() : void 0
1023
+ };
1024
+ }
1025
+ } catch {
1026
+ }
1027
+ return {};
1028
+ }
1029
+ function saveConfig(config, configPath = getDefaultConfigPath()) {
1030
+ try {
1031
+ const dir = path.dirname(configPath);
1032
+ if (!fs.existsSync(dir)) {
1033
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
1034
+ }
1035
+ const data = JSON.stringify(config, null, 2);
1036
+ fs.writeFileSync(configPath, data, { encoding: "utf8", mode: 384 });
1037
+ } catch {
1038
+ }
1039
+ }
1040
+
1041
+ // src/lib/cookies.ts
1042
+ import fs2 from "fs";
1043
+ import fsPromises from "fs/promises";
1044
+ import path2 from "path";
1045
+ import os4 from "os";
1046
+ var CANDIDATE_DIRS = [
1047
+ path2.join(process.cwd(), "cookies"),
1048
+ path2.join(os4.homedir(), ".open-nami", "cookies"),
1049
+ path2.join(os4.homedir(), ".nami", "cookies")
1050
+ ];
1051
+ function getCandidateDirs(customDir) {
1052
+ const dirs = [];
1053
+ const configured = loadConfig().cookiesDir;
1054
+ const custom = customDir || process.env.OPEN_NAMI_COOKIES_DIR || process.env.NAMI_COOKIES_DIR || configured;
1055
+ if (custom && custom.trim()) {
1056
+ dirs.push(path2.resolve(custom.trim()));
1057
+ }
1058
+ const seen = new Set(dirs.map((d) => path2.normalize(d).toLowerCase()));
1059
+ for (const dir of CANDIDATE_DIRS) {
1060
+ const norm = path2.normalize(dir).toLowerCase();
1061
+ if (!seen.has(norm)) {
1062
+ seen.add(norm);
1063
+ dirs.push(dir);
1064
+ }
1065
+ }
1066
+ return dirs;
1067
+ }
1068
+ function findCookieFile(platform, customDir) {
1069
+ if (platform === "unknown") return null;
1070
+ const domainNames = {
1071
+ instagram: ["instagram.com_cookies.txt"],
1072
+ tiktok: ["tiktok.com_cookies.txt"],
1073
+ facebook: ["facebook.com_cookies.txt", "fb.com_cookies.txt"],
1074
+ x: ["x.com_cookies.txt", "twitter.com_cookies.txt"],
1075
+ unknown: []
1076
+ };
1077
+ const filenames = domainNames[platform] || [];
1078
+ const candidateDirs = getCandidateDirs(customDir);
1079
+ for (const dir of candidateDirs) {
1080
+ if (!fs2.existsSync(dir)) continue;
1081
+ for (const file of filenames) {
1082
+ const fullPath = path2.join(dir, file);
1083
+ if (fs2.existsSync(fullPath)) {
1084
+ try {
1085
+ const stats = fs2.statSync(fullPath);
1086
+ if (stats.size > 20) {
1087
+ return fullPath;
1088
+ }
1089
+ } catch {
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+ return null;
1095
+ }
1096
+ function detectAvailableCookies(customDir) {
1097
+ const platforms = ["instagram", "tiktok", "facebook", "x"];
1098
+ return platforms.filter((p) => Boolean(findCookieFile(p, customDir)));
1099
+ }
1100
+ async function createSafeCookieCopy(masterPath) {
1101
+ const userTempDir = path2.join(os4.homedir(), ".open-nami", "temp");
1102
+ let targetDir = userTempDir;
1103
+ try {
1104
+ await fsPromises.mkdir(userTempDir, { recursive: true, mode: 448 });
1105
+ } catch {
1106
+ targetDir = os4.tmpdir();
1107
+ }
1108
+ const tempPath = path2.join(targetDir, `nami_cookie_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
1109
+ await fsPromises.copyFile(masterPath, tempPath);
1110
+ await fsPromises.chmod(tempPath, 384).catch(() => {
1111
+ });
1112
+ return {
1113
+ filePath: tempPath,
1114
+ isTemporary: true,
1115
+ cleanup: async () => {
1116
+ try {
1117
+ await fsPromises.unlink(tempPath);
1118
+ } catch {
1119
+ }
1120
+ }
1121
+ };
1122
+ }
1123
+
1124
+ // src/components/views/settings-view.tsx
1125
+ import { jsx as jsx15, jsxs as jsxs12 } from "react/jsx-runtime";
1126
+ function expandDir(dir) {
1127
+ if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
1128
+ return path3.join(os5.homedir(), dir.slice(1));
1129
+ }
1130
+ return dir;
1131
+ }
1132
+ function SettingsView({
1133
+ boxWidth,
1134
+ theme,
1135
+ gapComponent: Gap2,
1136
+ focusedField: controlledFocus,
1137
+ onFocusChange,
1138
+ onSave,
1139
+ onCancel
1140
+ }) {
1141
+ const initialConfig = useMemo2(() => loadConfig(), []);
1142
+ const [internalFocus, setInternalFocus] = useState5("cookies");
1143
+ const focusedField = controlledFocus ?? internalFocus;
1144
+ const setField = useCallback(
1145
+ (field) => {
1146
+ setInternalFocus(field);
1147
+ onFocusChange?.(field);
1148
+ },
1149
+ [onFocusChange]
1150
+ );
1151
+ const [cookiesDir, setCookiesDir] = useState5(initialConfig.cookiesDir ?? "");
1152
+ const [downloadDir, setDownloadDir] = useState5(initialConfig.downloadDir ?? "");
1153
+ const [saved, setSaved] = useState5(false);
1154
+ const handleSave = useCallback(() => {
1155
+ const existing = loadConfig();
1156
+ const nextConfig = {
1157
+ ...existing,
1158
+ cookiesDir: cookiesDir.trim() ? cookiesDir.trim() : void 0,
1159
+ downloadDir: downloadDir.trim() ? downloadDir.trim() : void 0
1160
+ };
1161
+ saveConfig(nextConfig);
1162
+ setSaved(true);
1163
+ onSave();
1164
+ }, [cookiesDir, downloadDir, onSave]);
1165
+ const cookieFeedback = useMemo2(() => {
1166
+ const dir = cookiesDir.trim();
1167
+ if (!dir) {
1168
+ const defaultCookies = detectAvailableCookies();
1169
+ return {
1170
+ ok: defaultCookies.length > 0,
1171
+ text: defaultCookies.length > 0 ? `default paths \xB7 ${defaultCookies.length} cookie(s) found (${defaultCookies.join(", ")})` : "default paths \xB7 no recognized cookies found"
1172
+ };
1173
+ }
1174
+ const expanded = expandDir(dir);
1175
+ if (!fs3.existsSync(expanded)) {
1176
+ return {
1177
+ ok: false,
1178
+ text: "directory does not exist yet"
1179
+ };
1180
+ }
1181
+ const detected = detectAvailableCookies(expanded);
1182
+ if (detected.length === 0) {
1183
+ return {
1184
+ ok: false,
1185
+ text: "no recognized cookie files found in this directory"
1186
+ };
1187
+ }
1188
+ return {
1189
+ ok: true,
1190
+ text: `\u2713 ${detected.length} cookie(s) found (${detected.join(", ")})`
1191
+ };
1192
+ }, [cookiesDir]);
1193
+ const downloadFeedback = useMemo2(() => {
1194
+ const dir = downloadDir.trim();
1195
+ if (!dir) {
1196
+ return "default: platform Downloads / Open Nami";
1197
+ }
1198
+ return `target: ${path3.resolve(expandDir(dir))}`;
1199
+ }, [downloadDir]);
1200
+ useInput2((input, key) => {
1201
+ if (key.escape) {
1202
+ onCancel();
1203
+ return;
1204
+ }
1205
+ if (key.ctrl && (input === "s" || input === "S")) {
1206
+ handleSave();
1207
+ return;
1208
+ }
1209
+ }, { isActive: Boolean(process.stdin.isTTY) });
1210
+ return /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", alignItems: "center", children: [
1211
+ /* @__PURE__ */ jsx15(
1212
+ FramedInput,
1213
+ {
1214
+ title: focusedField === "cookies" ? "\u25CF Cookies Folder" : "\u25CB Cookies Folder",
1215
+ width: boxWidth,
1216
+ children: /* @__PURE__ */ jsx15(
1217
+ TextInput,
1218
+ {
1219
+ value: cookiesDir,
1220
+ onChange: setCookiesDir,
1221
+ onSubmit: () => setField("download"),
1222
+ focus: focusedField === "cookies",
1223
+ onTab: () => setField("download"),
1224
+ onArrowDown: () => setField("download"),
1225
+ onArrowUp: () => setField("download"),
1226
+ placeholder: "e.g. F:\\Nami\\cookies or ./cookies",
1227
+ width: boxWidth - 6
1228
+ }
1229
+ )
1230
+ }
1231
+ ),
1232
+ /* @__PURE__ */ jsx15(Text14, { color: cookieFeedback.ok ? theme.primary : theme.gray, dimColor: !cookieFeedback.ok && theme.dimSecondary, children: cookieFeedback.text }),
1233
+ /* @__PURE__ */ jsx15(Gap2, {}),
1234
+ /* @__PURE__ */ jsx15(
1235
+ FramedInput,
1236
+ {
1237
+ title: focusedField === "download" ? "\u25CF Download Directory" : "\u25CB Download Directory",
1238
+ width: boxWidth,
1239
+ children: /* @__PURE__ */ jsx15(
1240
+ TextInput,
1241
+ {
1242
+ value: downloadDir,
1243
+ onChange: setDownloadDir,
1244
+ onSubmit: handleSave,
1245
+ focus: focusedField === "download",
1246
+ onTab: () => setField("cookies"),
1247
+ onArrowDown: () => setField("cookies"),
1248
+ onArrowUp: () => setField("cookies"),
1249
+ placeholder: "e.g. D:\\Downloads\\Open Nami",
1250
+ width: boxWidth - 6
1251
+ }
1252
+ )
1253
+ }
1254
+ ),
1255
+ /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: downloadFeedback }),
1256
+ /* @__PURE__ */ jsx15(Gap2, {}),
1257
+ /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: saved ? "\u2713 preferences saved to ~/.open-nami/config.json" : "tab / \u2191\u2193 switch field \xB7 \u21B5 advance/save \xB7 esc back" })
1258
+ ] });
1259
+ }
1260
+
1261
+ // src/lib/parser.ts
1262
+ var INVALID_CHARS_REGEX = /[<>:"/\\|?*\x00-\x1f]/g;
1263
+ var WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
1264
+ function sanitizeUsername(username) {
1265
+ let cleaned = username.replace(INVALID_CHARS_REGEX, "_").trim().replace(/^[.\s]+|[.\s]+$/g, "");
1266
+ if (WINDOWS_RESERVED_NAMES.test(cleaned)) {
1267
+ cleaned = `_${cleaned}`;
1268
+ }
1269
+ return cleaned.slice(0, 120) || "unknown";
1270
+ }
1271
+ function buildProfileUrl(platform, username) {
1272
+ const cleanUser = username.trim().replace(/^@/, "").replace(/^[.\s]+|[.\s]+$/g, "");
1273
+ switch (platform) {
1274
+ case "instagram":
1275
+ return `https://www.instagram.com/${cleanUser}/`;
1276
+ case "tiktok":
1277
+ return `https://www.tiktok.com/@${cleanUser}`;
1278
+ case "facebook":
1279
+ return `https://www.facebook.com/${cleanUser}`;
1280
+ case "x":
1281
+ return `https://x.com/${cleanUser}`;
1282
+ }
1283
+ }
1284
+ function buildCanonicalProfile(platform, username, rawInput) {
1285
+ const cleanUser = username.trim().replace(/^@/, "").replace(/^[.\s]+|[.\s]+$/g, "");
1286
+ return {
1287
+ platform,
1288
+ username: sanitizeUsername(cleanUser),
1289
+ cleanUrl: buildProfileUrl(platform, cleanUser),
1290
+ rawInput: rawInput ?? cleanUser
1291
+ };
1292
+ }
1293
+ function isBareUsername(input) {
1294
+ const trimmed = input.trim();
1295
+ if (!trimmed) return false;
1296
+ if (/^https?:\/\//i.test(trimmed) || /[\/\\:\s]/.test(trimmed)) return false;
1297
+ const user = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
1298
+ if (!user) return false;
1299
+ if (!/^[a-zA-Z0-9._-]+$/.test(user)) return false;
1300
+ if (!/[a-zA-Z0-9]/.test(user)) return false;
1301
+ const cleaned = user.replace(/^[.\s]+|[.\s]+$/g, "");
1302
+ return cleaned.length > 0 && /[a-zA-Z0-9]/.test(cleaned);
1303
+ }
1304
+ function parseProfileInput(input) {
1305
+ const trimmed = input.trim();
1306
+ if (!trimmed) {
1307
+ return { platform: "unknown", username: "", cleanUrl: "", rawInput: input };
1308
+ }
1309
+ const prefixMatch = trimmed.match(/^(ig|instagram|tt|tiktok|fb|facebook|x|twitter):([^\s/]+)$/i);
1310
+ if (prefixMatch) {
1311
+ const rawPrefix = prefixMatch[1].toLowerCase();
1312
+ const rawUser = prefixMatch[2];
1313
+ if (rawPrefix === "ig" || rawPrefix === "instagram") {
1314
+ return buildCanonicalProfile("instagram", rawUser, input);
1315
+ } else if (rawPrefix === "tt" || rawPrefix === "tiktok") {
1316
+ return buildCanonicalProfile("tiktok", rawUser, input);
1317
+ } else if (rawPrefix === "fb" || rawPrefix === "facebook") {
1318
+ return buildCanonicalProfile("facebook", rawUser, input);
1319
+ } else if (rawPrefix === "x" || rawPrefix === "twitter") {
1320
+ return buildCanonicalProfile("x", rawUser, input);
1321
+ }
1322
+ }
1323
+ let candidate = trimmed;
1324
+ if (!/^https?:\/\//i.test(candidate)) {
1325
+ if (candidate.includes("instagram.com") || candidate.includes("tiktok.com") || candidate.includes("facebook.com") || candidate.includes("x.com") || candidate.includes("twitter.com")) {
1326
+ candidate = "https://" + candidate;
1327
+ }
1328
+ }
1329
+ try {
1330
+ const url = new URL(candidate);
1331
+ const host = url.hostname.toLowerCase().replace(/^www\./, "").replace(/^m\./, "");
1332
+ const pathname = url.pathname.replace(/\/+$/, "");
1333
+ const pathParts = pathname.split("/").filter(Boolean);
1334
+ if (host.includes("tiktok.com")) {
1335
+ const userPart = pathParts[0] || "";
1336
+ const username = userPart.startsWith("@") ? userPart.slice(1) : userPart;
1337
+ if (username) {
1338
+ return {
1339
+ platform: "tiktok",
1340
+ username: sanitizeUsername(username),
1341
+ cleanUrl: `https://www.tiktok.com/@${username}`,
1342
+ rawInput: input
1343
+ };
1344
+ }
1345
+ }
1346
+ if (host.includes("instagram.com")) {
1347
+ const firstPart = pathParts[0] || "";
1348
+ const reserved = ["p", "reel", "reels", "tv", "stories", "explore", "direct", "accounts"];
1349
+ if (firstPart && !reserved.includes(firstPart.toLowerCase())) {
1350
+ const username = firstPart.replace(/^@/, "");
1351
+ return {
1352
+ platform: "instagram",
1353
+ username: sanitizeUsername(username),
1354
+ cleanUrl: `https://www.instagram.com/${username}/`,
1355
+ rawInput: input
1356
+ };
1357
+ }
1358
+ }
1359
+ if (host.includes("facebook.com") || host === "fb.com") {
1360
+ if (pathParts[0]?.toLowerCase() === "profile.php") {
1361
+ const id = url.searchParams.get("id");
1362
+ if (id) {
1363
+ return {
1364
+ platform: "facebook",
1365
+ username: sanitizeUsername(id),
1366
+ cleanUrl: `https://www.facebook.com/profile.php?id=${id}`,
1367
+ rawInput: input
1368
+ };
1369
+ }
1370
+ } else if (pathParts[0]?.toLowerCase() === "people" && pathParts[1]) {
1371
+ return {
1372
+ platform: "facebook",
1373
+ username: sanitizeUsername(pathParts[1]),
1374
+ cleanUrl: `https://www.facebook.com/people/${pathParts[1]}`,
1375
+ rawInput: input
1376
+ };
1377
+ } else if (pathParts[0]) {
1378
+ const reserved = ["watch", "videos", "reel", "groups", "pages", "events", "photo"];
1379
+ if (!reserved.includes(pathParts[0].toLowerCase())) {
1380
+ return {
1381
+ platform: "facebook",
1382
+ username: sanitizeUsername(pathParts[0]),
1383
+ cleanUrl: `https://www.facebook.com/${pathParts[0]}`,
1384
+ rawInput: input
1385
+ };
1386
+ }
1387
+ }
1388
+ }
1389
+ if (host.includes("x.com") || host.includes("twitter.com")) {
1390
+ const firstPart = pathParts[0] || "";
1391
+ const reserved = ["home", "explore", "notifications", "messages", "i", "settings"];
1392
+ if (firstPart && !reserved.includes(firstPart.toLowerCase())) {
1393
+ const username = firstPart.replace(/^@/, "");
1394
+ return {
1395
+ platform: "x",
1396
+ username: sanitizeUsername(username),
1397
+ cleanUrl: `https://x.com/${username}`,
1398
+ rawInput: input
1399
+ };
1400
+ }
1401
+ }
1402
+ } catch {
1403
+ }
1404
+ if (isBareUsername(trimmed)) {
1405
+ const rawUser = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
1406
+ const user = rawUser.replace(/^[.\s]+|[.\s]+$/g, "");
1407
+ return {
1408
+ platform: "unknown",
1409
+ username: user,
1410
+ cleanUrl: "",
1411
+ rawInput: input
1412
+ };
1413
+ }
1414
+ return {
1415
+ platform: "unknown",
1416
+ username: "",
1417
+ cleanUrl: "",
1418
+ rawInput: input
1419
+ };
1420
+ }
1421
+
1422
+ // src/lib/engines/dispatcher.ts
1423
+ import path9 from "path";
1424
+ import os8 from "os";
1425
+ import fs9 from "fs/promises";
1426
+
1427
+ // src/lib/known-folders.ts
1428
+ import { execFileSync } from "child_process";
1429
+ import fs4 from "fs";
1430
+ import os6 from "os";
1431
+ import path4 from "path";
1432
+ var KNOWN_FOLDER_DOWNLOADS_GUID = "{374DE290-123F-4565-9164-39C4925E467B}";
1433
+ var USER_SHELL_FOLDERS_REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders";
1434
+ function expandWindowsEnv(str, env = process.env, homeDir) {
1435
+ return str.replace(/%([^%]+)%/g, (original, name) => {
1436
+ if (homeDir && name.toLowerCase() === "userprofile") {
1437
+ return homeDir;
1438
+ }
1439
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
1440
+ if (key && env[key] !== void 0) {
1441
+ return env[key];
1442
+ }
1443
+ return original;
1444
+ });
1445
+ }
1446
+ function parseWindowsRegistryOutput(output, targetKey = KNOWN_FOLDER_DOWNLOADS_GUID) {
1447
+ const lines = output.split(/\r?\n/);
1448
+ const escaped = targetKey.replace(/[{}]/g, "\\$&");
1449
+ const pattern = new RegExp(`^\\s*${escaped}\\s+REG_\\w+\\s+(.*)$`, "i");
1450
+ for (const line of lines) {
1451
+ const match = line.match(pattern);
1452
+ if (match && match[1]) {
1453
+ const rawPath = match[1].trim();
1454
+ if (rawPath) return rawPath;
1455
+ }
1456
+ }
1457
+ return void 0;
1458
+ }
1459
+ function parseLinuxUserDirsOutput(content) {
1460
+ const lines = content.split(/\r?\n/);
1461
+ for (const line of lines) {
1462
+ const trimmed = line.trim();
1463
+ if (trimmed.startsWith("#")) continue;
1464
+ const match = trimmed.match(/^XDG_DOWNLOAD_DIR=["']?([^"']+)["']?$/);
1465
+ if (match && match[1]) {
1466
+ return match[1].trim();
1467
+ }
1468
+ }
1469
+ return void 0;
1470
+ }
1471
+ function isPathDriveAccessible(filePath, fsExists = fs4.existsSync, platform = process.platform) {
1472
+ try {
1473
+ if (fsExists(filePath)) {
1474
+ return true;
1475
+ }
1476
+ if (platform === "win32") {
1477
+ const match = filePath.match(/^([a-zA-Z]:[\\/])/i);
1478
+ const driveRoot = match ? match[1] : path4.win32.parse(path4.win32.resolve(filePath)).root;
1479
+ return fsExists(driveRoot);
1480
+ }
1481
+ const normalized = filePath.replace(/\\/g, "/");
1482
+ const mountMatch = normalized.match(/^(\/(?:media(?:\/[^/]+)?|mnt|run\/media(?:\/[^/]+)?)\/[^/]+)/);
1483
+ if (mountMatch) {
1484
+ return fsExists(mountMatch[1]);
1485
+ }
1486
+ let curr = path4.posix.dirname(normalized);
1487
+ while (curr && curr !== "/") {
1488
+ if (fsExists(curr)) return true;
1489
+ curr = path4.posix.dirname(curr);
1490
+ }
1491
+ return fsExists("/");
1492
+ } catch {
1493
+ return false;
1494
+ }
1495
+ }
1496
+ function resolveWindowsDownloadsDir(options = {}) {
1497
+ const homeDir = options.homeDir ?? os6.homedir();
1498
+ const env = options.env ?? process.env;
1499
+ const runQuery = options.regQueryFn ?? ((key, value) => {
1500
+ try {
1501
+ return execFileSync("reg", ["query", key, "/v", value], {
1502
+ encoding: "utf8",
1503
+ stdio: ["pipe", "pipe", "ignore"]
1504
+ });
1505
+ } catch {
1506
+ return "";
1507
+ }
1508
+ });
1509
+ const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs4.existsSync, "win32"));
1510
+ let raw = parseWindowsRegistryOutput(
1511
+ runQuery(USER_SHELL_FOLDERS_REG_KEY, KNOWN_FOLDER_DOWNLOADS_GUID),
1512
+ KNOWN_FOLDER_DOWNLOADS_GUID
1513
+ );
1514
+ if (!raw) {
1515
+ raw = parseWindowsRegistryOutput(runQuery(USER_SHELL_FOLDERS_REG_KEY, "Downloads"), "Downloads");
1516
+ }
1517
+ if (raw) {
1518
+ const expanded = expandWindowsEnv(raw, env, homeDir);
1519
+ if (checkAccessible(expanded)) {
1520
+ return path4.win32.resolve(expanded);
1521
+ }
1522
+ }
1523
+ return void 0;
1524
+ }
1525
+ function resolveLinuxDownloadsDir(options = {}) {
1526
+ const homeDir = options.homeDir ?? os6.homedir();
1527
+ const env = options.env ?? process.env;
1528
+ const readFn = options.readFileFn ?? ((p) => {
1529
+ try {
1530
+ return fs4.readFileSync(p, "utf8");
1531
+ } catch {
1532
+ return "";
1533
+ }
1534
+ });
1535
+ const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs4.existsSync, "linux"));
1536
+ const configHome = env.XDG_CONFIG_HOME || path4.join(homeDir, ".config");
1537
+ const userDirsFile = path4.join(configHome, "user-dirs.dirs");
1538
+ const content = readFn(userDirsFile);
1539
+ if (!content) return void 0;
1540
+ const raw = parseLinuxUserDirsOutput(content);
1541
+ if (!raw) return void 0;
1542
+ const expanded = raw.replace(/\$HOME/g, homeDir).replace(/\$\{HOME\}/g, homeDir);
1543
+ if (checkAccessible(expanded)) {
1544
+ return path4.posix.resolve(expanded);
1545
+ }
1546
+ return void 0;
1547
+ }
1548
+ function resolvePlatformDownloadsDir(options = {}) {
1549
+ const platform = options.platform ?? process.platform;
1550
+ const homeDir = options.homeDir ?? os6.homedir();
1551
+ const defaultFallback = path4.join(homeDir, "Downloads");
1552
+ if (platform === "win32") {
1553
+ const resolved = resolveWindowsDownloadsDir(options);
1554
+ if (resolved) return resolved;
1555
+ } else if (platform === "linux") {
1556
+ const resolved = resolveLinuxDownloadsDir(options);
1557
+ if (resolved) return resolved;
1558
+ }
1559
+ return defaultFallback;
1560
+ }
1561
+
1562
+ // src/lib/engines/gallery-dl.ts
1563
+ import { spawn as spawn2 } from "child_process";
1564
+ import path7 from "path";
1565
+ import fs7 from "fs/promises";
1566
+
1567
+ // src/lib/engines/exec.ts
1568
+ import fs6 from "fs";
1569
+ import path6 from "path";
1570
+
1571
+ // src/lib/engines/fetcher.ts
1572
+ import { spawn } from "child_process";
1573
+ import { createWriteStream } from "fs";
1574
+ import fs5 from "fs/promises";
1575
+ import os7 from "os";
1576
+ import path5 from "path";
1577
+ import { Readable } from "stream";
1578
+ import { pipeline } from "stream/promises";
1579
+ function getOpenNamiBinDir(env = process.env) {
1580
+ if (env.OPEN_NAMI_BIN_DIR && env.OPEN_NAMI_BIN_DIR.trim()) {
1581
+ return path5.resolve(env.OPEN_NAMI_BIN_DIR.trim());
1582
+ }
1583
+ const base = env.OPEN_NAMI_DIR && env.OPEN_NAMI_DIR.trim() ? env.OPEN_NAMI_DIR.trim() : os7.homedir();
1584
+ return path5.join(base, ".open-nami", "bin");
1585
+ }
1586
+ var YTDLP_RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
1587
+ var GALLERYDL_CODEBERG_API = "https://codeberg.org/api/v1/repos/mikf/gallery-dl/releases/latest";
1588
+ var GALLERYDL_GITHUB_FALLBACK = "https://github.com/mikf/gallery-dl/releases/latest/download";
1589
+ function ytDlpAssetName(platform = process.platform, arch = process.arch) {
1590
+ if (platform === "win32") return "yt-dlp.exe";
1591
+ if (platform === "darwin") return "yt-dlp_macos";
1592
+ return arch === "arm64" ? "yt-dlp_linux_aarch64" : "yt-dlp_linux";
1593
+ }
1594
+ function galleryDlAssetName(platform = process.platform) {
1595
+ if (platform === "win32") return "gallery-dl.exe";
1596
+ return "gallery-dl.bin";
1597
+ }
1598
+ function commandWorks(cmd, args, timeoutMs = 1e4) {
1599
+ return new Promise((resolve) => {
1600
+ let child;
1601
+ try {
1602
+ child = spawn(cmd, args, { stdio: "ignore", timeout: timeoutMs });
1603
+ } catch {
1604
+ resolve(false);
1605
+ return;
1606
+ }
1607
+ child.on("error", () => resolve(false));
1608
+ child.on("close", (code) => resolve(code === 0));
1609
+ });
1610
+ }
1611
+ function getBinaryVersion(executablePath) {
1612
+ return new Promise((resolve) => {
1613
+ let child;
1614
+ let out = "";
1615
+ try {
1616
+ child = spawn(executablePath, ["--version"]);
1617
+ } catch {
1618
+ resolve(void 0);
1619
+ return;
1620
+ }
1621
+ child.stdout?.on("data", (d) => {
1622
+ out += d.toString();
1623
+ });
1624
+ child.on("error", () => resolve(void 0));
1625
+ child.on("close", (code) => {
1626
+ if (code === 0 && out.trim()) {
1627
+ resolve(out.trim());
1628
+ } else {
1629
+ resolve(void 0);
1630
+ }
1631
+ });
1632
+ });
1633
+ }
1634
+ async function downloadBinary(url, targetFile, signal) {
1635
+ await fs5.mkdir(path5.dirname(targetFile), { recursive: true });
1636
+ const response = await fetch(url, {
1637
+ signal,
1638
+ headers: { "User-Agent": "open-nami" }
1639
+ });
1640
+ if (!response.ok || !response.body) {
1641
+ throw new Error(`Failed to download binary from ${url} (HTTP ${response.status}). Check your connection and try again.`);
1642
+ }
1643
+ const tmp = `${targetFile}.download`;
1644
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(tmp), { signal });
1645
+ try {
1646
+ await fs5.chmod(tmp, 493);
1647
+ } catch {
1648
+ }
1649
+ await fs5.rename(tmp, targetFile);
1650
+ return targetFile;
1651
+ }
1652
+ async function downloadLatestYtDlp(targetDir, signal, onStatus) {
1653
+ const dir = targetDir || getOpenNamiBinDir();
1654
+ const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
1655
+ const local = path5.join(dir, binaryName);
1656
+ const asset = ytDlpAssetName();
1657
+ const url = `${YTDLP_RELEASE_BASE}/${asset}`;
1658
+ onStatus?.(`fetching latest yt-dlp (${asset})\u2026`);
1659
+ return await downloadBinary(url, local, signal);
1660
+ }
1661
+ async function getGalleryDlDownloadUrls(assetName, fetchFn = fetch, signal) {
1662
+ const urls = [];
1663
+ try {
1664
+ const res = await fetchFn(GALLERYDL_CODEBERG_API, {
1665
+ signal,
1666
+ headers: { "User-Agent": "open-nami" }
1667
+ });
1668
+ if (res.ok) {
1669
+ const data = await res.json();
1670
+ const asset = data.assets?.find((a) => a.name === assetName);
1671
+ const downloadUrl = asset?.browser_download_url || asset?.download_url;
1672
+ if (downloadUrl) {
1673
+ urls.push(downloadUrl);
1674
+ } else if (data.tag_name) {
1675
+ urls.push(`https://codeberg.org/mikf/gallery-dl/releases/download/${data.tag_name}/${assetName}`);
1676
+ }
1677
+ }
1678
+ } catch {
1679
+ }
1680
+ urls.push(`${GALLERYDL_GITHUB_FALLBACK}/${assetName}`);
1681
+ return urls;
1682
+ }
1683
+ async function downloadLatestGalleryDl(targetDir, signal, onStatus) {
1684
+ if (process.platform === "darwin") {
1685
+ throw new Error(
1686
+ "Standalone gallery-dl binary is not distributed for macOS. Please install it via Homebrew: 'brew install gallery-dl'"
1687
+ );
1688
+ }
1689
+ const dir = targetDir || getOpenNamiBinDir();
1690
+ const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
1691
+ const local = path5.join(dir, binaryName);
1692
+ const asset = galleryDlAssetName();
1693
+ const candidateUrls = await getGalleryDlDownloadUrls(asset, fetch, signal);
1694
+ let lastError;
1695
+ for (const url of candidateUrls) {
1696
+ try {
1697
+ onStatus?.("fetching gallery-dl from Codeberg / GitHub\u2026");
1698
+ return await downloadBinary(url, local, signal);
1699
+ } catch (err) {
1700
+ if (signal?.aborted) throw err;
1701
+ lastError = err instanceof Error ? err : new Error(String(err));
1702
+ }
1703
+ }
1704
+ throw new Error(`Could not download gallery-dl (${lastError?.message || "unknown error"}). Check your connection and try again.`);
1705
+ }
1706
+ async function ensureYtDlp(onStatus, signal) {
1707
+ if (await commandWorks("yt-dlp", ["--version"])) return "yt-dlp";
1708
+ const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
1709
+ const local = path5.join(getOpenNamiBinDir(), binaryName);
1710
+ if (await commandWorks(local, ["--version"])) return local;
1711
+ if (await commandWorks("python", ["-m", "yt_dlp", "--version"])) return "python";
1712
+ if (await commandWorks("python3", ["-m", "yt_dlp", "--version"])) return "python3";
1713
+ onStatus?.("first run: downloading standalone yt-dlp\u2026");
1714
+ return await downloadLatestYtDlp(getOpenNamiBinDir(), signal, onStatus);
1715
+ }
1716
+ async function ensureGalleryDl(onStatus, signal) {
1717
+ if (await commandWorks("gallery-dl", ["--version"])) return "gallery-dl";
1718
+ const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
1719
+ const local = path5.join(getOpenNamiBinDir(), binaryName);
1720
+ if (await commandWorks(local, ["--version"])) return local;
1721
+ if (await commandWorks("python", ["-m", "gallery_dl", "--version"])) return "python";
1722
+ if (await commandWorks("python3", ["-m", "gallery_dl", "--version"])) return "python3";
1723
+ onStatus?.("first run: downloading standalone gallery-dl\u2026");
1724
+ return await downloadLatestGalleryDl(getOpenNamiBinDir(), signal, onStatus);
1725
+ }
1726
+ async function updateYtDlp(options) {
1727
+ const onStatus = options?.onStatus ?? (() => {
1728
+ });
1729
+ const signal = options?.signal;
1730
+ const dir = getOpenNamiBinDir();
1731
+ const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
1732
+ const local = path5.join(dir, binaryName);
1733
+ let previousVersion;
1734
+ if (await commandWorks(local, ["--version"])) {
1735
+ previousVersion = await getBinaryVersion(local);
1736
+ } else if (await commandWorks("yt-dlp", ["--version"])) {
1737
+ previousVersion = await getBinaryVersion("yt-dlp");
1738
+ }
1739
+ onStatus("fetching fresh standalone yt-dlp release\u2026");
1740
+ const downloadedPath = await downloadLatestYtDlp(dir, signal, onStatus);
1741
+ const currentVersion = await getBinaryVersion(downloadedPath) ?? "unknown";
1742
+ return {
1743
+ engine: "yt-dlp",
1744
+ previousVersion,
1745
+ currentVersion,
1746
+ updated: previousVersion !== currentVersion
1747
+ };
1748
+ }
1749
+ async function updateGalleryDl(options) {
1750
+ const onStatus = options?.onStatus ?? (() => {
1751
+ });
1752
+ const signal = options?.signal;
1753
+ const dir = getOpenNamiBinDir();
1754
+ const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
1755
+ const local = path5.join(dir, binaryName);
1756
+ let previousVersion;
1757
+ if (await commandWorks(local, ["--version"])) {
1758
+ previousVersion = await getBinaryVersion(local);
1759
+ } else if (await commandWorks("gallery-dl", ["--version"])) {
1760
+ previousVersion = await getBinaryVersion("gallery-dl");
1761
+ }
1762
+ onStatus("fetching fresh standalone gallery-dl release\u2026");
1763
+ const downloadedPath = await downloadLatestGalleryDl(dir, signal, onStatus);
1764
+ const currentVersion = await getBinaryVersion(downloadedPath) ?? "unknown";
1765
+ return {
1766
+ engine: "gallery-dl",
1767
+ previousVersion,
1768
+ currentVersion,
1769
+ updated: previousVersion !== currentVersion
1770
+ };
1771
+ }
1772
+
1773
+ // src/lib/engines/exec.ts
1774
+ function findExecutable(binName, pathEnv = process.env.PATH || "") {
1775
+ if (!pathEnv) return null;
1776
+ const delimiter = path6.delimiter;
1777
+ const dirs = pathEnv.split(delimiter).filter(Boolean);
1778
+ const isWin = process.platform === "win32";
1779
+ const extensions = isWin ? [".exe", ".cmd", ".bat", ""] : [""];
1780
+ for (const dir of dirs) {
1781
+ for (const ext of extensions) {
1782
+ const fullPath = path6.join(dir, `${binName}${ext}`);
1783
+ try {
1784
+ if (fs6.existsSync(fullPath)) {
1785
+ const stat = fs6.statSync(fullPath);
1786
+ if (stat.isFile()) {
1787
+ return fullPath;
1788
+ }
1789
+ }
1790
+ } catch {
1791
+ }
1792
+ }
1793
+ }
1794
+ return null;
1795
+ }
1796
+ function resolveEngineCommand(engine, options = {}) {
1797
+ const env = options.env ?? process.env;
1798
+ const finder = options.findExe ?? ((name) => findExecutable(name, env.PATH));
1799
+ const customOverride = engine === "gallery-dl" ? env.GALLERY_DL_PATH || env.GALLERY_DL_BINARY : env.YT_DLP_PATH || env.YT_DLP_BINARY;
1800
+ if (customOverride && customOverride.trim()) {
1801
+ return {
1802
+ command: customOverride.trim(),
1803
+ baseArgs: []
1804
+ };
1805
+ }
1806
+ const standalone = finder(engine);
1807
+ if (standalone) {
1808
+ return {
1809
+ command: standalone,
1810
+ baseArgs: []
1811
+ };
1812
+ }
1813
+ const binDir = getOpenNamiBinDir(env);
1814
+ const isWin = process.platform === "win32";
1815
+ const localBin = path6.join(binDir, `${engine}${isWin ? ".exe" : ""}`);
1816
+ try {
1817
+ if (fs6.existsSync(localBin)) {
1818
+ const stat = fs6.statSync(localBin);
1819
+ if (stat.isFile()) {
1820
+ return {
1821
+ command: localBin,
1822
+ baseArgs: []
1823
+ };
1824
+ }
1825
+ }
1826
+ } catch {
1827
+ }
1828
+ const moduleName = engine === "gallery-dl" ? "gallery_dl" : "yt_dlp";
1829
+ const pythonExe = finder("python");
1830
+ if (pythonExe) {
1831
+ return {
1832
+ command: pythonExe,
1833
+ baseArgs: ["-m", moduleName]
1834
+ };
1835
+ }
1836
+ const python3Exe = finder("python3");
1837
+ if (python3Exe) {
1838
+ return {
1839
+ command: python3Exe,
1840
+ baseArgs: ["-m", moduleName]
1841
+ };
1842
+ }
1843
+ return {
1844
+ command: "python",
1845
+ baseArgs: ["-m", moduleName]
1846
+ };
1847
+ }
1848
+
1849
+ // src/lib/engines/gallery-dl.ts
1850
+ var PHOTO_FILTER = "extension in ('jpg','jpeg','png','gif','webp','bmp','jfif','heic','avif','tiff','svg')";
1851
+ var VIDEO_FILTER = "extension in ('mp4','mov','webm','mkv','avi','flv','m4v')";
1852
+ var GDL_ARCHIVE = "archive_gallery-dl.sqlite3";
1853
+ var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
1854
+ var PLATFORM_SLEEP = {
1855
+ tiktok: "0.6-1.2",
1856
+ instagram: "2.0-2.8",
1857
+ facebook: "1.5-2.5"
1858
+ };
1859
+ function buildGalleryDlArgs(options) {
1860
+ const { destDir, subDir, platform, cleanUrl, username, cookiePath } = options;
1861
+ const args = [
1862
+ "-D",
1863
+ destDir,
1864
+ "-o",
1865
+ `user-agent=${UA}`,
1866
+ "-o",
1867
+ "cookies-update=false",
1868
+ "-o",
1869
+ 'headers.sec-ch-ua="Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
1870
+ "-o",
1871
+ "headers.sec-ch-ua-mobile=?0",
1872
+ "-o",
1873
+ 'headers.sec-ch-ua-platform="Windows"',
1874
+ "--download-archive",
1875
+ path7.join(destDir, GDL_ARCHIVE),
1876
+ "--retries",
1877
+ "3",
1878
+ "--sleep-429",
1879
+ "10"
1880
+ ];
1881
+ let targetUrl = cleanUrl;
1882
+ const sleepDelay = PLATFORM_SLEEP[platform] || "0.8-1.5";
1883
+ if (subDir === "Photos") {
1884
+ args.push("--filter", PHOTO_FILTER);
1885
+ if (platform === "tiktok") {
1886
+ args.push("-o", "videos=false", "-o", "audio=false");
1887
+ } else if (platform === "instagram") {
1888
+ args.push("-o", "videos=false");
1889
+ }
1890
+ args.push("--sleep-request", sleepDelay);
1891
+ } else if (subDir === "Videos") {
1892
+ args.push("--filter", VIDEO_FILTER);
1893
+ args.push("--sleep-request", sleepDelay);
1894
+ } else if (subDir === "Stories") {
1895
+ if (platform === "instagram") {
1896
+ targetUrl = `https://www.instagram.com/stories/${encodeURIComponent(username)}/`;
1897
+ args.push("--sleep-request", "2.0-3.0");
1898
+ }
1899
+ } else if (subDir === "Highlights") {
1900
+ if (platform === "instagram") {
1901
+ targetUrl = `https://www.instagram.com/${encodeURIComponent(username)}/highlights/`;
1902
+ args.push("--sleep-request", "2.0-3.0");
1903
+ }
1904
+ }
1905
+ if (cookiePath) {
1906
+ args.push("--cookies", cookiePath);
1907
+ }
1908
+ args.push("--", targetUrl);
1909
+ return args;
1910
+ }
1911
+ async function runGalleryDl(options) {
1912
+ const { profile, targetDir, subDir, cookiePath, onProgress } = options;
1913
+ const destDir = path7.join(targetDir, subDir);
1914
+ await fs7.mkdir(destDir, { recursive: true });
1915
+ const args = buildGalleryDlArgs({
1916
+ destDir,
1917
+ subDir,
1918
+ platform: profile.platform,
1919
+ cleanUrl: profile.cleanUrl,
1920
+ username: profile.username,
1921
+ cookiePath
1922
+ });
1923
+ let downloadedCount = 0;
1924
+ let skippedCount = 0;
1925
+ let errorOutput = "";
1926
+ try {
1927
+ await ensureGalleryDl((status) => {
1928
+ onProgress?.({
1929
+ job: subDir,
1930
+ downloadedCount: 0,
1931
+ skippedCount: 0,
1932
+ statusText: status
1933
+ });
1934
+ }, options.signal);
1935
+ } catch (err) {
1936
+ return {
1937
+ success: false,
1938
+ downloaded: 0,
1939
+ skipped: 0,
1940
+ error: err.message || String(err)
1941
+ };
1942
+ }
1943
+ const engineCmd = resolveEngineCommand("gallery-dl");
1944
+ const fullArgs = [...engineCmd.baseArgs, ...args];
1945
+ return new Promise((resolve) => {
1946
+ const child = spawn2(engineCmd.command, fullArgs, {
1947
+ windowsHide: true,
1948
+ stdio: ["ignore", "pipe", "pipe"],
1949
+ signal: options.signal
1950
+ });
1951
+ const handleLine = (line) => {
1952
+ const trimmed = line.trim();
1953
+ if (!trimmed) return;
1954
+ if (trimmed.startsWith("#")) {
1955
+ skippedCount++;
1956
+ const filename = path7.basename(trimmed.slice(1).trim());
1957
+ onProgress?.({
1958
+ job: subDir,
1959
+ downloadedCount,
1960
+ skippedCount,
1961
+ currentFile: filename,
1962
+ statusText: `Skipped: ${filename}`
1963
+ });
1964
+ } else if (trimmed.match(/\.(jpe?g|png|gif|webp|bmp|jfif|heic|avif|tiff|svg|mp4|mov|webm|mkv|avi|flv|m4v)$/i) || trimmed.includes(path7.sep)) {
1965
+ downloadedCount++;
1966
+ const filename = path7.basename(trimmed);
1967
+ onProgress?.({
1968
+ job: subDir,
1969
+ downloadedCount,
1970
+ skippedCount,
1971
+ currentFile: filename,
1972
+ statusText: `Downloaded: ${filename}`
1973
+ });
1974
+ }
1975
+ };
1976
+ let stdoutBuffer = "";
1977
+ child.stdout.on("data", (chunk) => {
1978
+ stdoutBuffer += chunk.toString("utf8");
1979
+ const lines = stdoutBuffer.split(/\r?\n/);
1980
+ stdoutBuffer = lines.pop() || "";
1981
+ for (const line of lines) handleLine(line);
1982
+ });
1983
+ child.stderr.on("data", (chunk) => {
1984
+ const text = chunk.toString("utf8");
1985
+ errorOutput += text;
1986
+ if (text.includes("429 Too Many Requests") || text.includes("Waiting for")) {
1987
+ onProgress?.({
1988
+ job: subDir,
1989
+ downloadedCount,
1990
+ skippedCount,
1991
+ statusText: "rate-limited by platform, waiting\u2026"
1992
+ });
1993
+ }
1994
+ });
1995
+ child.on("close", (code) => {
1996
+ if (stdoutBuffer) handleLine(stdoutBuffer);
1997
+ resolve({
1998
+ success: code === 0,
1999
+ downloaded: downloadedCount,
2000
+ skipped: skippedCount,
2001
+ error: code !== 0 ? errorOutput.slice(-300) : void 0
2002
+ });
2003
+ });
2004
+ child.on("error", (err) => {
2005
+ resolve({
2006
+ success: false,
2007
+ downloaded: downloadedCount,
2008
+ skipped: skippedCount,
2009
+ error: err.message
2010
+ });
2011
+ });
2012
+ });
2013
+ }
2014
+
2015
+ // src/lib/engines/yt-dlp.ts
2016
+ import { spawn as spawn3 } from "child_process";
2017
+ import path8 from "path";
2018
+ import fs8 from "fs/promises";
2019
+ var YTDLP_ARCHIVE = "archive_yt-dlp.txt";
2020
+ var UA2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
2021
+ function buildYtDlpArgs(options) {
2022
+ const { destDir, platform, cleanUrl, cookiePath } = options;
2023
+ const args = [
2024
+ "-o",
2025
+ path8.join(destDir, "%(title).150B [%(id)s].%(ext)s"),
2026
+ "--windows-filenames",
2027
+ "--newline",
2028
+ "--ignore-errors",
2029
+ "--no-overwrites",
2030
+ "--user-agent",
2031
+ UA2,
2032
+ "--download-archive",
2033
+ path8.join(destDir, YTDLP_ARCHIVE),
2034
+ "--concurrent-fragments",
2035
+ "4",
2036
+ "--retries",
2037
+ "3",
2038
+ "--fragment-retries",
2039
+ "5",
2040
+ "--impersonate",
2041
+ "chrome"
2042
+ ];
2043
+ if (platform === "tiktok") {
2044
+ args.push("--sleep-requests", "0.5:1.2", "--retry-sleep", "429:exp=5:30");
2045
+ } else if (platform === "instagram") {
2046
+ args.push("--sleep-requests", "1.0:2.0", "--retry-sleep", "429:exp=5:30");
2047
+ } else if (platform === "x") {
2048
+ args.push("--sleep-requests", "0.8:1.8", "--retry-sleep", "429:exp=5:30");
2049
+ }
2050
+ if (cookiePath) {
2051
+ args.push("--cookies", cookiePath);
2052
+ }
2053
+ args.push("--", cleanUrl);
2054
+ return args;
2055
+ }
2056
+ async function runYtDlp(options) {
2057
+ const { profile, targetDir, cookiePath, onProgress } = options;
2058
+ const destDir = path8.join(targetDir, "Videos");
2059
+ await fs8.mkdir(destDir, { recursive: true });
2060
+ const args = buildYtDlpArgs({
2061
+ destDir,
2062
+ platform: profile.platform,
2063
+ cleanUrl: profile.cleanUrl,
2064
+ cookiePath
2065
+ });
2066
+ let downloadedCount = 0;
2067
+ let skippedCount = 0;
2068
+ let errorOutput = "";
2069
+ try {
2070
+ await ensureYtDlp((status) => {
2071
+ onProgress?.({
2072
+ job: "Videos",
2073
+ downloadedCount: 0,
2074
+ skippedCount: 0,
2075
+ statusText: status
2076
+ });
2077
+ }, options.signal);
2078
+ } catch (err) {
2079
+ return {
2080
+ success: false,
2081
+ downloaded: 0,
2082
+ skipped: 0,
2083
+ error: err.message || String(err)
2084
+ };
2085
+ }
2086
+ const engineCmd = resolveEngineCommand("yt-dlp");
2087
+ const fullArgs = [...engineCmd.baseArgs, ...args];
2088
+ return new Promise((resolve) => {
2089
+ const child = spawn3(engineCmd.command, fullArgs, {
2090
+ windowsHide: true,
2091
+ stdio: ["ignore", "pipe", "pipe"],
2092
+ signal: options.signal
2093
+ });
2094
+ const handleLine = (line) => {
2095
+ const trimmed = line.trim();
2096
+ if (!trimmed) return;
2097
+ if (trimmed.includes("has already been recorded in the archive") || trimmed.includes("[download] Destination:") && trimmed.includes("already exists")) {
2098
+ skippedCount++;
2099
+ onProgress?.({
2100
+ job: "Videos",
2101
+ downloadedCount,
2102
+ skippedCount,
2103
+ statusText: "Skipped: Already archived"
2104
+ });
2105
+ } else if (trimmed.includes("[download] Destination:")) {
2106
+ const dest = trimmed.replace("[download] Destination:", "").trim();
2107
+ const filename = path8.basename(dest);
2108
+ onProgress?.({
2109
+ job: "Videos",
2110
+ downloadedCount,
2111
+ skippedCount,
2112
+ currentFile: filename,
2113
+ statusText: `Downloading: ${filename}`
2114
+ });
2115
+ } else if (trimmed.includes("100% of")) {
2116
+ downloadedCount++;
2117
+ onProgress?.({
2118
+ job: "Videos",
2119
+ downloadedCount,
2120
+ skippedCount,
2121
+ statusText: `Completed ${downloadedCount} videos`
2122
+ });
2123
+ } else if (trimmed.startsWith("ERROR:")) {
2124
+ errorOutput += trimmed + "\n";
2125
+ }
2126
+ };
2127
+ let stdoutBuffer = "";
2128
+ child.stdout.on("data", (chunk) => {
2129
+ stdoutBuffer += chunk.toString("utf8");
2130
+ const lines = stdoutBuffer.split(/\r?\n/);
2131
+ stdoutBuffer = lines.pop() || "";
2132
+ for (const line of lines) handleLine(line);
2133
+ });
2134
+ let stderrBuffer = "";
2135
+ child.stderr.on("data", (chunk) => {
2136
+ const text = chunk.toString("utf8");
2137
+ stderrBuffer += text;
2138
+ const lines = stderrBuffer.split(/\r?\n/);
2139
+ stderrBuffer = lines.pop() || "";
2140
+ for (const line of lines) handleLine(line);
2141
+ });
2142
+ child.on("close", (code) => {
2143
+ if (stdoutBuffer) handleLine(stdoutBuffer);
2144
+ if (stderrBuffer) handleLine(stderrBuffer);
2145
+ resolve({
2146
+ success: code === 0 || downloadedCount > 0,
2147
+ downloaded: downloadedCount,
2148
+ skipped: skippedCount,
2149
+ error: code !== 0 && downloadedCount === 0 ? errorOutput.slice(-300) : void 0
2150
+ });
2151
+ });
2152
+ child.on("error", (err) => {
2153
+ resolve({
2154
+ success: false,
2155
+ downloaded: downloadedCount,
2156
+ skipped: skippedCount,
2157
+ error: err.message
2158
+ });
2159
+ });
2160
+ });
2161
+ }
2162
+
2163
+ // src/lib/engines/dispatcher.ts
2164
+ function expandPath(dir) {
2165
+ if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
2166
+ return path9.join(os8.homedir(), dir.slice(1));
2167
+ }
2168
+ return dir;
2169
+ }
2170
+ function resolveDownloadBaseDir(customBaseDir) {
2171
+ if (customBaseDir && customBaseDir.trim()) {
2172
+ return path9.resolve(expandPath(customBaseDir.trim()));
2173
+ }
2174
+ const configDir = loadConfig().downloadDir;
2175
+ if (configDir && configDir.trim()) {
2176
+ return path9.resolve(expandPath(configDir.trim()));
2177
+ }
2178
+ const envDir = process.env.OPEN_NAMI_DIR || process.env.NAMI_DIR;
2179
+ if (envDir && envDir.trim()) {
2180
+ return path9.resolve(expandPath(envDir.trim()));
2181
+ }
2182
+ if (process.env.NAMI_BASE_DIR && process.env.NAMI_BASE_DIR.trim()) {
2183
+ const raw = process.env.NAMI_BASE_DIR.trim();
2184
+ return path9.resolve(expandPath(raw.endsWith("downloads") ? raw : path9.join(raw, "downloads")));
2185
+ }
2186
+ return path9.join(resolvePlatformDownloadsDir(), "Open Nami");
2187
+ }
2188
+ function resolveDefaultDownloadDir(platform, username, baseDir) {
2189
+ const root = resolveDownloadBaseDir(baseDir);
2190
+ return path9.join(root, platform, username);
2191
+ }
2192
+ async function dispatchDownload(options) {
2193
+ const { profile, choice, onProgress } = options;
2194
+ const targetDir = options.outputDir || resolveDefaultDownloadDir(profile.platform, profile.username);
2195
+ await fs9.mkdir(targetDir, { recursive: true });
2196
+ const masterCookie = findCookieFile(profile.platform);
2197
+ const cookieCopy = masterCookie ? await createSafeCookieCopy(masterCookie) : null;
2198
+ let totalDownloaded = 0;
2199
+ let totalSkipped = 0;
2200
+ const errors = [];
2201
+ try {
2202
+ const jobs = [];
2203
+ if (choice === "photos") {
2204
+ jobs.push({ type: "photos" });
2205
+ } else if (choice === "videos") {
2206
+ jobs.push({ type: "videos" });
2207
+ } else if (choice === "stories") {
2208
+ jobs.push({ type: "stories" });
2209
+ } else if (choice === "highlights") {
2210
+ jobs.push({ type: "highlights" });
2211
+ } else if (choice === "all") {
2212
+ jobs.push({ type: "photos" });
2213
+ jobs.push({ type: "videos" });
2214
+ if (profile.platform === "instagram") {
2215
+ jobs.push({ type: "stories" });
2216
+ jobs.push({ type: "highlights" });
2217
+ }
2218
+ }
2219
+ const runGdl = (subDir) => runGalleryDl({
2220
+ profile,
2221
+ targetDir,
2222
+ subDir,
2223
+ cookiePath: cookieCopy?.filePath,
2224
+ signal: options.signal,
2225
+ onProgress
2226
+ });
2227
+ const runYt = () => runYtDlp({
2228
+ profile,
2229
+ targetDir,
2230
+ cookiePath: cookieCopy?.filePath,
2231
+ signal: options.signal,
2232
+ onProgress
2233
+ });
2234
+ for (const job of jobs) {
2235
+ if (options.signal?.aborted) break;
2236
+ if (job.type === "photos" || job.type === "stories" || job.type === "highlights") {
2237
+ const subDir = job.type === "photos" ? "Photos" : job.type === "stories" ? "Stories" : "Highlights";
2238
+ const res = await runGdl(subDir);
2239
+ totalDownloaded += res.downloaded;
2240
+ totalSkipped += res.skipped;
2241
+ if (!res.success && res.error) errors.push(`[${subDir}] ${res.error}`);
2242
+ } else if (job.type === "videos") {
2243
+ const prefersGdl = profile.platform === "instagram" || profile.platform === "tiktok" || profile.platform === "x";
2244
+ const primary = prefersGdl ? () => runGdl("Videos") : runYt;
2245
+ const fallback = prefersGdl ? runYt : () => runGdl("Videos");
2246
+ let res = await primary();
2247
+ if (res.downloaded > 0 || res.skipped > 0) {
2248
+ totalDownloaded += res.downloaded;
2249
+ totalSkipped += res.skipped;
2250
+ } else if (!options.signal?.aborted) {
2251
+ const fbRes = await fallback();
2252
+ totalDownloaded += fbRes.downloaded;
2253
+ totalSkipped += fbRes.skipped;
2254
+ if (!fbRes.success && fbRes.error) errors.push(`[Videos] ${fbRes.error}`);
2255
+ }
2256
+ }
2257
+ }
2258
+ return {
2259
+ success: errors.length === 0 || totalDownloaded > 0,
2260
+ downloadedCount: totalDownloaded,
2261
+ skippedCount: totalSkipped,
2262
+ errors,
2263
+ outputDir: targetDir
2264
+ };
2265
+ } finally {
2266
+ if (cookieCopy) {
2267
+ await cookieCopy.cleanup();
2268
+ }
2269
+ }
2270
+ }
2271
+
2272
+ // src/lib/clipboard.ts
2273
+ import { execFileSync as execFileSync2 } from "child_process";
2274
+ var COMMANDS = process.platform === "darwin" ? [["pbpaste", []]] : process.platform === "win32" ? [["powershell", ["-NoProfile", "-Command", "Get-Clipboard"]]] : [
2275
+ ["wl-paste", ["--no-newline"]],
2276
+ ["xclip", ["-selection", "clipboard", "-o"]],
2277
+ ["xsel", ["--clipboard", "--output"]]
2278
+ ];
2279
+ function readClipboard(execFn = execFileSync2) {
2280
+ for (const [command, args] of COMMANDS) {
2281
+ try {
2282
+ const out = execFn(command, args, { encoding: "utf8", timeout: 1500, stdio: ["ignore", "pipe", "ignore"] });
2283
+ if (out && typeof out === "string") {
2284
+ return out.trim();
2285
+ }
2286
+ } catch {
2287
+ }
2288
+ }
2289
+ return "";
2290
+ }
2291
+
2292
+ // src/lib/reveal.ts
2293
+ import fs10 from "fs";
2294
+ import { spawn as spawn4 } from "child_process";
2295
+ import path10 from "path";
2296
+ function buildOpenBrowserCommand(url, platform = process.platform) {
2297
+ try {
2298
+ const parsed = new URL(url.trim());
2299
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2300
+ return null;
2301
+ }
2302
+ } catch {
2303
+ return null;
2304
+ }
2305
+ if (platform === "darwin") {
2306
+ return { command: "open", args: [url] };
2307
+ }
2308
+ if (platform === "win32") {
2309
+ return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
2310
+ }
2311
+ return { command: "xdg-open", args: [url] };
2312
+ }
2313
+ function openBrowser(url) {
2314
+ try {
2315
+ const spec = buildOpenBrowserCommand(url);
2316
+ if (!spec) return;
2317
+ spawn4(spec.command, spec.args, { detached: true, stdio: "ignore" }).unref();
2318
+ } catch {
2319
+ }
2320
+ }
2321
+ function getRevealCommand(targetPath, platform = process.platform, isDirectory) {
2322
+ const pathModule = platform === "win32" ? path10.win32 : platform === "darwin" ? path10.posix : path10;
2323
+ const resolved = pathModule.resolve(targetPath);
2324
+ const isDir = isDirectory !== void 0 ? isDirectory : (() => {
2325
+ try {
2326
+ if (fs10.existsSync(resolved)) {
2327
+ return fs10.statSync(resolved).isDirectory();
2328
+ }
2329
+ } catch {
2330
+ }
2331
+ return !pathModule.extname(resolved);
2332
+ })();
2333
+ if (platform === "darwin") {
2334
+ return isDir ? { command: "open", args: [resolved] } : { command: "open", args: ["-R", resolved] };
2335
+ }
2336
+ if (platform === "win32") {
2337
+ if (isDir) {
2338
+ return { command: "explorer.exe", args: [resolved] };
2339
+ }
2340
+ return { command: "explorer.exe", args: [`/select,${resolved}`] };
2341
+ }
2342
+ return { command: "xdg-open", args: [isDir ? resolved : pathModule.dirname(resolved)] };
2343
+ }
2344
+ function revealInFileManager(targetPath) {
2345
+ try {
2346
+ const { command, args } = getRevealCommand(targetPath);
2347
+ spawn4(command, args, { detached: true, stdio: "ignore" }).unref();
2348
+ } catch {
2349
+ }
2350
+ }
2351
+
2352
+ // src/lib/click-map.ts
2353
+ var ANSI_PATTERN = new RegExp(
2354
+ [
2355
+ "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
2356
+ "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))"
2357
+ ].join("|"),
2358
+ "g"
2359
+ );
2360
+ var stripAnsi = (text) => text.replace(ANSI_PATTERN, "");
2361
+ var frameLines = [];
2362
+ function captureFrames(stream) {
2363
+ return new Proxy(stream, {
2364
+ get(target, prop) {
2365
+ if (prop === "write") {
2366
+ return (chunk, ...rest) => {
2367
+ const lines = String(chunk).split("\n").map(stripAnsi);
2368
+ if (lines.some((line) => line.trim() !== "")) frameLines = lines;
2369
+ return target.write(chunk, ...rest);
2370
+ };
2371
+ }
2372
+ const value = Reflect.get(target, prop);
2373
+ return typeof value === "function" ? value.bind(target) : value;
2374
+ }
2375
+ });
2376
+ }
2377
+ function clickTargetAt(x, y, targets) {
2378
+ for (const target of targets) {
2379
+ const { match, padX = 1, padY = 0 } = target;
2380
+ for (let row = y - 1 - padY; row <= y - 1 + padY; row++) {
2381
+ const line = frameLines[row];
2382
+ if (!line) continue;
2383
+ let index = line.indexOf(match);
2384
+ while (index !== -1) {
2385
+ if (x - 1 >= index - padX && x - 1 <= index + match.length - 1 + padX) return target;
2386
+ index = line.indexOf(match, index + 1);
2387
+ }
2388
+ }
2389
+ }
2390
+ return void 0;
2391
+ }
2392
+ function findFrameRow(text) {
2393
+ return frameLines.findIndex((line) => line.includes(text));
2394
+ }
2395
+ function frameRowSpan(row) {
2396
+ const line = frameLines[row];
2397
+ if (!line) return void 0;
2398
+ const first = line.search(/\S/);
2399
+ if (first === -1) return void 0;
2400
+ return [first + 1, line.trimEnd().length];
2401
+ }
2402
+
2403
+ // src/app.tsx
2404
+ import { Fragment as Fragment3, jsx as jsx16, jsxs as jsxs13 } from "react/jsx-runtime";
2405
+ var TAGLINE = "grab any profile. paste. download. done.";
2406
+ var DOWNLOAD_BUTTON = "download";
2407
+ var Gap = ({ lines = 1 }) => /* @__PURE__ */ jsx16(Box13, { flexDirection: "column", flexShrink: 0, children: Array.from({ length: lines }, (_, i) => /* @__PURE__ */ jsx16(Text15, { children: " " }, i)) });
2408
+ var HINTS = {
2409
+ input: [
2410
+ ["\u21B5", "download"],
2411
+ ["^s", "settings"],
2412
+ ["^c", "quit"]
2413
+ ],
2414
+ platform: [
2415
+ ["\u2191\u2193", "choose"],
2416
+ ["\u21B5", "select"],
2417
+ ["esc", "back"],
2418
+ ["^c", "quit"]
2419
+ ],
2420
+ probing: [
2421
+ ["esc", "cancel"],
2422
+ ["^c", "quit"]
2423
+ ],
2424
+ picking: [
2425
+ ["\u2191\u2193", "choose"],
2426
+ ["\u21B5", "download"],
2427
+ ["esc", "back"],
2428
+ ["^c", "quit"]
2429
+ ],
2430
+ downloading: [
2431
+ ["esc", "cancel"],
2432
+ ["^c", "quit"]
2433
+ ],
2434
+ done: [
2435
+ ["o", "reveal"],
2436
+ ["\u21B5", "download another"],
2437
+ ["^c", "quit"]
2438
+ ],
2439
+ error: [
2440
+ ["\u21B5", "try again"],
2441
+ ["^c", "quit"]
2442
+ ],
2443
+ settings: [
2444
+ ["tab", "switch field"],
2445
+ ["\u21B5", "save"],
2446
+ ["esc", "back"],
2447
+ ["^c", "quit"]
2448
+ ]
2449
+ };
2450
+ function App({
2451
+ initialInput,
2452
+ initialChoice,
2453
+ initialThemeMode = "auto",
2454
+ outputDir,
2455
+ version = "1.0.0"
2456
+ }) {
2457
+ const [themeMode, setThemeMode] = useState6(initialThemeMode);
2458
+ const cycleTheme = useCallback2(() => {
2459
+ setThemeMode(nextThemeMode);
2460
+ }, []);
2461
+ return /* @__PURE__ */ jsx16(ThemeProvider, { mode: themeMode, children: /* @__PURE__ */ jsx16(
2462
+ InnerApp,
2463
+ {
2464
+ initialInput,
2465
+ initialChoice,
2466
+ outputDir,
2467
+ version,
2468
+ cycleTheme
2469
+ }
2470
+ ) });
2471
+ }
2472
+ function InnerApp({
2473
+ initialInput,
2474
+ initialChoice,
2475
+ outputDir,
2476
+ version = "1.0.0",
2477
+ cycleTheme
2478
+ }) {
2479
+ const theme = useTheme();
2480
+ const { exit } = useApp();
2481
+ const { stdout } = useStdout3();
2482
+ const [urlInput, setUrlInput] = useState6(initialInput ?? "");
2483
+ const [clipboardUrl, setClipboardUrl] = useState6(void 0);
2484
+ const [settingsFocus, setSettingsFocus] = useState6("cookies");
2485
+ const columns = stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
2486
+ const boxWidth = Math.max(14, Math.min(64, columns - 6));
2487
+ const contentWidth = Math.max(10, Math.min(columns - 4, 78));
2488
+ const [phase, setPhase] = useState6(() => {
2489
+ if (initialInput) {
2490
+ const parsed = parseProfileInput(initialInput);
2491
+ if (parsed.platform !== "unknown" && parsed.cleanUrl) {
2492
+ return { name: "picking", profile: parsed };
2493
+ }
2494
+ if (parsed.platform === "unknown" && parsed.username) {
2495
+ return { name: "platform", username: parsed.username, rawInput: initialInput };
2496
+ }
2497
+ }
2498
+ return { name: "input" };
2499
+ });
2500
+ useEffect5(() => {
2501
+ try {
2502
+ const clip = readClipboard();
2503
+ if (clip && (clip.startsWith("http://") || clip.startsWith("https://") || clip.startsWith("@"))) {
2504
+ setClipboardUrl(clip.trim());
2505
+ }
2506
+ } catch {
2507
+ }
2508
+ }, []);
2509
+ const clipboardOffered = Boolean(clipboardUrl) && urlInput === "";
2510
+ const clipboardAccepted = Boolean(clipboardUrl) && urlInput === clipboardUrl;
2511
+ const abortControllerRef = useRef3(null);
2512
+ const probingTimeoutRef = useRef3(null);
2513
+ useEffect5(() => {
2514
+ return () => {
2515
+ abortControllerRef.current?.abort();
2516
+ if (probingTimeoutRef.current) {
2517
+ clearTimeout(probingTimeoutRef.current);
2518
+ }
2519
+ };
2520
+ }, []);
2521
+ const resetToInput = useCallback2(() => {
2522
+ if (probingTimeoutRef.current) {
2523
+ clearTimeout(probingTimeoutRef.current);
2524
+ probingTimeoutRef.current = null;
2525
+ }
2526
+ abortControllerRef.current?.abort();
2527
+ abortControllerRef.current = null;
2528
+ setUrlInput("");
2529
+ setPhase({ name: "input" });
2530
+ }, []);
2531
+ const handleModeSelect = useCallback2(async (choice, profile) => {
2532
+ abortControllerRef.current?.abort();
2533
+ const controller = new AbortController();
2534
+ abortControllerRef.current = controller;
2535
+ setPhase({ name: "downloading", profile, choice });
2536
+ try {
2537
+ const targetDir = resolveDefaultDownloadDir(profile.platform, profile.username, outputDir);
2538
+ const result = await dispatchDownload({
2539
+ profile,
2540
+ choice,
2541
+ outputDir: targetDir,
2542
+ signal: controller.signal,
2543
+ onProgress: (progress) => {
2544
+ if (!controller.signal.aborted) {
2545
+ setPhase({ name: "downloading", profile, choice, progress });
2546
+ }
2547
+ }
2548
+ });
2549
+ if (controller.signal.aborted) return;
2550
+ if (!result.success && result.downloadedCount === 0 && result.skippedCount === 0) {
2551
+ const rawErr = result.errors[0] || "no media items could be found or downloaded.";
2552
+ const formatted = formatEngineError(rawErr);
2553
+ setPhase({
2554
+ name: "error",
2555
+ message: formatted.message,
2556
+ hint: formatted.hint
2557
+ });
2558
+ } else {
2559
+ setPhase({ name: "done", profile, result });
2560
+ }
2561
+ } catch (err) {
2562
+ if (controller.signal.aborted) return;
2563
+ setPhase({
2564
+ name: "error",
2565
+ message: err.message || "An error occurred during download.",
2566
+ hint: "Check your internet connection or verify the profile exists."
2567
+ });
2568
+ }
2569
+ }, [outputDir]);
2570
+ const handlePlatformSelect = useCallback2((platform, username, rawInput) => {
2571
+ const canonical = buildCanonicalProfile(platform, username, rawInput);
2572
+ setPhase({ name: "probing", target: canonical.cleanUrl, status: "probing profile\u2026" });
2573
+ if (probingTimeoutRef.current) clearTimeout(probingTimeoutRef.current);
2574
+ probingTimeoutRef.current = setTimeout(() => {
2575
+ probingTimeoutRef.current = null;
2576
+ if (initialChoice) {
2577
+ handleModeSelect(initialChoice, canonical);
2578
+ } else {
2579
+ setPhase({ name: "picking", profile: canonical });
2580
+ }
2581
+ }, 450);
2582
+ }, [initialChoice, handleModeSelect]);
2583
+ const handleInputSubmit = useCallback2((input) => {
2584
+ const trimmed = input.trim();
2585
+ if (!trimmed) return;
2586
+ const parsed = parseProfileInput(trimmed);
2587
+ if (parsed.platform !== "unknown" && parsed.cleanUrl) {
2588
+ setPhase({ name: "probing", target: trimmed, status: "probing profile\u2026" });
2589
+ if (probingTimeoutRef.current) clearTimeout(probingTimeoutRef.current);
2590
+ probingTimeoutRef.current = setTimeout(() => {
2591
+ probingTimeoutRef.current = null;
2592
+ if (initialChoice) {
2593
+ handleModeSelect(initialChoice, parsed);
2594
+ } else {
2595
+ setPhase({ name: "picking", profile: parsed });
2596
+ }
2597
+ }, 450);
2598
+ return;
2599
+ }
2600
+ if (parsed.platform === "unknown" && parsed.username) {
2601
+ setPhase({ name: "platform", username: parsed.username, rawInput: trimmed });
2602
+ return;
2603
+ }
2604
+ setPhase({
2605
+ name: "input",
2606
+ warning: "paste a full profile url (e.g. instagram.com/profile) or @profile"
2607
+ });
2608
+ }, [initialChoice, handleModeSelect]);
2609
+ useInput3((input, key) => {
2610
+ if (key.ctrl && input === "t") {
2611
+ cycleTheme();
2612
+ return;
2613
+ }
2614
+ if (key.ctrl && (input === "s" || input === "S")) {
2615
+ if (phase.name === "settings") {
2616
+ setPhase({ name: "input" });
2617
+ } else if (phase.name === "input") {
2618
+ setPhase({ name: "settings" });
2619
+ }
2620
+ return;
2621
+ }
2622
+ if (key.ctrl && input === "c") {
2623
+ exit();
2624
+ return;
2625
+ }
2626
+ if (key.escape) {
2627
+ if (phase.name === "settings") {
2628
+ setPhase({ name: "input" });
2629
+ return;
2630
+ }
2631
+ if (phase.name === "platform") {
2632
+ setPhase({ name: "input" });
2633
+ return;
2634
+ }
2635
+ if (phase.name === "probing" || phase.name === "downloading") {
2636
+ resetToInput();
2637
+ return;
2638
+ }
2639
+ if (phase.name === "picking" || phase.name === "done" || phase.name === "error") {
2640
+ resetToInput();
2641
+ return;
2642
+ }
2643
+ exit();
2644
+ return;
2645
+ }
2646
+ if (key.return) {
2647
+ if (phase.name === "done" || phase.name === "error") {
2648
+ resetToInput();
2649
+ return;
2650
+ }
2651
+ }
2652
+ if ((input === "o" || input === "O") && !key.ctrl) {
2653
+ if (phase.name === "done") {
2654
+ revealInFileManager(phase.result.outputDir);
2655
+ return;
2656
+ }
2657
+ }
2658
+ }, { isActive: Boolean(process.stdin.isTTY) });
2659
+ const hints = [...HINTS[phase.name], ["^t", `theme:${theme.mode}`]];
2660
+ const releaseUrl = `https://github.com/OpenSelena/open-nami/releases/tag/v${version}`;
2661
+ const clickTargets = useMemo3(() => {
2662
+ const hintAction = (key) => {
2663
+ if (key === "^c") return () => exit();
2664
+ if (key === "^t") return cycleTheme;
2665
+ if (key === "^s") return () => setPhase((prev) => prev.name === "settings" ? { name: "input" } : { name: "settings" });
2666
+ if (key === "esc") {
2667
+ if (phase.name === "settings" || phase.name === "platform") return () => setPhase({ name: "input" });
2668
+ return resetToInput;
2669
+ }
2670
+ if (key === "\u21B5") {
2671
+ if (phase.name === "input") return () => handleInputSubmit(urlInput);
2672
+ if (phase.name === "done" || phase.name === "error") return resetToInput;
2673
+ }
2674
+ if (key === "o") {
2675
+ if (phase.name === "done") return () => revealInFileManager(phase.result.outputDir);
2676
+ }
2677
+ return void 0;
2678
+ };
2679
+ const targets = [];
2680
+ if (phase.name === "input") {
2681
+ targets.push({ match: `v${version}`, padX: 1, action: () => openBrowser(releaseUrl) });
2682
+ targets.push({ match: ` ${DOWNLOAD_BUTTON} `, padY: 1, action: () => handleInputSubmit(urlInput) });
2683
+ if (clipboardOffered && clipboardUrl) {
2684
+ targets.push({ match: "Tab to paste it", action: () => setUrlInput(clipboardUrl) });
2685
+ targets.push({ match: "link in your clipboard", action: () => setUrlInput(clipboardUrl) });
2686
+ }
2687
+ if (clipboardAccepted) {
2688
+ targets.push({ match: "to download it", action: () => handleInputSubmit(urlInput) });
2689
+ }
2690
+ targets.push({ match: "OpenSelena", padX: 1, action: () => openBrowser("https://github.com/OpenSelena/open-nami") });
2691
+ }
2692
+ if (phase.name === "platform") {
2693
+ for (const item of PLATFORM_OPTIONS) {
2694
+ targets.push({
2695
+ match: item.label,
2696
+ padX: 2,
2697
+ action: () => handlePlatformSelect(item.value, phase.username, phase.rawInput)
2698
+ });
2699
+ }
2700
+ }
2701
+ if (phase.name === "picking") {
2702
+ const items = getPickingOptions(phase.profile.platform);
2703
+ for (const item of items) {
2704
+ targets.push({
2705
+ match: item.label,
2706
+ action: () => handleModeSelect(item.value, phase.profile)
2707
+ });
2708
+ }
2709
+ }
2710
+ if (phase.name === "done") {
2711
+ targets.push({ match: "\u21B5 download another", padX: 4, padY: 1, action: resetToInput });
2712
+ targets.push({ match: "[o] reveal in folder", padX: 1, action: () => revealInFileManager(phase.result.outputDir) });
2713
+ }
2714
+ if (phase.name === "settings") {
2715
+ targets.push({ match: "Cookies Folder", padX: 2, padY: 1, action: () => setSettingsFocus("cookies") });
2716
+ targets.push({ match: "Download Directory", padX: 2, padY: 1, action: () => setSettingsFocus("download") });
2717
+ }
2718
+ for (const [key, label] of hints) {
2719
+ const action = hintAction(key);
2720
+ if (action) targets.push({ match: `${key} ${label}`, action });
2721
+ }
2722
+ return targets;
2723
+ }, [
2724
+ phase,
2725
+ version,
2726
+ releaseUrl,
2727
+ urlInput,
2728
+ clipboardOffered,
2729
+ clipboardUrl,
2730
+ clipboardAccepted,
2731
+ hints,
2732
+ cycleTheme,
2733
+ exit,
2734
+ handleInputSubmit,
2735
+ handlePlatformSelect,
2736
+ handleModeSelect,
2737
+ resetToInput
2738
+ ]);
2739
+ useMouseClick(
2740
+ (x, y) => {
2741
+ const taglineRow = findFrameRow(TAGLINE);
2742
+ if (taglineRow > 3 && y - 1 >= taglineRow - 4 && y - 1 <= taglineRow - 2) {
2743
+ const span = frameRowSpan(y - 1);
2744
+ if (span && x >= span[0] - 1 && x <= span[1] + 1) {
2745
+ if (phase.name !== "input") resetToInput();
2746
+ return;
2747
+ }
2748
+ }
2749
+ clickTargetAt(x, y, clickTargets)?.action();
2750
+ },
2751
+ Boolean(process.stdin.isTTY)
2752
+ );
2753
+ return /* @__PURE__ */ jsxs13(FullScreen, { children: [
2754
+ /* @__PURE__ */ jsx16(Logo, {}),
2755
+ /* @__PURE__ */ jsx16(Gap, {}),
2756
+ /* @__PURE__ */ jsx16(Text15, { color: theme.primary, children: TAGLINE }),
2757
+ /* @__PURE__ */ jsx16(Text15, { color: theme.gray, dimColor: theme.dimSecondary, children: "instagram \xB7 tiktok \xB7 facebook \xB7 x \xB7 +more" }),
2758
+ /* @__PURE__ */ jsx16(Gap, {}),
2759
+ phase.name === "input" && /* @__PURE__ */ jsx16(
2760
+ InputView,
2761
+ {
2762
+ urlInput,
2763
+ setUrlInput,
2764
+ onSubmit: handleInputSubmit,
2765
+ boxWidth,
2766
+ clipboardOffered,
2767
+ clipboardAccepted,
2768
+ clipboardUrl,
2769
+ warning: phase.warning,
2770
+ version,
2771
+ releaseUrl,
2772
+ theme,
2773
+ buttonText: DOWNLOAD_BUTTON,
2774
+ gapComponent: Gap
2775
+ }
2776
+ ),
2777
+ phase.name === "platform" && /* @__PURE__ */ jsx16(
2778
+ PlatformView,
2779
+ {
2780
+ contentWidth,
2781
+ username: phase.username,
2782
+ onSelect: (platform) => handlePlatformSelect(platform, phase.username, phase.rawInput),
2783
+ theme,
2784
+ gapComponent: Gap
2785
+ }
2786
+ ),
2787
+ phase.name === "probing" && /* @__PURE__ */ jsx16(
2788
+ ProbingView,
2789
+ {
2790
+ target: phase.target,
2791
+ boxWidth,
2792
+ theme,
2793
+ buttonText: DOWNLOAD_BUTTON
2794
+ }
2795
+ ),
2796
+ phase.name === "picking" && /* @__PURE__ */ jsx16(
2797
+ PickingView,
2798
+ {
2799
+ contentWidth,
2800
+ profile: phase.profile,
2801
+ onSelect: (choice) => handleModeSelect(choice, phase.profile),
2802
+ theme,
2803
+ gapComponent: Gap
2804
+ }
2805
+ ),
2806
+ phase.name === "downloading" && /* @__PURE__ */ jsx16(
2807
+ DownloadingView,
2808
+ {
2809
+ profile: phase.profile,
2810
+ choice: phase.choice,
2811
+ progress: phase.progress,
2812
+ outputDir: resolveDefaultDownloadDir(phase.profile.platform, phase.profile.username, outputDir),
2813
+ theme,
2814
+ gapComponent: Gap
2815
+ }
2816
+ ),
2817
+ phase.name === "done" && /* @__PURE__ */ jsx16(
2818
+ CompletionView,
2819
+ {
2820
+ profile: phase.profile,
2821
+ result: phase.result,
2822
+ theme,
2823
+ gapComponent: Gap
2824
+ }
2825
+ ),
2826
+ phase.name === "error" && /* @__PURE__ */ jsx16(
2827
+ ErrorView,
2828
+ {
2829
+ message: phase.message,
2830
+ hint: phase.hint,
2831
+ columns,
2832
+ theme
2833
+ }
2834
+ ),
2835
+ phase.name === "settings" && /* @__PURE__ */ jsx16(
2836
+ SettingsView,
2837
+ {
2838
+ boxWidth,
2839
+ theme,
2840
+ gapComponent: Gap,
2841
+ focusedField: settingsFocus,
2842
+ onFocusChange: setSettingsFocus,
2843
+ onSave: () => setPhase({ name: "input" }),
2844
+ onCancel: () => setPhase({ name: "input" })
2845
+ }
2846
+ ),
2847
+ hints.length > 0 ? /* @__PURE__ */ jsxs13(Fragment3, { children: [
2848
+ /* @__PURE__ */ jsx16(Gap, { lines: 2 }),
2849
+ /* @__PURE__ */ jsx16(
2850
+ Shortcuts,
2851
+ {
2852
+ items: hints,
2853
+ leading: phase.name === "probing" ? /* @__PURE__ */ jsxs13(Text15, { children: [
2854
+ /* @__PURE__ */ jsx16(Text15, { color: theme.primary, children: /* @__PURE__ */ jsx16(Spinner2, { type: "dots" }) }),
2855
+ /* @__PURE__ */ jsxs13(Text15, { color: theme.gray, dimColor: theme.dimSecondary, children: [
2856
+ " ",
2857
+ phase.status
2858
+ ] })
2859
+ ] }) : void 0
2860
+ }
2861
+ )
2862
+ ] }) : null
2863
+ ] });
2864
+ }
2865
+
2866
+ // src/lib/completion.ts
2867
+ var SUPPORTED_SHELLS = ["bash", "zsh", "fish", "powershell"];
2868
+ function normalizeShell(shell) {
2869
+ const normalized = shell.toLowerCase().trim();
2870
+ if (normalized === "pwsh") return "powershell";
2871
+ if (SUPPORTED_SHELLS.includes(normalized)) {
2872
+ return normalized;
2873
+ }
2874
+ return void 0;
2875
+ }
2876
+ function generateBashCompletion() {
2877
+ return `# bash completion for open-nami
2878
+ # To load completions in current shell session:
2879
+ # eval "$(open-nami --completion bash)"
2880
+ # Or install persistently:
2881
+ # open-nami --completion bash > ~/.local/share/bash-completion/completions/open-nami
2882
+
2883
+ _open_nami_completions() {
2884
+ local cur prev
2885
+ cur="\${COMP_WORDS[COMP_CWORD]}"
2886
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
2887
+
2888
+ local options="--help -h --version -v --update -U --update-ytdlp --update-gallerydl --photos --videos --stories --highlights --all --output -o --theme --completion"
2889
+
2890
+ case "$prev" in
2891
+ --theme)
2892
+ COMPREPLY=($(compgen -W "auto light dark" -- "$cur"))
2893
+ return 0
2894
+ ;;
2895
+ --completion)
2896
+ COMPREPLY=($(compgen -W "bash zsh fish powershell" -- "$cur"))
2897
+ return 0
2898
+ ;;
2899
+ -o|--output)
2900
+ COMPREPLY=($(compgen -d -- "$cur"))
2901
+ return 0
2902
+ ;;
2903
+ esac
2904
+
2905
+ if [[ "$cur" == -* ]]; then
2906
+ COMPREPLY=($(compgen -W "$options" -- "$cur"))
2907
+ return 0
2908
+ fi
2909
+ }
2910
+
2911
+ complete -F _open_nami_completions open-nami
2912
+ complete -F _open_nami_completions opennami
2913
+ complete -F _open_nami_completions nami
2914
+ complete -F _open_nami_completions on
2915
+ `;
2916
+ }
2917
+ function generateZshCompletion() {
2918
+ return `#compdef open-nami opennami nami on
2919
+ # zsh completion for open-nami
2920
+ # To load completions in current session:
2921
+ # eval "$(open-nami --completion zsh)"
2922
+
2923
+ _open_nami() {
2924
+ _arguments -s -S \\
2925
+ '(-h --help)'{-h,--help}'[Show help message]' \\
2926
+ '(-v --version)'{-v,--version}'[Show version number]' \\
2927
+ '(-U --update)'{-U,--update}'[Update bundled engines]' \\
2928
+ '--update-ytdlp[Update only bundled yt-dlp binary]' \\
2929
+ '--update-gallerydl[Update only bundled gallery-dl binary]' \\
2930
+ '--photos[Download photos only]' \\
2931
+ '--videos[Download videos and reels only]' \\
2932
+ '--stories[Download stories only (Instagram)]' \\
2933
+ '--highlights[Download highlights only (Instagram)]' \\
2934
+ '--all[Download all available media]' \\
2935
+ '(-o --output)'{-o,--output}'[Destination download directory]:output directory:_files -/' \\
2936
+ '--theme[Set color theme]:theme:(auto light dark)' \\
2937
+ '--completion[Generate shell autocompletion script]:shell:(bash zsh fish powershell)'
2938
+ }
2939
+
2940
+ if [[ -n "$ZSH_VERSION" ]]; then
2941
+ compdef _open_nami open-nami
2942
+ compdef _open_nami opennami
2943
+ compdef _open_nami nami
2944
+ compdef _open_nami on
2945
+ fi
2946
+ `;
2947
+ }
2948
+ function generateFishCompletion() {
2949
+ return `# fish completion for open-nami
2950
+ # To load completions in current session:
2951
+ # open-nami --completion fish | source
2952
+
2953
+ complete -c open-nami -f
2954
+
2955
+ complete -c open-nami -s h -l help -d "Show help message"
2956
+ complete -c open-nami -s v -l version -d "Show version number"
2957
+ complete -c open-nami -s U -l update -d "Update bundled engines"
2958
+ complete -c open-nami -l update-ytdlp -d "Update only bundled yt-dlp binary"
2959
+ complete -c open-nami -l update-gallerydl -d "Update only bundled gallery-dl binary"
2960
+ complete -c open-nami -l photos -d "Download photos only"
2961
+ complete -c open-nami -l videos -d "Download videos and reels only"
2962
+ complete -c open-nami -l stories -d "Download stories only (Instagram)"
2963
+ complete -c open-nami -l highlights -d "Download highlights only (Instagram)"
2964
+ complete -c open-nami -l all -d "Download all available media"
2965
+ complete -c open-nami -s o -l output -r -a "(__fish_complete_directories)" -d "Destination directory"
2966
+ complete -c open-nami -l theme -x -a "auto light dark" -d "Set color theme"
2967
+ complete -c open-nami -l completion -x -a "bash zsh fish powershell" -d "Generate shell autocompletion script"
2968
+
2969
+ for cmd in opennami nami on
2970
+ complete -c $cmd -w open-nami
2971
+ end
2972
+ `;
2973
+ }
2974
+ function generatePowerShellCompletion() {
2975
+ return `# PowerShell completion for open-nami
2976
+ # To load completions in current session:
2977
+ # open-nami --completion powershell | Out-String | Invoke-Expression
2978
+
2979
+ $completer = {
2980
+ param($wordToComplete, $commandAst, $cursorPosition)
2981
+
2982
+ $elements = $commandAst.CommandElements
2983
+ $lastWord = if ($elements.Count -gt 0) { $elements[-1].Extent.Text } else { '' }
2984
+ $secondLastWord = if ($elements.Count -gt 1) { $elements[-2].Extent.Text } else { '' }
2985
+ $prev = if ([string]::IsNullOrEmpty($wordToComplete)) { $lastWord } else { $secondLastWord }
2986
+
2987
+ if ($prev -eq '--theme') {
2988
+ $themes = @('auto', 'light', 'dark')
2989
+ $themes | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
2990
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', "Theme: $_")
2991
+ }
2992
+ return
2993
+ }
2994
+
2995
+ if ($prev -eq '--completion') {
2996
+ $shells = @('bash', 'zsh', 'fish', 'powershell')
2997
+ $shells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
2998
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', "Shell: $_")
2999
+ }
3000
+ return
3001
+ }
3002
+
3003
+ if ($wordToComplete -like '-*') {
3004
+ $options = @(
3005
+ [PSCustomObject]@{ Option = '--help'; Short = '-h'; Tooltip = 'Show help message' },
3006
+ [PSCustomObject]@{ Option = '--version'; Short = '-v'; Tooltip = 'Show version number' },
3007
+ [PSCustomObject]@{ Option = '--update'; Short = '-U'; Tooltip = 'Update bundled engines' },
3008
+ [PSCustomObject]@{ Option = '--update-ytdlp'; Short = $null; Tooltip = 'Update only bundled yt-dlp binary' },
3009
+ [PSCustomObject]@{ Option = '--update-gallerydl'; Short = $null; Tooltip = 'Update only bundled gallery-dl binary' },
3010
+ [PSCustomObject]@{ Option = '--photos'; Short = $null; Tooltip = 'Download photos only' },
3011
+ [PSCustomObject]@{ Option = '--videos'; Short = $null; Tooltip = 'Download videos and reels only' },
3012
+ [PSCustomObject]@{ Option = '--stories'; Short = $null; Tooltip = 'Download stories only (Instagram)' },
3013
+ [PSCustomObject]@{ Option = '--highlights'; Short = $null; Tooltip = 'Download highlights only (Instagram)' },
3014
+ [PSCustomObject]@{ Option = '--all'; Short = $null; Tooltip = 'Download all available media' },
3015
+ [PSCustomObject]@{ Option = '--output'; Short = '-o'; Tooltip = 'Destination directory' },
3016
+ [PSCustomObject]@{ Option = '--theme'; Short = $null; Tooltip = 'Set color theme (auto, light, dark)' },
3017
+ [PSCustomObject]@{ Option = '--completion'; Short = $null; Tooltip = 'Generate shell completion script' }
3018
+ )
3019
+
3020
+ foreach ($opt in $options) {
3021
+ if ($opt.Option -like "$wordToComplete*") {
3022
+ [System.Management.Automation.CompletionResult]::new($opt.Option, $opt.Option, 'ParameterName', $opt.Tooltip)
3023
+ }
3024
+ if ($opt.Short -and $opt.Short -like "$wordToComplete*") {
3025
+ [System.Management.Automation.CompletionResult]::new($opt.Short, $opt.Short, 'ParameterName', $opt.Tooltip)
3026
+ }
3027
+ }
3028
+ return
3029
+ }
3030
+ }
3031
+
3032
+ 'open-nami', 'opennami', 'nami', 'on' | ForEach-Object {
3033
+ Register-ArgumentCompleter -Native -CommandName $_ -ScriptBlock $completer
3034
+ }
3035
+ `;
3036
+ }
3037
+ function generateCompletion(shell) {
3038
+ const target = normalizeShell(shell);
3039
+ if (!target) {
3040
+ throw new Error(
3041
+ `Unsupported shell "${shell}". Supported shells: ${SUPPORTED_SHELLS.join(", ")} (or "pwsh" for powershell).`
3042
+ );
3043
+ }
3044
+ switch (target) {
3045
+ case "bash":
3046
+ return generateBashCompletion();
3047
+ case "zsh":
3048
+ return generateZshCompletion();
3049
+ case "fish":
3050
+ return generateFishCompletion();
3051
+ case "powershell":
3052
+ return generatePowerShellCompletion();
3053
+ }
3054
+ }
3055
+
3056
+ // src/cli.tsx
3057
+ import { jsx as jsx17 } from "react/jsx-runtime";
3058
+ var resolveVersion = () => {
3059
+ try {
3060
+ if (typeof import.meta !== "undefined" && import.meta?.url) {
3061
+ return createRequire(import.meta.url)("../package.json").version;
3062
+ }
3063
+ } catch {
3064
+ }
3065
+ try {
3066
+ return require_package().version;
3067
+ } catch {
3068
+ }
3069
+ return "1.0.0";
3070
+ };
3071
+ var VERSION = resolveVersion();
3072
+ var HELP = `
3073
+ Open Nami \u2014 bulk social media profile downloader for Instagram, TikTok, Facebook & X.
3074
+
3075
+ Usage:
3076
+ $ open-nami Launch interactive TUI
3077
+ $ open-nami <profile-url-or-user> Open directly to format picker
3078
+ $ open-nami <url> --photos Download photos directly
3079
+ $ open-nami <url> --videos Download videos directly
3080
+ $ open-nami <url> --all Download all media directly
3081
+
3082
+ Options:
3083
+ -h, --help Show this help message
3084
+ -v, --version Show version
3085
+ -o, --output Set custom output directory
3086
+ -U, --update Update bundled engines (yt-dlp and gallery-dl) to latest release
3087
+ --update-ytdlp Update only bundled yt-dlp binary
3088
+ --update-gallerydl Update only bundled gallery-dl binary
3089
+ --theme Set color theme (auto, light, or dark)
3090
+ --photos Download photos only
3091
+ --videos Download videos & reels only
3092
+ --stories Download stories only (Instagram)
3093
+ --highlights Download highlights only (Instagram)
3094
+ --all Download all available media
3095
+ --completion Generate shell completion script (bash, zsh, fish, powershell)
3096
+
3097
+ Hotkeys in TUI:
3098
+ [Tab] Paste from clipboard
3099
+ [\u2191 / \u2193] Navigate menus
3100
+ [Enter] Submit / Start Download
3101
+ [^t] Cycle theme (auto / light / dark)
3102
+ [o] Open Downloads folder on completion
3103
+ [Esc] Back / Exit
3104
+ `;
3105
+ var enterAltScreen = () => process.stdout.write("\x1B[?1049h\x1B[H");
3106
+ var leaveAltScreen = () => process.stdout.write("\x1B[?1006l\x1B[?1000l\x1B[?1049l");
3107
+ async function main() {
3108
+ const args = process.argv.slice(2);
3109
+ if (args.includes("-h") || args.includes("--help")) {
3110
+ console.log(HELP);
3111
+ process.exit(0);
3112
+ }
3113
+ if (args.includes("-v") || args.includes("--version")) {
3114
+ console.log(`open-nami v${VERSION}`);
3115
+ process.exit(0);
3116
+ }
3117
+ const completionIdx = args.findIndex((arg) => arg === "--completion" || arg.startsWith("--completion="));
3118
+ if (completionIdx !== -1) {
3119
+ const arg = args[completionIdx];
3120
+ const shell = arg.startsWith("--completion=") ? arg.slice("--completion=".length) : args[completionIdx + 1];
3121
+ if (!shell || shell.startsWith("-")) {
3122
+ console.error("Error: --completion requires a shell name (bash, zsh, fish, powershell).");
3123
+ process.exit(1);
3124
+ }
3125
+ try {
3126
+ console.log(generateCompletion(shell));
3127
+ process.exit(0);
3128
+ } catch (err) {
3129
+ console.error(`Error: ${err.message}`);
3130
+ process.exit(1);
3131
+ }
3132
+ }
3133
+ if (args.includes("-U") || args.includes("--update") || args.includes("--update-ytdlp") || args.includes("--update-gallerydl")) {
3134
+ const doYt = args.includes("-U") || args.includes("--update") || args.includes("--update-ytdlp");
3135
+ const doGdl = args.includes("-U") || args.includes("--update") || args.includes("--update-gallerydl");
3136
+ let hasFailure = false;
3137
+ if (doYt) {
3138
+ try {
3139
+ const res = await updateYtDlp({
3140
+ onStatus: (status) => console.error(`[open-nami] ${status}`)
3141
+ });
3142
+ if (res.updated) {
3143
+ if (res.previousVersion && res.previousVersion !== res.currentVersion) {
3144
+ console.log(`\u2713 yt-dlp updated: ${res.previousVersion} \u2192 ${res.currentVersion}`);
3145
+ } else {
3146
+ console.log(`\u2713 yt-dlp updated to version ${res.currentVersion}`);
3147
+ }
3148
+ } else {
3149
+ console.log(`\u2713 yt-dlp is already up to date (version ${res.currentVersion})`);
3150
+ }
3151
+ } catch (err) {
3152
+ console.error(`open-nami: yt-dlp update failed: ${err.message || String(err)}`);
3153
+ hasFailure = true;
3154
+ }
3155
+ }
3156
+ if (doGdl) {
3157
+ try {
3158
+ const res = await updateGalleryDl({
3159
+ onStatus: (status) => console.error(`[open-nami] ${status}`)
3160
+ });
3161
+ if (res.updated) {
3162
+ if (res.previousVersion && res.previousVersion !== res.currentVersion) {
3163
+ console.log(`\u2713 gallery-dl updated: ${res.previousVersion} \u2192 ${res.currentVersion}`);
3164
+ } else {
3165
+ console.log(`\u2713 gallery-dl updated to version ${res.currentVersion}`);
3166
+ }
3167
+ } else {
3168
+ console.log(`\u2713 gallery-dl is already up to date (version ${res.currentVersion})`);
3169
+ }
3170
+ } catch (err) {
3171
+ console.error(`open-nami: gallery-dl update failed: ${err.message || String(err)}`);
3172
+ hasFailure = true;
3173
+ }
3174
+ }
3175
+ process.exit(hasFailure ? 1 : 0);
3176
+ }
3177
+ let initialInput;
3178
+ let initialChoice;
3179
+ let outputDir;
3180
+ let themeMode = "auto";
3181
+ for (let i = 0; i < args.length; i++) {
3182
+ const arg = args[i];
3183
+ if (arg === "-o" || arg === "--output") {
3184
+ outputDir = args[++i];
3185
+ } else if (arg.startsWith("--output=")) {
3186
+ outputDir = arg.slice("--output=".length);
3187
+ } else if (arg === "--theme") {
3188
+ themeMode = args[++i];
3189
+ } else if (arg.startsWith("--theme=")) {
3190
+ themeMode = arg.slice("--theme=".length);
3191
+ } else if (arg === "--photos") {
3192
+ initialChoice = "photos";
3193
+ } else if (arg === "--videos") {
3194
+ initialChoice = "videos";
3195
+ } else if (arg === "--stories") {
3196
+ initialChoice = "stories";
3197
+ } else if (arg === "--highlights") {
3198
+ initialChoice = "highlights";
3199
+ } else if (arg === "--all") {
3200
+ initialChoice = "all";
3201
+ } else if (!arg.startsWith("-") && !initialInput) {
3202
+ initialInput = arg;
3203
+ }
3204
+ }
3205
+ const isTTY = Boolean(process.stdout.isTTY);
3206
+ if (!isTTY && initialChoice && initialInput) {
3207
+ const parsed = parseProfileInput(initialInput);
3208
+ if (parsed.platform === "unknown" || !parsed.cleanUrl) {
3209
+ console.error(`open-nami: that doesn't look like a valid profile URL or username: ${initialInput}`);
3210
+ process.exit(1);
3211
+ }
3212
+ console.log(`[open-nami] downloading ${initialChoice} for @${parsed.username}\u2026`);
3213
+ try {
3214
+ const res = await dispatchDownload({
3215
+ profile: parsed,
3216
+ choice: initialChoice,
3217
+ outputDir,
3218
+ onProgress: (progress) => {
3219
+ if (progress.currentFile) {
3220
+ console.log(`[open-nami] [${progress.job}] ${progress.currentFile}`);
3221
+ }
3222
+ }
3223
+ });
3224
+ console.log(`\u2713 downloaded ${res.downloadedCount} media items to ${res.outputDir}`);
3225
+ process.exit(res.success ? 0 : 1);
3226
+ } catch (err) {
3227
+ console.error(`open-nami error: ${err.message || String(err)}`);
3228
+ process.exit(1);
3229
+ }
3230
+ }
3231
+ if (isTTY) {
3232
+ enterAltScreen();
3233
+ process.on("exit", leaveAltScreen);
3234
+ for (const event of ["uncaughtException", "unhandledRejection"]) {
3235
+ process.on(event, (error) => {
3236
+ leaveAltScreen();
3237
+ console.error(error);
3238
+ process.exit(1);
3239
+ });
3240
+ }
3241
+ }
3242
+ try {
3243
+ const { waitUntilExit } = render(
3244
+ /* @__PURE__ */ jsx17(
3245
+ App,
3246
+ {
3247
+ initialInput,
3248
+ initialChoice,
3249
+ initialThemeMode: themeMode,
3250
+ outputDir,
3251
+ version: VERSION
3252
+ }
3253
+ ),
3254
+ { stdout: captureFrames(process.stdout) }
3255
+ );
3256
+ await waitUntilExit();
3257
+ } finally {
3258
+ if (isTTY) leaveAltScreen();
3259
+ }
3260
+ }
3261
+ main().catch((err) => {
3262
+ console.error(err);
3263
+ process.exit(1);
3264
+ });
3265
+ export {
3266
+ VERSION,
3267
+ resolveVersion
3268
+ };