open-nami 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1018 -207
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -9,8 +9,8 @@ var require_package = __commonJS({
9
9
  "package.json"(exports, module) {
10
10
  module.exports = {
11
11
  name: "open-nami",
12
- version: "1.0.5",
13
- description: "Open Nami \u2014 terminal-first bulk social media profile downloader for Instagram, TikTok, Facebook & X.",
12
+ version: "1.1.0",
13
+ description: "Open Nami: terminal-first bulk social media profile downloader for Instagram, TikTok, Facebook & X.",
14
14
  type: "module",
15
15
  bin: {
16
16
  "open-nami": "dist/cli.js",
@@ -1437,7 +1437,7 @@ function parseProfileInput(input) {
1437
1437
  }
1438
1438
 
1439
1439
  // src/lib/engines/dispatcher.ts
1440
- import path10 from "path";
1440
+ import path11 from "path";
1441
1441
  import os8 from "os";
1442
1442
  import fs11 from "fs/promises";
1443
1443
 
@@ -1807,6 +1807,7 @@ function parseIgNodeToMediaItems(node, username) {
1807
1807
  const anyNode = node;
1808
1808
  const baseId = node.shortcode || node.id || anyNode.pk;
1809
1809
  const date = node.taken_at_timestamp || anyNode.taken_at;
1810
+ const caption = anyNode.edge_media_to_caption?.edges?.[0]?.node?.text || anyNode.caption?.text || (typeof anyNode.caption === "string" ? anyNode.caption : void 0);
1810
1811
  const childrenEdges = node.edge_sidecar_to_children?.edges;
1811
1812
  const carouselMedia = anyNode.carousel_media;
1812
1813
  const childrenList = childrenEdges ? childrenEdges.map((e) => e.node) : Array.isArray(carouselMedia) ? carouselMedia : [];
@@ -1815,7 +1816,13 @@ function parseIgNodeToMediaItems(node, username) {
1815
1816
  const subItems = parseIgNodeToMediaItems(childNode, username);
1816
1817
  for (const item of subItems) {
1817
1818
  item.id = `${baseId}_${index + 1}`;
1818
- item.filename = `${username}_${date || baseId}_${index + 1}`;
1819
+ item.filename = `${username}_${date ? `${date}_` : ""}${baseId}_${index + 1}`;
1820
+ item.author = username;
1821
+ item.platform = "instagram";
1822
+ item.caption = item.caption || caption;
1823
+ item.carouselId = baseId;
1824
+ item.carouselIndex = index + 1;
1825
+ item.carouselTotal = childrenList.length;
1819
1826
  items.push(item);
1820
1827
  }
1821
1828
  });
@@ -1829,9 +1836,12 @@ function parseIgNodeToMediaItems(node, username) {
1829
1836
  items.push({
1830
1837
  id: baseId,
1831
1838
  url: videoUrl,
1832
- filename: `${username}_${date || baseId}`,
1839
+ filename: `${username}_${date ? `${date}_` : ""}${baseId}`,
1833
1840
  extension: "mp4",
1834
1841
  type: "video",
1842
+ caption,
1843
+ author: username,
1844
+ platform: "instagram",
1835
1845
  date,
1836
1846
  width: video?.width ?? node.dimensions?.width,
1837
1847
  height: video?.height ?? node.dimensions?.height,
@@ -1844,9 +1854,12 @@ function parseIgNodeToMediaItems(node, username) {
1844
1854
  items.push({
1845
1855
  id: baseId,
1846
1856
  url: photoUrl,
1847
- filename: `${username}_${date || baseId}`,
1857
+ filename: `${username}_${date ? `${date}_` : ""}${baseId}`,
1848
1858
  extension: "jpg",
1849
1859
  type: "photo",
1860
+ caption,
1861
+ author: username,
1862
+ platform: "instagram",
1850
1863
  date,
1851
1864
  width: candidate?.width ?? node.dimensions?.width,
1852
1865
  height: candidate?.height ?? node.dimensions?.height
@@ -1860,9 +1873,12 @@ function parseIgNodeToMediaItems(node, username) {
1860
1873
  items.push({
1861
1874
  id: baseId,
1862
1875
  url: photoUrl,
1863
- filename: `${username}_${date || baseId}`,
1876
+ filename: `${username}_${date ? `${date}_` : ""}${baseId}`,
1864
1877
  extension: "jpg",
1865
1878
  type: "photo",
1879
+ caption,
1880
+ author: username,
1881
+ platform: "instagram",
1866
1882
  date,
1867
1883
  width: candidate?.width ?? node.dimensions?.width,
1868
1884
  height: candidate?.height ?? node.dimensions?.height
@@ -1874,6 +1890,7 @@ function parseIgNodeToMediaItems(node, username) {
1874
1890
  function parseReelItemToMediaItem(item, username) {
1875
1891
  const id = item.id || item.pk || String(Date.now());
1876
1892
  const date = item.taken_at || item.created_at;
1893
+ const caption = item.caption?.text || (typeof item.caption === "string" ? item.caption : void 0);
1877
1894
  const isVideo = item.media_type === 2 || Boolean(item.video_versions && item.video_versions.length > 0);
1878
1895
  if (isVideo) {
1879
1896
  const video = item.video_versions?.[0];
@@ -1882,9 +1899,12 @@ function parseReelItemToMediaItem(item, username) {
1882
1899
  return {
1883
1900
  id,
1884
1901
  url: videoUrl,
1885
- filename: `${username}_${date || id}`,
1902
+ filename: `${username}_${date ? `${date}_` : ""}${id}`,
1886
1903
  extension: "mp4",
1887
1904
  type: "video",
1905
+ caption,
1906
+ author: username,
1907
+ platform: "instagram",
1888
1908
  date,
1889
1909
  width: video?.width ?? item.original_width,
1890
1910
  height: video?.height ?? item.original_height,
@@ -1897,9 +1917,12 @@ function parseReelItemToMediaItem(item, username) {
1897
1917
  return {
1898
1918
  id,
1899
1919
  url: photoUrl,
1900
- filename: `${username}_${date || id}`,
1920
+ filename: `${username}_${date ? `${date}_` : ""}${id}`,
1901
1921
  extension: "jpg",
1902
1922
  type: "photo",
1923
+ caption,
1924
+ author: username,
1925
+ platform: "instagram",
1903
1926
  date,
1904
1927
  width: candidate?.width ?? item.original_width,
1905
1928
  height: candidate?.height ?? item.original_height
@@ -1961,14 +1984,17 @@ var InstagramExtractor = class {
1961
1984
  "Referer": "https://www.instagram.com/"
1962
1985
  }
1963
1986
  });
1964
- if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
1965
- const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
1966
- throw new Error(`Instagram GraphQL query error: ${errMsg}`);
1967
- }
1968
1987
  const edges = res?.data?.highlights?.edges;
1969
1988
  if (Array.isArray(edges) && edges.length > 0) {
1989
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
1990
+ logExtractorDebug("PolarisProfileStoryHighlightsTrayContentQuery partial field errors", res.errors);
1991
+ }
1970
1992
  return edges.map((e) => e.node);
1971
1993
  }
1994
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
1995
+ const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
1996
+ throw new Error(`Instagram GraphQL query error: ${errMsg}`);
1997
+ }
1972
1998
  } catch (err) {
1973
1999
  logExtractorDebug("PolarisProfileStoryHighlightsTrayContentQuery", err);
1974
2000
  }
@@ -2121,12 +2147,11 @@ var InstagramExtractor = class {
2121
2147
  signal: options.signal,
2122
2148
  headers
2123
2149
  });
2124
- if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2125
- const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
2126
- throw new Error(`Instagram GraphQL query error: ${errMsg}`);
2127
- }
2128
2150
  const connection = res?.data?.xdt_api__v1__feed__user_timeline_graphql_connection || res?.data?.user?.edge_owner_to_timeline_media;
2129
- if (connection?.edges && Array.isArray(connection.edges)) {
2151
+ if (connection?.edges && Array.isArray(connection.edges) && connection.edges.length > 0) {
2152
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2153
+ logExtractorDebug("PolarisProfilePostsTabContentQuery partial field errors", res.errors);
2154
+ }
2130
2155
  let resolvedUserId;
2131
2156
  for (const edge of connection.edges) {
2132
2157
  const node = edge?.node;
@@ -2150,21 +2175,35 @@ var InstagramExtractor = class {
2150
2175
  userId: resolvedUserId
2151
2176
  };
2152
2177
  }
2178
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2179
+ const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
2180
+ throw new Error(`Instagram GraphQL query error: ${errMsg}`);
2181
+ }
2153
2182
  return { edges: [], hasNextPage: false };
2154
2183
  }
2155
2184
  async fetchNextPostsPage(userId, username, cursor, options, tokens) {
2185
+ try {
2186
+ const page = await this.fetchPostsTabGraphQL(username, cursor, tokens || {}, options);
2187
+ if (page.edges && page.edges.length > 0) {
2188
+ return page;
2189
+ }
2190
+ } catch (err) {
2191
+ logExtractorDebug("PolarisProfilePostsTabContentQuery_connection", err);
2192
+ }
2156
2193
  if (userId) {
2157
2194
  const variables = JSON.stringify({ id: userId, first: 12, after: cursor });
2158
2195
  const url = `https://www.instagram.com/graphql/query/?query_hash=${IG_TIMELINE_QUERY_HASH}&variables=${encodeURIComponent(variables)}`;
2159
2196
  try {
2197
+ const headers = {
2198
+ "X-IG-App-ID": IG_APP_ID,
2199
+ "Referer": `https://www.instagram.com/${username}/`
2200
+ };
2201
+ if (tokens?.csrfToken) headers["X-CSRFToken"] = tokens.csrfToken;
2160
2202
  const res = await this.httpClient.fetchJson(url, {
2161
2203
  cookiePath: options.cookiePath,
2162
2204
  cookieDomain: "instagram.com",
2163
2205
  signal: options.signal,
2164
- headers: {
2165
- "X-IG-App-ID": IG_APP_ID,
2166
- "Referer": `https://www.instagram.com/${username}/`
2167
- }
2206
+ headers
2168
2207
  });
2169
2208
  const data = res?.data?.user?.edge_owner_to_timeline_media;
2170
2209
  if (data?.edges && data.edges.length > 0) {
@@ -2178,25 +2217,19 @@ var InstagramExtractor = class {
2178
2217
  logExtractorDebug("edge_owner_to_timeline_media", err);
2179
2218
  }
2180
2219
  }
2181
- try {
2182
- const page = await this.fetchPostsTabGraphQL(username, cursor, tokens || {}, options);
2183
- if (page.edges && page.edges.length > 0) {
2184
- return page;
2185
- }
2186
- } catch (err) {
2187
- logExtractorDebug("PolarisProfilePostsTabContentQuery_connection", err);
2188
- }
2189
2220
  if (userId) {
2190
2221
  try {
2191
2222
  const restUrl = `https://www.instagram.com/api/v1/feed/user/${userId}/?count=12&max_id=${encodeURIComponent(cursor)}`;
2223
+ const headers = {
2224
+ "X-IG-App-ID": IG_APP_ID,
2225
+ "Referer": `https://www.instagram.com/${username}/`
2226
+ };
2227
+ if (tokens?.csrfToken) headers["X-CSRFToken"] = tokens.csrfToken;
2192
2228
  const res = await this.httpClient.fetchJson(restUrl, {
2193
2229
  cookiePath: options.cookiePath,
2194
2230
  cookieDomain: "instagram.com",
2195
2231
  signal: options.signal,
2196
- headers: {
2197
- "X-IG-App-ID": IG_APP_ID,
2198
- "Referer": `https://www.instagram.com/${username}/`
2199
- }
2232
+ headers
2200
2233
  });
2201
2234
  if (Array.isArray(res?.items) && res.items.length > 0) {
2202
2235
  const edges = res.items.map((item) => ({ node: item }));
@@ -2242,10 +2275,6 @@ var InstagramExtractor = class {
2242
2275
  "Referer": "https://www.instagram.com/"
2243
2276
  }
2244
2277
  });
2245
- if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2246
- const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
2247
- throw new Error(`Instagram GraphQL query error: ${errMsg}`);
2248
- }
2249
2278
  const reelsData = res?.data?.fetch__XDTUserDict;
2250
2279
  let connection = reelsData?.reels;
2251
2280
  if (!connection && reelsData) {
@@ -2256,13 +2285,20 @@ var InstagramExtractor = class {
2256
2285
  }
2257
2286
  }
2258
2287
  }
2259
- if (connection?.edges && Array.isArray(connection.edges)) {
2288
+ if (connection?.edges && Array.isArray(connection.edges) && connection.edges.length > 0) {
2289
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2290
+ logExtractorDebug("PolarisProfileReelsTabContentQuery partial field errors", res.errors);
2291
+ }
2260
2292
  return {
2261
2293
  edges: connection.edges,
2262
2294
  hasNextPage: Boolean(connection.page_info?.has_next_page),
2263
2295
  nextCursor: connection.page_info?.end_cursor
2264
2296
  };
2265
2297
  }
2298
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2299
+ const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
2300
+ throw new Error(`Instagram GraphQL query error: ${errMsg}`);
2301
+ }
2266
2302
  } catch (err) {
2267
2303
  logExtractorDebug("PolarisProfileReelsTabContentQuery_connection", err);
2268
2304
  }
@@ -2283,8 +2319,10 @@ var InstagramExtractor = class {
2283
2319
  logExtractorDebug("fetchUserProfile", err);
2284
2320
  }
2285
2321
  let webTokens = {};
2286
- if (!user) {
2322
+ try {
2287
2323
  webTokens = await this.fetchWebTokens(username, options);
2324
+ } catch (err) {
2325
+ logExtractorDebug("fetchWebTokens", err);
2288
2326
  }
2289
2327
  let userId = user?.id || webTokens.userId;
2290
2328
  if (!userId && (subDir === "Stories" || subDir === "Highlights")) {
@@ -2344,7 +2382,19 @@ var InstagramExtractor = class {
2344
2382
  let currentEdges = user?.edge_owner_to_timeline_media?.edges ?? [];
2345
2383
  let hasNextPage = Boolean(user?.edge_owner_to_timeline_media?.page_info?.has_next_page);
2346
2384
  let endCursor = user?.edge_owner_to_timeline_media?.page_info?.end_cursor;
2347
- if (!user) {
2385
+ if (options.initialCursor) {
2386
+ endCursor = options.initialCursor;
2387
+ hasNextPage = true;
2388
+ currentEdges = [];
2389
+ try {
2390
+ const resumedPage = await this.fetchNextPostsPage(userId || "", username, endCursor, options, webTokens);
2391
+ currentEdges = resumedPage.edges;
2392
+ hasNextPage = resumedPage.hasNextPage;
2393
+ endCursor = resumedPage.nextCursor;
2394
+ } catch (err) {
2395
+ logExtractorDebug("fetchNextPostsPage resume", err);
2396
+ }
2397
+ } else if (!user) {
2348
2398
  try {
2349
2399
  const firstPage = await this.fetchPostsTabGraphQL(username, void 0, webTokens, options);
2350
2400
  currentEdges = firstPage.edges;
@@ -2358,11 +2408,17 @@ var InstagramExtractor = class {
2358
2408
  }
2359
2409
  }
2360
2410
  while (true) {
2411
+ let reachedSinceLimit = false;
2361
2412
  for (const edge of currentEdges) {
2362
2413
  const items = parseIgNodeToMediaItems(edge.node, username);
2363
2414
  for (const item of items) {
2364
2415
  if (subDir === "Photos" && item.type !== "photo") continue;
2365
2416
  if (subDir === "Videos" && item.type !== "video") continue;
2417
+ if (options.until !== void 0 && item.date && item.date > options.until) continue;
2418
+ if (options.since !== void 0 && item.date && item.date < options.since) {
2419
+ reachedSinceLimit = true;
2420
+ break;
2421
+ }
2366
2422
  if (emittedItemIds.has(item.id)) continue;
2367
2423
  emittedItemIds.add(item.id);
2368
2424
  found++;
@@ -2372,8 +2428,11 @@ var InstagramExtractor = class {
2372
2428
  message: `Found item: ${item.filename}`
2373
2429
  });
2374
2430
  yield item;
2431
+ if (options.limit !== void 0 && found >= options.limit) return;
2375
2432
  }
2433
+ if (reachedSinceLimit) break;
2376
2434
  }
2435
+ if (reachedSinceLimit) break;
2377
2436
  if (!hasNextPage || !endCursor || options.signal?.aborted) {
2378
2437
  break;
2379
2438
  }
@@ -2383,6 +2442,9 @@ var InstagramExtractor = class {
2383
2442
  currentEdges = nextPage.edges;
2384
2443
  hasNextPage = nextPage.hasNextPage;
2385
2444
  endCursor = nextPage.nextCursor;
2445
+ if (endCursor) {
2446
+ options.onCheckpoint?.("timeline", endCursor);
2447
+ }
2386
2448
  if (currentEdges.length === 0) {
2387
2449
  break;
2388
2450
  }
@@ -2390,7 +2452,7 @@ var InstagramExtractor = class {
2390
2452
  break;
2391
2453
  }
2392
2454
  }
2393
- if (subDir === "Videos" && userId) {
2455
+ if ((subDir === "Videos" || subDir === "All") && userId) {
2394
2456
  const videoEdges = user?.edge_felix_video_timeline?.edges ?? [];
2395
2457
  for (const edge of videoEdges) {
2396
2458
  const items = parseIgNodeToMediaItems(edge.node, username);
@@ -2507,10 +2569,11 @@ function parseTweetMediaEntities(tweet, username) {
2507
2569
  const entities = legacy.extended_entities?.media || legacy.entities?.media || [];
2508
2570
  const tweetId = tweetResult.rest_id || legacy.id_str || String(Date.now());
2509
2571
  const rawDate = legacy.created_at ? new Date(legacy.created_at).getTime() / 1e3 : void 0;
2572
+ const caption = legacy.full_text || legacy.text;
2510
2573
  for (let idx = 0; idx < entities.length; idx++) {
2511
2574
  const media = entities[idx];
2512
2575
  const subIndex = idx + 1;
2513
- const baseFilename = `${username}_${tweetId}_${subIndex}`;
2576
+ const baseFilename = `${username}_${rawDate ? `${rawDate}_` : ""}${tweetId}_${subIndex}`;
2514
2577
  if (media.type === "video" || media.type === "animated_gif") {
2515
2578
  const variants = (media.video_info?.variants || []).filter((v) => v.content_type === "video/mp4").sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0));
2516
2579
  const bestVariant = variants[0];
@@ -2521,8 +2584,16 @@ function parseTweetMediaEntities(tweet, username) {
2521
2584
  filename: baseFilename,
2522
2585
  extension: "mp4",
2523
2586
  type: "video",
2587
+ caption,
2588
+ author: username,
2589
+ platform: "x",
2524
2590
  date: rawDate,
2525
- thumbnailUrl: media.media_url_https
2591
+ thumbnailUrl: media.media_url_https,
2592
+ ...entities.length > 1 ? {
2593
+ carouselId: tweetId,
2594
+ carouselIndex: subIndex,
2595
+ carouselTotal: entities.length
2596
+ } : {}
2526
2597
  });
2527
2598
  }
2528
2599
  } else {
@@ -2538,9 +2609,17 @@ function parseTweetMediaEntities(tweet, username) {
2538
2609
  filename: baseFilename,
2539
2610
  extension: ext,
2540
2611
  type: "photo",
2612
+ caption,
2613
+ author: username,
2614
+ platform: "x",
2541
2615
  date: rawDate,
2542
2616
  width: media.original_info?.width,
2543
- height: media.original_info?.height
2617
+ height: media.original_info?.height,
2618
+ ...entities.length > 1 ? {
2619
+ carouselId: tweetId,
2620
+ carouselIndex: subIndex,
2621
+ carouselTotal: entities.length
2622
+ } : {}
2544
2623
  });
2545
2624
  }
2546
2625
  }
@@ -2668,7 +2747,7 @@ var XExtractor = class {
2668
2747
  });
2669
2748
  const userId = await this.fetchUserRestId(username, options);
2670
2749
  const endpoint = isLikes ? "https://x.com/i/api/graphql/TGEKkJG_meudeaFcqaxM-Q/Likes" : "https://x.com/i/api/graphql/jCRhbOzdgOHp6u9H4g2tEg/UserMedia";
2671
- let cursor;
2750
+ let cursor = options.initialCursor;
2672
2751
  let hasMore = true;
2673
2752
  let found = 0;
2674
2753
  while (hasMore) {
@@ -2715,10 +2794,16 @@ var XExtractor = class {
2715
2794
  if (shouldSkipTweetReply(tweetContent, userId, isLikes ? "likes" : "timeline")) {
2716
2795
  continue;
2717
2796
  }
2797
+ let reachedSinceLimit = false;
2718
2798
  const mediaItems = parseTweetMediaEntities(tweetContent, username);
2719
2799
  for (const item of mediaItems) {
2720
2800
  if (subDir === "Photos" && item.type !== "photo") continue;
2721
2801
  if (subDir === "Videos" && item.type !== "video") continue;
2802
+ if (options.until !== void 0 && item.date && item.date > options.until) continue;
2803
+ if (options.since !== void 0 && item.date && item.date < options.since) {
2804
+ reachedSinceLimit = true;
2805
+ break;
2806
+ }
2722
2807
  found++;
2723
2808
  pageItemCount++;
2724
2809
  options.onProgress?.({
@@ -2727,14 +2812,21 @@ var XExtractor = class {
2727
2812
  message: `Found item: ${item.filename}`
2728
2813
  });
2729
2814
  yield item;
2815
+ if (options.limit !== void 0 && found >= options.limit) return;
2816
+ }
2817
+ if (reachedSinceLimit) {
2818
+ hasMore = false;
2819
+ break;
2730
2820
  }
2731
2821
  }
2732
2822
  }
2733
2823
  }
2734
2824
  if (!newCursor || newCursor === cursor || pageItemCount === 0) {
2735
2825
  hasMore = false;
2826
+ options.onCheckpoint?.("timeline", "");
2736
2827
  } else {
2737
2828
  cursor = newCursor;
2829
+ options.onCheckpoint?.("timeline", cursor);
2738
2830
  await sleep(1500, 2500, options.signal);
2739
2831
  }
2740
2832
  }
@@ -2837,11 +2929,16 @@ function parseTikTokItem(item, username) {
2837
2929
  items.push({
2838
2930
  id: `${id}_${idx + 1}`,
2839
2931
  url: imgUrl,
2840
- filename: `${username}_${id}_${idx + 1}`,
2932
+ filename: `${username}_${date ? `${date}_` : ""}${id}_${idx + 1}`,
2841
2933
  extension: "jpg",
2842
2934
  type: "photo",
2843
2935
  caption,
2844
- date
2936
+ date,
2937
+ author: username,
2938
+ platform: "tiktok",
2939
+ carouselId: id,
2940
+ carouselIndex: idx + 1,
2941
+ carouselTotal: imageList.length
2845
2942
  });
2846
2943
  }
2847
2944
  });
@@ -2853,14 +2950,16 @@ function parseTikTokItem(item, username) {
2853
2950
  items.push({
2854
2951
  id,
2855
2952
  url: videoUrl,
2856
- filename: `${username}_${id}`,
2953
+ filename: `${username}_${date ? `${date}_` : ""}${id}`,
2857
2954
  extension: "mp4",
2858
2955
  type: "video",
2859
2956
  caption,
2860
2957
  date,
2861
2958
  width: video?.width,
2862
2959
  height: video?.height,
2863
- thumbnailUrl: video?.cover || video?.originCover
2960
+ thumbnailUrl: video?.cover || video?.originCover,
2961
+ author: username,
2962
+ platform: "tiktok"
2864
2963
  });
2865
2964
  }
2866
2965
  return items;
@@ -2912,7 +3011,7 @@ var TikTokExtractor = class {
2912
3011
  if (!secUid) {
2913
3012
  return;
2914
3013
  }
2915
- let cursor = "0";
3014
+ let cursor = options.initialCursor || "0";
2916
3015
  let hasMore = true;
2917
3016
  const visitedCursors = /* @__PURE__ */ new Set();
2918
3017
  while (hasMore) {
@@ -2947,11 +3046,17 @@ var TikTokExtractor = class {
2947
3046
  }
2948
3047
  const items = res?.itemList || [];
2949
3048
  if (items.length === 0) break;
3049
+ let reachedSinceLimit = false;
2950
3050
  for (const rawItem of items) {
2951
3051
  const mediaList = parseTikTokItem(rawItem, cleanUsername);
2952
3052
  for (const item of mediaList) {
2953
3053
  if (subDir === "Photos" && item.type !== "photo") continue;
2954
3054
  if (subDir === "Videos" && item.type !== "video") continue;
3055
+ if (options.until !== void 0 && item.date && item.date > options.until) continue;
3056
+ if (options.since !== void 0 && item.date && item.date < options.since) {
3057
+ reachedSinceLimit = true;
3058
+ break;
3059
+ }
2955
3060
  found++;
2956
3061
  options.onProgress?.({
2957
3062
  stage: subDir,
@@ -2959,10 +3064,18 @@ var TikTokExtractor = class {
2959
3064
  message: `Found item: ${item.filename}`
2960
3065
  });
2961
3066
  yield item;
3067
+ if (options.limit !== void 0 && found >= options.limit) return;
2962
3068
  }
3069
+ if (reachedSinceLimit) break;
2963
3070
  }
3071
+ if (reachedSinceLimit) break;
2964
3072
  hasMore = Boolean(res?.hasMore);
2965
3073
  cursor = String(res?.cursor ?? "0");
3074
+ if (hasMore && cursor && cursor !== "0") {
3075
+ options.onCheckpoint?.("timeline", cursor);
3076
+ } else {
3077
+ options.onCheckpoint?.("timeline", "");
3078
+ }
2966
3079
  await sleep(1e3, 2e3, options.signal);
2967
3080
  }
2968
3081
  }
@@ -3142,119 +3255,122 @@ var FacebookExtractor = class {
3142
3255
  found: 0,
3143
3256
  message: `Fetching Facebook profile @${username}...`
3144
3257
  });
3145
- const targetPath = subDir === "Photos" ? "photos" : "videos";
3146
- const desktopUrl = `https://www.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3147
- const mbasicUrl = `https://mbasic.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3148
- const mobileUrl = `https://m.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3149
- let currentUrl = desktopUrl;
3150
- let html = "";
3151
- try {
3152
- html = await this.httpClient.fetchText(desktopUrl, {
3153
- cookiePath: options.cookiePath,
3154
- cookieDomain: "facebook.com",
3155
- signal: options.signal,
3156
- retries: 0,
3157
- timeoutMs: 4e3
3158
- });
3159
- } catch {
3160
- html = "";
3161
- }
3162
- let initialItems = extractFacebookMediaFromHtml(html, username);
3163
- if (initialItems.length === 0) {
3164
- options.onProgress?.({
3165
- stage: "Profile",
3166
- found: 0,
3167
- message: `Desktop yielded 0 items. Falling back to mbasic.facebook.com for @${username}...`
3168
- });
3258
+ const pathsToScrape = subDir === "All" ? ["photos", "videos"] : [subDir === "Photos" ? "photos" : "videos"];
3259
+ const seenUrls = /* @__PURE__ */ new Set();
3260
+ let found = 0;
3261
+ for (const targetPath of pathsToScrape) {
3262
+ if (options.signal?.aborted) break;
3263
+ const desktopUrl = `https://www.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3264
+ const mbasicUrl = `https://mbasic.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3265
+ const mobileUrl = `https://m.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3266
+ let currentUrl = desktopUrl;
3267
+ let html = "";
3169
3268
  try {
3170
- const mbasicHtml = await this.httpClient.fetchText(mbasicUrl, {
3269
+ html = await this.httpClient.fetchText(desktopUrl, {
3171
3270
  cookiePath: options.cookiePath,
3172
3271
  cookieDomain: "facebook.com",
3173
3272
  signal: options.signal,
3174
- retries: 1,
3175
- timeoutMs: 5e3,
3176
- headers: {
3177
- "Referer": "https://mbasic.facebook.com/"
3178
- }
3273
+ retries: 0,
3274
+ timeoutMs: 4e3
3179
3275
  });
3180
- const mbasicItems = extractFacebookMediaFromHtml(mbasicHtml, username);
3181
- if (mbasicItems.length > 0 || mbasicHtml.length > 200) {
3182
- html = mbasicHtml;
3183
- currentUrl = mbasicUrl;
3184
- initialItems = mbasicItems;
3185
- }
3186
3276
  } catch {
3277
+ html = "";
3187
3278
  }
3279
+ let initialItems = extractFacebookMediaFromHtml(html, username);
3188
3280
  if (initialItems.length === 0) {
3281
+ options.onProgress?.({
3282
+ stage: "Profile",
3283
+ found,
3284
+ message: `Desktop yielded 0 items. Falling back to mbasic.facebook.com for @${username}/${targetPath}...`
3285
+ });
3189
3286
  try {
3190
- const mobileHtml = await this.httpClient.fetchText(mobileUrl, {
3287
+ const mbasicHtml = await this.httpClient.fetchText(mbasicUrl, {
3191
3288
  cookiePath: options.cookiePath,
3192
3289
  cookieDomain: "facebook.com",
3193
3290
  signal: options.signal,
3194
- retries: 0,
3291
+ retries: 1,
3195
3292
  timeoutMs: 5e3,
3196
3293
  headers: {
3197
- "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
3198
- "Referer": "https://m.facebook.com/"
3294
+ "Referer": "https://mbasic.facebook.com/"
3199
3295
  }
3200
3296
  });
3201
- const mobileItems = extractFacebookMediaFromHtml(mobileHtml, username);
3202
- if (mobileItems.length > 0) {
3203
- html = mobileHtml;
3204
- currentUrl = mobileUrl;
3205
- initialItems = mobileItems;
3297
+ const mbasicItems = extractFacebookMediaFromHtml(mbasicHtml, username);
3298
+ if (mbasicItems.length > 0 || mbasicHtml.length > 200) {
3299
+ html = mbasicHtml;
3300
+ currentUrl = mbasicUrl;
3301
+ initialItems = mbasicItems;
3206
3302
  }
3207
3303
  } catch {
3208
3304
  }
3209
- }
3210
- }
3211
- const seenUrls = /* @__PURE__ */ new Set();
3212
- let found = 0;
3213
- let page = 1;
3214
- const maxPages = 5;
3215
- while (true) {
3216
- const items = extractFacebookMediaFromHtml(html, username);
3217
- let pageNewCount = 0;
3218
- for (const item of items) {
3219
- if (subDir === "Photos" && item.type !== "photo") continue;
3220
- if (subDir === "Videos" && item.type !== "video") continue;
3221
- if (seenUrls.has(item.url)) continue;
3222
- seenUrls.add(item.url);
3223
- found++;
3224
- pageNewCount++;
3225
- options.onProgress?.({
3226
- stage: subDir,
3227
- found,
3228
- message: `Found item: ${item.filename}`
3229
- });
3230
- yield item;
3231
- }
3232
- if (page >= maxPages || options.signal?.aborted) {
3233
- break;
3234
- }
3235
- const nextUrl = extractFacebookNextPageUrl(html, currentUrl);
3236
- if (!nextUrl || nextUrl === currentUrl) {
3237
- break;
3238
- }
3239
- if (pageNewCount === 0 && page > 1) {
3240
- break;
3241
- }
3242
- await sleep(1e3, 2e3, options.signal);
3243
- try {
3244
- currentUrl = nextUrl;
3245
- html = await this.httpClient.fetchText(currentUrl, {
3246
- cookiePath: options.cookiePath,
3247
- cookieDomain: "facebook.com",
3248
- signal: options.signal,
3249
- retries: 1,
3250
- timeoutMs: 5e3,
3251
- headers: {
3252
- "Referer": currentUrl.includes("mbasic") ? "https://mbasic.facebook.com/" : "https://www.facebook.com/"
3305
+ if (initialItems.length === 0) {
3306
+ try {
3307
+ const mobileHtml = await this.httpClient.fetchText(mobileUrl, {
3308
+ cookiePath: options.cookiePath,
3309
+ cookieDomain: "facebook.com",
3310
+ signal: options.signal,
3311
+ retries: 0,
3312
+ timeoutMs: 5e3,
3313
+ headers: {
3314
+ "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
3315
+ "Referer": "https://m.facebook.com/"
3316
+ }
3317
+ });
3318
+ const mobileItems = extractFacebookMediaFromHtml(mobileHtml, username);
3319
+ if (mobileItems.length > 0) {
3320
+ html = mobileHtml;
3321
+ currentUrl = mobileUrl;
3322
+ initialItems = mobileItems;
3323
+ }
3324
+ } catch {
3253
3325
  }
3254
- });
3255
- page++;
3256
- } catch {
3257
- break;
3326
+ }
3327
+ }
3328
+ let page = 1;
3329
+ const maxPages = 5;
3330
+ while (true) {
3331
+ const items = extractFacebookMediaFromHtml(html, username);
3332
+ let pageNewCount = 0;
3333
+ for (const item of items) {
3334
+ if (subDir === "Photos" && item.type !== "photo") continue;
3335
+ if (subDir === "Videos" && item.type !== "video") continue;
3336
+ if (seenUrls.has(item.url)) continue;
3337
+ seenUrls.add(item.url);
3338
+ found++;
3339
+ pageNewCount++;
3340
+ options.onProgress?.({
3341
+ stage: subDir === "All" ? targetPath === "photos" ? "Photos" : "Videos" : subDir,
3342
+ found,
3343
+ message: `Found item: ${item.filename}`
3344
+ });
3345
+ yield item;
3346
+ }
3347
+ if (page >= maxPages || options.signal?.aborted) {
3348
+ break;
3349
+ }
3350
+ const nextUrl = extractFacebookNextPageUrl(html, currentUrl);
3351
+ if (!nextUrl || nextUrl === currentUrl) {
3352
+ break;
3353
+ }
3354
+ if (pageNewCount === 0 && page > 1) {
3355
+ break;
3356
+ }
3357
+ await sleep(1e3, 2e3, options.signal);
3358
+ try {
3359
+ currentUrl = nextUrl;
3360
+ html = await this.httpClient.fetchText(currentUrl, {
3361
+ cookiePath: options.cookiePath,
3362
+ cookieDomain: "facebook.com",
3363
+ signal: options.signal,
3364
+ retries: 1,
3365
+ timeoutMs: 5e3,
3366
+ headers: {
3367
+ "Referer": currentUrl.includes("mbasic") ? "https://mbasic.facebook.com/" : "https://www.facebook.com/"
3368
+ }
3369
+ });
3370
+ page++;
3371
+ } catch {
3372
+ break;
3373
+ }
3258
3374
  }
3259
3375
  }
3260
3376
  }
@@ -3278,10 +3394,66 @@ function getExtractor(platform) {
3278
3394
 
3279
3395
  // src/lib/engines/downloader.ts
3280
3396
  import fs6 from "fs";
3281
- import fsPromises2 from "fs/promises";
3397
+ import fsPromises3 from "fs/promises";
3282
3398
  import path5 from "path";
3283
3399
  import { Readable } from "stream";
3284
3400
  import { pipeline } from "stream/promises";
3401
+
3402
+ // src/lib/engines/metadata.ts
3403
+ import fsPromises2 from "fs/promises";
3404
+ function extractHashtags(caption) {
3405
+ if (!caption) return [];
3406
+ const matches = caption.match(/#([\p{L}\p{N}_]+)/gu);
3407
+ if (!matches) return [];
3408
+ const tags = matches.map((m) => m.replace(/^#/, ""));
3409
+ return Array.from(new Set(tags));
3410
+ }
3411
+ function buildMetadataSidecar(item, finalFilename, fallbackPlatform, fallbackAuthor) {
3412
+ const hashtags = extractHashtags(item.caption);
3413
+ const cleanExt = item.extension.replace(/^\./, "") || (item.type === "video" ? "mp4" : "jpg");
3414
+ const isoDate = item.date ? new Date(item.date * 1e3).toISOString() : void 0;
3415
+ let carousel;
3416
+ if (item.carouselId && item.carouselIndex !== void 0 && item.carouselTotal !== void 0) {
3417
+ carousel = {
3418
+ id: item.carouselId,
3419
+ index: item.carouselIndex,
3420
+ total: item.carouselTotal
3421
+ };
3422
+ }
3423
+ return {
3424
+ id: item.id,
3425
+ platform: item.platform || fallbackPlatform,
3426
+ author: item.author || fallbackAuthor,
3427
+ url: item.url,
3428
+ filename: finalFilename,
3429
+ type: item.type,
3430
+ extension: cleanExt,
3431
+ ...item.caption ? { caption: item.caption } : {},
3432
+ ...item.date ? { date: item.date, isoDate } : {},
3433
+ ...item.width ? { width: item.width } : {},
3434
+ ...item.height ? { height: item.height } : {},
3435
+ ...item.thumbnailUrl ? { thumbnailUrl: item.thumbnailUrl } : {},
3436
+ hashtags,
3437
+ ...carousel ? { carousel } : {},
3438
+ ...item.metadata ? { metadata: item.metadata } : {},
3439
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString()
3440
+ };
3441
+ }
3442
+ async function writeMetadataSidecar(sidecarPath, sidecar, date) {
3443
+ const tmpPath = `${sidecarPath}.tmp`;
3444
+ const jsonContent = JSON.stringify(sidecar, null, 2);
3445
+ await fsPromises2.writeFile(tmpPath, jsonContent, "utf-8");
3446
+ await fsPromises2.rename(tmpPath, sidecarPath);
3447
+ if (date) {
3448
+ try {
3449
+ const mtime = new Date(date * 1e3);
3450
+ await fsPromises2.utimes(sidecarPath, mtime, mtime);
3451
+ } catch {
3452
+ }
3453
+ }
3454
+ }
3455
+
3456
+ // src/lib/engines/downloader.ts
3285
3457
  var ARCHIVE_FILENAME = ".open-nami-archive.json";
3286
3458
  var YTDLP_ARCHIVE_FILENAME = "archive_yt-dlp.txt";
3287
3459
  var TEXT_ARCHIVE_FILENAME = "archive.txt";
@@ -3289,7 +3461,7 @@ async function loadArchive(destDir) {
3289
3461
  const archive = /* @__PURE__ */ new Set();
3290
3462
  const jsonPath = path5.join(destDir, ARCHIVE_FILENAME);
3291
3463
  try {
3292
- const raw = await fsPromises2.readFile(jsonPath, "utf-8");
3464
+ const raw = await fsPromises3.readFile(jsonPath, "utf-8");
3293
3465
  const parsed = JSON.parse(raw);
3294
3466
  if (Array.isArray(parsed)) {
3295
3467
  for (const id of parsed) {
@@ -3300,7 +3472,7 @@ async function loadArchive(destDir) {
3300
3472
  }
3301
3473
  const ytdlpPath = path5.join(destDir, YTDLP_ARCHIVE_FILENAME);
3302
3474
  try {
3303
- const raw = await fsPromises2.readFile(ytdlpPath, "utf-8");
3475
+ const raw = await fsPromises3.readFile(ytdlpPath, "utf-8");
3304
3476
  const lines = raw.split(/\r?\n/);
3305
3477
  for (const rawLine of lines) {
3306
3478
  const line = rawLine.trim();
@@ -3313,7 +3485,7 @@ async function loadArchive(destDir) {
3313
3485
  }
3314
3486
  const textPath = path5.join(destDir, TEXT_ARCHIVE_FILENAME);
3315
3487
  try {
3316
- const raw = await fsPromises2.readFile(textPath, "utf-8");
3488
+ const raw = await fsPromises3.readFile(textPath, "utf-8");
3317
3489
  const lines = raw.split(/\r?\n/);
3318
3490
  for (const rawLine of lines) {
3319
3491
  const line = rawLine.trim();
@@ -3330,8 +3502,8 @@ async function saveArchive(destDir, archive) {
3330
3502
  const archivePath = path5.join(destDir, ARCHIVE_FILENAME);
3331
3503
  const tmpPath = `${archivePath}.tmp`;
3332
3504
  try {
3333
- await fsPromises2.writeFile(tmpPath, JSON.stringify([...archive]), "utf-8");
3334
- await fsPromises2.rename(tmpPath, archivePath);
3505
+ await fsPromises3.writeFile(tmpPath, JSON.stringify([...archive]), "utf-8");
3506
+ await fsPromises3.rename(tmpPath, archivePath);
3335
3507
  } catch {
3336
3508
  }
3337
3509
  const textPaths = [
@@ -3342,7 +3514,7 @@ async function saveArchive(destDir, archive) {
3342
3514
  `).join("");
3343
3515
  for (const tPath of textPaths) {
3344
3516
  try {
3345
- await fsPromises2.writeFile(tPath, content, "utf-8");
3517
+ await fsPromises3.writeFile(tPath, content, "utf-8");
3346
3518
  } catch {
3347
3519
  }
3348
3520
  }
@@ -3359,12 +3531,34 @@ function sanitizeFilename(rawFilename, fallbackId, ext) {
3359
3531
  }
3360
3532
  return sanitizedBase.includes(".") ? sanitizedBase : `${sanitizedBase}.${cleanExt}`;
3361
3533
  }
3534
+ function isCdnUrl(rawUrl) {
3535
+ try {
3536
+ const hostname = new URL(rawUrl).hostname.toLowerCase();
3537
+ return hostname.endsWith(".cdninstagram.com") || hostname.endsWith(".fbcdn.net") || hostname.endsWith(".tiktokcdn.com") || hostname.endsWith(".akamaized.net") || hostname === "pbs.twimg.com" || hostname === "video.twimg.com";
3538
+ } catch {
3539
+ return false;
3540
+ }
3541
+ }
3362
3542
  async function downloadMediaStream(items, options) {
3363
3543
  const { jobName, destDir, signal, onProgress } = options;
3364
3544
  const httpClient = options.httpClient ?? new HttpClient();
3365
3545
  const concurrency = Math.max(1, options.concurrency ?? 3);
3366
- await fsPromises2.mkdir(destDir, { recursive: true });
3367
- const archive = await loadArchive(destDir);
3546
+ const dirArchives = /* @__PURE__ */ new Map();
3547
+ const getArchiveForDir = async (dir) => {
3548
+ let arch = dirArchives.get(dir);
3549
+ if (!arch) {
3550
+ await fsPromises3.mkdir(dir, { recursive: true });
3551
+ arch = await loadArchive(dir);
3552
+ dirArchives.set(dir, arch);
3553
+ }
3554
+ return arch;
3555
+ };
3556
+ await getArchiveForDir(destDir);
3557
+ if (options.destDirMap) {
3558
+ for (const d of Object.values(options.destDirMap)) {
3559
+ if (d) await getArchiveForDir(d);
3560
+ }
3561
+ }
3368
3562
  let downloaded = 0;
3369
3563
  let skipped = 0;
3370
3564
  const errors = [];
@@ -3385,6 +3579,10 @@ async function downloadMediaStream(items, options) {
3385
3579
  }
3386
3580
  async function downloadWorker() {
3387
3581
  while (!isDone && !signal?.aborted) {
3582
+ if (options.limit !== void 0 && downloaded >= options.limit) {
3583
+ isDone = true;
3584
+ break;
3585
+ }
3388
3586
  let item;
3389
3587
  try {
3390
3588
  const next = await getNextItem();
@@ -3412,10 +3610,20 @@ async function downloadMediaStream(items, options) {
3412
3610
  }
3413
3611
  continue;
3414
3612
  }
3613
+ const targetDir = options.destDirMap?.[item.type] ?? destDir;
3614
+ const archive = await getArchiveForDir(targetDir);
3415
3615
  const ext = item.extension.replace(/^\./, "") || (item.type === "video" ? "mp4" : "jpg");
3416
3616
  const finalFilename = sanitizeFilename(item.filename, item.id, ext);
3417
- const finalPath = path5.join(destDir, finalFilename);
3617
+ const finalPath = path5.join(targetDir, finalFilename);
3618
+ const sidecarPath = path5.join(targetDir, `${path5.parse(finalFilename).name}.json`);
3418
3619
  if (archive.has(item.id) && fs6.existsSync(finalPath)) {
3620
+ if (options.writeMetadata && !fs6.existsSync(sidecarPath)) {
3621
+ try {
3622
+ const sidecar = buildMetadataSidecar(item, finalFilename, options.defaultPlatform, options.defaultAuthor);
3623
+ await writeMetadataSidecar(sidecarPath, sidecar, item.date);
3624
+ } catch {
3625
+ }
3626
+ }
3419
3627
  skipped++;
3420
3628
  onProgress?.({
3421
3629
  job: jobName,
@@ -3439,10 +3647,13 @@ async function downloadMediaStream(items, options) {
3439
3647
  if (options.referer) {
3440
3648
  reqHeaders["Referer"] = options.referer;
3441
3649
  }
3650
+ const isCdn = isCdnUrl(item.url);
3442
3651
  const response = await httpClient.request(item.url, {
3443
3652
  signal,
3444
- cookiePath: options.cookiePath,
3445
- headers: reqHeaders
3653
+ cookiePath: isCdn ? void 0 : options.cookiePath,
3654
+ headers: reqHeaders,
3655
+ retries: 2,
3656
+ retryDelayMs: 1500
3446
3657
  });
3447
3658
  if (!response.ok || !response.body) {
3448
3659
  try {
@@ -3456,13 +3667,24 @@ async function downloadMediaStream(items, options) {
3456
3667
  if (item.date) {
3457
3668
  try {
3458
3669
  const mtime = new Date(item.date * 1e3);
3459
- await fsPromises2.utimes(tmpPath, mtime, mtime);
3670
+ await fsPromises3.utimes(tmpPath, mtime, mtime);
3671
+ } catch {
3672
+ }
3673
+ }
3674
+ await fsPromises3.rename(tmpPath, finalPath);
3675
+ if (options.writeMetadata) {
3676
+ try {
3677
+ const sidecar = buildMetadataSidecar(item, finalFilename, options.defaultPlatform, options.defaultAuthor);
3678
+ await writeMetadataSidecar(sidecarPath, sidecar, item.date);
3460
3679
  } catch {
3461
3680
  }
3462
3681
  }
3463
- await fsPromises2.rename(tmpPath, finalPath);
3464
3682
  archive.add(item.id);
3465
3683
  downloaded++;
3684
+ consecutiveErrors = 0;
3685
+ if (options.limit !== void 0 && downloaded >= options.limit) {
3686
+ isDone = true;
3687
+ }
3466
3688
  onProgress?.({
3467
3689
  job: jobName,
3468
3690
  downloadedCount: downloaded,
@@ -3473,7 +3695,7 @@ async function downloadMediaStream(items, options) {
3473
3695
  } catch (err) {
3474
3696
  try {
3475
3697
  if (fs6.existsSync(tmpPath)) {
3476
- await fsPromises2.unlink(tmpPath);
3698
+ await fsPromises3.unlink(tmpPath);
3477
3699
  }
3478
3700
  } catch {
3479
3701
  }
@@ -3494,33 +3716,172 @@ async function downloadMediaStream(items, options) {
3494
3716
  }
3495
3717
  const workers = Array.from({ length: concurrency }, () => downloadWorker());
3496
3718
  await Promise.all(workers);
3497
- await saveArchive(destDir, archive);
3719
+ for (const [d, arch] of dirArchives.entries()) {
3720
+ await saveArchive(d, arch);
3721
+ }
3498
3722
  return { downloaded, skipped, errors };
3499
3723
  }
3500
3724
 
3725
+ // src/lib/engines/checkpoint.ts
3726
+ import fsPromises4 from "fs/promises";
3727
+ import path6 from "path";
3728
+ var CHECKPOINT_FILENAME = ".open-nami-checkpoint.json";
3729
+ async function loadCheckpoint(destDir) {
3730
+ const filePath = path6.join(destDir, CHECKPOINT_FILENAME);
3731
+ try {
3732
+ const raw = await fsPromises4.readFile(filePath, "utf-8");
3733
+ const parsed = JSON.parse(raw);
3734
+ if (parsed && typeof parsed === "object" && parsed.cursors) {
3735
+ return parsed;
3736
+ }
3737
+ } catch {
3738
+ }
3739
+ return null;
3740
+ }
3741
+ async function saveCheckpoint(destDir, checkpoint) {
3742
+ const filePath = path6.join(destDir, CHECKPOINT_FILENAME);
3743
+ const tmpPath = `${filePath}.tmp`;
3744
+ try {
3745
+ await fsPromises4.mkdir(destDir, { recursive: true });
3746
+ await fsPromises4.writeFile(tmpPath, JSON.stringify(checkpoint, null, 2), "utf-8");
3747
+ await fsPromises4.rename(tmpPath, filePath);
3748
+ } catch {
3749
+ }
3750
+ }
3751
+ async function clearCheckpoint(destDir) {
3752
+ const filePath = path6.join(destDir, CHECKPOINT_FILENAME);
3753
+ try {
3754
+ await fsPromises4.unlink(filePath);
3755
+ } catch {
3756
+ }
3757
+ }
3758
+
3759
+ // src/lib/engines/filters.ts
3760
+ function parseDateBoundary(input, boundary) {
3761
+ if (input === void 0 || input === null) return void 0;
3762
+ if (input instanceof Date) {
3763
+ if (isNaN(input.getTime())) return void 0;
3764
+ return Math.floor(input.getTime() / 1e3);
3765
+ }
3766
+ if (typeof input === "number") {
3767
+ if (isNaN(input) || input <= 0) return void 0;
3768
+ if (input > 4e9) {
3769
+ return Math.floor(input / 1e3);
3770
+ }
3771
+ return Math.floor(input);
3772
+ }
3773
+ const trimmed = input.trim();
3774
+ if (!trimmed) return void 0;
3775
+ const relMatch = trimmed.match(/^(\d+)\s*(s|m|h|d|w|y)$/i);
3776
+ if (relMatch) {
3777
+ const amount = parseInt(relMatch[1], 10);
3778
+ const unit = relMatch[2].toLowerCase();
3779
+ const multipliers = {
3780
+ s: 1,
3781
+ m: 60,
3782
+ h: 3600,
3783
+ d: 86400,
3784
+ w: 604800,
3785
+ y: 31536e3
3786
+ };
3787
+ const offsetSeconds = amount * (multipliers[unit] ?? 86400);
3788
+ const nowSeconds = Math.floor(Date.now() / 1e3);
3789
+ return nowSeconds - offsetSeconds;
3790
+ }
3791
+ if (/^\d+$/.test(trimmed)) {
3792
+ if (trimmed.length === 8) {
3793
+ const year = parseInt(trimmed.slice(0, 4), 10);
3794
+ const month = parseInt(trimmed.slice(4, 6), 10) - 1;
3795
+ const day = parseInt(trimmed.slice(6, 8), 10);
3796
+ const date = new Date(Date.UTC(year, month, day, boundary === "until" ? 23 : 0, boundary === "until" ? 59 : 0, boundary === "until" ? 59 : 0));
3797
+ if (!isNaN(date.getTime())) {
3798
+ return Math.floor(date.getTime() / 1e3);
3799
+ }
3800
+ }
3801
+ const num = parseInt(trimmed, 10);
3802
+ if (num > 4e9) {
3803
+ return Math.floor(num / 1e3);
3804
+ }
3805
+ return num;
3806
+ }
3807
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
3808
+ const timeStr = boundary === "until" ? "T23:59:59.999Z" : "T00:00:00.000Z";
3809
+ const parsed2 = /* @__PURE__ */ new Date(`${trimmed}${timeStr}`);
3810
+ if (!isNaN(parsed2.getTime())) {
3811
+ return Math.floor(parsed2.getTime() / 1e3);
3812
+ }
3813
+ }
3814
+ const parsed = new Date(trimmed);
3815
+ if (!isNaN(parsed.getTime())) {
3816
+ return Math.floor(parsed.getTime() / 1e3);
3817
+ }
3818
+ return void 0;
3819
+ }
3820
+ function parseLimit(input) {
3821
+ if (input === void 0 || input === null) return void 0;
3822
+ const num = typeof input === "number" ? input : parseInt(String(input).trim(), 10);
3823
+ if (isNaN(num) || num <= 0) return void 0;
3824
+ return Math.floor(num);
3825
+ }
3826
+ function shouldIncludeItem(item, filters) {
3827
+ const reverseChrono = filters.reverseChronological ?? true;
3828
+ if (item.date !== void 0 && item.date !== null) {
3829
+ if (filters.until !== void 0 && item.date > filters.until) {
3830
+ return { include: false, stop: false };
3831
+ }
3832
+ if (filters.since !== void 0 && item.date < filters.since) {
3833
+ return { include: false, stop: reverseChrono };
3834
+ }
3835
+ }
3836
+ return { include: true, stop: false };
3837
+ }
3838
+ async function* filterMediaStream(items, filters) {
3839
+ const maxLimit = filters.limit;
3840
+ let count = 0;
3841
+ if (maxLimit !== void 0 && maxLimit <= 0) {
3842
+ return;
3843
+ }
3844
+ for await (const item of items) {
3845
+ if (maxLimit !== void 0 && count >= maxLimit) {
3846
+ break;
3847
+ }
3848
+ const { include, stop } = shouldIncludeItem(item, filters);
3849
+ if (include) {
3850
+ count++;
3851
+ yield item;
3852
+ if (maxLimit !== void 0 && count >= maxLimit) {
3853
+ break;
3854
+ }
3855
+ }
3856
+ if (stop) {
3857
+ break;
3858
+ }
3859
+ }
3860
+ }
3861
+
3501
3862
  // src/lib/engines/gallery-dl.ts
3502
3863
  import { spawn as spawn2 } from "child_process";
3503
- import path8 from "path";
3864
+ import path9 from "path";
3504
3865
  import fs9 from "fs/promises";
3505
3866
 
3506
3867
  // src/lib/engines/exec.ts
3507
3868
  import fs8 from "fs";
3508
- import path7 from "path";
3869
+ import path8 from "path";
3509
3870
 
3510
3871
  // src/lib/engines/fetcher.ts
3511
3872
  import { spawn } from "child_process";
3512
3873
  import { createWriteStream } from "fs";
3513
3874
  import fs7 from "fs/promises";
3514
3875
  import os7 from "os";
3515
- import path6 from "path";
3876
+ import path7 from "path";
3516
3877
  import { Readable as Readable2 } from "stream";
3517
3878
  import { pipeline as pipeline2 } from "stream/promises";
3518
3879
  function getOpenNamiBinDir(env = process.env) {
3519
3880
  if (env.OPEN_NAMI_BIN_DIR && env.OPEN_NAMI_BIN_DIR.trim()) {
3520
- return path6.resolve(env.OPEN_NAMI_BIN_DIR.trim());
3881
+ return path7.resolve(env.OPEN_NAMI_BIN_DIR.trim());
3521
3882
  }
3522
3883
  const base = env.OPEN_NAMI_DIR && env.OPEN_NAMI_DIR.trim() ? env.OPEN_NAMI_DIR.trim() : os7.homedir();
3523
- return path6.join(base, ".open-nami", "bin");
3884
+ return path7.join(base, ".open-nami", "bin");
3524
3885
  }
3525
3886
  var YTDLP_RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
3526
3887
  var GALLERYDL_CODEBERG_API = "https://codeberg.org/api/v1/repos/mikf/gallery-dl/releases/latest";
@@ -3571,7 +3932,7 @@ function getBinaryVersion(executablePath) {
3571
3932
  });
3572
3933
  }
3573
3934
  async function downloadBinary(url, targetFile, signal) {
3574
- await fs7.mkdir(path6.dirname(targetFile), { recursive: true });
3935
+ await fs7.mkdir(path7.dirname(targetFile), { recursive: true });
3575
3936
  const response = await fetch(url, {
3576
3937
  signal,
3577
3938
  headers: { "User-Agent": "open-nami" }
@@ -3603,7 +3964,7 @@ async function downloadBinary(url, targetFile, signal) {
3603
3964
  async function downloadLatestYtDlp(targetDir, signal, onStatus) {
3604
3965
  const dir = targetDir || getOpenNamiBinDir();
3605
3966
  const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
3606
- const local = path6.join(dir, binaryName);
3967
+ const local = path7.join(dir, binaryName);
3607
3968
  const asset = ytDlpAssetName();
3608
3969
  const url = `${YTDLP_RELEASE_BASE}/${asset}`;
3609
3970
  onStatus?.(`fetching latest yt-dlp (${asset})\u2026`);
@@ -3639,7 +4000,7 @@ async function downloadLatestGalleryDl(targetDir, signal, onStatus) {
3639
4000
  }
3640
4001
  const dir = targetDir || getOpenNamiBinDir();
3641
4002
  const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
3642
- const local = path6.join(dir, binaryName);
4003
+ const local = path7.join(dir, binaryName);
3643
4004
  const asset = galleryDlAssetName();
3644
4005
  const candidateUrls = await getGalleryDlDownloadUrls(asset, fetch, signal);
3645
4006
  let lastError;
@@ -3657,7 +4018,7 @@ async function downloadLatestGalleryDl(targetDir, signal, onStatus) {
3657
4018
  async function ensureYtDlp(onStatus, signal) {
3658
4019
  if (await commandWorks("yt-dlp", ["--version"])) return "yt-dlp";
3659
4020
  const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
3660
- const local = path6.join(getOpenNamiBinDir(), binaryName);
4021
+ const local = path7.join(getOpenNamiBinDir(), binaryName);
3661
4022
  if (await commandWorks(local, ["--version"])) return local;
3662
4023
  if (await commandWorks("python", ["-m", "yt_dlp", "--version"])) return "python";
3663
4024
  if (await commandWorks("python3", ["-m", "yt_dlp", "--version"])) return "python3";
@@ -3667,7 +4028,7 @@ async function ensureYtDlp(onStatus, signal) {
3667
4028
  async function ensureGalleryDl(onStatus, signal) {
3668
4029
  if (await commandWorks("gallery-dl", ["--version"])) return "gallery-dl";
3669
4030
  const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
3670
- const local = path6.join(getOpenNamiBinDir(), binaryName);
4031
+ const local = path7.join(getOpenNamiBinDir(), binaryName);
3671
4032
  if (await commandWorks(local, ["--version"])) return local;
3672
4033
  if (await commandWorks("python", ["-m", "gallery_dl", "--version"])) return "python";
3673
4034
  if (await commandWorks("python3", ["-m", "gallery_dl", "--version"])) return "python3";
@@ -3680,7 +4041,7 @@ async function updateYtDlp(options) {
3680
4041
  const signal = options?.signal;
3681
4042
  const dir = getOpenNamiBinDir();
3682
4043
  const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
3683
- const local = path6.join(dir, binaryName);
4044
+ const local = path7.join(dir, binaryName);
3684
4045
  let previousVersion;
3685
4046
  if (await commandWorks(local, ["--version"])) {
3686
4047
  previousVersion = await getBinaryVersion(local);
@@ -3703,7 +4064,7 @@ async function updateGalleryDl(options) {
3703
4064
  const signal = options?.signal;
3704
4065
  const dir = getOpenNamiBinDir();
3705
4066
  const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
3706
- const local = path6.join(dir, binaryName);
4067
+ const local = path7.join(dir, binaryName);
3707
4068
  let previousVersion;
3708
4069
  if (await commandWorks(local, ["--version"])) {
3709
4070
  previousVersion = await getBinaryVersion(local);
@@ -3724,13 +4085,13 @@ async function updateGalleryDl(options) {
3724
4085
  // src/lib/engines/exec.ts
3725
4086
  function findExecutable(binName, pathEnv = process.env.PATH || "") {
3726
4087
  if (!pathEnv) return null;
3727
- const delimiter = path7.delimiter;
4088
+ const delimiter = path8.delimiter;
3728
4089
  const dirs = pathEnv.split(delimiter).filter(Boolean);
3729
4090
  const isWin = process.platform === "win32";
3730
4091
  const extensions = isWin ? [".exe", ".cmd", ".bat", ""] : [""];
3731
4092
  for (const dir of dirs) {
3732
4093
  for (const ext of extensions) {
3733
- const fullPath = path7.join(dir, `${binName}${ext}`);
4094
+ const fullPath = path8.join(dir, `${binName}${ext}`);
3734
4095
  try {
3735
4096
  if (fs8.existsSync(fullPath)) {
3736
4097
  const stat = fs8.statSync(fullPath);
@@ -3763,7 +4124,7 @@ function resolveEngineCommand(engine, options = {}) {
3763
4124
  }
3764
4125
  const binDir = getOpenNamiBinDir(env);
3765
4126
  const isWin = process.platform === "win32";
3766
- const localBin = path7.join(binDir, `${engine}${isWin ? ".exe" : ""}`);
4127
+ const localBin = path8.join(binDir, `${engine}${isWin ? ".exe" : ""}`);
3767
4128
  try {
3768
4129
  if (fs8.existsSync(localBin)) {
3769
4130
  const stat = fs8.statSync(localBin);
@@ -3808,7 +4169,7 @@ var PLATFORM_SLEEP = {
3808
4169
  facebook: "1.0-1.8"
3809
4170
  };
3810
4171
  function buildGalleryDlArgs(options) {
3811
- const { destDir, subDir, platform, cleanUrl, username, cookiePath } = options;
4172
+ const { destDir, subDir, platform, cleanUrl, username, cookiePath, writeMetadata } = options;
3812
4173
  const args = [
3813
4174
  "-D",
3814
4175
  destDir,
@@ -3823,12 +4184,15 @@ function buildGalleryDlArgs(options) {
3823
4184
  "-o",
3824
4185
  'headers.sec-ch-ua-platform="Windows"',
3825
4186
  "--download-archive",
3826
- path8.join(destDir, GDL_ARCHIVE),
4187
+ path9.join(destDir, GDL_ARCHIVE),
3827
4188
  "--retries",
3828
4189
  "3",
3829
4190
  "--sleep-429",
3830
4191
  "10"
3831
4192
  ];
4193
+ if (writeMetadata) {
4194
+ args.push("--write-metadata");
4195
+ }
3832
4196
  let targetUrl = cleanUrl;
3833
4197
  const sleepDelay = PLATFORM_SLEEP[platform] || "0.8-1.5";
3834
4198
  if (subDir === "Photos") {
@@ -3861,7 +4225,7 @@ function buildGalleryDlArgs(options) {
3861
4225
  }
3862
4226
  async function runGalleryDl(options) {
3863
4227
  const { profile, targetDir, subDir, cookiePath, onProgress } = options;
3864
- const destDir = path8.join(targetDir, subDir);
4228
+ const destDir = path9.join(targetDir, subDir);
3865
4229
  await fs9.mkdir(destDir, { recursive: true });
3866
4230
  const args = buildGalleryDlArgs({
3867
4231
  destDir,
@@ -3869,7 +4233,8 @@ async function runGalleryDl(options) {
3869
4233
  platform: profile.platform,
3870
4234
  cleanUrl: profile.cleanUrl,
3871
4235
  username: profile.username,
3872
- cookiePath
4236
+ cookiePath,
4237
+ writeMetadata: options.writeMetadata
3873
4238
  });
3874
4239
  let downloadedCount = 0;
3875
4240
  let skippedCount = 0;
@@ -3892,7 +4257,7 @@ async function runGalleryDl(options) {
3892
4257
  error: err.message || String(err)
3893
4258
  };
3894
4259
  }
3895
- const engineCmd = engineCmdStr.includes(path8.sep) ? { command: engineCmdStr, baseArgs: [] } : resolveEngineCommand("gallery-dl");
4260
+ const engineCmd = engineCmdStr.includes(path9.sep) ? { command: engineCmdStr, baseArgs: [] } : resolveEngineCommand("gallery-dl");
3896
4261
  const fullArgs = [...engineCmd.baseArgs, ...args];
3897
4262
  return new Promise((resolve) => {
3898
4263
  onProgress?.({
@@ -3911,7 +4276,7 @@ async function runGalleryDl(options) {
3911
4276
  if (!trimmed) return;
3912
4277
  if (trimmed.startsWith("#")) {
3913
4278
  skippedCount++;
3914
- const filename = path8.basename(trimmed.slice(1).trim());
4279
+ const filename = path9.basename(trimmed.slice(1).trim());
3915
4280
  onProgress?.({
3916
4281
  job: subDir,
3917
4282
  downloadedCount,
@@ -3919,9 +4284,9 @@ async function runGalleryDl(options) {
3919
4284
  currentFile: filename,
3920
4285
  statusText: `Skipped: ${filename}`
3921
4286
  });
3922
- } 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(path8.sep)) {
4287
+ } 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(path9.sep)) {
3923
4288
  downloadedCount++;
3924
- const filename = path8.basename(trimmed);
4289
+ const filename = path9.basename(trimmed);
3925
4290
  onProgress?.({
3926
4291
  job: subDir,
3927
4292
  downloadedCount,
@@ -3972,15 +4337,15 @@ async function runGalleryDl(options) {
3972
4337
 
3973
4338
  // src/lib/engines/yt-dlp.ts
3974
4339
  import { spawn as spawn3 } from "child_process";
3975
- import path9 from "path";
4340
+ import path10 from "path";
3976
4341
  import fs10 from "fs/promises";
3977
4342
  var YTDLP_ARCHIVE = "archive_yt-dlp.txt";
3978
4343
  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";
3979
4344
  function buildYtDlpArgs(options) {
3980
- const { destDir, platform, cleanUrl, cookiePath } = options;
4345
+ const { destDir, platform, cleanUrl, cookiePath, writeMetadata } = options;
3981
4346
  const args = [
3982
4347
  "-o",
3983
- path9.join(destDir, "%(title).150B [%(id)s].%(ext)s"),
4348
+ path10.join(destDir, "%(title).150B [%(id)s].%(ext)s"),
3984
4349
  "--windows-filenames",
3985
4350
  "--newline",
3986
4351
  "--ignore-errors",
@@ -3988,7 +4353,7 @@ function buildYtDlpArgs(options) {
3988
4353
  "--user-agent",
3989
4354
  UA2,
3990
4355
  "--download-archive",
3991
- path9.join(destDir, YTDLP_ARCHIVE),
4356
+ path10.join(destDir, YTDLP_ARCHIVE),
3992
4357
  "--concurrent-fragments",
3993
4358
  "4",
3994
4359
  "--retries",
@@ -4005,6 +4370,9 @@ function buildYtDlpArgs(options) {
4005
4370
  } else if (platform === "x") {
4006
4371
  args.push("--sleep-requests", "0.8:1.8", "--retry-sleep", "429:exp=5:30");
4007
4372
  }
4373
+ if (writeMetadata) {
4374
+ args.push("--write-info-json", "--write-description");
4375
+ }
4008
4376
  if (cookiePath) {
4009
4377
  args.push("--cookies", cookiePath);
4010
4378
  }
@@ -4013,13 +4381,14 @@ function buildYtDlpArgs(options) {
4013
4381
  }
4014
4382
  async function runYtDlp(options) {
4015
4383
  const { profile, targetDir, cookiePath, onProgress } = options;
4016
- const destDir = path9.join(targetDir, "Videos");
4384
+ const destDir = path10.join(targetDir, "Videos");
4017
4385
  await fs10.mkdir(destDir, { recursive: true });
4018
4386
  const args = buildYtDlpArgs({
4019
4387
  destDir,
4020
4388
  platform: profile.platform,
4021
4389
  cleanUrl: profile.cleanUrl,
4022
- cookiePath
4390
+ cookiePath,
4391
+ writeMetadata: options.writeMetadata
4023
4392
  });
4024
4393
  let downloadedCount = 0;
4025
4394
  let skippedCount = 0;
@@ -4042,7 +4411,7 @@ async function runYtDlp(options) {
4042
4411
  error: err.message || String(err)
4043
4412
  };
4044
4413
  }
4045
- const engineCmd = engineCmdStr.includes(path9.sep) ? { command: engineCmdStr, baseArgs: [] } : resolveEngineCommand("yt-dlp");
4414
+ const engineCmd = engineCmdStr.includes(path10.sep) ? { command: engineCmdStr, baseArgs: [] } : resolveEngineCommand("yt-dlp");
4046
4415
  const fullArgs = [...engineCmd.baseArgs, ...args];
4047
4416
  return new Promise((resolve) => {
4048
4417
  onProgress?.({
@@ -4069,7 +4438,7 @@ async function runYtDlp(options) {
4069
4438
  });
4070
4439
  } else if (trimmed.includes("[download] Destination:")) {
4071
4440
  const dest = trimmed.replace("[download] Destination:", "").trim();
4072
- const filename = path9.basename(dest);
4441
+ const filename = path10.basename(dest);
4073
4442
  onProgress?.({
4074
4443
  job: "Videos",
4075
4444
  downloadedCount,
@@ -4134,31 +4503,31 @@ var REFERER_MAP = {
4134
4503
  };
4135
4504
  function expandPath(dir) {
4136
4505
  if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
4137
- return path10.join(os8.homedir(), dir.slice(1));
4506
+ return path11.join(os8.homedir(), dir.slice(1));
4138
4507
  }
4139
4508
  return dir;
4140
4509
  }
4141
4510
  function resolveDownloadBaseDir(customBaseDir) {
4142
4511
  if (customBaseDir && customBaseDir.trim()) {
4143
- return path10.resolve(expandPath(customBaseDir.trim()));
4512
+ return path11.resolve(expandPath(customBaseDir.trim()));
4144
4513
  }
4145
4514
  const envDir = process.env.OPEN_NAMI_DIR || process.env.NAMI_DIR;
4146
4515
  if (envDir && envDir.trim()) {
4147
- return path10.resolve(expandPath(envDir.trim()));
4516
+ return path11.resolve(expandPath(envDir.trim()));
4148
4517
  }
4149
4518
  const configDir = loadConfig().downloadDir;
4150
4519
  if (configDir && configDir.trim()) {
4151
- return path10.resolve(expandPath(configDir.trim()));
4520
+ return path11.resolve(expandPath(configDir.trim()));
4152
4521
  }
4153
4522
  if (process.env.NAMI_BASE_DIR && process.env.NAMI_BASE_DIR.trim()) {
4154
4523
  const raw = process.env.NAMI_BASE_DIR.trim();
4155
- return path10.resolve(expandPath(raw.endsWith("downloads") ? raw : path10.join(raw, "downloads")));
4524
+ return path11.resolve(expandPath(raw.endsWith("downloads") ? raw : path11.join(raw, "downloads")));
4156
4525
  }
4157
- return path10.join(resolvePlatformDownloadsDir(), "Open Nami");
4526
+ return path11.join(resolvePlatformDownloadsDir(), "Open Nami");
4158
4527
  }
4159
4528
  function resolveDefaultDownloadDir(platform, username, baseDir) {
4160
4529
  const root = resolveDownloadBaseDir(baseDir);
4161
- return path10.join(root, platform, username);
4530
+ return path11.join(root, platform, username);
4162
4531
  }
4163
4532
  async function dispatchDownload(options) {
4164
4533
  const { profile, choice, onProgress } = options;
@@ -4180,28 +4549,70 @@ async function dispatchDownload(options) {
4180
4549
  } else if (choice === "highlights") {
4181
4550
  jobs.push({ type: "highlights" });
4182
4551
  } else if (choice === "all") {
4183
- jobs.push({ type: "photos" });
4184
- jobs.push({ type: "videos" });
4552
+ jobs.push({ type: "all" });
4185
4553
  if (profile.platform === "instagram") {
4186
4554
  jobs.push({ type: "stories" });
4187
4555
  jobs.push({ type: "highlights" });
4188
4556
  }
4189
4557
  }
4558
+ const sinceSeconds = parseDateBoundary(options.since, "since");
4559
+ const untilSeconds = parseDateBoundary(options.until, "until");
4560
+ const globalLimit = parseLimit(options.limit);
4561
+ const savedCheckpoint = options.resume ? await loadCheckpoint(targetDir) : null;
4562
+ const currentCursors = savedCheckpoint?.cursors ? { ...savedCheckpoint.cursors } : {};
4190
4563
  const defaultRunNative = async (subDir) => {
4191
- const dest = path10.join(targetDir, subDir);
4564
+ const dest = path11.join(targetDir, subDir);
4565
+ const remainingLimit = globalLimit !== void 0 ? Math.max(0, globalLimit - totalDownloaded) : void 0;
4566
+ if (remainingLimit === 0) {
4567
+ return { success: true, downloaded: 0, skipped: 0 };
4568
+ }
4192
4569
  try {
4193
4570
  const extractor = getExtractor(profile.platform);
4571
+ const subDirKey = subDir === "All" ? "timeline" : subDir.toLowerCase();
4572
+ const initialCursor = options.resume ? currentCursors[subDirKey] : void 0;
4194
4573
  const items = extractor.extract(profile, {
4195
4574
  subDir,
4196
4575
  cookiePath: cookieCopy?.filePath,
4197
- signal: options.signal
4576
+ signal: options.signal,
4577
+ since: sinceSeconds,
4578
+ until: untilSeconds,
4579
+ limit: remainingLimit,
4580
+ initialCursor,
4581
+ onCheckpoint: async (key, val) => {
4582
+ if (val) {
4583
+ currentCursors[key] = val;
4584
+ } else {
4585
+ delete currentCursors[key];
4586
+ }
4587
+ await saveCheckpoint(targetDir, {
4588
+ version: 1,
4589
+ platform: profile.platform,
4590
+ username: profile.username,
4591
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4592
+ cursors: currentCursors,
4593
+ completed: Object.keys(currentCursors).length === 0
4594
+ });
4595
+ }
4198
4596
  });
4199
- const res = await downloadMediaStream(items, {
4597
+ const filtered = filterMediaStream(items, {
4598
+ since: sinceSeconds,
4599
+ until: untilSeconds,
4600
+ limit: remainingLimit
4601
+ });
4602
+ const res = await downloadMediaStream(filtered, {
4200
4603
  jobName: `${profile.username} ${subDir}`,
4201
4604
  destDir: dest,
4605
+ destDirMap: subDir === "All" ? {
4606
+ photo: path11.join(targetDir, "Photos"),
4607
+ video: path11.join(targetDir, "Videos")
4608
+ } : void 0,
4202
4609
  cookiePath: cookieCopy?.filePath,
4203
4610
  referer: REFERER_MAP[profile.platform],
4204
4611
  signal: options.signal,
4612
+ limit: remainingLimit,
4613
+ writeMetadata: options.writeMetadata,
4614
+ defaultPlatform: profile.platform,
4615
+ defaultAuthor: profile.username,
4205
4616
  onProgress
4206
4617
  });
4207
4618
  return {
@@ -4227,6 +4638,7 @@ async function dispatchDownload(options) {
4227
4638
  subDir,
4228
4639
  cookiePath: cookieCopy?.filePath,
4229
4640
  signal: options.signal,
4641
+ writeMetadata: options.writeMetadata,
4230
4642
  onProgress
4231
4643
  }));
4232
4644
  const runYt = options.runnerOverrides?.ytRunner ?? (() => runYtDlp({
@@ -4234,11 +4646,54 @@ async function dispatchDownload(options) {
4234
4646
  targetDir,
4235
4647
  cookiePath: cookieCopy?.filePath,
4236
4648
  signal: options.signal,
4649
+ writeMetadata: options.writeMetadata,
4237
4650
  onProgress
4238
4651
  }));
4239
4652
  for (const job of jobs) {
4240
4653
  if (options.signal?.aborted) break;
4241
- if (job.type === "photos" || job.type === "stories" || job.type === "highlights") {
4654
+ if (globalLimit !== void 0 && totalDownloaded >= globalLimit) break;
4655
+ if (job.type === "all") {
4656
+ let res = await runNative("All");
4657
+ if ((!res.success || res.downloaded === 0 && res.skipped === 0) && !options.signal?.aborted) {
4658
+ onProgress?.({
4659
+ job: "Photos",
4660
+ downloadedCount: totalDownloaded,
4661
+ skippedCount: totalSkipped,
4662
+ statusText: "switching to gallery-dl engine for photos\u2026"
4663
+ });
4664
+ const gdlPhotos = await runGdl("Photos");
4665
+ totalDownloaded += gdlPhotos.downloaded;
4666
+ totalSkipped += gdlPhotos.skipped;
4667
+ if (!gdlPhotos.success && gdlPhotos.error) errors.push(`[Photos] ${gdlPhotos.error}`);
4668
+ onProgress?.({
4669
+ job: "Videos",
4670
+ downloadedCount: totalDownloaded,
4671
+ skippedCount: totalSkipped,
4672
+ statusText: "switching to yt-dlp engine for videos\u2026"
4673
+ });
4674
+ const ytVideos = await runYt();
4675
+ if (ytVideos.downloaded > 0 || ytVideos.skipped > 0 || ytVideos.success) {
4676
+ totalDownloaded += ytVideos.downloaded;
4677
+ totalSkipped += ytVideos.skipped;
4678
+ if (!ytVideos.success && ytVideos.error) errors.push(`[Videos] ${ytVideos.error}`);
4679
+ } else if (!options.signal?.aborted) {
4680
+ onProgress?.({
4681
+ job: "Videos",
4682
+ downloadedCount: totalDownloaded,
4683
+ skippedCount: totalSkipped,
4684
+ statusText: "switching to gallery-dl engine for videos\u2026"
4685
+ });
4686
+ const gdlVideos = await runGdl("Videos");
4687
+ totalDownloaded += gdlVideos.downloaded;
4688
+ totalSkipped += gdlVideos.skipped;
4689
+ if (!gdlVideos.success && gdlVideos.error) errors.push(`[Videos] ${gdlVideos.error}`);
4690
+ }
4691
+ } else {
4692
+ totalDownloaded += res.downloaded;
4693
+ totalSkipped += res.skipped;
4694
+ if (!res.success && res.error) errors.push(`[All] ${res.error}`);
4695
+ }
4696
+ } else if (job.type === "photos" || job.type === "stories" || job.type === "highlights") {
4242
4697
  const subDir = job.type === "photos" ? "Photos" : job.type === "stories" ? "Stories" : "Highlights";
4243
4698
  let res = await runNative(subDir);
4244
4699
  if ((!res.success || res.downloaded === 0 && res.skipped === 0) && !options.signal?.aborted) {
@@ -4288,6 +4743,9 @@ async function dispatchDownload(options) {
4288
4743
  }
4289
4744
  }
4290
4745
  }
4746
+ if (errors.length === 0 && !options.signal?.aborted) {
4747
+ await clearCheckpoint(targetDir);
4748
+ }
4291
4749
  return {
4292
4750
  success: errors.length === 0 || totalDownloaded > 0,
4293
4751
  downloadedCount: totalDownloaded,
@@ -4325,7 +4783,7 @@ function readClipboard(execFn = execFileSync2) {
4325
4783
  // src/lib/reveal.ts
4326
4784
  import fs12 from "fs";
4327
4785
  import { spawn as spawn4 } from "child_process";
4328
- import path11 from "path";
4786
+ import path12 from "path";
4329
4787
  function buildOpenBrowserCommand(url, platform = process.platform) {
4330
4788
  try {
4331
4789
  const parsed = new URL(url.trim());
@@ -4352,7 +4810,7 @@ function openBrowser(url) {
4352
4810
  }
4353
4811
  }
4354
4812
  function getRevealCommand(targetPath, platform = process.platform, isDirectory) {
4355
- const pathModule = platform === "win32" ? path11.win32 : platform === "darwin" ? path11.posix : path11;
4813
+ const pathModule = platform === "win32" ? path12.win32 : platform === "darwin" ? path12.posix : path12;
4356
4814
  const resolved = pathModule.resolve(targetPath);
4357
4815
  const isDir = isDirectory !== void 0 ? isDirectory : (() => {
4358
4816
  try {
@@ -4485,6 +4943,11 @@ function App({
4485
4943
  initialChoice,
4486
4944
  initialThemeMode = "auto",
4487
4945
  outputDir,
4946
+ since,
4947
+ until,
4948
+ limit,
4949
+ resume,
4950
+ writeMetadata,
4488
4951
  version = "1.0.0"
4489
4952
  }) {
4490
4953
  const [themeMode, setThemeMode] = useState6(initialThemeMode);
@@ -4497,6 +4960,11 @@ function App({
4497
4960
  initialInput,
4498
4961
  initialChoice,
4499
4962
  outputDir,
4963
+ since,
4964
+ until,
4965
+ limit,
4966
+ resume,
4967
+ writeMetadata,
4500
4968
  version,
4501
4969
  cycleTheme
4502
4970
  }
@@ -4506,6 +4974,11 @@ function InnerApp({
4506
4974
  initialInput,
4507
4975
  initialChoice,
4508
4976
  outputDir,
4977
+ since,
4978
+ until,
4979
+ limit,
4980
+ resume,
4981
+ writeMetadata,
4509
4982
  version = "1.0.0",
4510
4983
  cycleTheme
4511
4984
  }) {
@@ -4572,6 +5045,11 @@ function InnerApp({
4572
5045
  profile,
4573
5046
  choice,
4574
5047
  outputDir: targetDir,
5048
+ since,
5049
+ until,
5050
+ limit,
5051
+ resume,
5052
+ writeMetadata,
4575
5053
  signal: controller.signal,
4576
5054
  onProgress: (progress) => {
4577
5055
  if (!controller.signal.aborted) {
@@ -4599,7 +5077,7 @@ function InnerApp({
4599
5077
  hint: "Check your internet connection or verify the profile exists."
4600
5078
  });
4601
5079
  }
4602
- }, [outputDir]);
5080
+ }, [outputDir, since, until, limit, resume, writeMetadata]);
4603
5081
  const handlePlatformSelect = useCallback2((platform, username, rawInput) => {
4604
5082
  const canonical = buildCanonicalProfile(platform, username, rawInput);
4605
5083
  setPhase({ name: "probing", target: canonical.cleanUrl, status: "probing profile\u2026" });
@@ -5086,6 +5564,198 @@ function generateCompletion(shell) {
5086
5564
  }
5087
5565
  }
5088
5566
 
5567
+ // src/lib/watcher.ts
5568
+ function startClipboardWatcher(options = {}) {
5569
+ const choice = options.choice || "all";
5570
+ const intervalMs = options.intervalMs ?? 1e3;
5571
+ const readClip = options.readClipboardFn ?? readClipboard;
5572
+ const dispatch = options.dispatchFn ?? dispatchDownload;
5573
+ let isRunning = true;
5574
+ let lastClipboardText = "";
5575
+ const processedUrls = /* @__PURE__ */ new Set();
5576
+ let timer = null;
5577
+ let resolvePromise = null;
5578
+ const stop = () => {
5579
+ if (!isRunning && !timer && !resolvePromise) return;
5580
+ isRunning = false;
5581
+ if (timer) {
5582
+ clearTimeout(timer);
5583
+ timer = null;
5584
+ }
5585
+ if (resolvePromise) {
5586
+ const resolve = resolvePromise;
5587
+ resolvePromise = null;
5588
+ resolve();
5589
+ }
5590
+ };
5591
+ if (options.signal) {
5592
+ if (options.signal.aborted) {
5593
+ isRunning = false;
5594
+ } else {
5595
+ options.signal.addEventListener("abort", stop, { once: true });
5596
+ }
5597
+ }
5598
+ const promise = new Promise((resolve) => {
5599
+ resolvePromise = resolve;
5600
+ if (!isRunning || options.signal?.aborted) {
5601
+ resolvePromise = null;
5602
+ resolve();
5603
+ return;
5604
+ }
5605
+ const loop = async () => {
5606
+ if (!isRunning || options.signal?.aborted) {
5607
+ stop();
5608
+ return;
5609
+ }
5610
+ try {
5611
+ const text = readClip().trim();
5612
+ if (text && text !== lastClipboardText) {
5613
+ lastClipboardText = text;
5614
+ const parsed = parseProfileInput(text);
5615
+ if (parsed.platform !== "unknown" && parsed.cleanUrl && !processedUrls.has(parsed.cleanUrl)) {
5616
+ processedUrls.add(parsed.cleanUrl);
5617
+ options.onDetected?.(parsed);
5618
+ try {
5619
+ const res = await dispatch({
5620
+ profile: parsed,
5621
+ choice,
5622
+ outputDir: options.outputDir,
5623
+ writeMetadata: options.writeMetadata,
5624
+ signal: options.signal,
5625
+ onProgress: (p) => {
5626
+ if (p.statusText) {
5627
+ options.onProgress?.(parsed, p.statusText);
5628
+ }
5629
+ }
5630
+ });
5631
+ options.onComplete?.(parsed, res);
5632
+ } catch (err) {
5633
+ const error = err instanceof Error ? err : new Error(String(err));
5634
+ options.onError?.(parsed, error);
5635
+ }
5636
+ }
5637
+ }
5638
+ } catch {
5639
+ }
5640
+ if (isRunning && !options.signal?.aborted) {
5641
+ timer = setTimeout(loop, intervalMs);
5642
+ } else {
5643
+ stop();
5644
+ }
5645
+ };
5646
+ loop();
5647
+ });
5648
+ return { stop, promise };
5649
+ }
5650
+
5651
+ // src/lib/batch.ts
5652
+ import fsPromises5 from "fs/promises";
5653
+ function parseBatchContent(content) {
5654
+ const lines = content.split(/\r?\n/);
5655
+ const result = [];
5656
+ for (const rawLine of lines) {
5657
+ let line = rawLine.trim();
5658
+ if (!line || line.startsWith("#") || line.startsWith("//")) {
5659
+ continue;
5660
+ }
5661
+ const hashIdx = line.indexOf(" #");
5662
+ if (hashIdx !== -1) {
5663
+ line = line.slice(0, hashIdx).trim();
5664
+ }
5665
+ const slashIdx = line.indexOf(" //");
5666
+ if (slashIdx !== -1) {
5667
+ line = line.slice(0, slashIdx).trim();
5668
+ }
5669
+ if (line) {
5670
+ result.push(line);
5671
+ }
5672
+ }
5673
+ return result;
5674
+ }
5675
+ async function loadBatchFile(filePath) {
5676
+ const content = await fsPromises5.readFile(filePath, "utf-8");
5677
+ return parseBatchContent(content);
5678
+ }
5679
+ async function runBatchQueue(options) {
5680
+ const rawEntries = options.lines ?? (options.filePath ? await loadBatchFile(options.filePath) : []);
5681
+ const choice = options.choice || "all";
5682
+ const minDelay = options.minDelayMs ?? 2e3;
5683
+ const maxDelay = options.maxDelayMs ?? 5e3;
5684
+ const items = rawEntries.map((raw) => ({
5685
+ raw,
5686
+ profile: parseProfileInput(raw),
5687
+ status: "pending",
5688
+ downloaded: 0,
5689
+ skipped: 0
5690
+ }));
5691
+ let totalDownloaded = 0;
5692
+ let totalSkipped = 0;
5693
+ let succeeded = 0;
5694
+ let failed = 0;
5695
+ for (let i = 0; i < items.length; i++) {
5696
+ if (options.signal?.aborted) {
5697
+ break;
5698
+ }
5699
+ const item = items[i];
5700
+ item.status = "running";
5701
+ if (item.profile.platform === "unknown" || !item.profile.cleanUrl) {
5702
+ item.status = "failed";
5703
+ item.error = `Invalid profile URL or unsupported username: ${item.raw}`;
5704
+ failed++;
5705
+ options.onAccountError?.(item, new Error(item.error));
5706
+ continue;
5707
+ }
5708
+ options.onAccountStart?.(item, i, items.length);
5709
+ try {
5710
+ const res = await dispatchDownload({
5711
+ profile: item.profile,
5712
+ choice,
5713
+ outputDir: options.outputDir,
5714
+ since: options.since,
5715
+ until: options.until,
5716
+ limit: options.limit,
5717
+ resume: options.resume,
5718
+ writeMetadata: options.writeMetadata,
5719
+ signal: options.signal,
5720
+ runnerOverrides: options.runnerOverrides,
5721
+ onProgress: (progress) => {
5722
+ options.onAccountProgress?.(item, progress);
5723
+ }
5724
+ });
5725
+ item.downloaded = res.downloadedCount;
5726
+ item.skipped = res.skippedCount;
5727
+ totalDownloaded += res.downloadedCount;
5728
+ totalSkipped += res.skippedCount;
5729
+ if (res.success) {
5730
+ item.status = "success";
5731
+ succeeded++;
5732
+ } else {
5733
+ item.status = "failed";
5734
+ item.error = res.errors.join("; ") || "Download failed";
5735
+ failed++;
5736
+ }
5737
+ options.onAccountComplete?.(item, i, items.length);
5738
+ } catch (err) {
5739
+ const errMsg = err instanceof Error ? err.message : String(err);
5740
+ item.status = "failed";
5741
+ item.error = errMsg;
5742
+ failed++;
5743
+ options.onAccountError?.(item, err instanceof Error ? err : new Error(errMsg));
5744
+ }
5745
+ if (i < items.length - 1 && !options.signal?.aborted) {
5746
+ await sleep(minDelay, maxDelay, options.signal);
5747
+ }
5748
+ }
5749
+ return {
5750
+ total: items.length,
5751
+ succeeded,
5752
+ failed,
5753
+ totalDownloaded,
5754
+ totalSkipped,
5755
+ items
5756
+ };
5757
+ }
5758
+
5089
5759
  // src/cli.tsx
5090
5760
  import { jsx as jsx17 } from "react/jsx-runtime";
5091
5761
  var resolveVersion = () => {
@@ -5116,6 +5786,8 @@ var HELP = `
5116
5786
  -h, --help Show this help message
5117
5787
  -v, --version Show version
5118
5788
  -o, --output Set custom output directory
5789
+ -w, --watch Watch clipboard for profile links and auto-download
5790
+ -b, --batch <file> Download multiple profiles listed in a text file (one URL/user per line)
5119
5791
  -U, --update Update bundled engines (yt-dlp and gallery-dl) to latest release
5120
5792
  --update-ytdlp Update only bundled yt-dlp binary
5121
5793
  --update-gallerydl Update only bundled gallery-dl binary
@@ -5125,6 +5797,11 @@ var HELP = `
5125
5797
  --stories Download stories only (Instagram)
5126
5798
  --highlights Download highlights only (Instagram)
5127
5799
  --all Download all available media
5800
+ --since <date> Filter posts on or after date (YYYY-MM-DD or 7d, 30d)
5801
+ --until <date> Filter posts on or before date (YYYY-MM-DD)
5802
+ -l, --limit <n> Limit maximum items to download
5803
+ --resume Resume download from saved pagination checkpoint
5804
+ --write-metadata Save post metadata & captions to sidecar .json files
5128
5805
  --completion Generate shell completion script (bash, zsh, fish, powershell)
5129
5806
 
5130
5807
  Hotkeys in TUI:
@@ -5211,12 +5888,21 @@ async function main() {
5211
5888
  let initialChoice;
5212
5889
  let outputDir;
5213
5890
  let themeMode = "auto";
5891
+ let isWatchMode = false;
5892
+ let batchFile;
5893
+ let since;
5894
+ let until;
5895
+ let limit;
5896
+ let isResume = false;
5897
+ let writeMetadata = false;
5214
5898
  for (let i = 0; i < args.length; i++) {
5215
5899
  const arg = args[i];
5216
5900
  if (arg === "-o" || arg === "--output") {
5217
5901
  outputDir = args[++i];
5218
5902
  } else if (arg.startsWith("--output=")) {
5219
5903
  outputDir = arg.slice("--output=".length);
5904
+ } else if (arg === "-w" || arg === "--watch") {
5905
+ isWatchMode = true;
5220
5906
  } else if (arg === "--theme") {
5221
5907
  themeMode = args[++i];
5222
5908
  } else if (arg.startsWith("--theme=")) {
@@ -5231,10 +5917,125 @@ async function main() {
5231
5917
  initialChoice = "highlights";
5232
5918
  } else if (arg === "--all") {
5233
5919
  initialChoice = "all";
5920
+ } else if (arg === "--since") {
5921
+ since = args[++i];
5922
+ } else if (arg.startsWith("--since=")) {
5923
+ since = arg.slice("--since=".length);
5924
+ } else if (arg === "--until") {
5925
+ until = args[++i];
5926
+ } else if (arg.startsWith("--until=")) {
5927
+ until = arg.slice("--until=".length);
5928
+ } else if (arg === "-l" || arg === "--limit") {
5929
+ const parsed = parseInt(args[++i], 10);
5930
+ if (!isNaN(parsed) && parsed > 0) limit = parsed;
5931
+ } else if (arg.startsWith("--limit=")) {
5932
+ const parsed = parseInt(arg.slice("--limit=".length), 10);
5933
+ if (!isNaN(parsed) && parsed > 0) limit = parsed;
5934
+ } else if (arg === "--resume") {
5935
+ isResume = true;
5936
+ } else if (arg === "--write-metadata") {
5937
+ writeMetadata = true;
5938
+ } else if (arg === "-b" || arg === "--batch") {
5939
+ batchFile = args[++i];
5940
+ } else if (arg.startsWith("--batch=")) {
5941
+ batchFile = arg.slice("--batch=".length);
5234
5942
  } else if (!arg.startsWith("-") && !initialInput) {
5235
5943
  initialInput = arg;
5236
5944
  }
5237
5945
  }
5946
+ if (isWatchMode) {
5947
+ const choice = initialChoice || "all";
5948
+ console.log(`
5949
+ \x1B[36mOpen Nami\x1B[0m \u2014 Clipboard Watcher Active (v${VERSION})`);
5950
+ console.log(` \x1B[90mTarget media:\x1B[0m \x1B[33m${choice}\x1B[0m`);
5951
+ if (outputDir) console.log(` \x1B[90mOutput directory:\x1B[0m ${outputDir}`);
5952
+ console.log(` \x1B[90mMonitoring clipboard for Instagram, TikTok, Facebook & X profiles\u2026\x1B[0m`);
5953
+ console.log(` \x1B[90mPress Ctrl+C to stop.\x1B[0m
5954
+ `);
5955
+ const controller = new AbortController();
5956
+ process.on("SIGINT", () => {
5957
+ console.log("\n[open-nami] Stopping clipboard watcher\u2026");
5958
+ controller.abort();
5959
+ process.exit(0);
5960
+ });
5961
+ const { promise } = startClipboardWatcher({
5962
+ choice,
5963
+ outputDir,
5964
+ writeMetadata,
5965
+ signal: controller.signal,
5966
+ onDetected: (p) => {
5967
+ console.log(`
5968
+ \u276F \x1B[32mDetected:\x1B[0m @${p.username} on \x1B[35m${p.platform}\x1B[0m`);
5969
+ console.log(` \x1B[90mDownloading ${choice}\u2026\x1B[0m`);
5970
+ },
5971
+ onProgress: (_p, statusText) => {
5972
+ process.stdout.write(`\r \x1B[90m\u2192 ${statusText.slice(0, 70)}\x1B[0m\x1B[K`);
5973
+ },
5974
+ onComplete: (p, res) => {
5975
+ process.stdout.write("\r\x1B[K");
5976
+ console.log(`\u2713 \x1B[32mFinished @${p.username}:\x1B[0m ${res.downloadedCount} downloaded, ${res.skippedCount} skipped`);
5977
+ if (res.outputDir) console.log(` \x1B[90mSaved to:\x1B[0m ${res.outputDir}`);
5978
+ },
5979
+ onError: (p, err) => {
5980
+ process.stdout.write("\r\x1B[K");
5981
+ console.error(`\u2717 \x1B[31mError downloading @${p.username}:\x1B[0m ${err.message}`);
5982
+ }
5983
+ });
5984
+ await promise;
5985
+ process.exit(0);
5986
+ }
5987
+ if (batchFile) {
5988
+ const choice = initialChoice || "all";
5989
+ console.log(`
5990
+ \x1B[36mOpen Nami\x1B[0m \u2014 Batch Download Queue (v${VERSION})`);
5991
+ console.log(` \x1B[90mBatch file:\x1B[0m ${batchFile}`);
5992
+ console.log(` \x1B[90mTarget media:\x1B[0m \x1B[33m${choice}\x1B[0m`);
5993
+ if (outputDir) console.log(` \x1B[90mOutput directory:\x1B[0m ${outputDir}`);
5994
+ if (limit) console.log(` \x1B[90mPer-account limit:\x1B[0m ${limit}`);
5995
+ console.log();
5996
+ const controller = new AbortController();
5997
+ process.on("SIGINT", () => {
5998
+ console.log("\n[open-nami] Aborting batch queue\u2026");
5999
+ controller.abort();
6000
+ });
6001
+ try {
6002
+ const result = await runBatchQueue({
6003
+ filePath: batchFile,
6004
+ choice,
6005
+ outputDir,
6006
+ since,
6007
+ until,
6008
+ limit,
6009
+ resume: isResume,
6010
+ writeMetadata,
6011
+ signal: controller.signal,
6012
+ onAccountStart: (item, idx, total) => {
6013
+ console.log(`\x1B[36m[${idx + 1}/${total}]\x1B[0m \x1B[1m@${item.profile.username}\x1B[0m on \x1B[35m${item.profile.platform}\x1B[0m\u2026`);
6014
+ },
6015
+ onAccountProgress: (_item, progress) => {
6016
+ if (progress.currentFile) {
6017
+ process.stdout.write(`\r \x1B[90m\u2192 [${progress.job}] ${progress.currentFile.slice(0, 60)}\x1B[0m\x1B[K`);
6018
+ }
6019
+ },
6020
+ onAccountComplete: (item) => {
6021
+ process.stdout.write("\r\x1B[K");
6022
+ console.log(` \x1B[32m\u2713 @${item.profile.username}:\x1B[0m ${item.downloaded} downloaded, ${item.skipped} skipped
6023
+ `);
6024
+ },
6025
+ onAccountError: (item, err) => {
6026
+ process.stdout.write("\r\x1B[K");
6027
+ console.error(` \x1B[31m\u2717 @${item.profile.username || item.raw}:\x1B[0m ${err.message}
6028
+ `);
6029
+ }
6030
+ });
6031
+ console.log(`\x1B[1mBatch completed:\x1B[0m ${result.succeeded} succeeded, ${result.failed} failed of ${result.total} total accounts.`);
6032
+ console.log(`\x1B[32mTotal media downloaded:\x1B[0m ${result.totalDownloaded} items (${result.totalSkipped} skipped).`);
6033
+ process.exit(result.failed > 0 ? 1 : 0);
6034
+ } catch (err) {
6035
+ console.error(`open-nami batch error: ${err.message || String(err)}`);
6036
+ process.exit(1);
6037
+ }
6038
+ }
5238
6039
  const isTTY = Boolean(process.stdout.isTTY);
5239
6040
  if (!isTTY && initialChoice && initialInput) {
5240
6041
  const parsed = parseProfileInput(initialInput);
@@ -5248,6 +6049,11 @@ async function main() {
5248
6049
  profile: parsed,
5249
6050
  choice: initialChoice,
5250
6051
  outputDir,
6052
+ since,
6053
+ until,
6054
+ limit,
6055
+ resume: isResume,
6056
+ writeMetadata,
5251
6057
  onProgress: (progress) => {
5252
6058
  if (progress.currentFile) {
5253
6059
  console.log(`[open-nami] [${progress.job}] ${progress.currentFile}`);
@@ -5281,6 +6087,11 @@ async function main() {
5281
6087
  initialChoice,
5282
6088
  initialThemeMode: themeMode,
5283
6089
  outputDir,
6090
+ since,
6091
+ until,
6092
+ limit,
6093
+ resume: isResume,
6094
+ writeMetadata,
5284
6095
  version: VERSION
5285
6096
  }
5286
6097
  ),