@postfetch/core 0.2.3 → 0.3.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.
@@ -0,0 +1,237 @@
1
+ import {
2
+ asUrl,
3
+ filename,
4
+ number,
5
+ object,
6
+ string,
7
+ type ResolveContext,
8
+ type Json,
9
+ type Net,
10
+ type PostfetchResult,
11
+ type MediaItem,
12
+ } from "./internal";
13
+ import { browserUserAgent } from "./fingerprint";
14
+ import { isMasterPlaylist, parseMaster, type HlsVariant } from "./hls";
15
+
16
+ export async function resolvePinterest(input: ResolveContext): Promise<PostfetchResult> {
17
+ const id = await pinId(input.net, input.url);
18
+ const pin = await pinResource(input.net, id);
19
+ const items = await mediaItems(input, pin, id);
20
+ if (items.length === 0) {
21
+ throw new Error("Pinterest media not found");
22
+ }
23
+ return { archiveFilename: filename(`pinterest_${id}.zip`), id, items, platform: "pinterest" };
24
+ }
25
+
26
+ // pin.it short links carry no pin id, so they are followed once to the canonical
27
+ // /pin/<id>/ location.
28
+ async function pinId(net: Net, input: string): Promise<string> {
29
+ const direct = parsePin(input);
30
+ if (direct) {
31
+ return direct;
32
+ }
33
+ const response = await net(input, { headers: { "user-agent": browserUserAgent() } }, 1);
34
+ const resolved = parsePin(response.url);
35
+ if (!resolved) {
36
+ throw new Error("Pinterest pin id not found");
37
+ }
38
+ return resolved;
39
+ }
40
+
41
+ function parsePin(input: string): string | null {
42
+ return asUrl(input).pathname.match(/\/pin\/(\d+)/)?.[1] ?? null;
43
+ }
44
+
45
+ // Logged out, the pin page no longer carries the pin data; the public PinResource
46
+ // endpoint returns it as JSON for any handler-tagged request, no cookie needed.
47
+ async function pinResource(net: Net, id: string): Promise<Json> {
48
+ const data = JSON.stringify({
49
+ options: { id, field_set_key: "unauth_react_main_pin", fetch_visual_search_objects: false },
50
+ context: {},
51
+ });
52
+ const url = new URL("https://www.pinterest.com/resource/PinResource/get/");
53
+ url.searchParams.set("source_url", `/pin/${id}/`);
54
+ url.searchParams.set("data", data);
55
+ const response = await net(url.href, {
56
+ headers: {
57
+ accept: "application/json",
58
+ "user-agent": browserUserAgent(),
59
+ "x-pinterest-pws-handler": "www/pin/[id].js",
60
+ },
61
+ });
62
+ if (!response.ok) {
63
+ throw new Error(`Pinterest resource failed: ${response.status}`);
64
+ }
65
+ const payload = await response.json().catch(() => null);
66
+ const wrapper = object(payload) && object(payload.resource_response) ? payload.resource_response : null;
67
+ const pin = wrapper && object(wrapper.data) ? wrapper.data : null;
68
+ if (!pin) {
69
+ throw new Error("Pinterest pin not found");
70
+ }
71
+ return pin;
72
+ }
73
+
74
+ async function mediaItems(input: ResolveContext, pin: Json, id: string): Promise<MediaItem[]> {
75
+ const video = await standardVideo(input, pin, id);
76
+ if (video.length > 0) {
77
+ return video;
78
+ }
79
+ const story = await storyItems(input, pin, id);
80
+ if (story.length > 0) {
81
+ return story;
82
+ }
83
+ return imageItems(pin, id);
84
+ }
85
+
86
+ async function standardVideo(input: ResolveContext, pin: Json, id: string): Promise<MediaItem[]> {
87
+ const item = await videoFromList(input, id, null, videoList(object(pin.videos) ? pin.videos : null));
88
+ return item ? [item] : [];
89
+ }
90
+
91
+ // Idea pins carry their media as story blocks, usually as HLS rather than a
92
+ // progressive file. Each video block is resolved to its best rendition; only a
93
+ // block with neither a progressive nor an HLS stream is treated as undownloadable.
94
+ async function storyItems(input: ResolveContext, pin: Json, id: string): Promise<MediaItem[]> {
95
+ const story = object(pin.story_pin_data) ? pin.story_pin_data : null;
96
+ if (!story) {
97
+ return [];
98
+ }
99
+ const pages = Array.isArray(story.pages) ? story.pages.filter(object) : [];
100
+ const blocks = pages.flatMap((page) => (Array.isArray(page.blocks) ? page.blocks.filter(object) : []));
101
+ const videoBlocks = blocks.filter((block) => object(block.video));
102
+ const videos: MediaItem[] = [];
103
+ for (const [index, block] of videoBlocks.entries()) {
104
+ const item = await videoFromList(input, id, index + 1, videoList(object(block.video) ? block.video : null));
105
+ if (item) {
106
+ videos.push(item);
107
+ }
108
+ }
109
+ if (videos.length > 0) {
110
+ return videos;
111
+ }
112
+ if (videoBlocks.length > 0) {
113
+ throw new Error("Pinterest idea pin video has no downloadable rendition");
114
+ }
115
+ return imageItems(pin, id);
116
+ }
117
+
118
+ // Prefer a progressive mp4; fall back to the HLS master, picking the variant
119
+ // nearest the preferred width and pairing it with its audio group.
120
+ async function videoFromList(input: ResolveContext, id: string, index: number | null, renditions: Json[]): Promise<MediaItem | null> {
121
+ const progressive = progressiveMp4(renditions);
122
+ if (progressive) {
123
+ return videoItem(id, index, progressive);
124
+ }
125
+ const master = hlsUrl(renditions);
126
+ return master ? hlsVideoItem(input.net, id, index, master, input.preferredWidth) : null;
127
+ }
128
+
129
+ async function hlsVideoItem(net: Net, id: string, index: number | null, masterUrl: string, preferredWidth: number): Promise<MediaItem> {
130
+ const headers = { "user-agent": browserUserAgent() };
131
+ const suffix = index === null ? "" : `_${index}`;
132
+ const base: MediaItem = {
133
+ filename: filename(`pinterest_${id}${suffix}.mp4`),
134
+ headers,
135
+ hls: true,
136
+ id,
137
+ kind: "video",
138
+ mime: "video/mp4",
139
+ platform: "pinterest",
140
+ url: masterUrl,
141
+ };
142
+ const response = await net(masterUrl, { headers });
143
+ if (!response.ok) {
144
+ throw new Error(`Pinterest HLS failed: ${response.status}`);
145
+ }
146
+ const playlist = await response.text();
147
+ if (!isMasterPlaylist(playlist)) {
148
+ return base;
149
+ }
150
+ const master = parseMaster(playlist, masterUrl);
151
+ const variant = pickVariant(master.variants, preferredWidth);
152
+ if (!variant) {
153
+ throw new Error("Pinterest HLS variant not found");
154
+ }
155
+ const audioUrl = variant.audioGroup ? master.audio[variant.audioGroup] : undefined;
156
+ return { ...base, url: variant.url, ...(audioUrl ? { audio: { headers, url: audioUrl } } : {}) };
157
+ }
158
+
159
+ function pickVariant(variants: HlsVariant[], preferredWidth: number): HlsVariant | null {
160
+ return variants.reduce<HlsVariant | null>((current, variant) => {
161
+ if (!current) {
162
+ return variant;
163
+ }
164
+ return Math.abs(variant.width - preferredWidth) < Math.abs(current.width - preferredWidth) ? variant : current;
165
+ }, null);
166
+ }
167
+
168
+ function hlsUrl(renditions: Json[]): string | null {
169
+ return renditions.map((rendition) => string(rendition.url)).find((url): url is string => url !== null && url.toLowerCase().includes(".m3u8")) ?? null;
170
+ }
171
+
172
+ function imageItems(pin: Json, id: string): MediaItem[] {
173
+ const images = object(pin.images) ? pin.images : null;
174
+ const orig = images && object(images.orig) ? images.orig : null;
175
+ const url = orig ? string(orig.url) : null;
176
+ if (!url) {
177
+ return [];
178
+ }
179
+ return [
180
+ {
181
+ filename: filename(`pinterest_${id}.${imageExtension(url)}`),
182
+ headers: { "user-agent": browserUserAgent() },
183
+ id,
184
+ kind: "image",
185
+ mime: imageMime(url),
186
+ platform: "pinterest",
187
+ url,
188
+ },
189
+ ];
190
+ }
191
+
192
+ function videoList(videos: Json | null): Json[] {
193
+ const list = videos && object(videos.video_list) ? videos.video_list : null;
194
+ return list ? Object.values(list).filter(object) : [];
195
+ }
196
+
197
+ function progressiveMp4(renditions: Json[]): string | null {
198
+ const mp4s = renditions.filter((rendition) => string(rendition.url)?.toLowerCase().endsWith(".mp4"));
199
+ const best = mp4s.reduce<Json | null>((current, rendition) => {
200
+ const width = number(rendition.width) ?? 0;
201
+ const currentWidth = current ? number(current.width) ?? 0 : -1;
202
+ return width > currentWidth ? rendition : current;
203
+ }, null);
204
+ return best ? string(best.url) : null;
205
+ }
206
+
207
+ function videoItem(id: string, index: number | null, url: string): MediaItem {
208
+ const suffix = index === null ? "" : `_${index}`;
209
+ return {
210
+ filename: filename(`pinterest_${id}${suffix}.mp4`),
211
+ headers: { "user-agent": browserUserAgent() },
212
+ id,
213
+ kind: "video",
214
+ mime: "video/mp4",
215
+ platform: "pinterest",
216
+ url,
217
+ };
218
+ }
219
+
220
+ function imageExtension(url: string): string {
221
+ const extension = asUrl(url).pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
222
+ return extension === "png" || extension === "webp" || extension === "gif" ? extension : "jpg";
223
+ }
224
+
225
+ function imageMime(url: string): string {
226
+ const extension = imageExtension(url);
227
+ if (extension === "png") {
228
+ return "image/png";
229
+ }
230
+ if (extension === "webp") {
231
+ return "image/webp";
232
+ }
233
+ if (extension === "gif") {
234
+ return "image/gif";
235
+ }
236
+ return "image/jpeg";
237
+ }
package/src/postfetch.ts CHANGED
@@ -1,15 +1,42 @@
1
1
  import { asUrl, createNet, PostfetchError, type Platform, type PostfetchResult } from "./internal";
