open-nami 1.0.6 → 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 +980 -187
  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.0.6",
12
+ version: "1.1.0",
13
13
  description: "Open Nami: terminal-first bulk social media profile downloader for Instagram, TikTok, Facebook & X.",
14
14
  type: "module",
15
15
  bin: {
@@ -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,6 +2175,10 @@ 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) {
@@ -2246,10 +2275,6 @@ var InstagramExtractor = class {
2246
2275
  "Referer": "https://www.instagram.com/"
2247
2276
  }
2248
2277
  });
2249
- if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2250
- const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
2251
- throw new Error(`Instagram GraphQL query error: ${errMsg}`);
2252
- }
2253
2278
  const reelsData = res?.data?.fetch__XDTUserDict;
2254
2279
  let connection = reelsData?.reels;
2255
2280
  if (!connection && reelsData) {
@@ -2260,13 +2285,20 @@ var InstagramExtractor = class {
2260
2285
  }
2261
2286
  }
2262
2287
  }
2263
- 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
+ }
2264
2292
  return {
2265
2293
  edges: connection.edges,
2266
2294
  hasNextPage: Boolean(connection.page_info?.has_next_page),
2267
2295
  nextCursor: connection.page_info?.end_cursor
2268
2296
  };
2269
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
+ }
2270
2302
  } catch (err) {
2271
2303
  logExtractorDebug("PolarisProfileReelsTabContentQuery_connection", err);
2272
2304
  }
@@ -2350,7 +2382,19 @@ var InstagramExtractor = class {
2350
2382
  let currentEdges = user?.edge_owner_to_timeline_media?.edges ?? [];
2351
2383
  let hasNextPage = Boolean(user?.edge_owner_to_timeline_media?.page_info?.has_next_page);
2352
2384
  let endCursor = user?.edge_owner_to_timeline_media?.page_info?.end_cursor;
2353
- 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) {
2354
2398
  try {
2355
2399
  const firstPage = await this.fetchPostsTabGraphQL(username, void 0, webTokens, options);
2356
2400
  currentEdges = firstPage.edges;
@@ -2364,11 +2408,17 @@ var InstagramExtractor = class {
2364
2408
  }
2365
2409
  }
2366
2410
  while (true) {
2411
+ let reachedSinceLimit = false;
2367
2412
  for (const edge of currentEdges) {
2368
2413
  const items = parseIgNodeToMediaItems(edge.node, username);
2369
2414
  for (const item of items) {
2370
2415
  if (subDir === "Photos" && item.type !== "photo") continue;
2371
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
+ }
2372
2422
  if (emittedItemIds.has(item.id)) continue;
2373
2423
  emittedItemIds.add(item.id);
2374
2424
  found++;
@@ -2378,8 +2428,11 @@ var InstagramExtractor = class {
2378
2428
  message: `Found item: ${item.filename}`
2379
2429
  });
2380
2430
  yield item;
2431
+ if (options.limit !== void 0 && found >= options.limit) return;
2381
2432
  }
2433
+ if (reachedSinceLimit) break;
2382
2434
  }
2435
+ if (reachedSinceLimit) break;
2383
2436
  if (!hasNextPage || !endCursor || options.signal?.aborted) {
2384
2437
  break;
2385
2438
  }
@@ -2389,6 +2442,9 @@ var InstagramExtractor = class {
2389
2442
  currentEdges = nextPage.edges;
2390
2443
  hasNextPage = nextPage.hasNextPage;
2391
2444
  endCursor = nextPage.nextCursor;
2445
+ if (endCursor) {
2446
+ options.onCheckpoint?.("timeline", endCursor);
2447
+ }
2392
2448
  if (currentEdges.length === 0) {
2393
2449
  break;
2394
2450
  }
@@ -2396,7 +2452,7 @@ var InstagramExtractor = class {
2396
2452
  break;
2397
2453
  }
2398
2454
  }
2399
- if (subDir === "Videos" && userId) {
2455
+ if ((subDir === "Videos" || subDir === "All") && userId) {
2400
2456
  const videoEdges = user?.edge_felix_video_timeline?.edges ?? [];
2401
2457
  for (const edge of videoEdges) {
2402
2458
  const items = parseIgNodeToMediaItems(edge.node, username);
@@ -2513,10 +2569,11 @@ function parseTweetMediaEntities(tweet, username) {
2513
2569
  const entities = legacy.extended_entities?.media || legacy.entities?.media || [];
2514
2570
  const tweetId = tweetResult.rest_id || legacy.id_str || String(Date.now());
2515
2571
  const rawDate = legacy.created_at ? new Date(legacy.created_at).getTime() / 1e3 : void 0;
2572
+ const caption = legacy.full_text || legacy.text;
2516
2573
  for (let idx = 0; idx < entities.length; idx++) {
2517
2574
  const media = entities[idx];
2518
2575
  const subIndex = idx + 1;
2519
- const baseFilename = `${username}_${tweetId}_${subIndex}`;
2576
+ const baseFilename = `${username}_${rawDate ? `${rawDate}_` : ""}${tweetId}_${subIndex}`;
2520
2577
  if (media.type === "video" || media.type === "animated_gif") {
2521
2578
  const variants = (media.video_info?.variants || []).filter((v) => v.content_type === "video/mp4").sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0));
2522
2579
  const bestVariant = variants[0];
@@ -2527,8 +2584,16 @@ function parseTweetMediaEntities(tweet, username) {
2527
2584
  filename: baseFilename,
2528
2585
  extension: "mp4",
2529
2586
  type: "video",
2587
+ caption,
2588
+ author: username,
2589
+ platform: "x",
2530
2590
  date: rawDate,
2531
- 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
+ } : {}
2532
2597
  });
2533
2598
  }
2534
2599
  } else {
@@ -2544,9 +2609,17 @@ function parseTweetMediaEntities(tweet, username) {
2544
2609
  filename: baseFilename,
2545
2610
  extension: ext,
2546
2611
  type: "photo",
2612
+ caption,
2613
+ author: username,
2614
+ platform: "x",
2547
2615
  date: rawDate,
2548
2616
  width: media.original_info?.width,
2549
- 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
+ } : {}
2550
2623
  });
2551
2624
  }
2552
2625
  }
@@ -2674,7 +2747,7 @@ var XExtractor = class {
2674
2747
  });
2675
2748
  const userId = await this.fetchUserRestId(username, options);
