@postfetch/core 0.13.3 → 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.
@@ -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 plays in full; when the minimum
22
- * slideshow duration is longer, it loops and is trimmed to the result.
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
- () => mobileMedia(input.net, code, input.preferredWidth),
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) {
@@ -409,13 +418,13 @@ async function instagramUnavailable(net, code, pageReason) {
409
418
  url.searchParams.set("url", `https://www.instagram.com/p/${code}/`);
410
419
  const response = await net(url.href, { headers: mobileHeaders() }, 1).catch(() => null);
411
420
  const body = response ? await response.text().catch(() => "") : "";
412
- const reason = pageReason ?? instagramUnavailableReason(response?.status ?? 0, body);
421
+ const reason = pageReason ?? instagramUnavailableReason(response?.status ?? 0, body, code);
413
422
  const probe = response ? `oembed ${response.status}` : "oembed unreachable";
414
423
  return new PostfetchError(reasonStatus(reason), `Instagram post unavailable: ${reason} (${probe})`, reason);
415
424
  }
416
- function instagramUnavailableReason(status, body) {
425
+ function instagramUnavailableReason(status, body, code) {
417
426
  if (status === 404) {
418
- return "notFound";
427
+ return code && isPrivateShortcode(code) ? "private" : "notFound";
419
428
  }
420
429
  if (status === 429) {
421
430
  return "rateLimited";
@@ -434,6 +443,9 @@ function instagramUnavailableReason(status, body) {
434
443
  }
435
444
  return "unavailable";
436
445
  }
446
+ function isPrivateShortcode(code) {
447
+ return code.length > 11;
448
+ }
437
449
  function reasonStatus(reason) {
438
450
  switch (reason) {
439
451
  case "ageRestricted":
@@ -546,8 +558,8 @@ function searchMedia(node, code) {
546
558
  }
547
559
  return null;
548
560
  }
549
- async function mobileMedia(net, code, preferredWidth) {
550
- 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);
551
563
  if (!id) {
552
564
  return null;
553
565
  }
@@ -703,15 +715,42 @@ function mediaItems(media, code, preferredWidth) {
703
715
  const node = object(edge) && object(edge.node) ? edge.node : null;
704
716
  return node ? instagramItem(node, code, index + 1, preferredWidth) : [];
705
717
  });
706
- if (oldItems.length > 0) {
707
- return oldItems;
708
- }
709
718
  const carousel = Array.isArray(media.carousel_media) ? media.carousel_media.filter(object) : [];
710
719
  const newItems = carousel.flatMap((item, index) => instagramItem(item, code, index + 1, preferredWidth));
711
- if (newItems.length > 0) {
712
- return newItems;
713
- }
714
- return instagramItem(media, code, null, preferredWidth);
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
+ };
715
754
  }
716
755
  function instagramCookies(headers, fallback) {
717
756
  const getter = headers.getSetCookie;
@@ -3910,6 +3949,10 @@ async function buildAudioSliderVideo(items, options) {
3910
3949
  if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
3911
3950
  throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
3912
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
+ }
3913
3956
  const width = options.width ?? 720;
3914
3957
  const height2 = options.height ?? 1280;
3915
3958
  for (const [name, value] of [["width", width], ["height", height2]]) {
@@ -3932,6 +3975,30 @@ async function buildAudioSliderVideo(items, options) {
3932
3975
  await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
3933
3976
  paths.push(path);
3934
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
+ }
3935
4002
  const probe = await io.run(options.ffprobePath ?? "ffprobe", [
3936
4003
  "-v",
3937
4004
  "error",
@@ -1,6 +1,6 @@
1
1
  import { type InstagramExtra, type PostMetadata, type PostfetchReason, type ResolveContext, type Json, type PostfetchResult } from "./internal";
2
2
  export declare function resolveInstagram(input: ResolveContext): Promise<PostfetchResult>;
3
- export declare function instagramUnavailableReason(status: number, body: string): PostfetchReason;
3
+ export declare function instagramUnavailableReason(status: number, body: string, code?: string): PostfetchReason;
4
4
  export declare function instagramMetadata(media: Json): PostMetadata & {
5
5
  extra?: InstagramExtra;
6
6
  };
@@ -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",
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",
@@ -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 plays in full; when the minimum
27
- * slideshow duration is longer, it loops and is trimmed to the result.
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
- () => mobileMedia(input.net, code, input.preferredWidth),
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
 
@@ -91,7 +105,7 @@ async function instagramUnavailable(net: Net, code: string, pageReason?: Postfet
91
105
  const body = response ? await response.text().catch(() => "") : "";
92
106
  // The page can identify an age gate even when oembed only reports a generic
93
107
  // geoblock_required error with no MIN_AGE_ACCOUNT marker.
94
- const reason = pageReason ?? instagramUnavailableReason(response?.status ?? 0, body);
108
+ const reason = pageReason ?? instagramUnavailableReason(response?.status ?? 0, body, code);
95
109
  // Carry the oembed status so an unclassified failure still leaves a raw clue
96
110
  // (200 = post is public but no media could be parsed; 429 = throttled; …).
97
111
  const probe = response ? `oembed ${response.status}` : "oembed unreachable";
@@ -101,10 +115,11 @@ async function instagramUnavailable(net: Net, code: string, pageReason?: Postfet
101
115
  // Classifies why the post is gone from the oembed endpoint, which returns a
102
116
  // telling status and body (404 when removed; 429 when throttled; 400 with a
103
117
  // MIN_AGE_ACCOUNT block for 18+ accounts) while the media endpoints just come
104
- // back empty.
105
- export function instagramUnavailableReason(status: number, body: string): PostfetchReason {
118
+ // back empty. Share links to posts from private accounts carry a long
119
+ // shortcode, and oembed answers those with the same 404 as a removed post.
120
+ export function instagramUnavailableReason(status: number, body: string, code?: string): PostfetchReason {
106
121
  if (status === 404) {
107
- return "notFound";
122
+ return code && isPrivateShortcode(code) ? "private" : "notFound";
108
123
  }
109
124
  if (status === 429) {
110
125
  return "rateLimited";
@@ -124,6 +139,12 @@ export function instagramUnavailableReason(status: number, body: string): Postfe
124
139
  return "unavailable";
125
140
  }
126
141
 
142
+ // Public shortcodes are at most 11 characters; private share links append an
143
+ // owner-scoped suffix (for example 39 characters in total).
144
+ function isPrivateShortcode(code: string): boolean {
145
+ return code.length > 11;
146
+ }
147
+
127
148
  function reasonStatus(reason: PostfetchReason): number {
128
149
  switch (reason) {
129
150
  case "ageRestricted":
@@ -254,8 +275,8 @@ function searchMedia(node: unknown, code: string): Json | null {
254
275
  return null;
255
276
  }
256
277
 
257
- async function mobileMedia(net: Net, code: string, preferredWidth: number): Promise<Json | null> {
258
- 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);
259
280
  if (!id) {
260
281
  return null;
261
282
  }
@@ -422,15 +443,46 @@ function mediaItems(media: Json, code: string, preferredWidth: number): MediaIte
422
443
  const node = object(edge) && object(edge.node) ? edge.node : null;
423
444
  return node ? instagramItem(node, code, index + 1, preferredWidth) : [];
424
445
  });
425
- if (oldItems.length > 0) {
426
- return oldItems;
427
- }
428
446
  const carousel = Array.isArray(media.carousel_media) ? media.carousel_media.filter(object) : [];
429
447
  const newItems = carousel.flatMap((item, index) => instagramItem(item, code, index + 1, preferredWidth));
430
- if (newItems.length > 0) {
431
- return newItems;
432
- }
433
- return instagramItem(media, code, null, preferredWidth);
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
+ };
434
486
  }
435
487
 
436
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. */