@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.
@@ -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) {
@@ -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
- if (newItems.length > 0) {
715
- return newItems;
716
- }
717
- 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
+ };
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",
@@ -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.4",
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
 
@@ -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
- if (newItems.length > 0) {
438
- return newItems;
439
- }
440
- 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
+ };
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. */