2676
2749
  const endpoint = isLikes ? "https://x.com/i/api/graphql/TGEKkJG_meudeaFcqaxM-Q/Likes" : "https://x.com/i/api/graphql/jCRhbOzdgOHp6u9H4g2tEg/UserMedia";
2677
- let cursor;
2750
+ let cursor = options.initialCursor;
2678
2751
  let hasMore = true;
2679
2752
  let found = 0;
2680
2753
  while (hasMore) {
@@ -2721,10 +2794,16 @@ var XExtractor = class {
2721
2794
  if (shouldSkipTweetReply(tweetContent, userId, isLikes ? "likes" : "timeline")) {
2722
2795
  continue;
2723
2796
  }
2797
+ let reachedSinceLimit = false;
2724
2798
  const mediaItems = parseTweetMediaEntities(tweetContent, username);
2725
2799
  for (const item of mediaItems) {
2726
2800
  if (subDir === "Photos" && item.type !== "photo") continue;
2727
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
+ }
2728
2807
  found++;
2729
2808
  pageItemCount++;
2730
2809
  options.onProgress?.({
@@ -2733,14 +2812,21 @@ var XExtractor = class {
2733
2812
  message: `Found item: ${item.filename}`
2734
2813
  });
2735
2814
  yield item;
2815
+ if (options.limit !== void 0 && found >= options.limit) return;
2816
+ }
2817
+ if (reachedSinceLimit) {
2818
+ hasMore = false;
2819
+ break;
2736
2820
  }
2737
2821
  }
2738
2822
  }
2739
2823
  }
2740
2824
  if (!newCursor || newCursor === cursor || pageItemCount === 0) {
2741
2825
  hasMore = false;
2826
+ options.onCheckpoint?.("timeline", "");
2742
2827
  } else {
2743
2828
  cursor = newCursor;
2829
+ options.onCheckpoint?.("timeline", cursor);
2744
2830
  await sleep(1500, 2500, options.signal);
2745
2831
  }
2746
2832
  }
@@ -2843,11 +2929,16 @@ function parseTikTokItem(item, username) {
2843
2929
  items.push({
2844
2930
  id: `${id}_${idx + 1}`,
2845
2931
  url: imgUrl,
2846
- filename: `${username}_${id}_${idx + 1}`,
2932
+ filename: `${username}_${date ? `${date}_` : ""}${id}_${idx + 1}`,
2847
2933
  extension: "jpg",
2848
2934
  type: "photo",
2849
2935
  caption,
2850
- date
2936
+ date,
2937
+ author: username,
2938
+ platform: "tiktok",
2939
+ carouselId: id,
2940
+ carouselIndex: idx + 1,
2941
+ carouselTotal: imageList.length
2851
2942
  });
2852
2943
  }
2853
2944
  });
@@ -2859,14 +2950,16 @@ function parseTikTokItem(item, username) {
2859
2950
  items.push({
2860
2951
  id,
2861
2952
  url: videoUrl,
2862
- filename: `${username}_${id}`,
2953
+ filename: `${username}_${date ? `${date}_` : ""}${id}`,
2863
2954
  extension: "mp4",
2864
2955
  type: "video",
2865
2956
  caption,
2866
2957
  date,
2867
2958
  width: video?.width,
2868
2959
  height: video?.height,
2869
- thumbnailUrl: video?.cover || video?.originCover
2960
+ thumbnailUrl: video?.cover || video?.originCover,
2961
+ author: username,
2962
+ platform: "tiktok"
2870
2963
  });
2871
2964
  }
2872
2965
  return items;
@@ -2918,7 +3011,7 @@ var TikTokExtractor = class {
2918
3011
  if (!secUid) {
2919
3012
  return;
2920
3013
  }
2921
- let cursor = "0";
3014
+ let cursor = options.initialCursor || "0";
2922
3015
  let hasMore = true;
2923
3016
  const visitedCursors = /* @__PURE__ */ new Set();
2924
3017
  while (hasMore) {
@@ -2953,11 +3046,17 @@ var TikTokExtractor = class {
2953
3046
  }
2954
3047
  const items = res?.itemList || [];
2955
3048
  if (items.length === 0) break;
3049
+ let reachedSinceLimit = false;
2956
3050
  for (const rawItem of items) {
2957
3051
  const mediaList = parseTikTokItem(rawItem, cleanUsername);
2958
3052
  for (const item of mediaList) {
2959
3053
  if (subDir === "Photos" && item.type !== "photo") continue;
2960
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
+ }
2961
3060
  found++;
2962
3061
  options.onProgress?.({
2963
3062
  stage: subDir,
@@ -2965,10 +3064,18 @@ var TikTokExtractor = class {
2965
3064
  message: `Found item: ${item.filename}`
2966
3065
  });
2967
3066
  yield item;
3067
+ if (options.limit !== void 0 && found >= options.limit) return;
2968
3068
  }
3069
+ if (reachedSinceLimit) break;
2969
3070
  }
3071
+ if (reachedSinceLimit) break;
2970
3072
  hasMore = Boolean(res?.hasMore);
2971
3073
  cursor = String(res?.cursor ?? "0");
3074
+ if (hasMore && cursor && cursor !== "0") {
3075
+ options.onCheckpoint?.("timeline", cursor);
3076
+ } else {
3077
+ options.onCheckpoint?.("timeline", "");
3078
+ }
2972
3079
  await sleep(1e3, 2e3, options.signal);
2973
3080
  }
2974
3081
  }
@@ -3148,119 +3255,122 @@ var FacebookExtractor = class {
3148
3255
  found: 0,
3149
3256
  message: `Fetching Facebook profile @${username}...`
3150
3257
  });
