@postfetch/core 0.2.4 → 0.3.1

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.
@@ -1,13 +1,30 @@
1
1
  /** A platform that {@link postfetch} can resolve. */
2
- export type Platform = "facebook" | "instagram" | "tiktok" | "twitter" | "youtube";
2
+ export type Platform = "facebook" | "instagram" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
3
3
  /** The kind of a {@link MediaItem}. */
4
4
  export type MediaKind = "audio" | "image" | "video";
5
5
  /** A single resolved media file: a direct URL plus everything needed to fetch and name it. */
6
6
  export type MediaItem = {
7
+ /**
8
+ * When present, this is a video whose audio is a separate stream: {@link MediaItem.url}
9
+ * carries the video track and `audio.url` the audio track (DASH/CMAF hands them out
10
+ * apart). {@link download}, {@link archive} and {@link toResponse} merge the two with
11
+ * the bundled remuxer; callers that read `url` directly get the silent video.
12
+ */
13
+ audio?: {
14
+ headers: HeadersInit;
15
+ url: string;
16
+ };
7
17
  /** Suggested download filename. */
8
18
  filename: string;
9
19
  /** Headers required to fetch {@link MediaItem.url} from the CDN. */
10
20
  headers: HeadersInit;
21
+ /**
22
+ * When `true`, {@link MediaItem.url} (and `audio.url`, if set) are HLS media
23
+ * playlists rather than direct files: {@link download} assembles their segments
24
+ * into a fragmented MP4 before serving — and merges video with audio when both
25
+ * are present. Callers that read `url` directly get the playlist, not the media.
26
+ */
27
+ hls?: boolean;
11
28
  /** Platform media id. */
12
29
  id: string;
13
30
  /** Whether this item is a video, image or audio file. */
@@ -0,0 +1,20 @@
1
+ export type Box = {
2
+ type: string;
3
+ /** Offset of the box header (its size field). */
4
+ start: number;
5
+ /** Offset just past the box. */
6
+ end: number;
7
+ /** Offset of the box payload (after the size/type, and any 64-bit largesize). */
8
+ dataStart: number;
9
+ };
10
+ export declare function parseBoxes(data: Uint8Array, start?: number, end?: number): Box[];
11
+ export declare function findBox(boxes: Box[], type: string): Box | undefined;
12
+ export declare function childBoxes(data: Uint8Array, box: Box): Box[];
13
+ /** A standalone copy of a box's bytes, header included. */
14
+ export declare function sliceBox(data: Uint8Array, box: Box): Uint8Array;
15
+ export declare function buildBox(type: string, payload: Uint8Array): Uint8Array;
16
+ export declare function concat(parts: Uint8Array[]): Uint8Array;
17
+ export declare function readU32(data: Uint8Array, offset: number): number;
18
+ export declare function writeU32(data: Uint8Array, offset: number, value: number): void;
19
+ /** The version byte of a full box (the first byte of its payload). */
20
+ export declare function boxVersion(data: Uint8Array, box: Box): number;
@@ -0,0 +1,2 @@
1
+ import { type ResolveContext, type PostfetchResult } from "./internal";
2
+ export declare function resolvePinterest(input: ResolveContext): Promise<PostfetchResult>;
@@ -0,0 +1,2 @@
1
+ import { type ResolveContext, type PostfetchResult } from "./internal";
2
+ export declare function resolveReddit(input: ResolveContext): Promise<PostfetchResult>;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Merge a video-only and an audio-only fragmented MP4 into one file with both
3
+ * tracks. The DASH/CMAF streams reddit and YouTube hand out keep video and audio
4
+ * apart; this recombines them without re-encoding — every `moof`/`mdat` pair is
5
+ * copied verbatim (so each `trun` data offset stays valid) and only the track id
6
+ * and fragment sequence number are patched.
7
+ */
8
+ export declare function mergeAudioVideo(video: Uint8Array, audio: Uint8Array): Uint8Array;
@@ -0,0 +1,2 @@
1
+ import { type ResolveContext, type PostfetchResult } from "./internal";
2
+ export declare function resolveSoundcloud(input: ResolveContext): Promise<PostfetchResult>;
package/dist/tiktok.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import { type ResolveContext, type PostfetchResult } from "./internal";
2
2
  export declare function resolveTiktok(input: ResolveContext): Promise<PostfetchResult>;
3
+ export declare function isShortlinkHost(hostname: string): boolean;
package/dist/twitter.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import { type ResolveContext, type PostfetchResult } from "./internal";
2
2
  export declare function resolveTwitter(input: ResolveContext): Promise<PostfetchResult>;
3
+ export declare function tweetId(input: string): string | null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.2.4",
4
- "description": "Zero-dependency typed core that turns Facebook, Instagram, TikTok, X and YouTube post URLs into media files.",
3
+ "version": "0.3.1",
4
+ "description": "Zero-dependency typed core that turns Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
@@ -31,6 +31,9 @@
31
31
  "facebook",
32
32
  "instagram",
33
33
  "media",
34
+ "pinterest",
35
+ "reddit",
36
+ "soundcloud",
34
37
  "tiktok",
35
38
  "twitter",
36
39
  "youtube",
package/src/download.ts CHANGED
@@ -1,6 +1,14 @@
1
- import { createNet, PostfetchError, type MediaItem, type PostfetchResult } from "./internal";
1
+ import { assembleHls } from "./hls";
2
+ import { createNet, PostfetchError, type MediaItem, type Net, type PostfetchResult } from "./internal";
3
+ import { mergeAudioVideo } from "./remux";
2
4
  import { zip } from "./zip";
3
5
 
6
+ // A muxed item carries a separate audio stream; an HLS item carries playlists.
7
+ // Either way the bytes must be assembled in-process rather than streamed.
8
+ function buffered(item: MediaItem): boolean {
9
+ return Boolean(item.audio) || item.hls === true;
10
+ }
11
+
4
12
  /** Options for {@link download}, {@link archive} and {@link toResponse}. */
5
13
  export type DownloadOptions = {
6
14
  /** Custom `fetch` implementation. Defaults to the global `fetch`. */
@@ -18,11 +26,13 @@ export type Archive = {
18
26
  };
19
27
 
20
28
  /**
21
- * Fetch a single media item from its CDN with the headers it requires.
29
+ * Fetch a single media item, ready to stream or buffer. A plain item streams
30
+ * straight from its CDN; a muxed item (one with {@link MediaItem.audio}) is
31
+ * downloaded as two streams and merged in-process, so its body is buffered.
22
32
  *
23
33
  * @param item A {@link MediaItem} from a {@link PostfetchResult}.
24
34
  * @param options See {@link DownloadOptions}.
25
- * @returns The upstream `Response`, ready to stream or buffer.
35
+ * @returns The upstream `Response`, or a buffered `Response` of the merged file.
26
36
  * @throws {PostfetchError} If the CDN responds with a non-OK status.
27
37
  *
28
38
  * @example
@@ -33,6 +43,10 @@ export type Archive = {
33
43
  */
34
44
  export async function download(item: MediaItem, options: DownloadOptions = {}): Promise<Response> {
35
45
  const net = createNet(options.fetch ?? globalThis.fetch);
46
+ if (buffered(item)) {
47
+ const bytes = await itemBytes(net, item);
48
+ return new Response(toArrayBuffer(bytes), { headers: { "content-length": String(bytes.length), "content-type": item.mime } });
49
+ }
36
50
  const response = await net(item.url, { headers: item.headers });
37
51
  if (!response.ok || !response.body) {
38
52
  throw new PostfetchError(502, `download failed: ${response.status}`);
@@ -50,21 +64,16 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
50
64
  export async function archive(result: PostfetchResult, options: DownloadOptions = {}): Promise<Archive> {
51
65
  const net = createNet(options.fetch ?? globalThis.fetch);
52
66
  const files = await Promise.all(
53
- result.items.map(async (item) => {
54
- const response = await net(item.url, { headers: item.headers });
55
- if (!response.ok) {
56
- throw new PostfetchError(502, `download failed: ${response.status}`);
57
- }
58
- return { data: new Uint8Array(await response.arrayBuffer()), name: item.filename };
59
- }),
67
+ result.items.map(async (item) => ({ data: await itemBytes(net, item), name: item.filename })),
60
68
  );
61
69
  return { bytes: zip(files), filename: result.archiveFilename, mime: "application/zip" };
62
70
  }
63
71
 
64
72
  /**
65
73
  * Turn a result into a ready-to-serve `Response`: a single item is streamed as
66
- * its file, multiple items become a zip. Sets `content-disposition` and the
67
- * `x-media-*` headers. This is what the showcase server and templates return.
74
+ * its file (or, when muxed, merged and buffered), multiple items become a zip.
75
+ * Sets `content-disposition` and the `x-media-*` headers. This is what the
76
+ * showcase server and templates return.
68
77
  *
69
78
  * @param result A {@link PostfetchResult}.
70
79
  * @param options See {@link DownloadOptions}.
@@ -78,6 +87,21 @@ export async function toResponse(result: PostfetchResult, options: DownloadOptio
78
87
  }
79
88
 
80
89
  async function singleResponse(item: MediaItem, options: DownloadOptions): Promise<Response> {
90
+ if (buffered(item)) {
91
+ const net = createNet(options.fetch ?? globalThis.fetch);
92
+ const bytes = await itemBytes(net, item);
93
+ return new Response(toArrayBuffer(bytes), {
94
+ headers: {
95
+ "content-disposition": `attachment; filename="${item.filename}"`,
96
+ "content-length": String(bytes.length),
97
+ "content-type": item.mime,
98
+ "x-media-count": "1",
99
+ "x-media-id": item.id,
100
+ "x-media-kind": item.kind,
101
+ "x-media-platform": item.platform,
102
+ },
103
+ });
104
+ }
81
105
  const media = await download(item, options);
82
106
  const headers = new Headers({
83
107
  "content-disposition": `attachment; filename="${item.filename}"`,
@@ -96,8 +120,7 @@ async function singleResponse(item: MediaItem, options: DownloadOptions): Promis
96
120
 
97
121
  async function archiveResponse(result: PostfetchResult, options: DownloadOptions): Promise<Response> {
98
122
  const { bytes, filename } = await archive(result, options);
99
- const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
100
- return new Response(body, {
123
+ return new Response(toArrayBuffer(bytes), {
101
124
  headers: {
102
125
  "content-disposition": `attachment; filename="${filename}"`,
103
126
  "content-length": String(bytes.length),
@@ -108,3 +131,38 @@ async function archiveResponse(result: PostfetchResult, options: DownloadOptions
108
131
  },
109
132
  });
110
133
  }
134
+
135
+ async function itemBytes(net: Net, item: MediaItem): Promise<Uint8Array> {
136
+ if (item.hls) {
137
+ const video = await assembleHls(net, item.url, item.headers);
138
+ if (item.audio) {
139
+ return mergeAudioVideo(video, await assembleHls(net, item.audio.url, item.audio.headers));
140
+ }
141
+ return video;
142
+ }
143
+ if (item.audio) {
144
+ return mergedBytes(net, item, item.audio);
145
+ }
146
+ return fetchBytes(net, item.url, item.headers);
147
+ }
148
+
149
+ // A muxed item's two streams are fetched in parallel and merged in a single pass.
150
+ async function mergedBytes(net: Net, item: MediaItem, audio: NonNullable<MediaItem["audio"]>): Promise<Uint8Array> {
151
+ const [video, audioBytes] = await Promise.all([
152
+ fetchBytes(net, item.url, item.headers),
153
+ fetchBytes(net, audio.url, audio.headers),
154
+ ]);
155
+ return mergeAudioVideo(video, audioBytes);
156
+ }
157
+
158
+ async function fetchBytes(net: Net, url: string, headers: HeadersInit): Promise<Uint8Array> {
159
+ const response = await net(url, { headers });
160
+ if (!response.ok) {
161
+ throw new PostfetchError(502, `download failed: ${response.status}`);
162
+ }
163
+ return new Uint8Array(await response.arrayBuffer());
164
+ }
165
+
166
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
167
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
168
+ }
package/src/hls.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { concat } from "./isobmff";
2
+ import type { Net } from "./internal";
3
+
4
+ // HLS support for the fMP4/CMAF playlists reddit-style muxing already covers:
5
+ // a media playlist is an EXT-X-MAP init plus media segments (often byte ranges of
6
+ // one CMAF file), so assembling it is just fetching those byte ranges in order and
7
+ // concatenating them into a fragmented MP4. No MPEG-TS demuxing is involved.
8
+
9
+ type Segment = { url: string; range: { length: number; offset: number } | null };
10
+
11
+ export type HlsVariant = { width: number; height: number; bandwidth: number; url: string; audioGroup: string | null };
12
+ export type HlsMaster = { variants: HlsVariant[]; audio: Record<string, string> };
13
+
14
+ export function isMasterPlaylist(text: string): boolean {
15
+ return text.includes("#EXT-X-STREAM-INF");
16
+ }
17
+
18
+ export function parseMaster(text: string, baseUrl: string): HlsMaster {
19
+ const lines = text.split("\n").map((line) => line.trim());
20
+ const audio: Record<string, string> = {};
21
+ const variants: HlsVariant[] = [];
22
+ for (let index = 0; index < lines.length; index += 1) {
23
+ const line = lines[index];
24
+ if (line.startsWith("#EXT-X-MEDIA:") && /TYPE=AUDIO/.test(line)) {
25
+ const group = line.match(/GROUP-ID="([^"]+)"/)?.[1];
26
+ const uri = line.match(/URI="([^"]+)"/)?.[1];
27
+ if (group && uri) {
28
+ audio[group] = new URL(uri, baseUrl).href;
29
+ }
30
+ } else if (line.startsWith("#EXT-X-STREAM-INF:")) {
31
+ const target = lines[index + 1];
32
+ if (target && !target.startsWith("#")) {
33
+ const resolution = line.match(/RESOLUTION=(\d+)x(\d+)/);
34
+ variants.push({
35
+ width: resolution ? Number(resolution[1]) : 0,
36
+ height: resolution ? Number(resolution[2]) : 0,
37
+ bandwidth: Number(line.match(/BANDWIDTH=(\d+)/)?.[1] ?? 0),
38
+ url: new URL(target, baseUrl).href,
39
+ audioGroup: line.match(/AUDIO="([^"]+)"/)?.[1] ?? null,
40
+ });
41
+ }
42
+ }
43
+ }
44
+ return { variants, audio };
45
+ }
46
+
47
+ export async function assembleHls(net: Net, playlistUrl: string, headers: HeadersInit): Promise<Uint8Array> {
48
+ const response = await net(playlistUrl, { headers });
49
+ if (!response.ok) {
50
+ throw new Error(`HLS playlist failed: ${response.status}`);
51
+ }
52
+ const segments = mediaSegments(await response.text(), playlistUrl);
53
+ if (segments.length === 0) {
54
+ throw new Error("HLS playlist has no segments");
55
+ }
56
+ return concat(await fetchSegments(net, segments, headers));
57
+ }
58
+
59
+ function mediaSegments(text: string, baseUrl: string): Segment[] {
60
+ const segments: Segment[] = [];
61
+ let pendingRange: { length: number; offset: number } | null = null;
62
+ let nextOffset = 0;
63
+ for (const raw of text.split("\n")) {
64
+ const line = raw.trim();
65
+ if (line.startsWith("#EXT-X-MAP:")) {
66
+ const uri = line.match(/URI="([^"]+)"/)?.[1];
67
+ if (uri) {
68
+ const range = byteRange(line.match(/BYTERANGE="([^"]+)"/)?.[1], nextOffset);
69
+ segments.push({ url: new URL(uri, baseUrl).href, range });
70
+ nextOffset = range ? range.offset + range.length : nextOffset;
71
+ }
72
+ } else if (line.startsWith("#EXT-X-BYTERANGE:")) {
73
+ pendingRange = byteRange(line.slice("#EXT-X-BYTERANGE:".length), nextOffset);
74
+ nextOffset = pendingRange ? pendingRange.offset + pendingRange.length : nextOffset;
75
+ } else if (line.length > 0 && !line.startsWith("#")) {
76
+ segments.push({ url: new URL(line, baseUrl).href, range: pendingRange });
77
+ pendingRange = null;
78
+ }
79
+ }
80
+ return segments;
81
+ }
82
+
83
+ // EXT-X-BYTERANGE is `length[@offset]`; a missing offset continues from the end of
84
+ // the previous sub-range of the same resource.
85
+ function byteRange(spec: string | undefined, runningOffset: number): { length: number; offset: number } | null {
86
+ const match = spec?.trim().match(/^(\d+)(?:@(\d+))?$/);
87
+ if (!match) {
88
+ return null;
89
+ }
90
+ return { length: Number(match[1]), offset: match[2] === undefined ? runningOffset : Number(match[2]) };
91
+ }
92
+
93
+ // Segments are fetched with a small concurrency window: parallel enough to be fast,
94
+ // bounded so a long playlist does not open hundreds of sockets at once.
95
+ async function fetchSegments(net: Net, segments: Segment[], headers: HeadersInit): Promise<Uint8Array[]> {
96
+ const results = new Array<Uint8Array>(segments.length);
97
+ let cursor = 0;
98
+ async function worker(): Promise<void> {
99
+ while (cursor < segments.length) {
100
+ const index = cursor;
101
+ cursor += 1;
102
+ results[index] = await fetchSegment(net, segments[index], headers);
103
+ }
104
+ }
105
+ const workers = Array.from({ length: Math.min(8, segments.length) }, worker);
106
+ await Promise.all(workers);
107
+ return results;
108
+ }
109
+
110
+ async function fetchSegment(net: Net, segment: Segment, headers: HeadersInit): Promise<Uint8Array> {
111
+ const requestHeaders = new Headers(headers);
112
+ if (segment.range) {
113
+ requestHeaders.set("range", `bytes=${segment.range.offset}-${segment.range.offset + segment.range.length - 1}`);
114
+ }
115
+ const response = await net(segment.url, { headers: requestHeaders });
116
+ if (!response.ok) {
117
+ throw new Error(`HLS segment failed: ${response.status}`);
118
+ }
119
+ return new Uint8Array(await response.arrayBuffer());
120
+ }
package/src/internal.ts CHANGED
@@ -1,15 +1,29 @@
1
1
  /** A platform that {@link postfetch} can resolve. */
2
- export type Platform = "facebook" | "instagram" | "tiktok" | "twitter" | "youtube";
2
+ export type Platform = "facebook" | "instagram" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
3
3
 
4
4
  /** The kind of a {@link MediaItem}. */
5
5
  export type MediaKind = "audio" | "image" | "video";
6
6
 
7
7
  /** A single resolved media file: a direct URL plus everything needed to fetch and name it. */
8
8
  export type MediaItem = {
9
+ /**
10
+ * When present, this is a video whose audio is a separate stream: {@link MediaItem.url}
11
+ * carries the video track and `audio.url` the audio track (DASH/CMAF hands them out
12
+ * apart). {@link download}, {@link archive} and {@link toResponse} merge the two with
13
+ * the bundled remuxer; callers that read `url` directly get the silent video.
14
+ */
15
+ audio?: { headers: HeadersInit; url: string };
9
16
  /** Suggested download filename. */
10
17
  filename: string;
11
18
  /** Headers required to fetch {@link MediaItem.url} from the CDN. */
12
19
  headers: HeadersInit;
20
+ /**
21
+ * When `true`, {@link MediaItem.url} (and `audio.url`, if set) are HLS media
22
+ * playlists rather than direct files: {@link download} assembles their segments
23
+ * into a fragmented MP4 before serving — and merges video with audio when both
24
+ * are present. Callers that read `url` directly get the playlist, not the media.
25
+ */
26
+ hls?: boolean;
13
27
  /** Platform media id. */
14
28
  id: string;
15
29
  /** Whether this item is a video, image or audio file. */
package/src/isobmff.ts ADDED
@@ -0,0 +1,85 @@
1
+ // A minimal ISO Base Media File Format reader/writer — just enough to remux
2
+ // fragmented MP4 (CMAF/DASH) by recombining whole boxes. It never decodes media,
3
+ // only walks the box tree and patches a handful of integer fields in place.
4
+
5
+ export type Box = {
6
+ type: string;
7
+ /** Offset of the box header (its size field). */
8
+ start: number;
9
+ /** Offset just past the box. */
10
+ end: number;
11
+ /** Offset of the box payload (after the size/type, and any 64-bit largesize). */
12
+ dataStart: number;
13
+ };
14
+
15
+ export function parseBoxes(data: Uint8Array, start = 0, end = data.length): Box[] {
16
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
17
+ const boxes: Box[] = [];
18
+ let offset = start;
19
+ while (offset + 8 <= end) {
20
+ let size = view.getUint32(offset);
21
+ const type = String.fromCharCode(data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]);
22
+ let dataStart = offset + 8;
23
+ if (size === 1) {
24
+ size = Number(view.getBigUint64(offset + 8));
25
+ dataStart = offset + 16;
26
+ } else if (size === 0) {
27
+ size = end - offset;
28
+ }
29
+ if (size < 8 || offset + size > end) {
30
+ break;
31
+ }
32
+ boxes.push({ type, start: offset, end: offset + size, dataStart });
33
+ offset += size;
34
+ }
35
+ return boxes;
36
+ }
37
+
38
+ export function findBox(boxes: Box[], type: string): Box | undefined {
39
+ return boxes.find((box) => box.type === type);
40
+ }
41
+
42
+ export function childBoxes(data: Uint8Array, box: Box): Box[] {
43
+ return parseBoxes(data, box.dataStart, box.end);
44
+ }
45
+
46
+ /** A standalone copy of a box's bytes, header included. */
47
+ export function sliceBox(data: Uint8Array, box: Box): Uint8Array {
48
+ return data.slice(box.start, box.end);
49
+ }
50
+
51
+ export function buildBox(type: string, payload: Uint8Array): Uint8Array {
52
+ const out = new Uint8Array(8 + payload.length);
53
+ const view = new DataView(out.buffer);
54
+ view.setUint32(0, out.length);
55
+ out[4] = type.charCodeAt(0);
56
+ out[5] = type.charCodeAt(1);
57
+ out[6] = type.charCodeAt(2);
58
+ out[7] = type.charCodeAt(3);
59
+ out.set(payload, 8);
60
+ return out;
61
+ }
62
+
63
+ export function concat(parts: Uint8Array[]): Uint8Array {
64
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
65
+ const out = new Uint8Array(total);
66
+ let offset = 0;
67
+ for (const part of parts) {
68
+ out.set(part, offset);
69
+ offset += part.length;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ export function readU32(data: Uint8Array, offset: number): number {
75
+ return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset);
76
+ }
77
+
78
+ export function writeU32(data: Uint8Array, offset: number, value: number): void {
79
+ new DataView(data.buffer, data.byteOffset, data.byteLength).setUint32(offset, value);
80
+ }
81
+
82
+ /** The version byte of a full box (the first byte of its payload). */
83
+ export function boxVersion(data: Uint8Array, box: Box): number {
84
+ return data[box.dataStart];
85
+ }