open-nami 1.3.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1078 -317
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ var require_package = __commonJS({
9
9
  "package.json"(exports, module) {
10
10
  module.exports = {
11
11
  name: "open-nami",
12
- version: "1.3.1",
12
+ version: "1.5.1",
13
13
  description: "Open Nami: terminal-first bulk social media profile downloader for Instagram, TikTok, Facebook & X.",
14
14
  type: "module",
15
15
  bin: {
@@ -73,11 +73,11 @@ var require_package = __commonJS({
73
73
  });
74
74
 
75
75
  // src/cli.tsx
76
- import { createRequire } from "module";
76
+ import { createRequire as createRequire2 } from "module";
77
77
  import { render } from "ink";
78
78
 
79
79
  // src/app.tsx
80
- import { useState as useState9, useCallback as useCallback4, useRef as useRef5, useEffect as useEffect8, useMemo as useMemo3 } from "react";
80
+ import { useState as useState10, useCallback as useCallback4, useRef as useRef5, useEffect as useEffect10, useMemo as useMemo3 } from "react";
81
81
  import { Box as Box15, Text as Text17, useApp, useInput as useInput5, useStdout as useStdout3 } from "ink";
82
82
  import Spinner4 from "ink-spinner";
83
83
 
@@ -730,16 +730,8 @@ async function clearCheckpoint(destDir) {
730
730
  }
731
731
 
732
732
  // src/lib/engines/dispatcher.ts
733
- import path10 from "path";
734
- import os5 from "os";
735
- import fs10 from "fs/promises";
736
- import fsSync from "fs";
737
-
738
- // src/lib/cookies.ts
739
- import fs2 from "fs";
740
- import fsPromises2 from "fs/promises";
741
- import path3 from "path";
742
- import os2 from "os";
733
+ import path13 from "path";
734
+ import fs13 from "fs/promises";
743
735
 
744
736
  // src/lib/config.ts
745
737
  import fs from "fs";
@@ -757,9 +749,13 @@ function loadConfig(configPath = getDefaultConfigPath()) {
757
749
  const raw = fs.readFileSync(configPath, "utf8");
758
750
  const parsed = JSON.parse(raw);
759
751
  if (parsed && typeof parsed === "object") {
752
+ const rawBrowser = typeof parsed.cookiesFromBrowser === "string" ? parsed.cookiesFromBrowser.trim().toLowerCase() : void 0;
753
+ const validBrowsers = ["chrome", "edge", "brave", "firefox", "none"];
754
+ const cookiesFromBrowser = rawBrowser && validBrowsers.includes(rawBrowser) ? rawBrowser : void 0;
760
755
  return {
761
756
  theme: typeof parsed.theme === "string" ? parsed.theme : void 0,
762
757
  cookiesDir: typeof parsed.cookiesDir === "string" ? parsed.cookiesDir.trim() : void 0,
758
+ cookiesFromBrowser,
763
759
  downloadDir: typeof parsed.downloadDir === "string" ? parsed.downloadDir.trim() : void 0,
764
760
  writeMetadata: typeof parsed.writeMetadata === "boolean" ? parsed.writeMetadata : void 0,
765
761
  defaultLimit: typeof parsed.defaultLimit === "number" ? parsed.defaultLimit : void 0
@@ -781,22 +777,77 @@ function saveConfig(config, configPath = getDefaultConfigPath()) {
781
777
  }
782
778
  }
783
779
 
780
+ // src/lib/cookies.ts
781
+ import fs3 from "fs";
782
+ import fsPromises2 from "fs/promises";
783
+ import path4 from "path";
784
+ import os3 from "os";
785
+
786
+ // src/lib/cookies/browser-bridge.ts
787
+ import fs2 from "fs";
788
+ import path3 from "path";
789
+ import os2 from "os";
790
+ function listDetectedBrowsers() {
791
+ const isWin = process.platform === "win32";
792
+ const isMac = process.platform === "darwin";
793
+ const home = os2.homedir();
794
+ const candidates = [
795
+ {
796
+ id: "chrome",
797
+ name: "Google Chrome",
798
+ winDir: path3.join(process.env.LOCALAPPDATA || path3.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
799
+ macDir: path3.join(home, "Library", "Application Support", "Google", "Chrome"),
800
+ linuxDir: path3.join(home, ".config", "google-chrome")
801
+ },
802
+ {
803
+ id: "edge",
804
+ name: "Microsoft Edge",
805
+ winDir: path3.join(process.env.LOCALAPPDATA || path3.join(home, "AppData", "Local"), "Microsoft", "Edge", "User Data"),
806
+ macDir: path3.join(home, "Library", "Application Support", "Microsoft Edge"),
807
+ linuxDir: path3.join(home, ".config", "microsoft-edge")
808
+ },
809
+ {
810
+ id: "brave",
811
+ name: "Brave Browser",
812
+ winDir: path3.join(process.env.LOCALAPPDATA || path3.join(home, "AppData", "Local"), "BraveSoftware", "Brave-Browser", "User Data"),
813
+ macDir: path3.join(home, "Library", "Application Support", "BraveSoftware", "Brave-Browser"),
814
+ linuxDir: path3.join(home, ".config", "BraveSoftware", "Brave-Browser")
815
+ },
816
+ {
817
+ id: "firefox",
818
+ name: "Mozilla Firefox",
819
+ winDir: path3.join(process.env.APPDATA || path3.join(home, "AppData", "Roaming"), "Mozilla", "Firefox", "Profiles"),
820
+ macDir: path3.join(home, "Library", "Application Support", "Firefox", "Profiles"),
821
+ linuxDir: path3.join(home, ".mozilla", "firefox")
822
+ }
823
+ ];
824
+ return candidates.map((c) => {
825
+ const profileDir = isWin ? c.winDir : isMac ? c.macDir : c.linuxDir;
826
+ return {
827
+ id: c.id,
828
+ name: c.name,
829
+ profileDir,
830
+ available: fs2.existsSync(profileDir)
831
+ };
832
+ });
833
+ }
834
+
784
835
  // src/lib/cookies.ts
785
836
  var CANDIDATE_DIRS = [
786
- path3.join(os2.homedir(), ".open-nami", "cookies"),
787
- path3.join(os2.homedir(), ".nami", "cookies"),
788
- path3.join(process.cwd(), "cookies")
837
+ path4.join(os3.homedir(), ".open-nami", "cookies"),
838
+ path4.join(os3.homedir(), ".nami", "cookies"),
839
+ path4.join(process.cwd(), "cookies")
789
840
  ];
790
841
  function getCandidateDirs(customDir) {
791
842
  const dirs = [];
792
843
  const configured = loadConfig().cookiesDir;
793
844
  const custom = customDir || process.env.OPEN_NAMI_COOKIES_DIR || process.env.NAMI_COOKIES_DIR || configured;
794
845
  if (custom && custom.trim()) {
795
- dirs.push(path3.resolve(custom.trim()));
846
+ dirs.push(path4.resolve(custom.trim()));
796
847
  }
797
- const seen = new Set(dirs.map((d) => path3.normalize(d).toLowerCase()));
848
+ const seen = new Set(dirs.map((d) => path4.normalize(d).toLowerCase()));
798
849
  for (const dir of CANDIDATE_DIRS) {
799
- const norm = path3.normalize(dir).toLowerCase();
850
+ const norm = path4.normalize(dir).toLowerCase();
800
851
  if (!seen.has(norm)) {
801
852
  seen.add(norm);
802
853
  dirs.push(dir);
@@ -816,12 +867,12 @@ function findCookieFile(platform, customDir) {
816
867
  const filenames = domainNames[platform] || [];
817
868
  const candidateDirs = getCandidateDirs(customDir);
818
869
  for (const dir of candidateDirs) {
819
- if (!fs2.existsSync(dir)) continue;
870
+ if (!fs3.existsSync(dir)) continue;
820
871
  for (const file of filenames) {
821
- const fullPath = path3.join(dir, file);
822
- if (fs2.existsSync(fullPath)) {
872
+ const fullPath = path4.join(dir, file);
873
+ if (fs3.existsSync(fullPath)) {
823
874
  try {
824
- const stats = fs2.statSync(fullPath);
875
+ const stats = fs3.statSync(fullPath);
825
876
  if (stats.size > 20) {
826
877
  return fullPath;
827
878
  }
@@ -837,14 +888,14 @@ function detectAvailableCookies(customDir) {
837
888
  return platforms.filter((p) => Boolean(findCookieFile(p, customDir)));
838
889
  }
839
890
  async function createSafeCookieCopy(masterPath) {
840
- const userTempDir = path3.join(os2.homedir(), ".open-nami", "temp");
891
+ const userTempDir = path4.join(os3.homedir(), ".open-nami", "temp");
841
892
  let targetDir = userTempDir;
842
893
  try {
843
894
  await fsPromises2.mkdir(userTempDir, { recursive: true, mode: 448 });
844
895
  } catch {
845
- targetDir = os2.tmpdir();
896
+ targetDir = os3.tmpdir();
846
897
  }
847
- const tempPath = path3.join(targetDir, `nami_cookie_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
898
+ const tempPath = path4.join(targetDir, `nami_cookie_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
848
899
  await fsPromises2.copyFile(masterPath, tempPath);
849
900
  await fsPromises2.chmod(tempPath, 384).catch(() => {
850
901
  });
@@ -860,141 +911,6 @@ async function createSafeCookieCopy(masterPath) {
860
911
  };
861
912
  }
862
913
 
863
- // src/lib/known-folders.ts
864
- import { execFileSync } from "child_process";
865
- import fs3 from "fs";
866
- import os3 from "os";
867
- import path4 from "path";
868
- var KNOWN_FOLDER_DOWNLOADS_GUID = "{374DE290-123F-4565-9164-39C4925E467B}";
869
- var USER_SHELL_FOLDERS_REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders";
870
- function expandWindowsEnv(str, env = process.env, homeDir) {
871
- return str.replace(/%([^%]+)%/g, (original, name) => {
872
- if (homeDir && name.toLowerCase() === "userprofile") {
873
- return homeDir;
874
- }
875
- const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
876
- if (key && env[key] !== void 0) {
877
- return env[key];
878
- }
879
- return original;
880
- });
881
- }
882
- function parseWindowsRegistryOutput(output, targetKey = KNOWN_FOLDER_DOWNLOADS_GUID) {
883
- const lines = output.split(/\r?\n/);
884
- const escaped = targetKey.replace(/[{}]/g, "\\$&");
885
- const pattern = new RegExp(`^\\s*${escaped}\\s+REG_\\w+\\s+(.*)$`, "i");
886
- for (const line of lines) {
887
- const match = line.match(pattern);
888
- if (match && match[1]) {
889
- const rawPath = match[1].trim();
890
- if (rawPath) return rawPath;
891
- }
892
- }
893
- return void 0;
894
- }
895
- function parseLinuxUserDirsOutput(content) {
896
- const lines = content.split(/\r?\n/);
897
- for (const line of lines) {
898
- const trimmed = line.trim();
899
- if (trimmed.startsWith("#")) continue;
900
- const match = trimmed.match(/^XDG_DOWNLOAD_DIR=["']?([^"']+)["']?$/);
901
- if (match && match[1]) {
902
- return match[1].trim();
903
- }
904
- }
905
- return void 0;
906
- }
907
- function isPathDriveAccessible(filePath, fsExists = fs3.existsSync, platform = process.platform) {
908
- try {
909
- if (fsExists(filePath)) {
910
- return true;
911
- }
912
- if (platform === "win32") {
913
- const match = filePath.match(/^([a-zA-Z]:[\\/])/i);
914
- const driveRoot = match ? match[1] : path4.win32.parse(path4.win32.resolve(filePath)).root;
915
- return fsExists(driveRoot);
916
- }
917
- const normalized = filePath.replace(/\\/g, "/");
918
- const mountMatch = normalized.match(/^(\/(?:media(?:\/[^/]+)?|mnt|run\/media(?:\/[^/]+)?)\/[^/]+)/);
919
- if (mountMatch) {
920
- return fsExists(mountMatch[1]);
921
- }
922
- let curr = path4.posix.dirname(normalized);
923
- while (curr && curr !== "/") {
924
- if (fsExists(curr)) return true;
925
- curr = path4.posix.dirname(curr);
926
- }
927
- return fsExists("/");
928
- } catch {
929
- return false;
930
- }
931
- }
932
- function resolveWindowsDownloadsDir(options = {}) {
933
- const homeDir = options.homeDir ?? os3.homedir();
934
- const env = options.env ?? process.env;
935
- const runQuery = options.regQueryFn ?? ((key, value) => {
936
- try {
937
- return execFileSync("reg", ["query", key, "/v", value], {
938
- encoding: "utf8",
939
- stdio: ["pipe", "pipe", "ignore"]
940
- });
941
- } catch {
942
- return "";
943
- }
944
- });
945
- const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs3.existsSync, "win32"));
946
- let raw = parseWindowsRegistryOutput(
947
- runQuery(USER_SHELL_FOLDERS_REG_KEY, KNOWN_FOLDER_DOWNLOADS_GUID),
948
- KNOWN_FOLDER_DOWNLOADS_GUID
949
- );
950
- if (!raw) {
951
- raw = parseWindowsRegistryOutput(runQuery(USER_SHELL_FOLDERS_REG_KEY, "Downloads"), "Downloads");
952
- }
953
- if (raw) {
954
- const expanded = expandWindowsEnv(raw, env, homeDir);
955
- if (checkAccessible(expanded)) {
956
- return path4.win32.resolve(expanded);
957
- }
958
- }
959
- return void 0;
960
- }
961
- function resolveLinuxDownloadsDir(options = {}) {
962
- const homeDir = options.homeDir ?? os3.homedir();
963
- const env = options.env ?? process.env;
964
- const readFn = options.readFileFn ?? ((p) => {
965
- try {
966
- return fs3.readFileSync(p, "utf8");
967
- } catch {
968
- return "";
969
- }
970
- });
971
- const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs3.existsSync, "linux"));
972
- const configHome = env.XDG_CONFIG_HOME || path4.join(homeDir, ".config");
973
- const userDirsFile = path4.join(configHome, "user-dirs.dirs");
974
- const content = readFn(userDirsFile);
975
- if (!content) return void 0;
976
- const raw = parseLinuxUserDirsOutput(content);
977
- if (!raw) return void 0;
978
- const expanded = raw.replace(/\$HOME/g, homeDir).replace(/\$\{HOME\}/g, homeDir);
979
- if (checkAccessible(expanded)) {
980
- return path4.posix.resolve(expanded);
981
- }
982
- return void 0;
983
- }
984
- function resolvePlatformDownloadsDir(options = {}) {
985
- const platform = options.platform ?? process.platform;
986
- const homeDir = options.homeDir ?? os3.homedir();
987
- const defaultFallback = path4.join(homeDir, "Downloads");
988
- if (platform === "win32") {
989
- const resolved = resolveWindowsDownloadsDir(options);
990
- if (resolved) return resolved;
991
- } else if (platform === "linux") {
992
- const resolved = resolveLinuxDownloadsDir(options);
993
- if (resolved) return resolved;
994
- }
995
- return defaultFallback;
996
- }
997
-
998
914
  // src/lib/engines/extractors/instagram.ts
999
915
  import crypto from "crypto";
1000
916
 
@@ -1379,8 +1295,8 @@ var InstagramExtractor = class {
1379
1295
  }
1380
1296
  const user = res.data.user;
1381
1297
  if (user.is_private) {
1382
- const fs14 = user.friendship_status;
1383
- const isFollowing = fs14 ? Boolean(fs14.following) : Boolean(user.followed_by_viewer);
1298
+ const fs18 = user.friendship_status;
1299
+ const isFollowing = fs18 ? Boolean(fs18.following) : Boolean(user.followed_by_viewer);
1384
1300
  if (!isFollowing) {
1385
1301
  throw new Error(`Instagram profile @${username} is private. You must provide a session cookie jar of an account following this profile.`);
1386
1302
  }
@@ -2947,6 +2863,30 @@ var FacebookExtractor = class {
2947
2863
  }
2948
2864
  };
2949
2865
 
2866
+ // src/lib/engines/core/plugin.ts
2867
+ var ExtractorPluginRegistry = class {
2868
+ plugins = /* @__PURE__ */ new Map();
2869
+ register(plugin) {
2870
+ this.plugins.set(plugin.platform, plugin);
2871
+ }
2872
+ get(platform) {
2873
+ return this.plugins.get(platform);
2874
+ }
2875
+ has(platform) {
2876
+ return this.plugins.has(platform);
2877
+ }
2878
+ list() {
2879
+ return Array.from(this.plugins.values());
2880
+ }
2881
+ findByUrl(url) {
2882
+ return this.list().find((plugin) => plugin.canHandle(url));
2883
+ }
2884
+ clear() {
2885
+ this.plugins.clear();
2886
+ }
2887
+ };
2888
+ var defaultPluginRegistry = new ExtractorPluginRegistry();
2889
+
2950
2890
  // src/lib/engines/extractors/index.ts
2951
2891
  function getExtractor(platform) {
2952
2892
  switch (platform) {
@@ -3750,7 +3690,7 @@ var PLATFORM_SLEEP = {
3750
3690
  facebook: "1.0-1.8"
3751
3691
  };
3752
3692
  function buildGalleryDlArgs(options) {
3753
- const { destDir, subDir, platform, cleanUrl, username, cookiePath, writeMetadata } = options;
3693
+ const { destDir, subDir, platform, cleanUrl, username, cookiePath, cookiesFromBrowser, writeMetadata } = options;
3754
3694
  const args = [
3755
3695
  "-D",
3756
3696
  destDir,
@@ -3801,6 +3741,9 @@ function buildGalleryDlArgs(options) {
3801
3741
  if (cookiePath) {
3802
3742
  args.push("--cookies", cookiePath);
3803
3743
  }
3744
+ if (cookiesFromBrowser && cookiesFromBrowser !== "none") {
3745
+ args.push("--cookies-from-browser", cookiesFromBrowser);
3746
+ }
3804
3747
  args.push("--", targetUrl);
3805
3748
  return args;
3806
3749
  }
@@ -3815,6 +3758,7 @@ async function runGalleryDl(options) {
3815
3758
  cleanUrl: profile.cleanUrl,
3816
3759
  username: profile.username,
3817
3760
  cookiePath,
3761
+ cookiesFromBrowser: options.cookiesFromBrowser,
3818
3762
  writeMetadata: options.writeMetadata
3819
3763
  });
3820
3764
  let downloadedCount = 0;
@@ -3923,7 +3867,7 @@ import fs9 from "fs/promises";
3923
3867
  var YTDLP_ARCHIVE = "archive_yt-dlp.txt";
3924
3868
  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";
3925
3869
  function buildYtDlpArgs(options) {
3926
- const { destDir, platform, cleanUrl, cookiePath, writeMetadata } = options;
3870
+ const { destDir, platform, cleanUrl, cookiePath, cookiesFromBrowser, writeMetadata } = options;
3927
3871
  const args = [
3928
3872
  "-o",
3929
3873
  path9.join(destDir, "%(title).150B [%(id)s].%(ext)s"),
@@ -3957,6 +3901,9 @@ function buildYtDlpArgs(options) {
3957
3901
  if (cookiePath) {
3958
3902
  args.push("--cookies", cookiePath);
3959
3903
  }
3904
+ if (cookiesFromBrowser && cookiesFromBrowser !== "none") {
3905
+ args.push("--cookies-from-browser", cookiesFromBrowser);
3906
+ }
3960
3907
  args.push("--", cleanUrl);
3961
3908
  return args;
3962
3909
  }
@@ -3969,6 +3916,7 @@ async function runYtDlp(options) {
3969
3916
  platform: profile.platform,
3970
3917
  cleanUrl: profile.cleanUrl,
3971
3918
  cookiePath,
3919
+ cookiesFromBrowser: options.cookiesFromBrowser,
3972
3920
  writeMetadata: options.writeMetadata
3973
3921
  });
3974
3922
  let downloadedCount = 0;
@@ -4075,98 +4023,507 @@ async function runYtDlp(options) {
4075
4023
  });
4076
4024
  }
4077
4025
 
4078
- // src/lib/engines/dispatcher.ts
4079
- var REFERER_MAP = {
4080
- tiktok: "https://www.tiktok.com/",
4081
- instagram: "https://www.instagram.com/",
4082
- x: "https://x.com/",
4083
- facebook: "https://www.facebook.com/"
4084
- };
4085
- function expandPath(dir) {
4086
- if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
4087
- return path10.join(os5.homedir(), dir.slice(1));
4026
+ // src/lib/engines/core/task-manager.ts
4027
+ import path12 from "path";
4028
+ import os7 from "os";
4029
+ import fs12 from "fs/promises";
4030
+ import fsSync from "fs";
4031
+
4032
+ // src/lib/engines/core/event-bus.ts
4033
+ import { EventEmitter } from "events";
4034
+ var EngineEventBus = class {
4035
+ emitter = new EventEmitter();
4036
+ constructor() {
4037
+ this.emitter.setMaxListeners(50);
4088
4038
  }
4089
- return dir;
4090
- }
4091
- function resolveDownloadBaseDir(customBaseDir) {
4092
- if (customBaseDir && customBaseDir.trim()) {
4093
- return path10.resolve(expandPath(customBaseDir.trim()));
4039
+ on(event, listener) {
4040
+ this.emitter.on(event, listener);
4041
+ return () => this.emitter.off(event, listener);
4094
4042
  }
4095
- const envDir = process.env.OPEN_NAMI_DIR || process.env.NAMI_DIR;
4096
- if (envDir && envDir.trim()) {
4097
- return path10.resolve(expandPath(envDir.trim()));
4043
+ off(event, listener) {
4044
+ this.emitter.off(event, listener);
4098
4045
  }
4099
- const configDir = loadConfig().downloadDir;
4100
- if (configDir && configDir.trim()) {
4101
- return path10.resolve(expandPath(configDir.trim()));
4046
+ once(event, listener) {
4047
+ this.emitter.once(event, listener);
4102
4048
  }
4103
- if (process.env.NAMI_BASE_DIR && process.env.NAMI_BASE_DIR.trim()) {
4104
- const raw = process.env.NAMI_BASE_DIR.trim();
4105
- return path10.resolve(expandPath(raw.endsWith("downloads") ? raw : path10.join(raw, "downloads")));
4049
+ emit(event, payload) {
4050
+ return this.emitter.emit(event, payload);
4106
4051
  }
4107
- return path10.join(resolvePlatformDownloadsDir(), "Open Nami");
4108
- }
4109
- function resolveDefaultDownloadDir(platform, username, baseDir) {
4110
- const root = resolveDownloadBaseDir(baseDir);
4111
- return path10.join(root, platform, username);
4112
- }
4113
- async function cleanupEmptyCategoryDirs(targetDir) {
4114
- const candidateDirs = ["All", "Stories", "Highlights", "Photos", "Videos"];
4115
- for (const dirName of candidateDirs) {
4116
- const fullPath = path10.join(targetDir, dirName);
4052
+ removeAllListeners(event) {
4053
+ if (event !== void 0) {
4054
+ this.emitter.removeAllListeners(event);
4055
+ } else {
4056
+ this.emitter.removeAllListeners();
4057
+ }
4058
+ }
4059
+ };
4060
+
4061
+ // src/lib/engines/storage/ledger.ts
4062
+ import fs10 from "fs";
4063
+ import path10 from "path";
4064
+ import os5 from "os";
4065
+ import { createRequire } from "module";
4066
+ var require2 = createRequire(import.meta.url);
4067
+ var MediaLedger = class {
4068
+ db = null;
4069
+ inMemoryFallback = {
4070
+ media: /* @__PURE__ */ new Map(),
4071
+ checkpoints: /* @__PURE__ */ new Map()
4072
+ };
4073
+ isFallback = false;
4074
+ resolvedPath;
4075
+ constructor(dbPath, forceFallback = false) {
4076
+ const resolvedPath = dbPath ?? (forceFallback ? ":memory:" : path10.join(os5.homedir(), ".open-nami", "nami.db"));
4077
+ this.resolvedPath = resolvedPath;
4078
+ if (forceFallback) {
4079
+ this.isFallback = true;
4080
+ this.loadFallbackDisk();
4081
+ return;
4082
+ }
4117
4083
  try {
4118
- if (!fsSync.existsSync(fullPath)) continue;
4119
- const entries = await fs10.readdir(fullPath);
4120
- const nonArchiveEntries = entries.filter(
4121
- (e) => e !== ".open-nami-archive.json" && e !== "archive.txt" && e !== "archive_yt-dlp.txt" && e !== "archive_gallery-dl.sqlite3"
4122
- );
4123
- if (nonArchiveEntries.length === 0) {
4124
- for (const entry of entries) {
4125
- try {
4126
- await fs10.unlink(path10.join(fullPath, entry));
4127
- } catch {
4084
+ if (resolvedPath !== ":memory:") {
4085
+ fs10.mkdirSync(path10.dirname(resolvedPath), { recursive: true });
4086
+ }
4087
+ const { DatabaseSync } = require2("node:sqlite");
4088
+ this.db = new DatabaseSync(resolvedPath);
4089
+ this.initTables();
4090
+ } catch {
4091
+ this.isFallback = true;
4092
+ this.loadFallbackDisk();
4093
+ }
4094
+ }
4095
+ loadFallbackDisk() {
4096
+ if (this.resolvedPath === ":memory:") return;
4097
+ try {
4098
+ if (fs10.existsSync(this.resolvedPath)) {
4099
+ const raw = fs10.readFileSync(this.resolvedPath, "utf8");
4100
+ const data = JSON.parse(raw);
4101
+ if (data && typeof data === "object") {
4102
+ if (data.media && typeof data.media === "object") {
4103
+ for (const [k, v] of Object.entries(data.media)) {
4104
+ this.inMemoryFallback.media.set(k, v);
4105
+ }
4106
+ }
4107
+ if (data.checkpoints && typeof data.checkpoints === "object") {
4108
+ for (const [k, v] of Object.entries(data.checkpoints)) {
4109
+ this.inMemoryFallback.checkpoints.set(k, v);
4110
+ }
4128
4111
  }
4129
4112
  }
4130
- await fs10.rmdir(fullPath).catch(() => {
4131
- });
4132
4113
  }
4133
4114
  } catch {
4134
4115
  }
4135
4116
  }
4136
- }
4137
- async function dispatchDownload(options) {
4138
- const { profile, choice, onProgress } = options;
4139
- const targetDir = options.outputDir || resolveDefaultDownloadDir(profile.platform, profile.username);
4140
- await fs10.mkdir(targetDir, { recursive: true });
4141
- const masterCookie = findCookieFile(profile.platform);
4142
- const cookieCopy = masterCookie ? await createSafeCookieCopy(masterCookie) : null;
4143
- let totalDownloaded = 0;
4144
- let totalSkipped = 0;
4145
- const errors = [];
4146
- try {
4147
- const jobs = [];
4148
- if (choice === "photos") {
4149
- jobs.push({ type: "photos" });
4150
- } else if (choice === "videos") {
4151
- jobs.push({ type: "videos" });
4152
- } else if (choice === "stories") {
4153
- jobs.push({ type: "stories" });
4154
- } else if (choice === "highlights") {
4155
- jobs.push({ type: "highlights" });
4156
- } else if (choice === "all") {
4157
- jobs.push({ type: "all" });
4158
- if (profile.platform === "instagram") {
4159
- jobs.push({ type: "stories" });
4160
- jobs.push({ type: "highlights" });
4161
- }
4117
+ flushFallbackDisk() {
4118
+ if (this.resolvedPath === ":memory:") return;
4119
+ try {
4120
+ fs10.mkdirSync(path10.dirname(this.resolvedPath), { recursive: true });
4121
+ const data = {
4122
+ media: Object.fromEntries(this.inMemoryFallback.media),
4123
+ checkpoints: Object.fromEntries(this.inMemoryFallback.checkpoints)
4124
+ };
4125
+ fs10.writeFileSync(this.resolvedPath, JSON.stringify(data, null, 2), "utf8");
4126
+ } catch {
4162
4127
  }
4163
- const sinceSeconds = parseDateBoundary(options.since, "since");
4164
- const untilSeconds = parseDateBoundary(options.until, "until");
4165
- const globalLimit = parseLimit(options.limit);
4166
- const savedCheckpoint = options.resume ? await loadCheckpoint(targetDir) : null;
4167
- const currentCursors = savedCheckpoint?.cursors ? { ...savedCheckpoint.cursors } : {};
4168
- const defaultRunNative = async (subDir) => {
4169
- const dest = subDir === "All" ? targetDir : path10.join(targetDir, subDir);
4128
+ }
4129
+ initTables() {
4130
+ if (!this.db) return;
4131
+ this.db.exec(`
4132
+ CREATE TABLE IF NOT EXISTS media_ledger (
4133
+ id TEXT PRIMARY KEY,
4134
+ platform TEXT,
4135
+ author TEXT,
4136
+ url TEXT,
4137
+ filename TEXT,
4138
+ file_path TEXT,
4139
+ downloaded_at TEXT
4140
+ );
4141
+ CREATE TABLE IF NOT EXISTS checkpoints (
4142
+ key TEXT PRIMARY KEY,
4143
+ platform TEXT,
4144
+ username TEXT,
4145
+ target_dir TEXT,
4146
+ cursors_json TEXT,
4147
+ updated_at TEXT
4148
+ );
4149
+ `);
4150
+ }
4151
+ hasMedia(id) {
4152
+ if (this.isFallback || !this.db) {
4153
+ return this.inMemoryFallback.media.has(id);
4154
+ }
4155
+ const stmt = this.db.prepare("SELECT 1 FROM media_ledger WHERE id = ?");
4156
+ const row = stmt.get(id);
4157
+ return Boolean(row);
4158
+ }
4159
+ recordMedia(media) {
4160
+ if (this.isFallback || !this.db) {
4161
+ this.inMemoryFallback.media.set(media.id, media);
4162
+ this.flushFallbackDisk();
4163
+ return;
4164
+ }
4165
+ const stmt = this.db.prepare(`
4166
+ INSERT OR REPLACE INTO media_ledger (id, platform, author, url, filename, file_path, downloaded_at)
4167
+ VALUES (?, ?, ?, ?, ?, ?, ?)
4168
+ `);
4169
+ stmt.run(
4170
+ media.id,
4171
+ media.platform,
4172
+ media.author,
4173
+ media.url,
4174
+ media.filename,
4175
+ media.filePath,
4176
+ media.downloadedAt
4177
+ );
4178
+ }
4179
+ saveCheckpoint(checkpoint) {
4180
+ const key = `${checkpoint.platform}:${checkpoint.username.toLowerCase()}`;
4181
+ if (this.isFallback || !this.db) {
4182
+ this.inMemoryFallback.checkpoints.set(key, checkpoint);
4183
+ this.flushFallbackDisk();
4184
+ return;
4185
+ }
4186
+ const stmt = this.db.prepare(`
4187
+ INSERT OR REPLACE INTO checkpoints (key, platform, username, target_dir, cursors_json, updated_at)
4188
+ VALUES (?, ?, ?, ?, ?, ?)
4189
+ `);
4190
+ stmt.run(
4191
+ key,
4192
+ checkpoint.platform,
4193
+ checkpoint.username,
4194
+ checkpoint.targetDir,
4195
+ JSON.stringify(checkpoint.cursors),
4196
+ checkpoint.updatedAt
4197
+ );
4198
+ }
4199
+ getCheckpoint(platform, username) {
4200
+ const key = `${platform}:${username.toLowerCase()}`;
4201
+ if (this.isFallback || !this.db) {
4202
+ return this.inMemoryFallback.checkpoints.get(key) || null;
4203
+ }
4204
+ const stmt = this.db.prepare(
4205
+ "SELECT platform, username, target_dir, cursors_json, updated_at FROM checkpoints WHERE key = ?"
4206
+ );
4207
+ const row = stmt.get(key);
4208
+ if (!row) return null;
4209
+ return {
4210
+ platform: row.platform,
4211
+ username: row.username,
4212
+ targetDir: row.target_dir,
4213
+ cursors: JSON.parse(row.cursors_json || "{}"),
4214
+ updatedAt: row.updated_at
4215
+ };
4216
+ }
4217
+ clearCheckpoint(platform, username) {
4218
+ const key = `${platform}:${username.toLowerCase()}`;
4219
+ if (this.isFallback || !this.db) {
4220
+ this.inMemoryFallback.checkpoints.delete(key);
4221
+ this.flushFallbackDisk();
4222
+ return;
4223
+ }
4224
+ const stmt = this.db.prepare("DELETE FROM checkpoints WHERE key = ?");
4225
+ stmt.run(key);
4226
+ }
4227
+ close() {
4228
+ if (this.db) {
4229
+ try {
4230
+ this.db.close();
4231
+ } catch {
4232
+ }
4233
+ this.db = null;
4234
+ }
4235
+ }
4236
+ };
4237
+
4238
+ // src/lib/known-folders.ts
4239
+ import { execFileSync } from "child_process";
4240
+ import fs11 from "fs";
4241
+ import os6 from "os";
4242
+ import path11 from "path";
4243
+ var KNOWN_FOLDER_DOWNLOADS_GUID = "{374DE290-123F-4565-9164-39C4925E467B}";
4244
+ var USER_SHELL_FOLDERS_REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders";
4245
+ function expandWindowsEnv(str, env = process.env, homeDir) {
4246
+ return str.replace(/%([^%]+)%/g, (original, name) => {
4247
+ if (homeDir && name.toLowerCase() === "userprofile") {
4248
+ return homeDir;
4249
+ }
4250
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
4251
+ if (key && env[key] !== void 0) {
4252
+ return env[key];
4253
+ }
4254
+ return original;
4255
+ });
4256
+ }
4257
+ function parseWindowsRegistryOutput(output, targetKey = KNOWN_FOLDER_DOWNLOADS_GUID) {
4258
+ const lines = output.split(/\r?\n/);
4259
+ const escaped = targetKey.replace(/[{}]/g, "\\$&");
4260
+ const pattern = new RegExp(`^\\s*${escaped}\\s+REG_\\w+\\s+(.*)$`, "i");
4261
+ for (const line of lines) {
4262
+ const match = line.match(pattern);
4263
+ if (match && match[1]) {
4264
+ const rawPath = match[1].trim();
4265
+ if (rawPath) return rawPath;
4266
+ }
4267
+ }
4268
+ return void 0;
4269
+ }
4270
+ function parseLinuxUserDirsOutput(content) {
4271
+ const lines = content.split(/\r?\n/);
4272
+ for (const line of lines) {
4273
+ const trimmed = line.trim();
4274
+ if (trimmed.startsWith("#")) continue;
4275
+ const match = trimmed.match(/^XDG_DOWNLOAD_DIR=["']?([^"']+)["']?$/);
4276
+ if (match && match[1]) {
4277
+ return match[1].trim();
4278
+ }
4279
+ }
4280
+ return void 0;
4281
+ }
4282
+ function isPathDriveAccessible(filePath, fsExists = fs11.existsSync, platform = process.platform) {
4283
+ try {
4284
+ if (fsExists(filePath)) {
4285
+ return true;
4286
+ }
4287
+ if (platform === "win32") {
4288
+ const match = filePath.match(/^([a-zA-Z]:[\\/])/i);
4289
+ const driveRoot = match ? match[1] : path11.win32.parse(path11.win32.resolve(filePath)).root;
4290
+ return fsExists(driveRoot);
4291
+ }
4292
+ const normalized = filePath.replace(/\\/g, "/");
4293
+ const mountMatch = normalized.match(/^(\/(?:media(?:\/[^/]+)?|mnt|run\/media(?:\/[^/]+)?)\/[^/]+)/);
4294
+ if (mountMatch) {
4295
+ return fsExists(mountMatch[1]);
4296
+ }
4297
+ let curr = path11.posix.dirname(normalized);
4298
+ while (curr && curr !== "/") {
4299
+ if (fsExists(curr)) return true;
4300
+ curr = path11.posix.dirname(curr);
4301
+ }
4302
+ return fsExists("/");
4303
+ } catch {
4304
+ return false;
4305
+ }
4306
+ }
4307
+ function resolveWindowsDownloadsDir(options = {}) {
4308
+ const homeDir = options.homeDir ?? os6.homedir();
4309
+ const env = options.env ?? process.env;
4310
+ const runQuery = options.regQueryFn ?? ((key, value) => {
4311
+ try {
4312
+ return execFileSync("reg", ["query", key, "/v", value], {
4313
+ encoding: "utf8",
4314
+ stdio: ["pipe", "pipe", "ignore"]
4315
+ });
4316
+ } catch {
4317
+ return "";
4318
+ }
4319
+ });
4320
+ const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs11.existsSync, "win32"));
4321
+ let raw = parseWindowsRegistryOutput(
4322
+ runQuery(USER_SHELL_FOLDERS_REG_KEY, KNOWN_FOLDER_DOWNLOADS_GUID),
4323
+ KNOWN_FOLDER_DOWNLOADS_GUID
4324
+ );
4325
+ if (!raw) {
4326
+ raw = parseWindowsRegistryOutput(runQuery(USER_SHELL_FOLDERS_REG_KEY, "Downloads"), "Downloads");
4327
+ }
4328
+ if (raw) {
4329
+ const expanded = expandWindowsEnv(raw, env, homeDir);
4330
+ if (checkAccessible(expanded)) {
4331
+ return path11.win32.resolve(expanded);
4332
+ }
4333
+ }
4334
+ return void 0;
4335
+ }
4336
+ function resolveLinuxDownloadsDir(options = {}) {
4337
+ const homeDir = options.homeDir ?? os6.homedir();
4338
+ const env = options.env ?? process.env;
4339
+ const readFn = options.readFileFn ?? ((p) => {
4340
+ try {
4341
+ return fs11.readFileSync(p, "utf8");
4342
+ } catch {
4343
+ return "";
4344
+ }
4345
+ });
4346
+ const checkAccessible = options.driveAccessibleFn ?? ((p) => isPathDriveAccessible(p, fs11.existsSync, "linux"));
4347
+ const configHome = env.XDG_CONFIG_HOME || path11.join(homeDir, ".config");
4348
+ const userDirsFile = path11.join(configHome, "user-dirs.dirs");
4349
+ const content = readFn(userDirsFile);
4350
+ if (!content) return void 0;
4351
+ const raw = parseLinuxUserDirsOutput(content);
4352
+ if (!raw) return void 0;
4353
+ const expanded = raw.replace(/\$HOME/g, homeDir).replace(/\$\{HOME\}/g, homeDir);
4354
+ if (checkAccessible(expanded)) {
4355
+ return path11.posix.resolve(expanded);
4356
+ }
4357
+ return void 0;
4358
+ }
4359
+ function resolvePlatformDownloadsDir(options = {}) {
4360
+ const platform = options.platform ?? process.platform;
4361
+ const homeDir = options.homeDir ?? os6.homedir();
4362
+ const defaultFallback = path11.join(homeDir, "Downloads");
4363
+ if (platform === "win32") {
4364
+ const resolved = resolveWindowsDownloadsDir(options);
4365
+ if (resolved) return resolved;
4366
+ } else if (platform === "linux") {
4367
+ const resolved = resolveLinuxDownloadsDir(options);
4368
+ if (resolved) return resolved;
4369
+ }
4370
+ return defaultFallback;
4371
+ }
4372
+
4373
+ // src/lib/engines/core/task-manager.ts
4374
+ function expandPath(dir) {
4375
+ if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
4376
+ return path12.join(os7.homedir(), dir.slice(1));
4377
+ }
4378
+ return dir;
4379
+ }
4380
+ function resolveDownloadBaseDir(customBaseDir) {
4381
+ if (customBaseDir && customBaseDir.trim()) {
4382
+ return path12.resolve(expandPath(customBaseDir.trim()));
4383
+ }
4384
+ const envDir = process.env.OPEN_NAMI_DIR || process.env.NAMI_DIR;
4385
+ if (envDir && envDir.trim()) {
4386
+ return path12.resolve(expandPath(envDir.trim()));
4387
+ }
4388
+ const configDir = loadConfig().downloadDir;
4389
+ if (configDir && configDir.trim()) {
4390
+ return path12.resolve(expandPath(configDir.trim()));
4391
+ }
4392
+ if (process.env.NAMI_BASE_DIR && process.env.NAMI_BASE_DIR.trim()) {
4393
+ const raw = process.env.NAMI_BASE_DIR.trim();
4394
+ return path12.resolve(expandPath(raw.endsWith("downloads") ? raw : path12.join(raw, "downloads")));
4395
+ }
4396
+ return path12.join(resolvePlatformDownloadsDir(), "Open Nami");
4397
+ }
4398
+ function resolveDefaultDownloadDir(platform, username, baseDir) {
4399
+ const root = resolveDownloadBaseDir(baseDir);
4400
+ return path12.join(root, platform, username);
4401
+ }
4402
+ async function cleanupEmptyCategoryDirs(targetDir) {
4403
+ const candidateDirs = ["All", "Stories", "Highlights", "Photos", "Videos"];
4404
+ for (const dirName of candidateDirs) {
4405
+ const fullPath = path12.join(targetDir, dirName);
4406
+ try {
4407
+ if (!fsSync.existsSync(fullPath)) continue;
4408
+ const entries = await fs12.readdir(fullPath);
4409
+ const nonArchiveEntries = entries.filter(
4410
+ (e) => e !== ".open-nami-archive.json" && e !== "archive.txt" && e !== "archive_yt-dlp.txt" && e !== "archive_gallery-dl.sqlite3"
4411
+ );
4412
+ if (nonArchiveEntries.length === 0) {
4413
+ for (const entry of entries) {
4414
+ try {
4415
+ await fs12.unlink(path12.join(fullPath, entry));
4416
+ } catch {
4417
+ }
4418
+ }
4419
+ await fs12.rmdir(fullPath).catch(() => {
4420
+ });
4421
+ }
4422
+ } catch {
4423
+ }
4424
+ }
4425
+ }
4426
+ var TaskManager = class {
4427
+ events;
4428
+ ledger;
4429
+ plugins;
4430
+ constructor(eventBus, ledger, plugins) {
4431
+ this.events = eventBus || new EngineEventBus();
4432
+ this.ledger = ledger || new MediaLedger();
4433
+ this.plugins = plugins || defaultPluginRegistry;
4434
+ }
4435
+ async execute(options) {
4436
+ const { profile, choice } = options;
4437
+ const targetDir = options.outputDir || resolveDefaultDownloadDir(profile.platform, profile.username);
4438
+ this.events.emit("job:start", { profile, choice, outputDir: targetDir });
4439
+ let downloadedCount = 0;
4440
+ let skippedCount = 0;
4441
+ const errors = [];
4442
+ if (options.runnerOverrides?.nativeRunner) {
4443
+ const subDir = choice === "all" ? "All" : choice.charAt(0).toUpperCase() + choice.slice(1);
4444
+ const runRes = await options.runnerOverrides.nativeRunner(subDir);
4445
+ downloadedCount += runRes.downloaded;
4446
+ skippedCount += runRes.skipped;
4447
+ if (!runRes.success) errors.push(runRes.error || "Execution failed");
4448
+ }
4449
+ await cleanupEmptyCategoryDirs(targetDir);
4450
+ const result = {
4451
+ success: errors.length === 0,
4452
+ downloadedCount,
4453
+ skippedCount,
4454
+ errors,
4455
+ outputDir: targetDir
4456
+ };
4457
+ this.events.emit("job:complete", { result });
4458
+ return result;
4459
+ }
4460
+ };
4461
+ var defaultTaskManager = new TaskManager();
4462
+
4463
+ // src/lib/engines/dispatcher.ts
4464
+ var REFERER_MAP = {
4465
+ tiktok: "https://www.tiktok.com/",
4466
+ instagram: "https://www.instagram.com/",
4467
+ x: "https://x.com/",
4468
+ facebook: "https://www.facebook.com/"
4469
+ };
4470
+ async function dispatchDownload(options) {
4471
+ const { profile, choice, onProgress } = options;
4472
+ const targetDir = options.outputDir || resolveDefaultDownloadDir(profile.platform, profile.username);
4473
+ await fs13.mkdir(targetDir, { recursive: true });
4474
+ const cookiesFromBrowser = options.cookiesFromBrowser ?? loadConfig().cookiesFromBrowser;
4475
+ defaultTaskManager.events.emit("job:start", { profile, choice, outputDir: targetDir });
4476
+ const masterCookie = findCookieFile(profile.platform);
4477
+ const cookieCopy = masterCookie ? await createSafeCookieCopy(masterCookie) : null;
4478
+ let totalDownloaded = 0;
4479
+ let totalSkipped = 0;
4480
+ const errors = [];
4481
+ const recordError = (err) => {
4482
+ errors.push(err);
4483
+ defaultTaskManager.events.emit("job:error", { error: err });
4484
+ };
4485
+ const handleProgress = (progress) => {
4486
+ defaultTaskManager.events.emit("job:progress", progress);
4487
+ if (progress.currentFile && progress.statusText?.startsWith("Saved:")) {
4488
+ try {
4489
+ defaultTaskManager.ledger.recordMedia({
4490
+ id: `${profile.platform}:${profile.username}:${progress.currentFile}`,
4491
+ platform: profile.platform,
4492
+ author: profile.username,
4493
+ url: profile.cleanUrl || "",
4494
+ filename: progress.currentFile,
4495
+ filePath: path13.join(targetDir, progress.currentFile),
4496
+ downloadedAt: (/* @__PURE__ */ new Date()).toISOString()
4497
+ });
4498
+ } catch {
4499
+ }
4500
+ }
4501
+ onProgress?.(progress);
4502
+ };
4503
+ try {
4504
+ const jobs = [];
4505
+ if (choice === "photos") {
4506
+ jobs.push({ type: "photos" });
4507
+ } else if (choice === "videos") {
4508
+ jobs.push({ type: "videos" });
4509
+ } else if (choice === "stories") {
4510
+ jobs.push({ type: "stories" });
4511
+ } else if (choice === "highlights") {
4512
+ jobs.push({ type: "highlights" });
4513
+ } else if (choice === "all") {
4514
+ jobs.push({ type: "all" });
4515
+ if (profile.platform === "instagram") {
4516
+ jobs.push({ type: "stories" });
4517
+ jobs.push({ type: "highlights" });
4518
+ }
4519
+ }
4520
+ const sinceSeconds = parseDateBoundary(options.since, "since");
4521
+ const untilSeconds = parseDateBoundary(options.until, "until");
4522
+ const globalLimit = parseLimit(options.limit);
4523
+ const savedCheckpoint = options.resume ? await loadCheckpoint(targetDir) : null;
4524
+ const currentCursors = savedCheckpoint?.cursors ? { ...savedCheckpoint.cursors } : {};
4525
+ const defaultRunNative = async (subDir) => {
4526
+ const dest = subDir === "All" ? targetDir : path13.join(targetDir, subDir);
4170
4527
  const remainingLimit = globalLimit !== void 0 ? Math.max(0, globalLimit - totalDownloaded) : void 0;
4171
4528
  if (remainingLimit === 0) {
4172
4529
  return { success: true, downloaded: 0, skipped: 0 };
@@ -4208,8 +4565,8 @@ async function dispatchDownload(options) {
4208
4565
  jobName: `${profile.username} ${subDir}`,
4209
4566
  destDir: dest,
4210
4567
  destDirMap: subDir === "All" ? {
4211
- photo: path10.join(targetDir, "Photos"),
4212
- video: path10.join(targetDir, "Videos")
4568
+ photo: path13.join(targetDir, "Photos"),
4569
+ video: path13.join(targetDir, "Videos")
4213
4570
  } : void 0,
4214
4571
  cookiePath: cookieCopy?.filePath,
4215
4572
  referer: REFERER_MAP[profile.platform],
@@ -4218,7 +4575,7 @@ async function dispatchDownload(options) {
4218
4575
  writeMetadata: options.writeMetadata,
4219
4576
  defaultPlatform: profile.platform,
4220
4577
  defaultAuthor: profile.username,
4221
- onProgress
4578
+ onProgress: handleProgress
4222
4579
  });
4223
4580
  return {
4224
4581
  success: res.errors.length === 0,
@@ -4232,7 +4589,7 @@ async function dispatchDownload(options) {
4232
4589
  success: false,
4233
4590
  downloaded: 0,
4234
4591
  skipped: 0,
4235
- error: errMsg
4592
+ error: errMsg || "Execution failed"
4236
4593
  };
4237
4594
  }
4238
4595
  };
@@ -4242,17 +4599,19 @@ async function dispatchDownload(options) {
4242
4599
  targetDir,
4243
4600
  subDir,
4244
4601
  cookiePath: cookieCopy?.filePath,
4602
+ cookiesFromBrowser,
4245
4603
  signal: options.signal,
4246
4604
  writeMetadata: options.writeMetadata,
4247
- onProgress
4605
+ onProgress: handleProgress
4248
4606
  }));
4249
4607
  const runYt = options.runnerOverrides?.ytRunner ?? (() => runYtDlp({
4250
4608
  profile,
4251
4609
  targetDir,
4252
4610
  cookiePath: cookieCopy?.filePath,
4611
+ cookiesFromBrowser,
4253
4612
  signal: options.signal,
4254
4613
  writeMetadata: options.writeMetadata,
4255
- onProgress
4614
+ onProgress: handleProgress
4256
4615
  }));
4257
4616
  for (const job of jobs) {
4258
4617
  if (options.signal?.aborted) break;
@@ -4260,7 +4619,7 @@ async function dispatchDownload(options) {
4260
4619
  if (job.type === "all") {
4261
4620
  let res = await runNative("All");
4262
4621
  if ((!res.success || res.downloaded === 0 && res.skipped === 0) && !options.signal?.aborted) {
4263
- onProgress?.({
4622
+ handleProgress({
4264
4623
  job: "Photos",
4265
4624
  downloadedCount: totalDownloaded,
4266
4625
  skippedCount: totalSkipped,
@@ -4269,8 +4628,8 @@ async function dispatchDownload(options) {
4269
4628
  const gdlPhotos = await runGdl("Photos");
4270
4629
  totalDownloaded += gdlPhotos.downloaded;
4271
4630
  totalSkipped += gdlPhotos.skipped;
4272
- if (!gdlPhotos.success && gdlPhotos.error) errors.push(`[Photos] ${gdlPhotos.error}`);
4273
- onProgress?.({
4631
+ if (!gdlPhotos.success) recordError(gdlPhotos.error ? `[Photos] ${gdlPhotos.error}` : "[Photos] Execution failed");
4632
+ handleProgress({
4274
4633
  job: "Videos",
4275
4634
  downloadedCount: totalDownloaded,
4276
4635
  skippedCount: totalSkipped,
@@ -4280,9 +4639,9 @@ async function dispatchDownload(options) {
4280
4639
  if (ytVideos.downloaded > 0 || ytVideos.skipped > 0 || ytVideos.success) {
4281
4640
  totalDownloaded += ytVideos.downloaded;
4282
4641
  totalSkipped += ytVideos.skipped;
4283
- if (!ytVideos.success && ytVideos.error) errors.push(`[Videos] ${ytVideos.error}`);
4642
+ if (!ytVideos.success) recordError(ytVideos.error ? `[Videos] ${ytVideos.error}` : "[Videos] Execution failed");
4284
4643
  } else if (!options.signal?.aborted) {
4285
- onProgress?.({
4644
+ handleProgress({
4286
4645
  job: "Videos",
4287
4646
  downloadedCount: totalDownloaded,
4288
4647
  skippedCount: totalSkipped,
@@ -4291,19 +4650,19 @@ async function dispatchDownload(options) {
4291
4650
  const gdlVideos = await runGdl("Videos");
4292
4651
  totalDownloaded += gdlVideos.downloaded;
4293
4652
  totalSkipped += gdlVideos.skipped;
4294
- if (!gdlVideos.success && gdlVideos.error) errors.push(`[Videos] ${gdlVideos.error}`);
4653
+ if (!gdlVideos.success) recordError(gdlVideos.error ? `[Videos] ${gdlVideos.error}` : "[Videos] Execution failed");
4295
4654
  }
4296
4655
  } else {
4297
4656
  totalDownloaded += res.downloaded;
4298
4657
  totalSkipped += res.skipped;
4299
- if (!res.success && res.error) errors.push(`[All] ${res.error}`);
4658
+ if (!res.success) recordError(res.error ? `[All] ${res.error}` : "[All] Execution failed");
4300
4659
  }
4301
4660
  } else if (job.type === "photos" || job.type === "stories" || job.type === "highlights") {
4302
4661
  const subDir = job.type === "photos" ? "Photos" : job.type === "stories" ? "Stories" : "Highlights";
4303
4662
  let res = await runNative(subDir);
4304
4663
  const isAuthError = Boolean(res.error && (res.error.includes("cookie") || res.error.includes("authenticated session") || res.error.includes("Settings (^s)")));
4305
4664
  if (!isAuthError && (!res.success || res.downloaded === 0 && res.skipped === 0) && !options.signal?.aborted) {
4306
- onProgress?.({
4665
+ handleProgress({
4307
4666
  job: subDir,
4308
4667
  downloadedCount: totalDownloaded,
4309
4668
  skippedCount: totalSkipped,
@@ -4316,14 +4675,14 @@ async function dispatchDownload(options) {
4316
4675
  }
4317
4676
  totalDownloaded += res.downloaded;
4318
4677
  totalSkipped += res.skipped;
4319
- if (!res.success && res.error) errors.push(`[${subDir}] ${res.error}`);
4678
+ if (!res.success) recordError(res.error ? `[${subDir}] ${res.error}` : `[${subDir}] Execution failed`);
4320
4679
  } else if (job.type === "videos") {
4321
4680
  let res = await runNative("Videos");
4322
4681
  if (res.downloaded > 0 || res.skipped > 0) {
4323
4682
  totalDownloaded += res.downloaded;
4324
4683
  totalSkipped += res.skipped;
4325
4684
  } else if (!options.signal?.aborted) {
4326
- onProgress?.({
4685
+ handleProgress({
4327
4686
  job: "Videos",
4328
4687
  downloadedCount: totalDownloaded,
4329
4688
  skippedCount: totalSkipped,
@@ -4333,9 +4692,9 @@ async function dispatchDownload(options) {
4333
4692
  if (fbRes.downloaded > 0 || fbRes.skipped > 0 || fbRes.success) {
4334
4693
  totalDownloaded += fbRes.downloaded;
4335
4694
  totalSkipped += fbRes.skipped;
4336
- if (!fbRes.success && fbRes.error) errors.push(`[Videos] ${fbRes.error}`);
4695
+ if (!fbRes.success) recordError(fbRes.error ? `[Videos] ${fbRes.error}` : "[Videos] Execution failed");
4337
4696
  } else if (!options.signal?.aborted) {
4338
- onProgress?.({
4697
+ handleProgress({
4339
4698
  job: "Videos",
4340
4699
  downloadedCount: totalDownloaded,
4341
4700
  skippedCount: totalSkipped,
@@ -4344,7 +4703,7 @@ async function dispatchDownload(options) {
4344
4703
  const gdlRes = await runGdl("Videos");
4345
4704
  totalDownloaded += gdlRes.downloaded;
4346
4705
  totalSkipped += gdlRes.skipped;
4347
- if (!gdlRes.success && gdlRes.error) errors.push(`[Videos] ${gdlRes.error}`);
4706
+ if (!gdlRes.success) recordError(gdlRes.error ? `[Videos] ${gdlRes.error}` : "[Videos] Execution failed");
4348
4707
  }
4349
4708
  }
4350
4709
  }
@@ -4353,13 +4712,19 @@ async function dispatchDownload(options) {
4353
4712
  if (errors.length === 0 && !options.signal?.aborted) {
4354
4713
  await clearCheckpoint(targetDir);
4355
4714
  }
4356
- return {
4715
+ const engineResult = {
4357
4716
  success: errors.length === 0 || totalDownloaded > 0,
4358
4717
  downloadedCount: totalDownloaded,
4359
4718
  skippedCount: totalSkipped,
4360
4719
  errors,
4361
4720
  outputDir: targetDir
4362
4721
  };
4722
+ defaultTaskManager.events.emit("job:complete", { result: engineResult });
4723
+ return engineResult;
4724
+ } catch (err) {
4725
+ const errMsg = err instanceof Error ? err.message : String(err);
4726
+ defaultTaskManager.events.emit("job:error", { error: errMsg || "Execution failed" });
4727
+ throw err;
4363
4728
  } finally {
4364
4729
  if (cookieCopy) {
4365
4730
  await cookieCopy.cleanup();
@@ -4559,7 +4924,7 @@ function ProbingView({
4559
4924
 
4560
4925
  // src/components/views/downloading-view.tsx
4561
4926
  import { useState as useState5, useEffect as useEffect5 } from "react";
4562
- import os6 from "os";
4927
+ import os8 from "os";
4563
4928
  import { Box as Box9, Text as Text11 } from "ink";
4564
4929
  import Spinner from "ink-spinner";
4565
4930
 
@@ -4695,35 +5060,51 @@ function DownloadingView({
4695
5060
  activeBadges.length > 0 ? /* @__PURE__ */ jsx12(Text11, { color: theme.gray, dimColor: theme.dimSecondary, children: activeBadges.join(" \xB7 ") }) : null,
4696
5061
  outputDir ? /* @__PURE__ */ jsxs9(Text11, { color: theme.gray, dimColor: theme.dimSecondary, children: [
4697
5062
  "saving to ",
4698
- shortenPath(outputDir, os6.homedir(), 54)
5063
+ shortenPath(outputDir, os8.homedir(), 54)
4699
5064
  ] }) : null
4700
5065
  ] });
4701
5066
  }
4702
5067
 
4703
5068
  // src/components/views/completion-view.tsx
4704
- import os7 from "os";
5069
+ import os9 from "os";
4705
5070
  import { Box as Box10, Text as Text12 } from "ink";
4706
- import { Fragment as Fragment2, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
5071
+ import { jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
4707
5072
  var DONE_LABEL = "\u21B5 download another";
5073
+ var LEDGER_INDEXED_LABEL = "\u2713 indexed in media ledger";
5074
+ function shouldShowLedgerBadge(result, showLedgerBadge) {
5075
+ return showLedgerBadge ?? result.downloadedCount > 0;
5076
+ }
5077
+ function formatCompletionSummary(result) {
5078
+ const isZero = result.downloadedCount === 0 && result.skippedCount === 0;
5079
+ const plural = result.downloadedCount === 1 ? "" : "s";
5080
+ const skipText = result.skippedCount > 0 ? ` (${result.skippedCount} archived)` : "";
5081
+ return {
5082
+ isZero,
5083
+ headerPrefix: isZero ? "\u2713 profile scanned " : "\u2713 profile downloaded! ",
5084
+ headerSuffix: isZero ? ": 0 media items found" : "saved to:",
5085
+ itemsText: isZero ? "no photos or videos available" : `${result.downloadedCount} media item${plural} downloaded${skipText}`
5086
+ };
5087
+ }
5088
+ function LedgerBadge({ theme }) {
5089
+ return /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: LEDGER_INDEXED_LABEL });
5090
+ }
4708
5091
  function CompletionView({
4709
5092
  profile,
4710
5093
  result,
4711
5094
  theme,
4712
- gapComponent: Gap2
5095
+ gapComponent: Gap2,
5096
+ showLedgerBadge
4713
5097
  }) {
4714
- const isZero = result.downloadedCount === 0 && result.skippedCount === 0;
4715
- const plural = result.downloadedCount === 1 ? "" : "s";
4716
- const skipText = result.skippedCount > 0 ? ` (${result.skippedCount} archived)` : "";
5098
+ const { headerPrefix, headerSuffix, itemsText } = formatCompletionSummary(result);
5099
+ const displayLedger = shouldShowLedgerBadge(result, showLedgerBadge);
4717
5100
  return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", alignItems: "center", children: [
4718
- /* @__PURE__ */ jsx13(Text12, { children: isZero ? /* @__PURE__ */ jsxs10(Fragment2, { children: [
4719
- /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "\u2713 profile scanned " }),
4720
- /* @__PURE__ */ jsx13(Text12, { color: theme.primary, children: "\u2014 0 media items found" })
4721
- ] }) : /* @__PURE__ */ jsxs10(Fragment2, { children: [
4722
- /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "\u2713 profile downloaded! " }),
4723
- /* @__PURE__ */ jsx13(Text12, { color: theme.primary, children: "saved to:" })
4724
- ] }) }),
4725
- /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: shortenPath(result.outputDir, os7.homedir(), 60) }),
4726
- /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: isZero ? "no photos or videos available" : `${result.downloadedCount} media item${plural} downloaded${skipText}` }),
5101
+ /* @__PURE__ */ jsxs10(Text12, { children: [
5102
+ /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: headerPrefix }),
5103
+ /* @__PURE__ */ jsx13(Text12, { color: theme.primary, children: headerSuffix })
5104
+ ] }),
5105
+ /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: shortenPath(result.outputDir, os9.homedir(), 60) }),
5106
+ /* @__PURE__ */ jsx13(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: itemsText }),
5107
+ displayLedger && /* @__PURE__ */ jsx13(LedgerBadge, { theme }),
4727
5108
  /* @__PURE__ */ jsx13(Box10, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text12, { color: theme.gray, dimColor: theme.dimSecondary, children: [
4728
5109
  /* @__PURE__ */ jsx13(Text12, { bold: true, color: theme.primary, children: "[o]" }),
4729
5110
  " reveal in folder"
@@ -4765,18 +5146,206 @@ function ErrorView({ message, hint, columns, theme }) {
4765
5146
  }
4766
5147
 
4767
5148
  // src/components/views/settings-view.tsx
4768
- import { useState as useState6, useMemo as useMemo2, useCallback } from "react";
5149
+ import { useState as useState6, useMemo as useMemo2, useCallback, useEffect as useEffect6 } from "react";
4769
5150
  import { Box as Box12, Text as Text14, useInput as useInput2 } from "ink";
4770
- import fs11 from "fs";
4771
- import path11 from "path";
4772
- import os8 from "os";
4773
- import { jsx as jsx15, jsxs as jsxs12 } from "react/jsx-runtime";
5151
+ import fs15 from "fs";
5152
+ import path15 from "path";
5153
+ import os11 from "os";
5154
+
5155
+ // src/lib/diagnostics/doctor.ts
5156
+ import { execFile } from "child_process";
5157
+ import { promisify } from "util";
5158
+ import os10 from "os";
5159
+ import fs14 from "fs";
5160
+ import path14 from "path";
5161
+ var execFileAsync = promisify(execFile);
5162
+ async function defaultCheckBinary(bin) {
5163
+ const versionFlag = bin === "ffmpeg" ? "-version" : "--version";
5164
+ try {
5165
+ await execFileAsync(bin, [versionFlag], { timeout: 3e3 });
5166
+ return true;
5167
+ } catch {
5168
+ if (bin === "gallery-dl" || bin === "yt-dlp") {
5169
+ try {
5170
+ const resolved = resolveEngineCommand(bin);
5171
+ await execFileAsync(resolved.command, [...resolved.baseArgs, versionFlag], { timeout: 3e3 });
5172
+ return true;
5173
+ } catch {
5174
+ return false;
5175
+ }
5176
+ }
5177
+ return false;
5178
+ }
5179
+ }
5180
+ async function runDoctor(options = {}) {
5181
+ const check = options.binaryChecker ?? defaultCheckBinary;
5182
+ const [gdl, ytdlp, ffmpeg] = await Promise.all([
5183
+ check("gallery-dl"),
5184
+ check("yt-dlp"),
5185
+ check("ffmpeg")
5186
+ ]);
5187
+ const availableCookies = detectAvailableCookies(options.cookiesDir);
5188
+ const cookieStatus = {
5189
+ instagram: availableCookies.includes("instagram"),
5190
+ tiktok: availableCookies.includes("tiktok"),
5191
+ facebook: availableCookies.includes("facebook"),
5192
+ x: availableCookies.includes("x")
5193
+ };
5194
+ let storageOk = false;
5195
+ try {
5196
+ const testDir = options.storageDir || path14.join(os10.homedir(), ".open-nami");
5197
+ fs14.mkdirSync(testDir, { recursive: true });
5198
+ const testFile = path14.join(testDir, `.doctor_test_${Date.now()}_${Math.random().toString(36).slice(2)}`);
5199
+ fs14.writeFileSync(testFile, "ok");
5200
+ fs14.unlinkSync(testFile);
5201
+ storageOk = true;
5202
+ } catch {
5203
+ }
5204
+ return {
5205
+ nodeVersion: process.version,
5206
+ binaries: {
5207
+ "gallery-dl": gdl,
5208
+ "yt-dlp": ytdlp,
5209
+ ffmpeg
5210
+ },
5211
+ cookies: cookieStatus,
5212
+ storageOk
5213
+ };
5214
+ }
5215
+
5216
+ // src/components/views/settings-view.tsx
5217
+ import { Fragment as Fragment2, jsx as jsx15, jsxs as jsxs12 } from "react/jsx-runtime";
4774
5218
  function expandDir(dir) {
4775
5219
  if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
4776
- return path11.join(os8.homedir(), dir.slice(1));
5220
+ return path15.join(os11.homedir(), dir.slice(1));
4777
5221
  }
4778
5222
  return dir;
4779
5223
  }
5224
+ function resolveBrowserOptions(detected) {
5225
+ const available = detected.filter((b) => b.available !== false);
5226
+ return ["none", ...available.map((b) => b.id)];
5227
+ }
5228
+ function cycleBrowserOption(current, options, forward = true) {
5229
+ if (options.length === 0) return "none";
5230
+ const idx = options.indexOf(current);
5231
+ const currentIndex = idx === -1 ? 0 : idx;
5232
+ const nextIdx = forward ? (currentIndex + 1) % options.length : (currentIndex - 1 + options.length) % options.length;
5233
+ return options[nextIdx];
5234
+ }
5235
+ function formatDoctorStorage(storageOk) {
5236
+ return {
5237
+ ok: storageOk,
5238
+ text: storageOk ? "\u2713 SQLite ready" : "\u2717 SQLite unavailable"
5239
+ };
5240
+ }
5241
+ function formatDoctorCookies(cookies) {
5242
+ const platforms = Object.entries(cookies).filter(([_, ok]) => ok).map(([name]) => name);
5243
+ const count = platforms.length;
5244
+ const text = count > 0 ? `\u2713 ${count} (${platforms.join(", ")})` : "none detected";
5245
+ return { count, platforms, text };
5246
+ }
5247
+ function DoctorCard({ boxWidth, theme, report }) {
5248
+ const cookieSummary = report ? formatDoctorCookies(report.cookies) : { count: 0, platforms: [], text: "" };
5249
+ const storageSummary = report ? formatDoctorStorage(report.storageOk) : { ok: false, text: "" };
5250
+ return /* @__PURE__ */ jsxs12(
5251
+ Box12,
5252
+ {
5253
+ width: boxWidth,
5254
+ borderStyle: "round",
5255
+ borderColor: theme.gray,
5256
+ borderDimColor: theme.dimSecondary,
5257
+ paddingX: 2,
5258
+ flexDirection: "column",
5259
+ children: [
5260
+ /* @__PURE__ */ jsxs12(Box12, { justifyContent: "space-between", children: [
5261
+ /* @__PURE__ */ jsx15(Text14, { color: theme.primary, bold: true, children: "System Diagnostics" }),
5262
+ /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: report ? report.nodeVersion : "checking\u2026" })
5263
+ ] }),
5264
+ report ? /* @__PURE__ */ jsxs12(Fragment2, { children: [
5265
+ /* @__PURE__ */ jsxs12(Box12, { marginTop: 0, children: [
5266
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5267
+ "Engines:",
5268
+ " "
5269
+ ] }),
5270
+ /* @__PURE__ */ jsxs12(
5271
+ Text14,
5272
+ {
5273
+ color: report.binaries["gallery-dl"] ? "green" : "red",
5274
+ dimColor: !report.binaries["gallery-dl"] && theme.dimSecondary,
5275
+ children: [
5276
+ report.binaries["gallery-dl"] ? "\u2713" : "\u2717",
5277
+ " gallery-dl"
5278
+ ]
5279
+ }
5280
+ ),
5281
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5282
+ " ",
5283
+ "\xB7",
5284
+ " "
5285
+ ] }),
5286
+ /* @__PURE__ */ jsxs12(
5287
+ Text14,
5288
+ {
5289
+ color: report.binaries["yt-dlp"] ? "green" : "red",
5290
+ dimColor: !report.binaries["yt-dlp"] && theme.dimSecondary,
5291
+ children: [
5292
+ report.binaries["yt-dlp"] ? "\u2713" : "\u2717",
5293
+ " yt-dlp"
5294
+ ]
5295
+ }
5296
+ ),
5297
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5298
+ " ",
5299
+ "\xB7",
5300
+ " "
5301
+ ] }),
5302
+ /* @__PURE__ */ jsxs12(
5303
+ Text14,
5304
+ {
5305
+ color: report.binaries.ffmpeg ? "green" : "red",
5306
+ dimColor: !report.binaries.ffmpeg && theme.dimSecondary,
5307
+ children: [
5308
+ report.binaries.ffmpeg ? "\u2713" : "\u2717",
5309
+ " ffmpeg"
5310
+ ]
5311
+ }
5312
+ )
5313
+ ] }),
5314
+ /* @__PURE__ */ jsxs12(Box12, { justifyContent: "space-between", marginTop: 0, children: [
5315
+ /* @__PURE__ */ jsxs12(Box12, { children: [
5316
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5317
+ "Storage:",
5318
+ " "
5319
+ ] }),
5320
+ /* @__PURE__ */ jsx15(
5321
+ Text14,
5322
+ {
5323
+ color: storageSummary.ok ? "green" : "red",
5324
+ dimColor: !storageSummary.ok && theme.dimSecondary,
5325
+ children: storageSummary.text
5326
+ }
5327
+ )
5328
+ ] }),
5329
+ /* @__PURE__ */ jsxs12(Box12, { children: [
5330
+ /* @__PURE__ */ jsxs12(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: [
5331
+ "Cookies:",
5332
+ " "
5333
+ ] }),
5334
+ /* @__PURE__ */ jsx15(
5335
+ Text14,
5336
+ {
5337
+ color: cookieSummary.count > 0 ? theme.primary : theme.gray,
5338
+ dimColor: cookieSummary.count === 0 && theme.dimSecondary,
5339
+ children: cookieSummary.text
5340
+ }
5341
+ )
5342
+ ] })
5343
+ ] })
5344
+ ] }) : /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: "checking system engines and storage\u2026" })
5345
+ ]
5346
+ }
5347
+ );
5348
+ }
4780
5349
  var LIMIT_OPTIONS = [void 0, 10, 25, 50, 100];
4781
5350
  function SettingsView({
4782
5351
  boxWidth,
@@ -4785,7 +5354,10 @@ function SettingsView({
4785
5354
  focusedField: controlledFocus,
4786
5355
  onFocusChange,
4787
5356
  onSave,
4788
- onCancel
5357
+ onCancel,
5358
+ doctorChecker,
5359
+ initialReport,
5360
+ browsers: browsersProp
4789
5361
  }) {
4790
5362
  const initialConfig = useMemo2(() => loadConfig(), []);
4791
5363
  const [internalFocus, setInternalFocus] = useState6("cookies");
@@ -4797,16 +5369,61 @@ function SettingsView({
4797
5369
  },
4798
5370
  [onFocusChange]
4799
5371
  );
5372
+ const detectedBrowsers = useMemo2(() => {
5373
+ return browsersProp ?? listDetectedBrowsers();
5374
+ }, [browsersProp]);
5375
+ const browserOptions = useMemo2(() => {
5376
+ return resolveBrowserOptions(detectedBrowsers);
5377
+ }, [detectedBrowsers]);
4800
5378
  const [cookiesDir, setCookiesDir] = useState6(initialConfig.cookiesDir ?? "");
5379
+ const [cookiesFromBrowser, setCookiesFromBrowser] = useState6(
5380
+ initialConfig.cookiesFromBrowser ?? "none"
5381
+ );
4801
5382
  const [downloadDir, setDownloadDir] = useState6(initialConfig.downloadDir ?? "");
4802
5383
  const [writeMetadata, setWriteMetadata] = useState6(Boolean(initialConfig.writeMetadata));
4803
5384
  const [defaultLimit, setDefaultLimit] = useState6(initialConfig.defaultLimit);
4804
5385
  const [saved, setSaved] = useState6(false);
5386
+ const [doctorReport, setDoctorReport] = useState6(initialReport ?? null);
5387
+ useEffect6(() => {
5388
+ let cancelled = false;
5389
+ const fetchReport = async () => {
5390
+ try {
5391
+ const options = {
5392
+ cookiesDir: cookiesDir.trim() ? expandDir(cookiesDir.trim()) : void 0
5393
+ };
5394
+ let report;
5395
+ if (doctorChecker) {
5396
+ const res = await doctorChecker(options);
5397
+ if (res && typeof res === "object" && "binaries" in res) {
5398
+ report = res;
5399
+ } else {
5400
+ report = await runDoctor({
5401
+ ...options,
5402
+ binaryChecker: doctorChecker
5403
+ });
5404
+ }
5405
+ } else {
5406
+ report = await runDoctor(options);
5407
+ }
5408
+ if (!cancelled) {
5409
+ setDoctorReport(report);
5410
+ }
5411
+ } catch {
5412
+ }
5413
+ };
5414
+ if (initialReport === void 0 || doctorChecker !== void 0) {
5415
+ fetchReport();
5416
+ }
5417
+ return () => {
5418
+ cancelled = true;
5419
+ };
5420
+ }, [cookiesDir, doctorChecker, initialReport]);
4805
5421
  const handleSave = useCallback(() => {
4806
5422
  const existing = loadConfig();
4807
5423
  const nextConfig = {
4808
5424
  ...existing,
4809
5425
  cookiesDir: cookiesDir.trim() ? cookiesDir.trim() : void 0,
5426
+ cookiesFromBrowser,
4810
5427
  downloadDir: downloadDir.trim() ? downloadDir.trim() : void 0,
4811
5428
  writeMetadata,
4812
5429
  defaultLimit
@@ -4814,7 +5431,15 @@ function SettingsView({
4814
5431
  saveConfig(nextConfig);
4815
5432
  setSaved(true);
4816
5433
  onSave();
4817
- }, [cookiesDir, downloadDir, writeMetadata, defaultLimit, onSave]);
5434
+ }, [cookiesDir, cookiesFromBrowser, downloadDir, writeMetadata, defaultLimit, onSave]);
5435
+ const browserFeedback = useMemo2(() => {
5436
+ if (!cookiesFromBrowser || cookiesFromBrowser === "none") {
5437
+ return "manual cookie files only";
5438
+ }
5439
+ const found = detectedBrowsers.find((b) => b.id === cookiesFromBrowser);
5440
+ const browserName = found ? found.name : cookiesFromBrowser;
5441
+ return `live session cookies from ${browserName}`;
5442
+ }, [cookiesFromBrowser, detectedBrowsers]);
4818
5443
  const cookieFeedback = useMemo2(() => {
4819
5444
  const dir = cookiesDir.trim();
4820
5445
  if (!dir) {
@@ -4825,7 +5450,7 @@ function SettingsView({
4825
5450
  };
4826
5451
  }
4827
5452
  const expanded = expandDir(dir);
4828
- if (!fs11.existsSync(expanded)) {
5453
+ if (!fs15.existsSync(expanded)) {
4829
5454
  return {
4830
5455
  ok: false,
4831
5456
  text: "directory does not exist yet"
@@ -4848,14 +5473,20 @@ function SettingsView({
4848
5473
  if (!dir) {
4849
5474
  return "default: platform Downloads / Open Nami";
4850
5475
  }
4851
- return `target: ${path11.resolve(expandDir(dir))}`;
5476
+ return `target: ${path15.resolve(expandDir(dir))}`;
4852
5477
  }, [downloadDir]);
4853
5478
  const cycleFocus = useCallback((forward = true) => {
4854
- const fields = ["cookies", "download", "metadata", "limit"];
5479
+ const fields = ["cookies", "browser", "download", "metadata", "limit"];
4855
5480
  const idx = fields.indexOf(focusedField);
4856
5481
  const nextIdx = forward ? (idx + 1) % fields.length : (idx - 1 + fields.length) % fields.length;
4857
5482
  setField(fields[nextIdx]);
4858
5483
  }, [focusedField, setField]);
5484
+ const cycleBrowser = useCallback((forward = true) => {
5485
+ setCookiesFromBrowser((prev) => {
5486
+ const next = cycleBrowserOption(prev ?? "none", browserOptions, forward);
5487
+ return next;
5488
+ });
5489
+ }, [browserOptions]);
4859
5490
  const cycleLimit = useCallback(() => {
4860
5491
  setDefaultLimit((prev) => {
4861
5492
  const idx = LIMIT_OPTIONS.indexOf(prev);
@@ -4872,6 +5503,20 @@ function SettingsView({
4872
5503
  handleSave();
4873
5504
  return;
4874
5505
  }
5506
+ if (focusedField === "browser") {
5507
+ if (input === " " || key.return) {
5508
+ cycleBrowser(true);
5509
+ return;
5510
+ }
5511
+ if (key.tab || key.downArrow) {
5512
+ cycleFocus(true);
5513
+ return;
5514
+ }
5515
+ if (key.upArrow) {
5516
+ cycleFocus(false);
5517
+ return;
5518
+ }
5519
+ }
4875
5520
  if (focusedField === "metadata") {
4876
5521
  if (input === " " || key.return) {
4877
5522
  setWriteMetadata((prev) => !prev);
@@ -4912,7 +5557,7 @@ function SettingsView({
4912
5557
  {
4913
5558
  value: cookiesDir,
4914
5559
  onChange: setCookiesDir,
4915
- onSubmit: () => setField("download"),
5560
+ onSubmit: () => setField("browser"),
4916
5561
  focus: focusedField === "cookies",
4917
5562
  onTab: () => cycleFocus(true),
4918
5563
  onArrowDown: () => cycleFocus(true),
@@ -4925,6 +5570,26 @@ function SettingsView({
4925
5570
  ),
4926
5571
  /* @__PURE__ */ jsx15(Text14, { color: cookieFeedback.ok ? theme.primary : theme.gray, dimColor: !cookieFeedback.ok && theme.dimSecondary, children: cookieFeedback.text }),
4927
5572
  /* @__PURE__ */ jsx15(Gap2, {}),
5573
+ /* @__PURE__ */ jsxs12(
5574
+ Box12,
5575
+ {
5576
+ width: boxWidth,
5577
+ borderStyle: "round",
5578
+ borderColor: focusedField === "browser" ? theme.primary : theme.gray,
5579
+ borderDimColor: focusedField !== "browser",
5580
+ paddingX: 2,
5581
+ justifyContent: "space-between",
5582
+ children: [
5583
+ /* @__PURE__ */ jsxs12(Text14, { color: focusedField === "browser" ? theme.primary : theme.gray, bold: focusedField === "browser", children: [
5584
+ focusedField === "browser" ? "\u25CF " : "\u25CB ",
5585
+ "Browser Cookie Bridge"
5586
+ ] }),
5587
+ /* @__PURE__ */ jsx15(Text14, { color: cookiesFromBrowser && cookiesFromBrowser !== "none" ? theme.primary : theme.gray, bold: true, children: `[${(cookiesFromBrowser ?? "none").toUpperCase()}]` })
5588
+ ]
5589
+ }
5590
+ ),
5591
+ /* @__PURE__ */ jsx15(Text14, { color: theme.gray, dimColor: theme.dimSecondary, children: browserFeedback }),
5592
+ /* @__PURE__ */ jsx15(Gap2, {}),
4928
5593
  /* @__PURE__ */ jsx15(
4929
5594
  FramedInput,
4930
5595
  {
@@ -4986,12 +5651,14 @@ function SettingsView({
4986
5651
  }
4987
5652
  ),
4988
5653
  /* @__PURE__ */ jsx15(Gap2, {}),
5654
+ /* @__PURE__ */ jsx15(DoctorCard, { boxWidth, theme, report: doctorReport }),
5655
+ /* @__PURE__ */ jsx15(Gap2, {}),
4989
5656
  /* @__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 space toggle/cycle \xB7 ^s / \u21B5 save \xB7 esc back" })
4990
5657
  ] });
4991
5658
  }
4992
5659
 
4993
5660
  // src/components/views/watcher-view.tsx
4994
- import { useState as useState7, useEffect as useEffect6, useRef as useRef3, useCallback as useCallback2 } from "react";
5661
+ import { useState as useState7, useEffect as useEffect7, useRef as useRef3, useCallback as useCallback2 } from "react";
4995
5662
  import { Box as Box13, Text as Text15, useInput as useInput3 } from "ink";
4996
5663
  import Spinner2 from "ink-spinner";
4997
5664
 
@@ -5278,9 +5945,9 @@ function startClipboardWatcher(options = {}) {
5278
5945
  }
5279
5946
 
5280
5947
  // src/lib/reveal.ts
5281
- import fs12 from "fs";
5948
+ import fs16 from "fs";
5282
5949
  import { spawn as spawn4 } from "child_process";
5283
- import path12 from "path";
5950
+ import path16 from "path";
5284
5951
  function buildOpenBrowserCommand(url, platform = process.platform) {
5285
5952
  try {
5286
5953
  const parsed = new URL(url.trim());
@@ -5307,12 +5974,12 @@ function openBrowser(url) {
5307
5974
  }
5308
5975
  }
5309
5976
  function getRevealCommand(targetPath, platform = process.platform, isDirectory) {
5310
- const pathModule = platform === "win32" ? path12.win32 : platform === "darwin" ? path12.posix : path12;
5977
+ const pathModule = platform === "win32" ? path16.win32 : platform === "darwin" ? path16.posix : path16;
5311
5978
  const resolved = pathModule.resolve(targetPath);
5312
5979
  const isDir = isDirectory !== void 0 ? isDirectory : (() => {
5313
5980
  try {
5314
- if (fs12.existsSync(resolved)) {
5315
- return fs12.statSync(resolved).isDirectory();
5981
+ if (fs16.existsSync(resolved)) {
5982
+ return fs16.statSync(resolved).isDirectory();
5316
5983
  }
5317
5984
  } catch {
5318
5985
  }
@@ -5360,13 +6027,13 @@ function WatcherView({
5360
6027
  mediaChoiceRef.current = mediaChoice;
5361
6028
  const writeMetadataRef = useRef3(writeMetadata);
5362
6029
  writeMetadataRef.current = writeMetadata;
5363
- useEffect6(() => {
6030
+ useEffect7(() => {
5364
6031
  const pulseTimer = setInterval(() => {
5365
6032
  setPulseTick((t) => t + 1);
5366
6033
  }, 120);
5367
6034
  return () => clearInterval(pulseTimer);
5368
6035
  }, []);
5369
- useEffect6(() => {
6036
+ useEffect7(() => {
5370
6037
  const watcher = startClipboardWatcher({
5371
6038
  choice: mediaChoiceRef.current,
5372
6039
  getChoice: () => mediaChoiceRef.current,
@@ -5440,7 +6107,7 @@ function WatcherView({
5440
6107
  const target = outputDir || resolveDefaultDownloadDir("instagram", "watcher");
5441
6108
  revealInFileManager(target);
5442
6109
  }, [outputDir]);
5443
- useEffect6(() => {
6110
+ useEffect7(() => {
5444
6111
  if (!onRegisterClickTargets) return;
5445
6112
  const targets = [
5446
6113
  { match: "Target:", padX: 1, action: () => {
@@ -5606,10 +6273,10 @@ function WatcherView({
5606
6273
  }
5607
6274
 
5608
6275
  // src/components/views/batch-view.tsx
5609
- import { useState as useState8, useRef as useRef4, useCallback as useCallback3, useEffect as useEffect7 } from "react";
6276
+ import { useState as useState8, useRef as useRef4, useCallback as useCallback3, useEffect as useEffect8 } from "react";
5610
6277
  import { Box as Box14, Text as Text16, useInput as useInput4 } from "ink";
5611
6278
  import Spinner3 from "ink-spinner";
5612
- import fs13 from "fs";
6279
+ import fs17 from "fs";
5613
6280
 
5614
6281
  // src/lib/batch.ts
5615
6282
  import fsPromises5 from "fs/promises";
@@ -5816,7 +6483,7 @@ function BatchView({
5816
6483
  setErrorMessage("Please enter a valid batch file path");
5817
6484
  return;
5818
6485
  }
5819
- if (!fs13.existsSync(trimmed)) {
6486
+ if (!fs17.existsSync(trimmed)) {
5820
6487
  setErrorMessage(`File not found: ${trimmed}`);
5821
6488
  return;
5822
6489
  }
@@ -6148,7 +6815,7 @@ function BatchView({
6148
6815
  }
6149
6816
  }
6150
6817
  }, { isActive: Boolean(process.stdin.isTTY) });
6151
- useEffect7(() => {
6818
+ useEffect8(() => {
6152
6819
  if (!onRegisterClickTargets) return;
6153
6820
  const targets = [];
6154
6821
  if (phase === "input") {
@@ -6649,6 +7316,80 @@ function frameRowSpan(row) {
6649
7316
  return [first + 1, line.trimEnd().length];
6650
7317
  }
6651
7318
 
7319
+ // src/lib/engines/core/use-engine-events.ts
7320
+ import { useState as useState9, useEffect as useEffect9 } from "react";
7321
+ var INITIAL_ENGINE_EVENT_STATE = {
7322
+ isDownloading: false,
7323
+ activeJob: void 0,
7324
+ progress: void 0,
7325
+ lastError: void 0,
7326
+ lastResult: void 0
7327
+ };
7328
+ function subscribeToEngineEvents(bus, onChange) {
7329
+ let state = { ...INITIAL_ENGINE_EVENT_STATE };
7330
+ const onJobStart = (payload) => {
7331
+ state = {
7332
+ ...state,
7333
+ isDownloading: true,
7334
+ activeJob: payload,
7335
+ progress: void 0,
7336
+ lastError: void 0,
7337
+ lastResult: void 0
7338
+ };
7339
+ onChange(state);
7340
+ };
7341
+ const onJobProgress = (payload) => {
7342
+ state = {
7343
+ ...state,
7344
+ isDownloading: true,
7345
+ progress: payload
7346
+ };
7347
+ onChange(state);
7348
+ };
7349
+ const onJobComplete = (payload) => {
7350
+ state = {
7351
+ ...state,
7352
+ isDownloading: false,
7353
+ lastResult: payload.result
7354
+ };
7355
+ onChange(state);
7356
+ };
7357
+ const onJobError = (payload) => {
7358
+ state = {
7359
+ ...state,
7360
+ isDownloading: false,
7361
+ lastError: payload.error
7362
+ };
7363
+ onChange(state);
7364
+ };
7365
+ const unsubStart = bus.on("job:start", onJobStart);
7366
+ const unsubProgress = bus.on("job:progress", onJobProgress);
7367
+ const unsubComplete = bus.on("job:complete", onJobComplete);
7368
+ const unsubError = bus.on("job:error", onJobError);
7369
+ return () => {
7370
+ if (typeof bus.off === "function") {
7371
+ bus.off("job:start", onJobStart);
7372
+ bus.off("job:progress", onJobProgress);
7373
+ bus.off("job:complete", onJobComplete);
7374
+ bus.off("job:error", onJobError);
7375
+ }
7376
+ unsubStart?.();
7377
+ unsubProgress?.();
7378
+ unsubComplete?.();
7379
+ unsubError?.();
7380
+ };
7381
+ }
7382
+ function useEngineEvents(customBus) {
7383
+ const bus = customBus || defaultTaskManager.events;
7384
+ const [state, setState] = useState9(() => ({
7385
+ ...INITIAL_ENGINE_EVENT_STATE
7386
+ }));
7387
+ useEffect9(() => {
7388
+ return subscribeToEngineEvents(bus, setState);
7389
+ }, [bus]);
7390
+ return state;
7391
+ }
7392
+
6652
7393
  // src/app.tsx
6653
7394
  import { Fragment as Fragment4, jsx as jsx18, jsxs as jsxs15 } from "react/jsx-runtime";
6654
7395
  var TAGLINE = "grab any profile. paste. download. done.";
@@ -6728,7 +7469,7 @@ function App({
6728
7469
  writeMetadata,
6729
7470
  version = "1.0.0"
6730
7471
  }) {
6731
- const [themeMode, setThemeMode] = useState9(initialThemeMode);
7472
+ const [themeMode, setThemeMode] = useState10(initialThemeMode);
6732
7473
  const cycleTheme = useCallback4(() => {
6733
7474
  setThemeMode(nextThemeMode);
6734
7475
  }, []);
@@ -6763,18 +7504,19 @@ function InnerApp({
6763
7504
  const theme = useTheme();
6764
7505
  const { exit } = useApp();
6765
7506
  const { stdout } = useStdout3();
7507
+ const engineEvents = useEngineEvents();
6766
7508
  const config = useMemo3(() => loadConfig(), []);
6767
7509
  const effectiveLimit = limit ?? config.defaultLimit;
6768
7510
  const effectiveWriteMetadata = writeMetadata ?? Boolean(config.writeMetadata);
6769
- const [urlInput, setUrlInput] = useState9(initialInput ?? "");
6770
- const [clipboardUrl, setClipboardUrl] = useState9(void 0);
6771
- const [settingsFocus, setSettingsFocus] = useState9("cookies");
6772
- const [batchClickTargets, setBatchClickTargets] = useState9([]);
6773
- const [watcherClickTargets, setWatcherClickTargets] = useState9([]);
7511
+ const [urlInput, setUrlInput] = useState10(initialInput ?? "");
7512
+ const [clipboardUrl, setClipboardUrl] = useState10(void 0);
7513
+ const [settingsFocus, setSettingsFocus] = useState10("cookies");
7514
+ const [batchClickTargets, setBatchClickTargets] = useState10([]);
7515
+ const [watcherClickTargets, setWatcherClickTargets] = useState10([]);
6774
7516
  const columns = stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
6775
7517
  const boxWidth = Math.max(14, Math.min(64, columns - 6));
6776
7518
  const contentWidth = Math.max(10, Math.min(columns - 4, 78));
6777
- const [phase, setPhase] = useState9(() => {
7519
+ const [phase, setPhase] = useState10(() => {
6778
7520
  if (initialInput) {
6779
7521
  const parsed = parseProfileInput(initialInput);
6780
7522
  if (parsed.platform !== "unknown" && parsed.cleanUrl) {
@@ -6786,7 +7528,7 @@ function InnerApp({
6786
7528
  }
6787
7529
  return { name: "input" };
6788
7530
  });
6789
- useEffect8(() => {
7531
+ useEffect10(() => {
6790
7532
  try {
6791
7533
  const clip = readClipboard();
6792
7534
  if (clip && (clip.startsWith("http://") || clip.startsWith("https://") || clip.startsWith("@"))) {
@@ -6799,7 +7541,7 @@ function InnerApp({
6799
7541
  const clipboardAccepted = Boolean(clipboardUrl) && urlInput === clipboardUrl;
6800
7542
  const abortControllerRef = useRef5(null);
6801
7543
  const probingTimeoutRef = useRef5(null);
6802
- useEffect8(() => {
7544
+ useEffect10(() => {
6803
7545
  return () => {
6804
7546
  abortControllerRef.current?.abort();
6805
7547
  if (probingTimeoutRef.current) {
@@ -7159,7 +7901,7 @@ function InnerApp({
7159
7901
  {
7160
7902
  profile: phase.profile,
7161
7903
  choice: phase.choice,
7162
- progress: phase.progress,
7904
+ progress: engineEvents.progress ?? phase.progress,
7163
7905
  outputDir: resolveDefaultDownloadDir(phase.profile.platform, phase.profile.username, outputDir),
7164
7906
  writeMetadata: effectiveWriteMetadata,
7165
7907
  limit: effectiveLimit,
@@ -7439,7 +8181,7 @@ import { jsx as jsx19 } from "react/jsx-runtime";
7439
8181
  var resolveVersion = () => {
7440
8182
  try {
7441
8183
  if (typeof import.meta !== "undefined" && import.meta?.url) {
7442
- return createRequire(import.meta.url)("../package.json").version;
8184
+ return createRequire2(import.meta.url)("../package.json").version;
7443
8185
  }
7444
8186
  } catch {
7445
8187
  }
@@ -7463,6 +8205,7 @@ var HELP = `
7463
8205
  Options:
7464
8206
  -h, --help Show this help message
7465
8207
  -v, --version Show version
8208
+ --doctor Run environment & dependency diagnostics
7466
8209
  -o, --output Set custom output directory
7467
8210
  -w, --watch Watch clipboard for profile links and auto-download
7468
8211
  -b, --batch <file> Download multiple profiles listed in a text file (one URL/user per line)
@@ -7509,6 +8252,24 @@ async function main() {
7509
8252
  console.log(`open-nami v${VERSION}`);
7510
8253
  process.exit(0);
7511
8254
  }
8255
+ if (args.includes("--doctor")) {
8256
+ const report = await runDoctor();
8257
+ console.log(`
8258
+ \x1B[36mOpen Nami Doctor\x1B[0m (v${VERSION})
8259
+ `);
8260
+ console.log(` \x1B[1mRuntime:\x1B[0m Node.js ${report.nodeVersion}`);
8261
+ console.log(` \x1B[1mStorage:\x1B[0m ${report.storageOk ? "\x1B[32m\u2713 Ready\x1B[0m" : "\x1B[31m\u2717 Inaccessible\x1B[0m"}`);
8262
+ console.log(` \x1B[1mEngines:\x1B[0m`);
8263
+ for (const [bin, ok] of Object.entries(report.binaries)) {
8264
+ console.log(` ${ok ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m"} ${bin}`);
8265
+ }
8266
+ console.log(` \x1B[1mCookies:\x1B[0m`);
8267
+ for (const [platform, ok] of Object.entries(report.cookies)) {
8268
+ console.log(` ${ok ? "\x1B[32m\u2713\x1B[0m" : "\x1B[90m\u25CB\x1B[0m"} ${platform}`);
8269
+ }
8270
+ console.log();
8271
+ process.exit(0);
8272
+ }
7512
8273
  const completionIdx = args.findIndex((arg) => arg === "--completion" || arg.startsWith("--completion="));
7513
8274
  if (completionIdx !== -1) {
7514
8275
  const arg = args[completionIdx];