3151
- const targetPath = subDir === "Photos" ? "photos" : "videos";
3152
- const desktopUrl = `https://www.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3153
- const mbasicUrl = `https://mbasic.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3154
- const mobileUrl = `https://m.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3155
- let currentUrl = desktopUrl;
3156
- let html = "";
3157
- try {
3158
- html = await this.httpClient.fetchText(desktopUrl, {
3159
- cookiePath: options.cookiePath,
3160
- cookieDomain: "facebook.com",
3161
- signal: options.signal,
3162
- retries: 0,
3163
- timeoutMs: 4e3
3164
- });
3165
- } catch {
3166
- html = "";
3167
- }
3168
- let initialItems = extractFacebookMediaFromHtml(html, username);
3169
- if (initialItems.length === 0) {
3170
- options.onProgress?.({
3171
- stage: "Profile",
3172
- found: 0,
3173
- message: `Desktop yielded 0 items. Falling back to mbasic.facebook.com for @${username}...`
3174
- });
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 = "";
3175
3268
  try {
3176
- const mbasicHtml = await this.httpClient.fetchText(mbasicUrl, {
3269
+ html = await this.httpClient.fetchText(desktopUrl, {
3177
3270
  cookiePath: options.cookiePath,
3178
3271
  cookieDomain: "facebook.com",
3179
3272
  signal: options.signal,
3180
- retries: 1,
3181
- timeoutMs: 5e3,
3182
- headers: {
3183
- "Referer": "https://mbasic.facebook.com/"
3184
- }
3273
+ retries: 0,
3274
+ timeoutMs: 4e3
3185
3275
  });
3186
- const mbasicItems = extractFacebookMediaFromHtml(mbasicHtml, username);
3187
- if (mbasicItems.length > 0 || mbasicHtml.length > 200) {
3188
- html = mbasicHtml;
3189
- currentUrl = mbasicUrl;
3190
- initialItems = mbasicItems;
3191
- }
3192
3276
  } catch {
3277
+ html = "";
3193
3278
  }
3279
+ let initialItems = extractFacebookMediaFromHtml(html, username);
3194
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
+ });
3195
3286
  try {
3196
- const mobileHtml = await this.httpClient.fetchText(mobileUrl, {
3287
+ const mbasicHtml = await this.httpClient.fetchText(mbasicUrl, {
3197
3288
  cookiePath: options.cookiePath,
3198
3289
  cookieDomain: "facebook.com",
3199
3290
  signal: options.signal,
3200
- retries: 0,
3291
+ retries: 1,
3201
3292
  timeoutMs: 5e3,
3202
3293
  headers: {
3203
- "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
3204
- "Referer": "https://m.facebook.com/"
3294
+ "Referer": "https://mbasic.facebook.com/"
3205
3295
  }
3206
3296
  });
3207
- const mobileItems = extractFacebookMediaFromHtml(mobileHtml, username);
3208
- if (mobileItems.length > 0) {
3209
- html = mobileHtml;
3210
- currentUrl = mobileUrl;
3211
- 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;
3212
3302
  }
3213
3303
  } catch {
3214
3304
  }
3215
- }
3216
- }
3217
- const seenUrls = /* @__PURE__ */ new Set();
3218
- let found = 0;
3219
- let page = 1;
3220
- const maxPages = 5;
3221
- while (true) {
3222
- const items = extractFacebookMediaFromHtml(html, username);
3223
- let pageNewCount = 0;
3224
- for (const item of items) {
3225
- if (subDir === "Photos" && item.type !== "photo") continue;
3226
- if (subDir === "Videos" && item.type !== "video") continue;
3227
- if (seenUrls.has(item.url)) continue;
3228
- seenUrls.add(item.url);
3229
- found++;
3230
- pageNewCount++;
3231
- options.onProgress?.({
3232
- stage: subDir,
3233
- found,
3234
- message: `Found item: ${item.filename}`
3235
- });
3236
- yield item;
3237
- }
3238
- if (page >= maxPages || options.signal?.aborted) {
3239
- break;
3240
- }
3241
- const nextUrl = extractFacebookNextPageUrl(html, currentUrl);
3242
- if (!nextUrl || nextUrl === currentUrl) {
3243
- break;
3244
- }
3245
- if (pageNewCount === 0 && page > 1) {
3246
- break;
3247
- }
3248
- await sleep(1e3, 2e3, options.signal);
3249
- try {
3250
- currentUrl = nextUrl;
3251
- html = await this.httpClient.fetchText(currentUrl, {
3252
- cookiePath: options.cookiePath,
3253
- cookieDomain: "facebook.com",
3254
- signal: options.signal,
3255
- retries: 1,
3256
- timeoutMs: 5e3,
3257
- headers: {
3258
- "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 {
3259
3325
  }
3260
- });
3261
- page++;
3262
- } catch {
3263
- 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
+ }
3264
3374
  }
3265
3375
  }
3266
3376
  }
@@ -3284,10 +3394,66 @@ function getExtractor(platform) {
3284
3394
 
3285
3395
  // src/lib/engines/downloader.ts
3286
3396
  import fs6 from "fs";
3287
- import fsPromises2 from "fs/promises";
3397
+ import fsPromises3 from "fs/promises";
3288
3398
  import path5 from "path";
3289
3399
  import { Readable } from "stream";
3290
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
3291
3457
  var ARCHIVE_FILENAME = ".open-nami-archive.json";
3292
3458
  var YTDLP_ARCHIVE_FILENAME = "archive_yt-dlp.txt";
3293
3459
  var TEXT_ARCHIVE_FILENAME = "archive.txt";
@@ -3295,7 +3461,7 @@ async function loadArchive(destDir) {
3295
3461
  const archive = /* @__PURE__ */ new Set();
3296
3462
  const jsonPath = path5.join(destDir, ARCHIVE_FILENAME);
3297
3463
  try {
3298
- const raw = await fsPromises2.readFile(jsonPath, "utf-8");
3464
+ const raw = await fsPromises3.readFile(jsonPath, "utf-8");
3299
3465
  const parsed = JSON.parse(raw);
3300
3466
  if (Array.isArray(parsed)) {
3301
3467
  for (const id of parsed) {
@@ -3306,7 +3472,7 @@ async function loadArchive(destDir) {
3306
3472
  }
3307
3473
  const ytdlpPath = path5.join(destDir, YTDLP_ARCHIVE_FILENAME);
3308
3474
  try {
3309
- const raw = await fsPromises2.readFile(ytdlpPath, "utf-8");
3475
+ const raw = await fsPromises3.readFile(ytdlpPath, "utf-8");
3310
3476
  const lines = raw.split(/\r?\n/);
3311
3477
  for (const rawLine of lines) {
3312
3478
  const line = rawLine.trim();
@@ -3319,7 +3485,7 @@ async function loadArchive(destDir) {
3319
3485
  }
3320
3486
  const textPath = path5.join(destDir, TEXT_ARCHIVE_FILENAME);
3321
3487
  try {
3322
- const raw = await fsPromises2.readFile(textPath, "utf-8");
3488
+ const raw = await fsPromises3.readFile(textPath, "utf-8");
3323
3489
  const lines = raw.split(/\r?\n/);
3324
3490
  for (const rawLine of lines) {
3325
3491
  const line = rawLine.trim();
@@ -3336,8 +3502,8 @@ async function saveArchive(destDir, archive) {
3336
3502
  const archivePath = path5.join(destDir, ARCHIVE_FILENAME);
3337
3503
  const tmpPath = `${archivePath}.tmp`;
3338
3504
  try {
3339
- await fsPromises2.writeFile(tmpPath, JSON.stringify([...archive]), "utf-8");
3340
- await fsPromises2.rename(tmpPath, archivePath);
3505
+ await fsPromises3.writeFile(tmpPath, JSON.stringify([...archive]), "utf-8");
3506
+ await fsPromises3.rename(tmpPath, archivePath);
3341
3507
  } catch {
3342
3508
  }
3343
3509
  const textPaths = [
@@ -3348,7 +3514,7 @@ async function saveArchive(destDir, archive) {
3348
3514
  `).join("");