2
2
  import { resolveFacebook } from "./facebook";
3
3
  import { resolveInstagram } from "./instagram";
4
+ import { resolvePinterest } from "./pinterest";
5
+ import { resolveReddit } from "./reddit";
6
+ import { resolveSoundcloud } from "./soundcloud";
4
7
  import { resolveTiktok } from "./tiktok";
5
8
  import { resolveTwitter } from "./twitter";
6
9
  import { resolveYoutube } from "./youtube";
7
10
 
11
+ /** Options for {@link postfetch}. */
8
12
  export type PostfetchOptions = {
13
+ /** Custom `fetch` implementation — inject one to unit-test resolvers offline. Defaults to the global `fetch`. */
9
14
  fetch?: typeof fetch;
15
+ /** Preferred media width in pixels; the closest available rendition is chosen. Defaults to `720`. */
10
16
  preferredWidth?: number;
11
17
  };
12
18
 
19
+ /**
20
+ * Resolve a public social post URL to its media.
21
+ *
22
+ * Detects the platform from the URL host, then returns the post's media as typed
23
+ * {@link MediaItem}s — direct CDN URLs plus the headers needed to fetch them. It
24
+ * performs no side effects beyond the lookup; use {@link download}, {@link archive}
25
+ * or {@link toResponse} to turn the result into bytes or a `Response`.
26
+ *
27
+ * @param url A post URL from Instagram, TikTok, YouTube, Facebook or X.
28
+ * @param options See {@link PostfetchOptions}.
29
+ * @returns The resolved post and its media items.
30
+ * @throws {PostfetchError} If the URL is empty or its host is unsupported.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { postfetch } from "@postfetch/core";
35
+ *
36
+ * const result = await postfetch("https://vt.tiktok.com/ZSxpHvCUM/");
37
+ * for (const item of result.items) console.log(item.kind, item.url);
38
+ * ```
39
+ */
13
40
  export async function postfetch(url: string, options: PostfetchOptions = {}): Promise<PostfetchResult> {
14
41
  const trimmed = url.trim();
15
42
  if (trimmed.length === 0) {
@@ -25,6 +52,12 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
25
52
  return resolveFacebook(context);
26
53
  case "instagram":
27
54
  return resolveInstagram(context);
55
+ case "pinterest":
56
+ return resolvePinterest(context);
57
+ case "reddit":
58
+ return resolveReddit(context);
59
+ case "soundcloud":
60
+ return resolveSoundcloud(context);
28
61
  case "tiktok":
29
62
  return resolveTiktok(context);
30
63
  case "twitter":
@@ -34,6 +67,13 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
34
67
  }
35
68
  }
