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.
- package/dist/cli.js +980 -187
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
3152
|
-
const
|
|
3153
|
-
|
|
3154
|
-
const
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
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
|
-
|
|
3269
|
+
html = await this.httpClient.fetchText(desktopUrl, {
|
|
3177
3270
|
cookiePath: options.cookiePath,
|
|
3178
3271
|
cookieDomain: "facebook.com",
|
|
3179
3272
|
signal: options.signal,
|
|
3180
|
-
retries:
|
|
3181
|
-
timeoutMs:
|
|
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
|
|
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:
|
|
3291
|
+
retries: 1,
|
|
3201
3292
|
timeoutMs: 5e3,
|
|
3202
3293
|
headers: {
|
|
3203
|
-
"
|
|
3204
|
-
"Referer": "https://m.facebook.com/"
|
|
3294
|
+
"Referer": "https://mbasic.facebook.com/"
|
|
3205
3295
|
}
|
|
3206
3296
|
});
|
|
3207
|
-
const
|
|
3208
|
-
if (
|
|
3209
|
-
html =
|
|
3210
|
-
currentUrl =
|
|
3211
|
-
initialItems =
|
|
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
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
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
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3340
|
-
await
|
|
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
|
|
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
|
-
|
|
3381
|
-
const
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
4516
|
+
return path11.resolve(expandPath(envDir.trim()));
|
|
4166
4517
|
}
|
|
4167
4518
|
const configDir = loadConfig().downloadDir;
|
|
4168
4519
|
if (configDir && configDir.trim()) {
|
|
4169
|
-
return
|
|
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
|
|
4524
|
+
return path11.resolve(expandPath(raw.endsWith("downloads") ? raw : path11.join(raw, "downloads")));
|
|
4174
4525
|
}
|
|
4175
|
-
return
|
|
4526
|
+
return path11.join(resolvePlatformDownloadsDir(), "Open Nami");
|
|
4176
4527
|
}
|
|
4177
4528
|
function resolveDefaultDownloadDir(platform, username, baseDir) {
|
|
4178
4529
|
const root = resolveDownloadBaseDir(baseDir);
|
|
4179
|
-
return
|
|
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: "
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
|
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" ?
|
|
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
|
),
|