3349
3515
  for (const tPath of textPaths) {
3350
3516
  try {
3351
- await fsPromises2.writeFile(tPath, content, "utf-8");
3517
+ await fsPromises3.writeFile(tPath, content, "utf-8");
3352
3518
  } catch {
3353
3519
  }
3354
3520
  }
@@ -3377,8 +3543,22 @@ async function downloadMediaStream(items, options) {
3377
3543
  const { jobName, destDir, signal, onProgress } = options;
3378
3544
  const httpClient = options.httpClient ?? new HttpClient();
3379
3545
  const concurrency = Math.max(1, options.concurrency ?? 3);
3380
- await fsPromises2.mkdir(destDir, { recursive: true });
3381
- 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
+ }
3382
3562
  let downloaded = 0;
3383
3563
  let skipped = 0;
3384
3564
  const errors = [];
@@ -3399,6 +3579,10 @@ async function downloadMediaStream(items, options) {
3399
3579
  }
3400
3580
  async function downloadWorker() {
3401
3581
  while (!isDone && !signal?.aborted) {
3582
+ if (options.limit !== void 0 && downloaded >= options.limit) {
3583
+ isDone = true;
3584
+ break;
3585
+ }
3402
3586
  let item;
3403
3587
  try {
3404
3588
  const next = await getNextItem();
@@ -3426,10 +3610,20 @@ async function downloadMediaStream(items, options) {
3426
3610
  }
3427
3611
  continue;
3428
3612
  }
3613
+ const targetDir = options.destDirMap?.[item.type] ?? destDir;
3614
+ const archive = await getArchiveForDir(targetDir);
3429
3615
  const ext = item.extension.replace(/^\./, "") || (item.type === "video" ? "mp4" : "jpg");
3430
3616
  const finalFilename = sanitizeFilename(item.filename, item.id, ext);
3431
- const finalPath = path5.join(destDir, finalFilename);
3617
+ const finalPath = path5.join(targetDir, finalFilename);
3618
+ const sidecarPath = path5.join(targetDir, `${path5.parse(finalFilename).name}.json`);
3432
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
+ }
3433
3627
  skipped++;
3434
3628
  onProgress?.({
3435
3629
  job: jobName,
@@ -3473,14 +3667,24 @@ async function downloadMediaStream(items, options) {
3473
3667
  if (item.date) {
3474
3668
  try {
3475
3669
  const mtime = new Date(item.date * 1e3);
3476
- 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);
3477
3679
  } catch {
3478
3680
  }
3479
3681
  }
3480
- await fsPromises2.rename(tmpPath, finalPath);
3481
3682
  archive.add(item.id);
3482
3683
  downloaded++;
3483
3684
  consecutiveErrors = 0;
3685
+ if (options.limit !== void 0 && downloaded >= options.limit) {
3686
+ isDone = true;
3687
+ }
3484
3688
  onProgress?.({
3485
3689
  job: jobName,
3486
3690
  downloadedCount: downloaded,
@@ -3491,7 +3695,7 @@ async function downloadMediaStream(items, options) {
3491
3695
  } catch (err) {
3492
3696
  try {
3493
3697
  if (fs6.existsSync(tmpPath)) {
3494
- await fsPromises2.unlink(tmpPath);
3698
+ await fsPromises3.unlink(tmpPath);
3495
3699
  }
3496
3700
  } catch {
3497
3701
  }
@@ -3512,33 +3716,172 @@ async function downloadMediaStream(items, options) {
3512
3716
  }
3513
3717
  const workers = Array.from({ length: concurrency }, () => downloadWorker());
3514
3718
  await Promise.all(workers);
3515
- await saveArchive(destDir, archive);
3719
+ for (const [d, arch] of dirArchives.entries()) {
3720
+ await saveArchive(d, arch);
3721
+ }
3516
3722
  return { downloaded, skipped, errors };
3517
3723
  }
3518
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
+
3519
3862
  // src/lib/engines/gallery-dl.ts
3520
3863
  import { spawn as spawn2 } from "child_process";
3521
- import path8 from "path";
3864
+ import path9 from "path";
3522
3865
  import fs9 from "fs/promises";
3523
3866
 
3524
3867
  // src/lib/engines/exec.ts
3525
3868
  import fs8 from "fs";
3526
- import path7 from "path";
3869
+ import path8 from "path";
3527
3870
 
3528
3871
  // src/lib/engines/fetcher.ts
3529
3872
  import { spawn } from "child_process";
3530
3873
  import { createWriteStream } from "fs";
3531
3874
  import fs7 from "fs/promises";
3532
3875
  import os7 from "os";
3533
- import path6 from "path";
3876
+ import path7 from "path";
3534
3877
  import { Readable as Readable2 } from "stream";
3535
3878
  import { pipeline as pipeline2 } from "stream/promises";
3536
3879
  function getOpenNamiBinDir(env = process.env) {
3537
3880
  if (env.OPEN_NAMI_BIN_DIR && env.OPEN_NAMI_BIN_DIR.trim()) {
3538
- return path6.resolve(env.OPEN_NAMI_BIN_DIR.trim());
3881
+ return path7.resolve(env.OPEN_NAMI_BIN_DIR.trim());
3539
3882
  }
3540
3883
  const base = env.OPEN_NAMI_DIR && env.OPEN_NAMI_DIR.trim() ? env.OPEN_NAMI_DIR.trim() : os7.homedir();
3541
- return path6.join(base, ".open-nami", "bin");
3884
+ return path7.join(base, ".open-nami", "bin");
3542
3885
  }
3543
3886
  var YTDLP_RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
3544
3887
  var GALLERYDL_CODEBERG_API = "https://codeberg.org/api/v1/repos/mikf/gallery-dl/releases/latest";
@@ -3589,7 +3932,7 @@ function getBinaryVersion(executablePath) {
3589
3932
  });
3590
3933
  }
3591
3934
  async function downloadBinary(url, targetFile, signal) {
3592
- await fs7.mkdir(path6.dirname(targetFile), { recursive: true });
3935
+ await fs7.mkdir(path7.dirname(targetFile), { recursive: true });
3593
3936
  const response = await fetch(url, {
3594
3937
  signal,
3595
3938
  headers: { "User-Agent": "open-nami" }
@@ -3621,7 +3964,7 @@ async function downloadBinary(url, targetFile, signal) {
3621
3964
  async function downloadLatestYtDlp(targetDir, signal, onStatus) {
3622
3965
  const dir = targetDir || getOpenNamiBinDir();
3623
3966
  const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
3624
- const local = path6.join(dir, binaryName);
3967
+ const local = path7.join(dir, binaryName);
3625
3968
  const asset = ytDlpAssetName();
3626
3969
  const url = `${YTDLP_RELEASE_BASE}/${asset}`;
3627
3970
  onStatus?.(`fetching latest yt-dlp (${asset})\u2026`);
@@ -3657,7 +4000,7 @@ async function downloadLatestGalleryDl(targetDir, signal, onStatus) {
3657
4000
  }
3658
4001
  const dir = targetDir || getOpenNamiBinDir();
3659
4002
  const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
3660
- const local = path6.join(dir, binaryName);
4003
+ const local = path7.join(dir, binaryName);
3661
4004
  const asset = galleryDlAssetName();
3662
4005
  const candidateUrls = await getGalleryDlDownloadUrls(asset, fetch, signal);
3663
4006
  let lastError;
@@ -3675,7 +4018,7 @@ async function downloadLatestGalleryDl(targetDir, signal, onStatus) {
3675
4018
  async function ensureYtDlp(onStatus, signal) {
3676
4019
  if (await commandWorks("yt-dlp", ["--version"])) return "yt-dlp";
3677
4020
  const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
3678
- const local = path6.join(getOpenNamiBinDir(), binaryName);
4021
+ const local = path7.join(getOpenNamiBinDir(), binaryName);
3679
4022
  if (await commandWorks(local, ["--version"])) return local;
3680
4023
  if (await commandWorks("python", ["-m", "yt_dlp", "--version"])) return "python";
3681
4024
  if (await commandWorks("python3", ["-m", "yt_dlp", "--version"])) return "python3";
@@ -3685,7 +4028,7 @@ async function ensureYtDlp(onStatus, signal) {
3685
4028
  async function ensureGalleryDl(onStatus, signal) {
3686
4029
  if (await commandWorks("gallery-dl", ["--version"])) return "gallery-dl";
3687
4030
  const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
3688
- const local = path6.join(getOpenNamiBinDir(), binaryName);
4031
+ const local = path7.join(getOpenNamiBinDir(), binaryName);
3689
4032
  if (await commandWorks(local, ["--version"])) return local;
3690
4033
  if (await commandWorks("python", ["-m", "gallery_dl", "--version"])) return "python";
3691
4034
  if (await commandWorks("python3", ["-m", "gallery_dl", "--version"])) return "python3";
@@ -3698,7 +4041,7 @@ async function updateYtDlp(options) {
3698
4041
  const signal = options?.signal;
3699
4042
  const dir = getOpenNamiBinDir();
3700
4043
  const binaryName = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
3701
- const local = path6.join(dir, binaryName);
4044
+ const local = path7.join(dir, binaryName);
3702
4045
  let previousVersion;
3703
4046
  if (await commandWorks(local, ["--version"])) {
3704
4047
  previousVersion = await getBinaryVersion(local);
@@ -3721,7 +4064,7 @@ async function updateGalleryDl(options) {
3721
4064
  const signal = options?.signal;
3722
4065
  const dir = getOpenNamiBinDir();
3723
4066
  const binaryName = process.platform === "win32" ? "gallery-dl.exe" : "gallery-dl";
3724
- const local = path6.join(dir, binaryName);
4067
+ const local = path7.join(dir, binaryName);
3725
4068
  let previousVersion;
3726
4069
  if (await commandWorks(local, ["--version"])) {
3727
4070
  previousVersion = await getBinaryVersion(local);
@@ -3742,13 +4085,13 @@ async function updateGalleryDl(options) {
3742
4085
  // src/lib/engines/exec.ts
3743
4086
  function findExecutable(binName, pathEnv = process.env.PATH || "") {
3744
4087
  if (!pathEnv) return null;
3745
- const delimiter = path7.delimiter;
4088
+ const delimiter = path8.delimiter;
3746
4089
  const dirs = pathEnv.split(delimiter).filter(Boolean);
3747
4090
  const isWin = process.platform === "win32";
3748
4091
  const extensions = isWin ? [".exe", ".cmd", ".bat", ""] : [""];
3749
4092
  for (const dir of dirs) {
3750
4093
  for (const ext of extensions) {
3751
- const fullPath = path7.join(dir, `${binName}${ext}`);
4094
+ const fullPath = path8.join(dir, `${binName}${ext}`);
3752
4095
  try {
3753
4096
  if (fs8.existsSync(fullPath)) {
3754
4097
  const stat = fs8.statSync(fullPath);
@@ -3781,7 +4124,7 @@ function resolveEngineCommand(engine, options = {}) {
3781
4124
  }
3782
4125
  const binDir = getOpenNamiBinDir(env);
3783
4126
  const isWin = process.platform === "win32";
3784
- const localBin = path7.join(binDir, `${engine}${isWin ? ".exe" : ""}`);
4127
+ const localBin = path8.join(binDir, `${engine}${isWin ? ".exe" : ""}`);
3785
4128
  try {
3786
4129
  if (fs8.existsSync(localBin)) {
3787
4130
  const stat = fs8.statSync(localBin);
@@ -3826,7 +4169,7 @@ var PLATFORM_SLEEP = {
3826
4169
  facebook: "1.0-1.8"
3827
4170
  };
3828
4171
  function buildGalleryDlArgs(options) {
3829
- const { destDir, subDir, platform, cleanUrl, username, cookiePath } = options;
4172
+ const { destDir, subDir, platform, cleanUrl, username, cookiePath, writeMetadata } = options;
3830
4173
  const args = [
3831
4174
  "-D",
3832
4175
  destDir,
@@ -3841,12 +4184,15 @@ function buildGalleryDlArgs(options) {
3841
4184
  "-o",
3842
4185
  'headers.sec-ch-ua-platform="Windows"',
3843
4186
  "--download-archive",
3844
- path8.join(destDir, GDL_ARCHIVE),
4187
+ path9.join(destDir, GDL_ARCHIVE),
3845
4188
  "--retries",
3846
4189
  "3",
3847
4190
  "--sleep-429",
3848
4191
  "10"
3849
4192
  ];
4193
+ if (writeMetadata) {
4194
+ args.push("--write-metadata");
4195
+ }
3850
4196
  let targetUrl = cleanUrl;
3851
4197
  const sleepDelay = PLATFORM_SLEEP[platform] || "0.8-1.5";
3852
4198
  if (subDir === "Photos") {
@@ -3879,7 +4225,7 @@ function buildGalleryDlArgs(options) {
3879
4225
  }
3880
4226
  async function runGalleryDl(options) {
3881
4227
  const { profile, targetDir, subDir, cookiePath, onProgress } = options;
3882
- const destDir = path8.join(targetDir, subDir);
4228
+ const destDir = path9.join(targetDir, subDir);
3883
4229
  await fs9.mkdir(destDir, { recursive: true });
3884
4230
  const args = buildGalleryDlArgs({
3885
4231
  destDir,
@@ -3887,7 +4233,8 @@ async function runGalleryDl(options) {
3887
4233
  platform: profile.platform,
3888
4234
  cleanUrl: profile.cleanUrl,
3889
4235
  username: profile.username,
3890
- cookiePath
4236
+ cookiePath,
4237
+ writeMetadata: options.writeMetadata
3891
4238
  });
3892
4239
  let downloadedCount = 0;
3893
4240
  let skippedCount = 0;
@@ -3910,7 +4257,7 @@ async function runGalleryDl(options) {
3910
4257
  error: err.message || String(err)
3911
4258
  };
3912
4259
  }
3913
- 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");
3914
4261
  const fullArgs = [...engineCmd.baseArgs, ...args];
3915
4262
  return new Promise((resolve) => {
3916
4263
  onProgress?.({
@@ -3929,7 +4276,7 @@ async function runGalleryDl(options) {
3929
4276
  if (!trimmed) return;
3930
4277
  if (trimmed.startsWith("#")) {
3931
4278
  skippedCount++;
3932
- const filename = path8.basename(trimmed.slice(1).trim());
4279
+ const filename = path9.basename(trimmed.slice(1).trim());
3933
4280
  onProgress?.({
3934
4281
  job: subDir,
3935
4282
  downloadedCount,
@@ -3937,9 +4284,9 @@ async function runGalleryDl(options) {
3937
4284
  currentFile: filename,
3938
4285
  statusText: `Skipped: ${filename}`
3939
4286
  });
3940
- } 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)) {
3941
4288
  downloadedCount++;
3942
- const filename = path8.basename(trimmed);
4289
+ const filename = path9.basename(trimmed);
3943
4290
  onProgress?.({
3944
4291
  job: subDir,
3945
4292
  downloadedCount,
@@ -3990,15 +4337,15 @@ async function runGalleryDl(options) {
3990
4337
 
3991
4338
  // src/lib/engines/yt-dlp.ts
3992
4339
  import { spawn as spawn3 } from "child_process";
3993
- import path9 from "path";
4340
+ import path10 from "path";
3994
4341
  import fs10 from "fs/promises";
3995
4342
  var YTDLP_ARCHIVE = "archive_yt-dlp.txt";
3996
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";
3997
4344
  function buildYtDlpArgs(options) {
3998
- const { destDir, platform, cleanUrl, cookiePath } = options;
4345
+ const { destDir, platform, cleanUrl, cookiePath, writeMetadata } = options;
3999
4346
  const args = [
4000
4347
  "-o",
4001
- path9.join(destDir, "%(title).150B [%(id)s].%(ext)s"),
4348
+ path10.join(destDir, "%(title).150B [%(id)s].%(ext)s"),
4002
4349
  "--windows-filenames",
4003
4350
  "--newline",
4004
4351
  "--ignore-errors",
@@ -4006,7 +4353,7 @@ function buildYtDlpArgs(options) {
4006
4353
  "--user-agent",
4007
4354
  UA2,
4008
4355
  "--download-archive",
4009
- path9.join(destDir, YTDLP_ARCHIVE),
4356
+ path10.join(destDir, YTDLP_ARCHIVE),
4010
4357
  "--concurrent-fragments",
4011
4358
  "4",
4012
4359
  "--retries",
@@ -4023,6 +4370,9 @@ function buildYtDlpArgs(options) {
4023
4370
  } else if (platform === "x") {
4024
4371
  args.push("--sleep-requests", "0.8:1.8", "--retry-sleep", "429:exp=5:30");
4025
4372
  }
4373
+ if (writeMetadata) {
4374
+ args.push("--write-info-json", "--write-description");
4375
+ }
4026
4376
  if (cookiePath) {
4027
4377
  args.push("--cookies", cookiePath);
4028
4378
  }
@@ -4031,13 +4381,14 @@ function buildYtDlpArgs(options) {
4031
4381
  }
4032
4382
  async function runYtDlp(options) {
4033
4383
  const { profile, targetDir, cookiePath, onProgress } = options;
4034
- const destDir = path9.join(targetDir, "Videos");
4384
+ const destDir = path10.join(targetDir, "Videos");
4035
4385
  await fs10.mkdir(destDir, { recursive: true });
4036
4386
  const args = buildYtDlpArgs({
4037
4387
  destDir,
4038
4388
  platform: profile.platform,
4039
4389
  cleanUrl: profile.cleanUrl,
4040
- cookiePath
4390
+ cookiePath,
4391
+ writeMetadata: options.writeMetadata
4041
4392
  });
4042
4393
  let downloadedCount = 0;
4043
4394
  let skippedCount = 0;
@@ -4060,7 +4411,7 @@ async function runYtDlp(options) {
4060
4411
  error: err.message || String(err)
4061
4412
  };
4062
4413
  }
4063
- 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");
4064
4415
  const fullArgs = [...engineCmd.baseArgs, ...args];
4065
4416
  return new Promise((resolve) => {
4066
4417
  onProgress?.({
@@ -4087,7 +4438,7 @@ async function runYtDlp(options) {
4087
4438
  });
4088
4439
  } else if (trimmed.includes("[download] Destination:")) {
4089
4440
  const dest = trimmed.replace("[download] Destination:", "").trim();
4090
- const filename = path9.basename(dest);
4441
+ const filename = path10.basename(dest);
4091
4442
  onProgress?.({
4092
4443
  job: "Videos",
4093
4444
  downloadedCount,
@@ -4152,31 +4503,31 @@ var REFERER_MAP = {
4152
4503
  };
4153
4504
  function expandPath(dir) {
4154
4505
  if (dir.startsWith("~/") || dir.startsWith("~\\") || dir === "~") {
4155
- return path10.join(os8.homedir(), dir.slice(1));
4506
+ return path11.join(os8.homedir(), dir.slice(1));
4156
4507
  }
4157
4508
  return dir;
4158
4509
  }
4159
4510
  function resolveDownloadBaseDir(customBaseDir) {
4160
4511
  if (customBaseDir && customBaseDir.trim()) {
4161
- return path10.resolve(expandPath(customBaseDir.trim()));
4512
+ return path11.resolve(expandPath(customBaseDir.trim()));
4162
4513
  }
4163
4514
  const envDir = process.env.OPEN_NAMI_DIR || process.env.NAMI_DIR;
4164
4515
  if (envDir && envDir.trim()) {
4165
- return path10.resolve(expandPath(envDir.trim()));
4516
+ return path11.resolve(expandPath(envDir.trim()));
4166
4517
  }
4167
4518
  const configDir = loadConfig().downloadDir;
4168
4519
  if (configDir && configDir.trim()) {
4169
- return path10.resolve(expandPath(configDir.trim()));
4520
+ return path11.resolve(expandPath(configDir.trim()));
4170
4521
  }
4171
4522
  if (process.env.NAMI_BASE_DIR && process.env.NAMI_BASE_DIR.trim()) {
4172
4523
  const raw = process.env.NAMI_BASE_DIR.trim();
4173
- 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")));
4174
4525
  }
4175
- return path10.join(resolvePlatformDownloadsDir(), "Open Nami");
4526
+ return path11.join(resolvePlatformDownloadsDir(), "Open Nami");
4176
4527
  }
4177
4528
  function resolveDefaultDownloadDir(platform, username, baseDir) {
4178
4529
  const root = resolveDownloadBaseDir(baseDir);
4179
- return path10.join(root, platform, username);
4530
+ return path11.join(root, platform, username);
4180
4531
  }
4181
4532
  async function dispatchDownload(options) {
4182
4533
  const { profile, choice, onProgress } = options;
@@ -4198,28 +4549,70 @@ async function dispatchDownload(options) {
4198
4549
  } else if (choice === "highlights") {
4199
4550
  jobs.push({ type: "highlights" });
4200
4551
  } else if (choice === "all") {
4201
- jobs.push({ type: "photos" });
4202
- jobs.push({ type: "videos" });
4552
+ jobs.push({ type: "all" });
4203
4553
  if (profile.platform === "instagram") {
4204
4554
  jobs.push({ type: "stories" });
4205
4555
  jobs.push({ type: "highlights" });
4206
4556
  }
4207
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 } : {};
4208
4563
  const defaultRunNative = async (subDir) => {
4209
- 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
+ }
4210
4569
  try {
4211
4570
  const extractor = getExtractor(profile.platform);
4571
+ const subDirKey = subDir === "All" ? "timeline" : subDir.toLowerCase();
4572
+ const initialCursor = options.resume ? currentCursors[subDirKey] : void 0;
4212
4573
  const items = extractor.extract(profile, {
4213
4574
  subDir,
4214
4575
  cookiePath: cookieCopy?.filePath,
4215
- 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
+ }
4216
4596
  });
4217
- 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, {
4218
4603
  jobName: `${profile.username} ${subDir}`,
4219
4604
  destDir: dest,
4605
+ destDirMap: subDir === "All" ? {
4606
+ photo: path11.join(targetDir, "Photos"),
4607
+ video: path11.join(targetDir, "Videos")
4608
+ } : void 0,
4220
4609
  cookiePath: cookieCopy?.filePath,
4221
4610
  referer: REFERER_MAP[profile.platform],
4222
4611
  signal: options.signal,
4612
+ limit: remainingLimit,
4613
+ writeMetadata: options.writeMetadata,
4614
+ defaultPlatform: profile.platform,
4615
+ defaultAuthor: profile.username,
4223
4616
  onProgress
4224
4617
  });
4225
4618
  return {
@@ -4245,6 +4638,7 @@ async function dispatchDownload(options) {
4245
4638
  subDir,
4246
4639
  cookiePath: cookieCopy?.filePath,
4247
4640
  signal: options.signal,
4641
+ writeMetadata: options.writeMetadata,
4248
4642
  onProgress
4249
4643
  }));
4250
4644
  const runYt = options.runnerOverrides?.ytRunner ?? (() => runYtDlp({
@@ -4252,11 +4646,54 @@ async function dispatchDownload(options) {
4252
4646
  targetDir,
4253
4647
  cookiePath: cookieCopy?.filePath,
4254
4648
  signal: options.signal,
4649
+ writeMetadata: options.writeMetadata,
4255
4650
  onProgress
4256
4651
  }));
4257
4652
  for (const job of jobs) {
4258
4653
  if (options.signal?.aborted) break;
4259
- 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") {
4260
4697
  const subDir = job.type === "photos" ? "Photos" : job.type === "stories" ? "Stories" : "Highlights";
4261
4698
  let res = await runNative(subDir);
4262
4699
  if ((!res.success || res.downloaded === 0 && res.skipped === 0) && !options.signal?.aborted) {
@@ -4306,6 +4743,9 @@ async function dispatchDownload(options) {
4306
4743
  }
4307
4744
  }
4308
4745
  }
4746
+ if (errors.length === 0 && !options.signal?.aborted) {
4747
+ await clearCheckpoint(targetDir);
4748
+ }
4309
4749
  return {
4310
4750
  success: errors.length === 0 || totalDownloaded > 0,
4311
4751
  downloadedCount: totalDownloaded,
@@ -4343,7 +4783,7 @@ function readClipboard(execFn = execFileSync2) {
4343
4783
  // src/lib/reveal.ts
4344
4784
  import fs12 from "fs";
4345
4785
  import { spawn as spawn4 } from "child_process";
4346
- import path11 from "path";
4786
+ import path12 from "path";
4347
4787
  function buildOpenBrowserCommand(url, platform = process.platform) {
4348
4788
  try {
4349
4789
  const parsed = new URL(url.trim());
@@ -4370,7 +4810,7 @@ function openBrowser(url) {
4370
4810
  }
4371
4811
  }
4372
4812
  function getRevealCommand(targetPath, platform = process.platform, isDirectory) {
4373
- const pathModule = platform === "win32" ? path11.win32 : platform === "darwin" ? path11.posix : path11;
4813
+ const pathModule = platform === "win32" ? path12.win32 : platform === "darwin" ? path12.posix : path12;
4374
4814
  const resolved = pathModule.resolve(targetPath);
4375
4815
  const isDir = isDirectory !== void 0 ? isDirectory : (() => {
4376
4816
  try {
@@ -4503,6 +4943,11 @@ function App({
4503
4943
  initialChoice,
4504
4944
  initialThemeMode = "auto",
4505
4945
  outputDir,
4946
+ since,
4947
+ until,
4948
+ limit,
4949
+ resume,
4950
+ writeMetadata,
4506
4951
  version = "1.0.0"
4507
4952
  }) {
4508
4953
  const [themeMode, setThemeMode] = useState6(initialThemeMode);
@@ -4515,6 +4960,11 @@ function App({
4515
4960
  initialInput,
4516
4961
  initialChoice,
4517
4962
  outputDir,
4963
+ since,
4964
+ until,
4965
+ limit,
4966
+ resume,
4967
+ writeMetadata,
4518
4968
  version,
4519
4969
  cycleTheme
4520
4970
  }
@@ -4524,6 +4974,11 @@ function InnerApp({
4524
4974
  initialInput,
4525
4975
  initialChoice,
4526
4976
  outputDir,
4977
+ since,
4978
+ until,
4979
+ limit,
4980
+ resume,
4981
+ writeMetadata,
4527
4982
  version = "1.0.0",
4528
4983
  cycleTheme
4529
4984
  }) {
@@ -4590,6 +5045,11 @@ function InnerApp({
4590
5045
  profile,
4591
5046
  choice,
4592
5047
  outputDir: targetDir,
5048
+ since,
5049
+ until,
5050
+ limit,
5051
+ resume,
5052
+ writeMetadata,
4593
5053
  signal: controller.signal,
4594
5054
  onProgress: (progress) => {
4595
5055
  if (!controller.signal.aborted) {
@@ -4617,7 +5077,7 @@ function InnerApp({
4617
5077
  hint: "Check your internet connection or verify the profile exists."
4618
5078
  });
4619
5079
  }
4620
- }, [outputDir]);
5080
+ }, [outputDir, since, until, limit, resume, writeMetadata]);
4621
5081
  const handlePlatformSelect = useCallback2((platform, username, rawInput) => {
4622
5082
  const canonical = buildCanonicalProfile(platform, username, rawInput);
4623
5083
  setPhase({ name: "probing", target: canonical.cleanUrl, status: "probing profile\u2026" });
@@ -5104,6 +5564,198 @@ function generateCompletion(shell) {
5104
5564
  }
5105
5565
  }
5106
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
+
5107
5759
  // src/cli.tsx
5108
5760
  import { jsx as jsx17 } from "react/jsx-runtime";
5109
5761
  var resolveVersion = () => {
@@ -5134,6 +5786,8 @@ var HELP = `
5134
5786
  -h, --help Show this help message
5135
5787
  -v, --version Show version
5136
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)
5137
5791
  -U, --update Update bundled engines (yt-dlp and gallery-dl) to latest release
5138
5792
  --update-ytdlp Update only bundled yt-dlp binary
5139
5793
  --update-gallerydl Update only bundled gallery-dl binary
@@ -5143,6 +5797,11 @@ var HELP = `
5143
5797
  --stories Download stories only (Instagram)
5144
5798
  --highlights Download highlights only (Instagram)
5145
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
5146
5805
  --completion Generate shell completion script (bash, zsh, fish, powershell)
5147
5806
 
5148
5807
  Hotkeys in TUI:
@@ -5229,12 +5888,21 @@ async function main() {
5229
5888
  let initialChoice;
5230
5889
  let outputDir;
5231
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;
5232
5898
  for (let i = 0; i < args.length; i++) {
5233
5899
  const arg = args[i];
5234
5900
  if (arg === "-o" || arg === "--output") {
5235
5901
  outputDir = args[++i];
5236
5902
  } else if (arg.startsWith("--output=")) {
5237
5903
  outputDir = arg.slice("--output=".length);
5904
+ } else if (arg === "-w" || arg === "--watch") {
5905
+ isWatchMode = true;
5238
5906
  } else if (arg === "--theme") {
5239
5907
  themeMode = args[++i];
5240
5908
  } else if (arg.startsWith("--theme=")) {
@@ -5249,10 +5917,125 @@ async function main() {
5249
5917
  initialChoice = "highlights";
5250
5918
  } else if (arg === "--all") {
5251
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);
5252
5942
  } else if (!arg.startsWith("-") && !initialInput) {
5253
5943
  initialInput = arg;
5254
5944
  }
5255
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
+ }
5256
6039
  const isTTY = Boolean(process.stdout.isTTY);
5257
6040
  if (!isTTY && initialChoice && initialInput) {
5258
6041
  const parsed = parseProfileInput(initialInput);
@@ -5266,6 +6049,11 @@ async function main() {
5266
6049
  profile: parsed,
5267
6050
  choice: initialChoice,
5268
6051
  outputDir,
6052
+ since,
6053
+ until,
6054
+ limit,
6055
+ resume: isResume,
6056
+ writeMetadata,
5269
6057
  onProgress: (progress) => {
5270
6058
  if (progress.currentFile) {
5271
6059
  console.log(`[open-nami] [${progress.job}] ${progress.currentFile}`);
@@ -5299,6 +6087,11 @@ async function main() {
5299
6087
  initialChoice,
5300
6088
  initialThemeMode: themeMode,
5301
6089
  outputDir,
6090
+ since,
6091
+ until,
6092
+ limit,
6093
+ resume: isResume,
6094
+ writeMetadata,
5302
6095
  version: VERSION
5303
6096
  }
5304
6097
  ),