36
69
 
70
+ /**
71
+ * Detect which platform a URL belongs to, from its host.
72
+ *
73
+ * @param url Any URL.
74
+ * @returns The matched {@link Platform}.
75
+ * @throws {PostfetchError} If the host is not a supported platform.
76
+ */
37
77
  export function detect(url: string): Platform {
38
78
  const host = asUrl(url).hostname;
39
79
  if (host.includes("tiktok.com")) {
@@ -42,6 +82,15 @@ export function detect(url: string): Platform {
42
82
  if (host.includes("instagram.com")) {
43
83
  return "instagram";
44
84
  }
85
+ if (host.includes("pinterest.") || host === "pin.it") {
86
+ return "pinterest";
87
+ }
88
+ if (host === "reddit.com" || host.endsWith(".reddit.com") || host === "redd.it") {
89
+ return "reddit";
90
+ }
91
+ if (host.includes("soundcloud.com") || host === "snd.sc") {
92
+ return "soundcloud";
93
+ }
45
94
  if (host.includes("youtube.com") || host === "youtu.be") {
46
95
  return "youtube";
47
96
  }
@@ -51,5 +100,5 @@ export function detect(url: string): Platform {
51
100
  if (host === "x.com" || host.endsWith(".x.com") || host.includes("twitter.com")) {
52
101
  return "twitter";
53
102
  }
54
- throw new PostfetchError(400, "only Facebook, Instagram, TikTok, X and YouTube URLs are supported");
103
+ throw new PostfetchError(400, "only Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
55
104
  }
package/src/reddit.ts ADDED
@@ -0,0 +1,293 @@
1
+ import {
2
+ asUrl,
3
+ filename,
4
+ object,
5
+ string,
6
+ type ResolveContext,
7
+ type Json,
8
+ type Net,
9
+ type PostfetchResult,
10
+ type MediaItem,
11
+ } from "./internal";
12
+ import { browserUserAgent } from "./fingerprint";
13
+
14
+ // Logged-out reddit (www/old/api/.json) is fully 403-walled. The public
15
+ // installed-app OAuth flow still works: a client id with no secret plus the
16
+ // opt-out device id returns a bearer that reads any public post through
17
+ // oauth.reddit.com.
18
+ const installedClientId = "ohXpoqrZYub1kg";
19
+
20
+ export async function resolveReddit(input: ResolveContext): Promise<PostfetchResult> {
21
+ const id = await postId(input.net, input.url);
22
+ const token = await appToken(input.net);
23
+ const post = await fetchPost(input.net, token, id);
24
+ const items = await mediaItems(input, post, id);
25
+ if (items.length === 0) {
26
+ throw new Error("Reddit media not found");
27
+ }
28
+ return { archiveFilename: filename(`reddit_${id}.zip`), id, items, platform: "reddit" };
29
+ }
30
+
31
+ // Share and short links (redd.it/<id>, /r/<sub>/s/<share>) carry no post id, so
32
+ // they are followed once to read the canonical /comments/<id> location.
33
+ async function postId(net: Net, input: string): Promise<string> {
34
+ const direct = parseId(input);
35
+ if (direct) {
36
+ return direct;
37
+ }
38
+ const response = await net(input, { headers: { "user-agent": browserUserAgent() } }, 1);
39
+ const resolved = parseId(response.url);
40
+ if (!resolved) {
41
+ throw new Error("Reddit post id not found");
42
+ }
43
+ return resolved;
44
+ }
45
+
46
+ function parseId(input: string): string | null {
47
+ const url = asUrl(input);
48
+ if (url.hostname === "redd.it") {
49
+ const slug = url.pathname.split("/").filter(Boolean)[0];
50
+ return slug && /^[a-z0-9]+$/i.test(slug) ? slug : null;
51
+ }
52
+ return url.pathname.match(/\/comments\/([a-z0-9]+)/i)?.[1] ?? null;
53
+ }
54
+
55
+ async function appToken(net: Net): Promise<string> {
56
+ const response = await net("https://www.reddit.com/api/v1/access_token", {
57
+ body: "grant_type=https://oauth.reddit.com/grants/installed_client&device_id=DO_NOT_TRACK_THIS_DEVICE",
58
+ headers: {
59
+ authorization: `Basic ${btoa(`${installedClientId}:`)}`,
60
+ "content-type": "application/x-www-form-urlencoded",
61
+ "user-agent": browserUserAgent(),
62
+ },
63
+ method: "POST",
64
+ });
65
+ if (!response.ok) {
66
+ throw new Error(`Reddit auth failed: ${response.status}`);
67
+ }
68
+ const payload = await response.json().catch(() => null);
69
+ const token = object(payload) ? string(payload.access_token) : null;
70
+ if (!token) {
71
+ throw new Error("Reddit token not found");
72
+ }
73
+ return token;
74
+ }
75
+
76
+ async function fetchPost(net: Net, token: string, id: string): Promise<Json> {
77
+ const response = await net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=1`, {
78
+ headers: { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() },
79
+ });
80
+ if (!response.ok) {
81
+ throw new Error(`Reddit post failed: ${response.status}`);
82
+ }
83
+ const payload = await response.json().catch(() => null);
84
+ const listing = Array.isArray(payload) ? payload[0] : null;
85
+ const children = object(listing) && object(listing.data) && Array.isArray(listing.data.children) ? listing.data.children : [];
86
+ const first = children[0];
87
+ const post = object(first) && object(first.data) ? first.data : null;
88
+ if (!post) {
89
+ throw new Error("Reddit post not found");
90
+ }
91
+ return post;
92
+ }
93
+
94
+ async function mediaItems(input: ResolveContext, post: Json, id: string): Promise<MediaItem[]> {
95
+ const own = await extract(input, post, id);
96
+ if (own.length > 0) {
97
+ return own;
98
+ }
99
+ const parents = Array.isArray(post.crosspost_parent_list) ? post.crosspost_parent_list.filter(object) : [];
100
+ return parents[0] ? extract(input, parents[0], id) : [];
101
+ }
102
+
103
+ async function extract(input: ResolveContext, post: Json, id: string): Promise<MediaItem[]> {
104
+ const gallery = galleryItems(post, id);
105
+ if (gallery.length > 0) {
106
+ return gallery;
107
+ }
108
+ const video = await videoItems(input, post, id);
109
+ if (video.length > 0) {
110
+ return video;
111
+ }
112
+ return imageItems(post, id);
113
+ }
114
+
115
+ function galleryItems(post: Json, id: string): MediaItem[] {
116
+ const data = object(post.gallery_data) ? post.gallery_data : null;
117
+ const order = data && Array.isArray(data.items) ? data.items.filter(object) : [];
118
+ const metadata = object(post.media_metadata) ? post.media_metadata : {};
119
+ return order.flatMap((entry, index) => {
120
+ const mediaId = string(entry.media_id);
121
+ const meta = mediaId && object(metadata[mediaId]) ? metadata[mediaId] : null;
122
+ const best = meta && object(meta.s) ? meta.s : null;
123
+ if (!best) {
124
+ return [];
125
+ }
126
+ const video = string(best.mp4);
127
+ if (video) {
128
+ return [mediaItem(id, index + 1, "video", "video/mp4", video)];
129
+ }
130
+ const image = string(best.u) ?? string(best.gif);
131
+ return image ? [mediaItem(id, index + 1, "image", mimeOf(string(meta?.m), image), image)] : [];
132
+ });
133
+ }
134
+
135
+ type Rendition = { width: number | null; bandwidth: number; base: string };
136
+
137
+ async function videoItems(input: ResolveContext, post: Json, id: string): Promise<MediaItem[]> {
138
+ const media = redditVideo(post);
139
+ const fallback = media ? string(media.fallback_url) : null;
140
+ if (!media || !fallback) {
141
+ return [];
142
+ }
143
+ if (media.has_audio !== true) {
144
+ return [mediaItem(id, null, "video", "video/mp4", fallback)];
145
+ }
146
+ // The fallback stream is video-only; reddit keeps audio in a separate DASH
147
+ // rendition, so a complete file is the video and audio streams merged.
148
+ const dashUrl = string(media.dash_url);
149
+ const streams = dashUrl ? await dashStreams(input.net, dashUrl, input.preferredWidth) : null;
150
+ if (!streams) {
151
+ throw new Error("Reddit DASH manifest unavailable for muxing");
152
+ }
153
+ return [muxedVideoItem(id, streams.video, streams.audio)];
154
+ }
155
+
156
+ async function dashStreams(net: Net, dashUrl: string, preferredWidth: number): Promise<{ video: string; audio: string } | null> {
157
+ const response = await net(dashUrl, { headers: { "user-agent": browserUserAgent() } });
158
+ if (!response.ok) {
159
+ return null;
160
+ }
161
+ const manifest = await response.text();
162
+ const base = dashUrl.slice(0, dashUrl.lastIndexOf("/") + 1);
163
+ const query = dashUrl.includes("?") ? dashUrl.slice(dashUrl.indexOf("?")) : "";
164
+ const video = videoRendition(manifest, preferredWidth);
165
+ const audio = audioRendition(manifest);
166
+ if (!video || !audio) {
167
+ return null;
168
+ }
169
+ return { video: `${base}${video}${query}`, audio: `${base}${audio}${query}` };
170
+ }
171
+
172
+ function videoRendition(manifest: string, preferredWidth: number): string | null {
173
+ const renditions = renditionsOf(manifest, false);
174
+ const sized = renditions.filter((rendition) => rendition.width !== null);
175
+ const pool = sized.length > 0 ? sized : renditions;
176
+ const best = pool.reduce<Rendition | null>((current, rendition) => {
177
+ if (!current) {
178
+ return rendition;
179
+ }
180
+ if (rendition.width !== null && current.width !== null) {
181
+ return Math.abs(rendition.width - preferredWidth) < Math.abs(current.width - preferredWidth) ? rendition : current;
182
+ }
183
+ return rendition.bandwidth > current.bandwidth ? rendition : current;
184
+ }, null);
185
+ return best ? best.base : null;
186
+ }
187
+
188
+ function audioRendition(manifest: string): string | null {
189
+ const best = renditionsOf(manifest, true).reduce<Rendition | null>(
190
+ (current, rendition) => (!current || rendition.bandwidth > current.bandwidth ? rendition : current),
191
+ null,
192
+ );
193
+ return best ? best.base : null;
194
+ }
195
+
196
+ function renditionsOf(manifest: string, audio: boolean): Rendition[] {
197
+ return manifest
198
+ .split("<AdaptationSet")
199
+ .slice(1)
200
+ .filter((chunk) => isAudioSet(chunk) === audio)
201
+ .flatMap((chunk) =>
202
+ [...chunk.matchAll(/<Representation\b([^>]*)>([\s\S]*?)<\/Representation>/g)].flatMap((match) => {
203
+ const segment = match[2].match(/<BaseURL>([^<]+)<\/BaseURL>/)?.[1];
204
+ if (!segment) {
205
+ return [];
206
+ }
207
+ const width = match[1].match(/(?:^|[^a-zA-Z])width="(\d+)"/)?.[1];
208
+ return [{ width: width ? Number(width) : null, bandwidth: Number(match[1].match(/bandwidth="(\d+)"/)?.[1] ?? 0), base: segment }];
209
+ }),
210
+ );
211
+ }
212
+
213
+ function isAudioSet(chunk: string): boolean {
214
+ const head = chunk.slice(0, chunk.indexOf(">"));
215
+ return /contentType="audio"/.test(head) || /mimeType="audio/.test(head);
216
+ }
217
+
218
+ function muxedVideoItem(id: string, videoUrl: string, audioUrl: string): MediaItem {
219
+ return {
220
+ audio: { headers: { "user-agent": browserUserAgent() }, url: audioUrl },
221
+ filename: filename(`reddit_${id}.mp4`),
222
+ headers: { "user-agent": browserUserAgent() },
223
+ id,
224
+ kind: "video",
225
+ mime: "video/mp4",
226
+ platform: "reddit",
227
+ url: videoUrl,
228
+ };
229
+ }
230
+
231
+ function redditVideo(post: Json): Json | null {
232
+ const secure = object(post.secure_media) ? post.secure_media : null;
233
+ const media = object(post.media) ? post.media : null;
234
+ const fromSecure = secure && object(secure.reddit_video) ? secure.reddit_video : null;
235
+ const fromMedia = media && object(media.reddit_video) ? media.reddit_video : null;
236
+ return fromSecure ?? fromMedia;
237
+ }
238
+
239
+ function imageItems(post: Json, id: string): MediaItem[] {
240
+ const url = string(post.url_overridden_by_dest) ?? string(post.url);
241
+ if (!url) {
242
+ return [];
243
+ }
244
+ const path = asUrl(url).pathname;
245
+ const isImage = string(post.post_hint) === "image" || asUrl(url).hostname === "i.redd.it" || /\.(jpe?g|png|webp|gif)$/i.test(path);
246
+ return isImage ? [mediaItem(id, null, "image", mimeOf(null, url), url)] : [];
247
+ }
248
+
249
+ function mediaItem(id: string, index: number | null, kind: MediaItem["kind"], mime: string, url: string): MediaItem {
250
+ const suffix = index === null ? "" : `_${index}`;
251
+ return {
252
+ filename: filename(`reddit_${id}${suffix}.${extensionOf(mime)}`),
253
+ headers: { "user-agent": browserUserAgent() },
254
+ id,
255
+ kind,
256
+ mime,
257
+ platform: "reddit",
258
+ url,
259
+ };
260
+ }
261
+
262
+ function mimeOf(declared: string | null, url: string): string {
263
+ if (declared && declared.includes("/")) {
264
+ return declared.replace("image/jpg", "image/jpeg");
265
+ }
266
+ const extension = asUrl(url).pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
267
+ if (extension === "png") {
268
+ return "image/png";
269
+ }
270
+ if (extension === "webp") {
271
+ return "image/webp";
272
+ }
273
+ if (extension === "gif") {
274
+ return "image/gif";
275
+ }
276
+ return "image/jpeg";
277
+ }
278
+
279
+ function extensionOf(mime: string): string {
280
+ if (mime === "video/mp4") {
281
+ return "mp4";
282
+ }
283
+ if (mime === "image/png") {
284
+ return "png";
285
+ }
286
+ if (mime === "image/webp") {
287
+ return "webp";
288
+ }
289
+ if (mime === "image/gif") {
290
+ return "gif";
291
+ }
292
+ return "jpg";
293
+ }