@postfetch/core 0.13.4 → 0.13.5
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/audio-slider.d.ts +3 -2
- package/dist/index.js +74 -10
- package/dist/internal.d.ts +9 -0
- package/package.json +1 -1
- package/src/audio-slider.ts +21 -2
- package/src/instagram.ts +55 -10
- package/src/internal.ts +6 -0
package/dist/audio-slider.d.ts
CHANGED
|
@@ -18,8 +18,9 @@ export type AudioSliderVideoOptions = DownloadOptions & {
|
|
|
18
18
|
* one audio item. Downloads preserve each item's headers and assemble HLS.
|
|
19
19
|
* Visuals retain their aspect ratio, padded with black to the output size.
|
|
20
20
|
* Video clips play from the beginning, looping or trimming to fit; their own
|
|
21
|
-
* audio is discarded. The final audio item
|
|
22
|
-
*
|
|
21
|
+
* audio is discarded. The final audio item's optional `clip` selects the attached
|
|
22
|
+
* segment; otherwise the whole track plays. When the minimum slideshow duration
|
|
23
|
+
* is longer, that segment/track loops and is trimmed to the result.
|
|
23
24
|
*
|
|
24
25
|
* Each visual gets at least `delay` ms or audio duration / visual count,
|
|
25
26
|
* whichever is longer (rounded up to 30 fps, minimum two frames). Adjacent
|
package/dist/index.js
CHANGED
|
@@ -381,9 +381,11 @@ async function resolveInstagram(input) {
|
|
|
381
381
|
const code = shortcode(input.url);
|
|
382
382
|
const videoRequired = /\/(?:reel|reels|tv)\//.test(asUrl(input.url).pathname);
|
|
383
383
|
const page = await pageMedia(input.net, code, input.preferredWidth);
|
|
384
|
+
let mobile;
|
|
385
|
+
const getMobile = () => mobile ??= mobileMedia(input.net, code, input.preferredWidth, page.media);
|
|
384
386
|
const resolvers = [
|
|
385
387
|
async () => page.media,
|
|
386
|
-
|
|
388
|
+
getMobile,
|
|
387
389
|
() => embedMedia(input.net, code),
|
|
388
390
|
() => graphqlMedia(input.net, code)
|
|
389
391
|
];
|
|
@@ -402,6 +404,13 @@ async function resolveInstagram(input) {
|
|
|
402
404
|
if (items.length === 0) {
|
|
403
405
|
throw new Error("Instagram media url not found");
|
|
404
406
|
}
|
|
407
|
+
if (soundtrackEligible(items) && !items.some((item) => item.kind === "audio") && media.music_metadata === undefined && !object(media.clips_metadata)) {
|
|
408
|
+
const richer = await getMobile().catch(() => null);
|
|
409
|
+
const samePost = richer && (string(richer.code) ? richer.code === code : string(richer.pk) !== null && string(richer.pk) === string(media.pk));
|
|
410
|
+
const audio = samePost ? instagramAudio(richer, code) : null;
|
|
411
|
+
if (audio)
|
|
412
|
+
items.push(audio);
|
|
413
|
+
}
|
|
405
414
|
return { archiveFilename: filename(`instagram_${code}.zip`), comments: [], id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
406
415
|
}
|
|
407
416
|
async function instagramUnavailable(net, code, pageReason) {
|
|
@@ -549,8 +558,8 @@ function searchMedia(node, code) {
|
|
|
549
558
|
}
|
|
550
559
|
return null;
|
|
551
560
|
}
|
|
552
|
-
async function mobileMedia(net, code, preferredWidth) {
|
|
553
|
-
const id = await mediaId(net, code);
|
|
561
|
+
async function mobileMedia(net, code, preferredWidth, hint) {
|
|
562
|
+
const id = string(hint?.pk) ?? string(hint?.id) ?? await mediaId(net, code);
|
|
554
563
|
if (!id) {
|
|
555
564
|
return null;
|
|
556
565
|
}
|
|
@@ -706,15 +715,42 @@ function mediaItems(media, code, preferredWidth) {
|
|
|
706
715
|
const node = object(edge) && object(edge.node) ? edge.node : null;
|
|
707
716
|
return node ? instagramItem(node, code, index + 1, preferredWidth) : [];
|
|
708
717
|
});
|
|
709
|
-
if (oldItems.length > 0) {
|
|
710
|
-
return oldItems;
|
|
711
|
-
}
|
|
712
718
|
const carousel = Array.isArray(media.carousel_media) ? media.carousel_media.filter(object) : [];
|
|
713
719
|
const newItems = carousel.flatMap((item, index) => instagramItem(item, code, index + 1, preferredWidth));
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
720
|
+
const items = oldItems.length > 0 ? oldItems : newItems.length > 0 ? newItems : instagramItem(media, code, null, preferredWidth);
|
|
721
|
+
const audio = soundtrackEligible(items) ? instagramAudio(media, code) : null;
|
|
722
|
+
return audio ? [...items, audio] : items;
|
|
723
|
+
}
|
|
724
|
+
function soundtrackEligible(items) {
|
|
725
|
+
return items.length > 1 || items[0]?.kind === "image";
|
|
726
|
+
}
|
|
727
|
+
function instagramAudio(media, code) {
|
|
728
|
+
const metadata = object(media.music_metadata) ? media.music_metadata : object(media.clips_metadata) ? media.clips_metadata : null;
|
|
729
|
+
if (!metadata)
|
|
730
|
+
return null;
|
|
731
|
+
const info = object(metadata.music_info) ? metadata.music_info : null;
|
|
732
|
+
const consumption = info && object(info.music_consumption_info) ? info.music_consumption_info : null;
|
|
733
|
+
if (consumption?.should_mute_audio === true)
|
|
734
|
+
return null;
|
|
735
|
+
const asset = info && object(info.music_asset_info) ? info.music_asset_info : null;
|
|
736
|
+
const original = object(metadata.original_sound_info) ? metadata.original_sound_info : null;
|
|
737
|
+
const selected = [asset, original].find((value) => value && (string(value.progressive_download_url) ?? string(value.fast_start_progressive_download_url)));
|
|
738
|
+
const url = selected && (string(selected.progressive_download_url) ?? string(selected.fast_start_progressive_download_url));
|
|
739
|
+
if (!url || !/^https?:\/\//i.test(url))
|
|
740
|
+
return null;
|
|
741
|
+
const start = number(consumption?.audio_asset_start_time_in_ms);
|
|
742
|
+
const duration = number(consumption?.overlap_duration_in_ms);
|
|
743
|
+
const clip = start !== null && start >= 0 || duration !== null && duration > 0 ? { start: Math.max(0, start ?? 0) / 1000, ...duration !== null && duration > 0 ? { duration: duration / 1000 } : {} } : undefined;
|
|
744
|
+
return {
|
|
745
|
+
filename: filename(`instagram_${code}_audio.m4a`),
|
|
746
|
+
headers: { "user-agent": browserUserAgent() },
|
|
747
|
+
id: code,
|
|
748
|
+
kind: "audio",
|
|
749
|
+
mime: "audio/mp4",
|
|
750
|
+
platform: "instagram",
|
|
751
|
+
url,
|
|
752
|
+
...clip && { clip }
|
|
753
|
+
};
|
|
718
754
|
}
|
|
719
755
|
function instagramCookies(headers, fallback) {
|
|
720
756
|
const getter = headers.getSetCookie;
|
|
@@ -3913,6 +3949,10 @@ async function buildAudioSliderVideo(items, options) {
|
|
|
3913
3949
|
if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
|
|
3914
3950
|
throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
|
|
3915
3951
|
}
|
|
3952
|
+
const clip = items.at(-1)?.clip;
|
|
3953
|
+
if (clip && (!Number.isFinite(clip.start) || clip.start < 0 || clip.duration !== undefined && (!Number.isFinite(clip.duration) || clip.duration <= 0))) {
|
|
3954
|
+
throw new PostfetchError(400, "Audio clip must have a non-negative finite start and a positive finite duration");
|
|
3955
|
+
}
|
|
3916
3956
|
const width = options.width ?? 720;
|
|
3917
3957
|
const height2 = options.height ?? 1280;
|
|
3918
3958
|
for (const [name, value] of [["width", width], ["height", height2]]) {
|
|
@@ -3935,6 +3975,30 @@ async function buildAudioSliderVideo(items, options) {
|
|
|
3935
3975
|
await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
3936
3976
|
paths.push(path);
|
|
3937
3977
|
}
|
|
3978
|
+
if (clip) {
|
|
3979
|
+
const path = `${directory}/soundtrack.wav`;
|
|
3980
|
+
const trimmed = await io.run(options.ffmpegPath ?? "ffmpeg", [
|
|
3981
|
+
"-nostdin",
|
|
3982
|
+
"-hide_banner",
|
|
3983
|
+
"-loglevel",
|
|
3984
|
+
"error",
|
|
3985
|
+
"-y",
|
|
3986
|
+
"-ss",
|
|
3987
|
+
String(clip.start),
|
|
3988
|
+
"-i",
|
|
3989
|
+
paths[visuals.length],
|
|
3990
|
+
...clip.duration === undefined ? [] : ["-t", String(clip.duration)],
|
|
3991
|
+
"-map",
|
|
3992
|
+
"0:a:0",
|
|
3993
|
+
"-c:a",
|
|
3994
|
+
"pcm_s16le",
|
|
3995
|
+
path
|
|
3996
|
+
]);
|
|
3997
|
+
if (!trimmed.success) {
|
|
3998
|
+
throw new PostfetchError(500, `Audio slider soundtrack trimming failed: ${trimmed.stderr}`);
|
|
3999
|
+
}
|
|
4000
|
+
paths[visuals.length] = path;
|
|
4001
|
+
}
|
|
3938
4002
|
const probe = await io.run(options.ffprobePath ?? "ffprobe", [
|
|
3939
4003
|
"-v",
|
|
3940
4004
|
"error",
|
package/dist/internal.d.ts
CHANGED
|
@@ -14,6 +14,15 @@ export type MediaItem = {
|
|
|
14
14
|
headers: HeadersInit;
|
|
15
15
|
url: string;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Playback segment in seconds when this audio item is a slideshow soundtrack.
|
|
19
|
+
* Download helpers return the source file; buildAudioSliderVideo trims this
|
|
20
|
+
* segment before measuring or looping it. Omitted duration plays to the end.
|
|
21
|
+
*/
|
|
22
|
+
clip?: {
|
|
23
|
+
start: number;
|
|
24
|
+
duration?: number;
|
|
25
|
+
};
|
|
17
26
|
/** Suggested download filename. */
|
|
18
27
|
filename: string;
|
|
19
28
|
/** Headers required to fetch {@link MediaItem.url} from the CDN. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
4
4
|
"description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, Spotify, TikTok, X and YouTube post URLs into media files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/audio-slider.ts
CHANGED
|
@@ -23,8 +23,9 @@ const fps = 30;
|
|
|
23
23
|
* one audio item. Downloads preserve each item's headers and assemble HLS.
|
|
24
24
|
* Visuals retain their aspect ratio, padded with black to the output size.
|
|
25
25
|
* Video clips play from the beginning, looping or trimming to fit; their own
|
|
26
|
-
* audio is discarded. The final audio item
|
|
27
|
-
*
|
|
26
|
+
* audio is discarded. The final audio item's optional `clip` selects the attached
|
|
27
|
+
* segment; otherwise the whole track plays. When the minimum slideshow duration
|
|
28
|
+
* is longer, that segment/track loops and is trimmed to the result.
|
|
28
29
|
*
|
|
29
30
|
* Each visual gets at least `delay` ms or audio duration / visual count,
|
|
30
31
|
* whichever is longer (rounded up to 30 fps, minimum two frames). Adjacent
|
|
@@ -55,6 +56,11 @@ export async function buildAudioSliderVideo(
|
|
|
55
56
|
if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
|
|
56
57
|
throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
|
|
57
58
|
}
|
|
59
|
+
const clip = items.at(-1)?.clip;
|
|
60
|
+
if (clip && (!Number.isFinite(clip.start) || clip.start < 0 ||
|
|
61
|
+
(clip.duration !== undefined && (!Number.isFinite(clip.duration) || clip.duration <= 0)))) {
|
|
62
|
+
throw new PostfetchError(400, "Audio clip must have a non-negative finite start and a positive finite duration");
|
|
63
|
+
}
|
|
58
64
|
const width = options.width ?? 720;
|
|
59
65
|
const height = options.height ?? 1280;
|
|
60
66
|
for (const [name, value] of [["width", width], ["height", height]] as const) {
|
|
@@ -78,6 +84,19 @@ export async function buildAudioSliderVideo(
|
|
|
78
84
|
await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
79
85
|
paths.push(path);
|
|
80
86
|
}
|
|
87
|
+
if (clip) {
|
|
88
|
+
const path = `${directory}/soundtrack.wav`;
|
|
89
|
+
const trimmed = await io.run(options.ffmpegPath ?? "ffmpeg", [
|
|
90
|
+
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
|
91
|
+
"-ss", String(clip.start), "-i", paths[visuals.length],
|
|
92
|
+
...(clip.duration === undefined ? [] : ["-t", String(clip.duration)]),
|
|
93
|
+
"-map", "0:a:0", "-c:a", "pcm_s16le", path,
|
|
94
|
+
]);
|
|
95
|
+
if (!trimmed.success) {
|
|
96
|
+
throw new PostfetchError(500, `Audio slider soundtrack trimming failed: ${trimmed.stderr}`);
|
|
97
|
+
}
|
|
98
|
+
paths[visuals.length] = path;
|
|
99
|
+
}
|
|
81
100
|
const probe = await io.run(options.ffprobePath ?? "ffprobe", [
|
|
82
101
|
"-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
83
102
|
"stream=duration:format=duration", "-of", "json", paths[visuals.length],
|
package/src/instagram.ts
CHANGED
|
@@ -60,9 +60,11 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
60
60
|
const code = shortcode(input.url);
|
|
61
61
|
const videoRequired = /\/(?:reel|reels|tv)\//.test(asUrl(input.url).pathname);
|
|
62
62
|
const page = await pageMedia(input.net, code, input.preferredWidth);
|
|
63
|
+
let mobile: Promise<Json | null> | undefined;
|
|
64
|
+
const getMobile = () => mobile ??= mobileMedia(input.net, code, input.preferredWidth, page.media);
|
|
63
65
|
const resolvers: Array<() => Promise<Json | null>> = [
|
|
64
66
|
async () => page.media,
|
|
65
|
-
|
|
67
|
+
getMobile,
|
|
66
68
|
() => embedMedia(input.net, code),
|
|
67
69
|
() => graphqlMedia(input.net, code),
|
|
68
70
|
];
|
|
@@ -81,6 +83,18 @@ export async function resolveInstagram(input: ResolveContext): Promise<Postfetch
|
|
|
81
83
|
if (items.length === 0) {
|
|
82
84
|
throw new Error("Instagram media url not found");
|
|
83
85
|
}
|
|
86
|
+
// The logged-out page often omits music_metadata altogether. Ask the mobile
|
|
87
|
+
// endpoint once for that post's soundtrack, retaining the page's visuals and
|
|
88
|
+
// metadata even when the optional lookup is gated or fails. An explicit null
|
|
89
|
+
// means there is no soundtrack; a video's audio is already in its MP4.
|
|
90
|
+
if (soundtrackEligible(items) && !items.some((item) => item.kind === "audio") &&
|
|
91
|
+
media.music_metadata === undefined && !object(media.clips_metadata)) {
|
|
92
|
+
const richer = await getMobile().catch(() => null);
|
|
93
|
+
const samePost = richer && (string(richer.code) ? richer.code === code :
|
|
94
|
+
string(richer.pk) !== null && string(richer.pk) === string(media.pk));
|
|
95
|
+
const audio = samePost ? instagramAudio(richer, code) : null;
|
|
96
|
+
if (audio) items.push(audio);
|
|
97
|
+
}
|
|
84
98
|
return { archiveFilename: filename(`instagram_${code}.zip`), comments: [], id: code, items, metadata: instagramMetadata(media), platform: "instagram" };
|
|
85
99
|
}
|
|
86
100
|
|
|
@@ -261,8 +275,8 @@ function searchMedia(node: unknown, code: string): Json | null {
|
|
|
261
275
|
return null;
|
|
262
276
|
}
|
|
263
277
|
|
|
264
|
-
async function mobileMedia(net: Net, code: string, preferredWidth: number): Promise<Json | null> {
|
|
265
|
-
const id = await mediaId(net, code);
|
|
278
|
+
async function mobileMedia(net: Net, code: string, preferredWidth: number, hint?: Json | null): Promise<Json | null> {
|
|
279
|
+
const id = string(hint?.pk) ?? string(hint?.id) ?? await mediaId(net, code);
|
|
266
280
|
if (!id) {
|
|
267
281
|
return null;
|
|
268
282
|
}
|
|
@@ -429,15 +443,46 @@ function mediaItems(media: Json, code: string, preferredWidth: number): MediaIte
|
|
|
429
443
|
const node = object(edge) && object(edge.node) ? edge.node : null;
|
|
430
444
|
return node ? instagramItem(node, code, index + 1, preferredWidth) : [];
|
|
431
445
|
});
|
|
432
|
-
if (oldItems.length > 0) {
|
|
433
|
-
return oldItems;
|
|
434
|
-
}
|
|
435
446
|
const carousel = Array.isArray(media.carousel_media) ? media.carousel_media.filter(object) : [];
|
|
436
447
|
const newItems = carousel.flatMap((item, index) => instagramItem(item, code, index + 1, preferredWidth));
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
448
|
+
const items = oldItems.length > 0 ? oldItems : newItems.length > 0 ? newItems : instagramItem(media, code, null, preferredWidth);
|
|
449
|
+
const audio = soundtrackEligible(items) ? instagramAudio(media, code) : null;
|
|
450
|
+
return audio ? [...items, audio] : items;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function soundtrackEligible(items: MediaItem[]): boolean {
|
|
454
|
+
return items.length > 1 || items[0]?.kind === "image";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Normalize Instagram's field names to the same trailing audio item used by
|
|
458
|
+
// other resolvers. Consumers can build a slideshow without platform branches.
|
|
459
|
+
function instagramAudio(media: Json, code: string): MediaItem | null {
|
|
460
|
+
const metadata = object(media.music_metadata) ? media.music_metadata : object(media.clips_metadata) ? media.clips_metadata : null;
|
|
461
|
+
if (!metadata) return null;
|
|
462
|
+
const info = object(metadata.music_info) ? metadata.music_info : null;
|
|
463
|
+
const consumption = info && object(info.music_consumption_info) ? info.music_consumption_info : null;
|
|
464
|
+
if (consumption?.should_mute_audio === true) return null;
|
|
465
|
+
const asset = info && object(info.music_asset_info) ? info.music_asset_info : null;
|
|
466
|
+
const original = object(metadata.original_sound_info) ? metadata.original_sound_info : null;
|
|
467
|
+
const selected = [asset, original].find((value) => value &&
|
|
468
|
+
(string(value.progressive_download_url) ?? string(value.fast_start_progressive_download_url)));
|
|
469
|
+
const url = selected && (string(selected.progressive_download_url) ?? string(selected.fast_start_progressive_download_url));
|
|
470
|
+
if (!url || !/^https?:\/\//i.test(url)) return null;
|
|
471
|
+
const start = number(consumption?.audio_asset_start_time_in_ms);
|
|
472
|
+
const duration = number(consumption?.overlap_duration_in_ms);
|
|
473
|
+
const clip = (start !== null && start >= 0) || (duration !== null && duration > 0)
|
|
474
|
+
? { start: Math.max(0, start ?? 0) / 1000, ...(duration !== null && duration > 0 ? { duration: duration / 1000 } : {}) }
|
|
475
|
+
: undefined;
|
|
476
|
+
return {
|
|
477
|
+
filename: filename(`instagram_${code}_audio.m4a`),
|
|
478
|
+
headers: { "user-agent": browserUserAgent() },
|
|
479
|
+
id: code,
|
|
480
|
+
kind: "audio",
|
|
481
|
+
mime: "audio/mp4",
|
|
482
|
+
platform: "instagram",
|
|
483
|
+
url,
|
|
484
|
+
...(clip && { clip }),
|
|
485
|
+
};
|
|
441
486
|
}
|
|
442
487
|
|
|
443
488
|
function instagramCookies(headers: Headers, fallback: Record<string, string | null>): string {
|
package/src/internal.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type MediaItem = {
|
|
|
13
13
|
* the bundled remuxer; callers that read `url` directly get the silent video.
|
|
14
14
|
*/
|
|
15
15
|
audio?: { headers: HeadersInit; url: string };
|
|
16
|
+
/**
|
|
17
|
+
* Playback segment in seconds when this audio item is a slideshow soundtrack.
|
|
18
|
+
* Download helpers return the source file; buildAudioSliderVideo trims this
|
|
19
|
+
* segment before measuring or looping it. Omitted duration plays to the end.
|
|
20
|
+
*/
|
|
21
|
+
clip?: { start: number; duration?: number };
|
|
16
22
|
/** Suggested download filename. */
|
|
17
23
|
filename: string;
|
|
18
24
|
/** Headers required to fetch {@link MediaItem.url} from the CDN. */
|