@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.
- package/dist/audio-slider.d.ts +3 -2
- package/dist/index.js +80 -13
- package/dist/instagram.d.ts +1 -1
- package/dist/internal.d.ts +9 -0
- package/package.json +1 -1
- package/src/audio-slider.ts +21 -2
- package/src/instagram.ts +66 -14
- 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) {
|
|
@@ -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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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",
|
package/dist/instagram.d.ts
CHANGED
|
@@ -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
|
};
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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. */
|