@postfetch/core 0.9.1 → 0.9.3

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/README.md CHANGED
@@ -13,9 +13,11 @@ for (const item of result.items) {
13
13
 
14
14
  `postfetch(url, options?)` detects the platform and resolves its media into a typed `PostfetchResult` (URLs plus the headers needed to fetch them). It performs no side effects beyond the lookup — `download`, `downloadBlob`, `archive` and `toResponse` materialize the result.
15
15
 
16
- `downloadBlob(url, options?)` materializes an already-resolved media URL for
17
- clients that need an uploadable `Blob`. Pass the resolved item's headers for
18
- protected CDN URLs: `downloadBlob(item.url, { headers: item.headers })`. Set
16
+ `downloadBlob(itemOrUrl, options?)` materializes resolved media for clients that
17
+ need an uploadable `Blob`. Prefer `downloadBlob(item, { remux: true })` to
18
+ include separate audio, assemble HLS playlists and use the item's headers.
19
+ Passing only `item.url` fetches that URL alone and may omit separate audio;
20
+ for direct URLs, pass `{ headers: item.headers }` when needed. Set
19
21
  `remux: true` to return `{ blob, thumbnail, width, height, duration }`: a
20
22
  normalized MP4, upload thumbnail and calculated presentation metadata. Remuxing
21
23
  defaults to off, uses `ffmpeg` and `ffprobe` from `PATH` unless their paths are
@@ -50,31 +50,32 @@ export type Archive = {
50
50
  */
51
51
  export declare function download(item: MediaItem, options?: DownloadOptions): Promise<Response>;
52
52
  /**
53
- * Download an already-resolved media URL as a Blob.
53
+ * Download a resolved media item or direct media URL as a Blob.
54
54
  *
55
55
  * This is useful for consumers that need to upload media themselves instead of
56
- * passing its URL to a third party. When the resolved {@link MediaItem} carries
57
- * required CDN headers, pass them in the options object. Set `remux: true` to
56
+ * passing its URL to a third party. Pass the full {@link MediaItem} to include
57
+ * separate audio, HLS playlists and required headers. A direct URL only fetches
58
+ * that URL; pass its required CDN headers in the options object. Set `remux: true` to
58
59
  * normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
59
60
  * remux or metadata extraction fails, the operation throws.
60
61
  *
61
- * @param url A direct media URL returned by a resolver.
62
+ * @param input A resolved media item (recommended) or direct media URL.
62
63
  * @param options Headers, fetch implementation and opt-in remux behavior.
63
64
  * @returns A Blob, or with `remux: true`, the normalized video and its metadata.
64
65
  *
65
66
  * @example Upload a video with FormData
66
67
  * ```ts
67
68
  * const [media] = (await postfetch(sourceUrl)).items;
68
- * const video = await downloadBlob(media.url, { headers: media.headers, remux: true });
69
+ * const video = await downloadBlob(media, { remux: true });
69
70
  * form.append("video", video.blob, media.filename);
70
71
  * form.append("thumbnail", video.thumbnail, "thumbnail.jpg");
71
72
  * ```
72
73
  */
73
- export declare function downloadBlob(url: string, options: DownloadBlobOptions & {
74
+ export declare function downloadBlob(input: string | MediaItem, options: DownloadBlobOptions & {
74
75
  remux: true;
75
76
  }): Promise<RemuxedVideo>;
76
77
  /** Download without remuxing and return the upstream media Blob. */
77
- export declare function downloadBlob(url: string, options?: DownloadBlobOptions & {
78
+ export declare function downloadBlob(input: string | MediaItem, options?: DownloadBlobOptions & {
78
79
  remux?: false;
79
80
  }): Promise<Blob>;
80
81
  /** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
package/dist/hls.d.ts CHANGED
@@ -5,6 +5,7 @@ export type HlsVariant = {
5
5
  bandwidth: number;
6
6
  url: string;
7
7
  audioGroup: string | null;
8
+ codecs?: string;
8
9
  };
9
10
  export type HlsMaster = {
10
11
  variants: HlsVariant[];
package/dist/index.js CHANGED
@@ -1038,6 +1038,175 @@ function boxVersion(data, box) {
1038
1038
  return data[box.dataStart];
1039
1039
  }
1040
1040
 
1041
+ // src/packed-aac.ts
1042
+ var sampleRates = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
1043
+ var encoder = new TextEncoder;
1044
+ function packedAacToMp4(segments) {
1045
+ const first = segments[0];
1046
+ if (!first || !id3(first, 0) && !adts(first, 0)) {
1047
+ return null;
1048
+ }
1049
+ let config;
1050
+ let nextTimestamp = 0;
1051
+ const fragments = [];
1052
+ for (const segment of segments) {
1053
+ let offset = 0;
1054
+ let timestamp;
1055
+ while (id3(segment, offset)) {
1056
+ const tag = readId3(segment, offset);
1057
+ timestamp ??= tag.timestamp;
1058
+ offset = tag.end;
1059
+ }
1060
+ const samples = [];
1061
+ while (offset < segment.length) {
1062
+ if (offset + 7 > segment.length || !adts(segment, offset)) {
1063
+ throw new Error("Invalid packed AAC frame");
1064
+ }
1065
+ const profile = (segment[offset + 2] >> 6) + 1;
1066
+ const frequencyIndex = segment[offset + 2] >> 2 & 15;
1067
+ const sampleRate = sampleRates[frequencyIndex];
1068
+ const channels = (segment[offset + 2] & 1) << 2 | segment[offset + 3] >> 6;
1069
+ if (profile !== 2 || !sampleRate || sampleRate > 65535 || channels === 0 || (segment[offset + 6] & 3) !== 0) {
1070
+ throw new Error("Unsupported packed AAC configuration (expected AAC-LC)");
1071
+ }
1072
+ if (config && (config.frequencyIndex !== frequencyIndex || config.channels !== channels)) {
1073
+ throw new Error("Packed AAC configuration changed between frames");
1074
+ }
1075
+ config ??= { frequencyIndex, sampleRate, channels };
1076
+ const length = (segment[offset + 3] & 3) << 11 | segment[offset + 4] << 3 | segment[offset + 5] >> 5;
1077
+ const headerLength = segment[offset + 1] & 1 ? 7 : 9;
1078
+ if (length <= headerLength || offset + length > segment.length) {
1079
+ throw new Error("Truncated packed AAC frame");
1080
+ }
1081
+ samples.push(segment.subarray(offset + headerLength, offset + length));
1082
+ offset += length;
1083
+ }
1084
+ if (!config || samples.length === 0) {
1085
+ throw new Error("Packed AAC segment has no audio frames");
1086
+ }
1087
+ const start = timestamp === undefined ? nextTimestamp : Math.round(timestamp * config.sampleRate / 90000);
1088
+ fragments.push({ samples, timestamp: start });
1089
+ nextTimestamp = start + samples.length * 1024;
1090
+ }
1091
+ return concat([initSegment(config), ...fragments.flatMap((fragment, index) => mediaFragment(fragment, index + 1))]);
1092
+ }
1093
+ function id3(bytes, offset) {
1094
+ return bytes[offset] === 73 && bytes[offset + 1] === 68 && bytes[offset + 2] === 51;
1095
+ }
1096
+ function adts(bytes, offset) {
1097
+ return bytes[offset] === 255 && (bytes[offset + 1] & 246) === 240;
1098
+ }
1099
+ function synchsafe(bytes, offset) {
1100
+ if (offset + 4 > bytes.length || bytes.subarray(offset, offset + 4).some((byte) => byte & 128)) {
1101
+ throw new Error("Invalid packed AAC ID3 size");
1102
+ }
1103
+ return bytes[offset] * 2 ** 21 + bytes[offset + 1] * 2 ** 14 + bytes[offset + 2] * 128 + bytes[offset + 3];
1104
+ }
1105
+ function readId3(bytes, offset) {
1106
+ const version = bytes[offset + 3];
1107
+ if (offset + 10 > bytes.length || version !== 3 && version !== 4 || bytes[offset + 5] & 192) {
1108
+ throw new Error("Unsupported packed AAC ID3 tag");
1109
+ }
1110
+ const end = offset + 10 + synchsafe(bytes, offset + 6);
1111
+ if (end > bytes.length) {
1112
+ throw new Error("Truncated packed AAC ID3 tag");
1113
+ }
1114
+ const owner = encoder.encode("com.apple.streaming.transportStreamTimestamp\x00");
1115
+ let timestamp;
1116
+ for (let cursor = offset + 10;cursor + 10 <= end && bytes[cursor] !== 0; ) {
1117
+ const size = version === 4 ? synchsafe(bytes, cursor + 4) : readU32(bytes, cursor + 4);
1118
+ const body = cursor + 10;
1119
+ if (body + size > end) {
1120
+ throw new Error("Truncated packed AAC ID3 frame");
1121
+ }
1122
+ if (String.fromCharCode(...bytes.subarray(cursor, cursor + 4)) === "PRIV" && size === owner.length + 8 && owner.every((byte, index) => bytes[body + index] === byte)) {
1123
+ if (bytes[cursor + 8] !== 0 || bytes[cursor + 9] !== 0) {
1124
+ throw new Error("Unsupported packed AAC timestamp encoding");
1125
+ }
1126
+ timestamp = Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(body + owner.length) & 0x1ffffffffn);
1127
+ }
1128
+ cursor = body + size;
1129
+ }
1130
+ const footer = version === 4 && bytes[offset + 5] & 16 ? 10 : 0;
1131
+ if (end + footer > bytes.length) {
1132
+ throw new Error("Truncated packed AAC ID3 footer");
1133
+ }
1134
+ return { end: end + footer, timestamp };
1135
+ }
1136
+ function u32(...values) {
1137
+ const bytes = new Uint8Array(values.length * 4);
1138
+ values.forEach((value, index) => writeU32(bytes, index * 4, value));
1139
+ return bytes;
1140
+ }
1141
+ function fullBox(type, version, flags, payload) {
1142
+ return buildBox(type, concat([u32(version * 16777216 + flags), payload]));
1143
+ }
1144
+ function matrix(bytes, offset) {
1145
+ bytes.set(u32(65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824), offset);
1146
+ }
1147
+ function initSegment(config) {
1148
+ const mvhd = new Uint8Array(96);
1149
+ writeU32(mvhd, 8, 1000);
1150
+ writeU32(mvhd, 16, 65536);
1151
+ new DataView(mvhd.buffer).setUint16(20, 256);
1152
+ matrix(mvhd, 32);
1153
+ writeU32(mvhd, 92, 2);
1154
+ const tkhd = new Uint8Array(80);
1155
+ writeU32(tkhd, 8, 1);
1156
+ new DataView(tkhd.buffer).setUint16(32, 256);
1157
+ matrix(tkhd, 36);
1158
+ const mdhd = concat([u32(0, 0, config.sampleRate, 0), Uint8Array.of(85, 196, 0, 0)]);
1159
+ const hdlr = concat([u32(0), encoder.encode("soun"), u32(0, 0, 0), encoder.encode("SoundHandler\x00")]);
1160
+ const stbl = buildBox("stbl", concat([
1161
+ fullBox("stsd", 0, 0, concat([u32(1), sampleEntry(config)])),
1162
+ ...["stts", "stsc", "stco"].map((type) => fullBox(type, 0, 0, u32(0))),
1163
+ fullBox("stsz", 0, 0, u32(0, 0))
1164
+ ]));
1165
+ const minf = buildBox("minf", concat([
1166
+ fullBox("smhd", 0, 0, u32(0)),
1167
+ buildBox("dinf", fullBox("dref", 0, 0, concat([u32(1), fullBox("url ", 0, 1, new Uint8Array)]))),
1168
+ stbl
1169
+ ]));
1170
+ const trak = buildBox("trak", concat([
1171
+ fullBox("tkhd", 0, 7, tkhd),
1172
+ buildBox("mdia", concat([fullBox("mdhd", 0, 0, mdhd), fullBox("hdlr", 0, 0, hdlr), minf]))
1173
+ ]));
1174
+ return concat([
1175
+ buildBox("ftyp", concat([encoder.encode("isom"), u32(512), encoder.encode("isomiso6mp41")])),
1176
+ buildBox("moov", concat([
1177
+ fullBox("mvhd", 0, 0, mvhd),
1178
+ trak,
1179
+ buildBox("mvex", fullBox("trex", 0, 0, u32(1, 1, 1024, 0, 33554432)))
1180
+ ]))
1181
+ ]);
1182
+ }
1183
+ function descriptor(tag, payload) {
1184
+ return concat([Uint8Array.of(tag, payload.length), payload]);
1185
+ }
1186
+ function sampleEntry(config) {
1187
+ const entry = new Uint8Array(28);
1188
+ const view = new DataView(entry.buffer);
1189
+ view.setUint16(6, 1);
1190
+ view.setUint16(16, config.channels === 7 ? 8 : config.channels);
1191
+ view.setUint16(18, 16);
1192
+ view.setUint32(24, config.sampleRate * 65536);
1193
+ const asc = Uint8Array.of(2 << 3 | config.frequencyIndex >> 1, (config.frequencyIndex & 1) << 7 | config.channels << 3);
1194
+ const decoder = descriptor(4, concat([Uint8Array.of(64, 21, 0, 0, 0), u32(0, 0), descriptor(5, asc)]));
1195
+ const esds = descriptor(3, concat([Uint8Array.of(0, 1, 0), decoder, descriptor(6, Uint8Array.of(2))]));
1196
+ return buildBox("mp4a", concat([entry, fullBox("esds", 0, 0, esds)]));
1197
+ }
1198
+ function mediaFragment(fragment, sequence) {
1199
+ const timestamp = new Uint8Array(8);
1200
+ new DataView(timestamp.buffer).setBigUint64(0, BigInt(fragment.timestamp));
1201
+ const trun = fullBox("trun", 0, 513, concat([u32(fragment.samples.length, 0), u32(...fragment.samples.map((sample) => sample.length))]));
1202
+ const moof = buildBox("moof", concat([
1203
+ fullBox("mfhd", 0, 0, u32(sequence)),
1204
+ buildBox("traf", concat([fullBox("tfhd", 0, 131072, u32(1)), fullBox("tfdt", 1, 0, timestamp), trun]))
1205
+ ]));
1206
+ writeU32(moof, moof.length - trun.length + 16, moof.length + 8);
1207
+ return [moof, buildBox("mdat", concat(fragment.samples))];
1208
+ }
1209
+
1041
1210
  // src/hls.ts
1042
1211
  function isMasterPlaylist(text) {
1043
1212
  return text.includes("#EXT-X-STREAM-INF");
@@ -1046,25 +1215,31 @@ function parseMaster(text, baseUrl) {
1046
1215
  const lines = text.split(`
1047
1216
  `).map((line) => line.trim());
1048
1217
  const audio = {};
1218
+ const defaultAudio = new Set;
1049
1219
  const variants = [];
1050
1220
  for (let index = 0;index < lines.length; index += 1) {
1051
1221
  const line = lines[index];
1052
1222
  if (line.startsWith("#EXT-X-MEDIA:") && /TYPE=AUDIO/.test(line)) {
1053
1223
  const group = line.match(/GROUP-ID="([^"]+)"/)?.[1];
1054
1224
  const uri = line.match(/URI="([^"]+)"/)?.[1];
1055
- if (group && uri) {
1225
+ const isDefault = /(?:^|,)DEFAULT=YES(?:,|$)/.test(line);
1226
+ if (group && uri && (!audio[group] || isDefault && !defaultAudio.has(group))) {
1056
1227
  audio[group] = new URL(uri, baseUrl).href;
1228
+ if (isDefault)
1229
+ defaultAudio.add(group);
1057
1230
  }
1058
1231
  } else if (line.startsWith("#EXT-X-STREAM-INF:")) {
1059
1232
  const target = lines[index + 1];
1060
1233
  if (target && !target.startsWith("#")) {
1061
1234
  const resolution = line.match(/RESOLUTION=(\d+)x(\d+)/);
1235
+ const codecs = line.match(/CODECS="([^"]+)"/)?.[1];
1062
1236
  variants.push({
1063
1237
  width: resolution ? Number(resolution[1]) : 0,
1064
1238
  height: resolution ? Number(resolution[2]) : 0,
1065
1239
  bandwidth: Number(line.match(/BANDWIDTH=(\d+)/)?.[1] ?? 0),
1066
1240
  url: new URL(target, baseUrl).href,
1067
- audioGroup: line.match(/AUDIO="([^"]+)"/)?.[1] ?? null
1241
+ audioGroup: line.match(/AUDIO="([^"]+)"/)?.[1] ?? null,
1242
+ ...codecs ? { codecs } : {}
1068
1243
  });
1069
1244
  }
1070
1245
  }
@@ -1080,7 +1255,8 @@ async function assembleHls(net, playlistUrl, headers) {
1080
1255
  if (segments.length === 0) {
1081
1256
  throw new Error("HLS playlist has no segments");
1082
1257
  }
1083
- return concat(await fetchSegments(net, segments, headers));
1258
+ const bytes = await fetchSegments(net, segments, headers);
1259
+ return packedAacToMp4(bytes) ?? concat(bytes);
1084
1260
  }
1085
1261
  function mediaSegments(text, baseUrl) {
1086
1262
  const segments = [];
@@ -2229,16 +2405,45 @@ var androidVrClient = {
2229
2405
  userAgent: "com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip",
2230
2406
  version: "1.65.10"
2231
2407
  };
2408
+ var visionOsClient = {
2409
+ clientName: "VISIONOS",
2410
+ clientVersion: "1.02",
2411
+ deviceMake: "Apple",
2412
+ deviceModel: "RealityDevice17,1",
2413
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
2414
+ osName: "visionOS",
2415
+ osVersion: "26.5.23O471"
2416
+ };
2232
2417
  var browserCookie = "PREF=hl=en&tz=UTC; SOCS=CAI";
2233
2418
  async function resolveYoutube(input) {
2234
2419
  const id = youtubeVideoId(input.url);
2235
2420
  if (!id) {
2236
2421
  throw new Error("YouTube video id not found");
2237
2422
  }
2238
- const session = await youtubeSession(input.net, id);
2239
- const response = await input.net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
2240
- body: JSON.stringify(playerBody(id, session)),
2241
- headers: playerHeaders(session),
2423
+ let session;
2424
+ try {
2425
+ session = await youtubeSession(input.net, id);
2426
+ const payload = await youtubePlayer(input.net, playerBody(id, session), playerHeaders(session));
2427
+ const streams = selectStreams(payload, input.preferredWidth);
2428
+ if (!streams) {
2429
+ throw new Error("YouTube mp4 stream not found");
2430
+ }
2431
+ const headers = { "user-agent": androidVrClient.userAgent };
2432
+ await Promise.all([streams.video, streams.audio].filter((url) => url !== null).map((url) => probeStream(input.net, url, headers)));
2433
+ return youtubeResult(id, payload, streams, headers);
2434
+ } catch (primaryError) {
2435
+ try {
2436
+ return await resolveVisionOs(input, id, session?.visitorData);
2437
+ } catch (fallbackError) {
2438
+ const message = (error) => error instanceof Error ? error.message : String(error);
2439
+ throw new Error(`YouTube resolution failed: ${message(primaryError)}; visionOS HLS: ${message(fallbackError)}`, { cause: fallbackError });
2440
+ }
2441
+ }
2442
+ }
2443
+ async function youtubePlayer(net, body, headers) {
2444
+ const response = await net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
2445
+ body: JSON.stringify(body),
2446
+ headers,
2242
2447
  method: "POST"
2243
2448
  });
2244
2449
  if (!response.ok) {
@@ -2250,12 +2455,10 @@ async function resolveYoutube(input) {
2250
2455
  const reason = object(payload) && object(payload.playabilityStatus) ? string(payload.playabilityStatus.reason) : null;
2251
2456
  throw new Error(reason ?? "YouTube video unavailable");
2252
2457
  }
2253
- const streams = selectStreams(payload, input.preferredWidth);
2254
- if (!streams) {
2255
- throw new Error("YouTube mp4 stream not found");
2256
- }
2458
+ return payload;
2459
+ }
2460
+ function youtubeResult(id, payload, streams, headers) {
2257
2461
  const title = object(payload) && object(payload.videoDetails) ? string(payload.videoDetails.title) : null;
2258
- const headers = { "user-agent": androidVrClient.userAgent };
2259
2462
  const media = {
2260
2463
  filename: filename(`youtube_${title ?? id}_${id}.mp4`),
2261
2464
  headers,
@@ -2264,10 +2467,56 @@ async function resolveYoutube(input) {
2264
2467
  mime: "video/mp4",
2265
2468
  platform: "youtube",
2266
2469
  url: streams.video,
2470
+ ...streams.hls ? { hls: true } : {},
2267
2471
  ...streams.audio ? { audio: { headers, url: streams.audio } } : {}
2268
2472
  };
2269
2473
  return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
2270
2474
  }
2475
+ async function probeStream(net, url, headers) {
2476
+ const response = await net(url, { headers }, 1);
2477
+ await response.body?.cancel();
2478
+ if (!response.ok) {
2479
+ throw new Error(`YouTube stream failed: ${response.status}`);
2480
+ }
2481
+ }
2482
+ async function resolveVisionOs(input, id, visitorData) {
2483
+ const headers = { "user-agent": visionOsClient.userAgent };
2484
+ const payload = await youtubePlayer(input.net, {
2485
+ videoId: id,
2486
+ contentCheckOk: true,
2487
+ racyCheckOk: true,
2488
+ context: { client: { ...visionOsClient, hl: "en", gl: "US", ...visitorData ? { visitorData } : {} } }
2489
+ }, {
2490
+ ...headers,
2491
+ "content-type": "application/json",
2492
+ "x-youtube-client-name": "101",
2493
+ "x-youtube-client-version": visionOsClient.clientVersion,
2494
+ ...visitorData ? { "x-goog-visitor-id": visitorData } : {}
2495
+ });
2496
+ const streaming = object(payload) && object(payload.streamingData) ? payload.streamingData : null;
2497
+ const manifestUrl = streaming ? string(streaming.hlsManifestUrl) : null;
2498
+ if (!manifestUrl) {
2499
+ throw new Error("YouTube HLS manifest not found");
2500
+ }
2501
+ const response = await input.net(manifestUrl, { headers });
2502
+ if (!response.ok) {
2503
+ throw new Error(`YouTube HLS manifest failed: ${response.status}`);
2504
+ }
2505
+ const master = parseMaster(await response.text(), manifestUrl);
2506
+ const variants = master.variants.filter((variant2) => /(?:^|,)(?:vp09|av01)\./i.test(variant2.codecs ?? "") && /(?:^|,)mp4a\.40\.2(?:,|$)/i.test(variant2.codecs ?? "") && variant2.audioGroup && master.audio[variant2.audioGroup]).sort((left, right) => Math.abs(left.width - input.preferredWidth) - Math.abs(right.width - input.preferredWidth) || right.bandwidth - left.bandwidth);
2507
+ const variant = variants[0];
2508
+ if (!variant?.audioGroup) {
2509
+ throw new Error("YouTube fragmented MP4 HLS video with AAC audio not found");
2510
+ }
2511
+ const playlist = await input.net(variant.url, { headers });
2512
+ if (!playlist.ok) {
2513
+ throw new Error(`YouTube HLS playlist failed: ${playlist.status}`);
2514
+ }
2515
+ if (!(await playlist.text()).includes("#EXT-X-MAP:")) {
2516
+ throw new Error("YouTube HLS video is not fragmented MP4");
2517
+ }
2518
+ return youtubeResult(id, payload, { video: variant.url, audio: master.audio[variant.audioGroup], hls: true }, headers);
2519
+ }
2271
2520
  function youtubeMetadata(payload) {
2272
2521
  const details = object(payload) && object(payload.videoDetails) ? payload.videoDetails : null;
2273
2522
  const renderer = object(payload) && object(payload.microformat) && object(payload.microformat.playerMicroformatRenderer) ? payload.microformat.playerMicroformatRenderer : null;
@@ -2840,7 +3089,7 @@ function rewriteMoof(data, moofBox, trackId2, sequence) {
2840
3089
  }
2841
3090
 
2842
3091
  // src/zip.ts
2843
- var encoder = new TextEncoder;
3092
+ var encoder2 = new TextEncoder;
2844
3093
  var table = new Uint32Array(256);
2845
3094
  for (let value = 0;value < table.length; value += 1) {
2846
3095
  let crc = value;
@@ -2851,7 +3100,7 @@ for (let value = 0;value < table.length; value += 1) {
2851
3100
  }
2852
3101
  function zip(entries) {
2853
3102
  const files = entries.map((entry) => {
2854
- const name = encoder.encode(entry.name);
3103
+ const name = encoder2.encode(entry.name);
2855
3104
  const crc = crc32(entry.data);
2856
3105
  return { ...entry, crc, name };
2857
3106
  });
@@ -2947,12 +3196,12 @@ async function download(item, options = {}) {
2947
3196
  }
2948
3197
  return response;
2949
3198
  }
2950
- async function downloadBlob(url, optionsOrHeaders = {}, legacyOptions = {}) {
3199
+ async function downloadBlob(input, optionsOrHeaders = {}, legacyOptions = {}) {
2951
3200
  const modern = isDownloadBlobOptions(optionsOrHeaders);
2952
3201
  const options = modern ? optionsOrHeaders : legacyOptions;
2953
3202
  const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
2954
3203
  const net = createNet(options.fetch ?? globalThis.fetch);
2955
- const response = await net(url, { headers });
3204
+ const response = typeof input === "string" ? await net(input, { headers }) : await download({ ...input, headers: modern ? optionsOrHeaders.headers ?? input.headers : input.headers }, options);
2956
3205
  if (!response.ok || !response.body) {
2957
3206
  throw new PostfetchError(502, `download failed: ${response.status}`);
2958
3207
  }
@@ -20,8 +20,8 @@ export type MediaItem = {
20
20
  headers: HeadersInit;
21
21
  /**
22
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
23
+ * playlists rather than direct files: {@link download} assembles their fMP4
24
+ * segments (or packages packed AAC audio) before serving, and merges tracks when both
25
25
  * are present. Callers that read `url` directly get the playlist, not the media.
26
26
  */
27
27
  hls?: boolean;
@@ -0,0 +1 @@
1
+ export declare function packedAacToMp4(segments: Uint8Array[]): Uint8Array | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/download.ts CHANGED
@@ -77,39 +77,40 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
77
77
  }
78
78
 
79
79
  /**
80
- * Download an already-resolved media URL as a Blob.
80
+ * Download a resolved media item or direct media URL as a Blob.
81
81
  *
82
82
  * This is useful for consumers that need to upload media themselves instead of
83
- * passing its URL to a third party. When the resolved {@link MediaItem} carries
84
- * required CDN headers, pass them in the options object. Set `remux: true` to
83
+ * passing its URL to a third party. Pass the full {@link MediaItem} to include
84
+ * separate audio, HLS playlists and required headers. A direct URL only fetches
85
+ * that URL; pass its required CDN headers in the options object. Set `remux: true` to
85
86
  * normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
86
87
  * remux or metadata extraction fails, the operation throws.
87
88
  *
88
- * @param url A direct media URL returned by a resolver.
89
+ * @param input A resolved media item (recommended) or direct media URL.
89
90
  * @param options Headers, fetch implementation and opt-in remux behavior.
90
91
  * @returns A Blob, or with `remux: true`, the normalized video and its metadata.
91
92
  *
92
93
  * @example Upload a video with FormData
93
94
  * ```ts
94
95
  * const [media] = (await postfetch(sourceUrl)).items;
95
- * const video = await downloadBlob(media.url, { headers: media.headers, remux: true });
96
+ * const video = await downloadBlob(media, { remux: true });
96
97
  * form.append("video", video.blob, media.filename);
97
98
  * form.append("thumbnail", video.thumbnail, "thumbnail.jpg");
98
99
  * ```
99
100
  */
100
101
  export async function downloadBlob(
101
- url: string,
102
+ input: string | MediaItem,
102
103
  options: DownloadBlobOptions & { remux: true },
103
104
  ): Promise<RemuxedVideo>;
104
105
  /** Download without remuxing and return the upstream media Blob. */
105
106
  export async function downloadBlob(
106
- url: string,
107
+ input: string | MediaItem,
107
108
  options?: DownloadBlobOptions & { remux?: false },
108
109
  ): Promise<Blob>;
109
110
  /** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
110
111
  export async function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
111
112
  export async function downloadBlob(
112
- url: string,
113
+ input: string | MediaItem,
113
114
  optionsOrHeaders: DownloadBlobOptions | HeadersInit = {},
114
115
  legacyOptions: DownloadOptions = {},
115
116
  ): Promise<Blob | RemuxedVideo> {
@@ -117,7 +118,9 @@ export async function downloadBlob(
117
118
  const options = modern ? optionsOrHeaders : legacyOptions;
118
119
  const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
119
120
  const net = createNet(options.fetch ?? globalThis.fetch);
120
- const response = await net(url, { headers });
121
+ const response = typeof input === "string"
122
+ ? await net(input, { headers })
123
+ : await download({ ...input, headers: modern ? optionsOrHeaders.headers ?? input.headers : input.headers }, options);
121
124
  if (!response.ok || !response.body) {
122
125
  throw new PostfetchError(502, `download failed: ${response.status}`);
123
126
  }
package/src/hls.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import { concat } from "./isobmff";
2
2
  import type { Net } from "./internal";
3
+ import { packedAacToMp4 } from "./packed-aac";
3
4
 
4
5
  // HLS support for the fMP4/CMAF playlists reddit-style muxing already covers:
5
6
  // 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.
7
+ // one CMAF file). Packed AAC audio is also packaged into fMP4 for merging with
8
+ // YouTube's HLS video. No MPEG-TS demuxing is involved.
8
9
 
9
10
  type Segment = { url: string; range: { length: number; offset: number } | null };
10
11
 
11
- export type HlsVariant = { width: number; height: number; bandwidth: number; url: string; audioGroup: string | null };
12
+ export type HlsVariant = { width: number; height: number; bandwidth: number; url: string; audioGroup: string | null; codecs?: string };
12
13
  export type HlsMaster = { variants: HlsVariant[]; audio: Record<string, string> };
13
14
 
14
15
  export function isMasterPlaylist(text: string): boolean {
@@ -18,25 +19,30 @@ export function isMasterPlaylist(text: string): boolean {
18
19
  export function parseMaster(text: string, baseUrl: string): HlsMaster {
19
20
  const lines = text.split("\n").map((line) => line.trim());
20
21
  const audio: Record<string, string> = {};
22
+ const defaultAudio = new Set<string>();
21
23
  const variants: HlsVariant[] = [];
22
24
  for (let index = 0; index < lines.length; index += 1) {
23
25
  const line = lines[index];
24
26
  if (line.startsWith("#EXT-X-MEDIA:") && /TYPE=AUDIO/.test(line)) {
25
27
  const group = line.match(/GROUP-ID="([^"]+)"/)?.[1];
26
28
  const uri = line.match(/URI="([^"]+)"/)?.[1];
27
- if (group && uri) {
29
+ const isDefault = /(?:^|,)DEFAULT=YES(?:,|$)/.test(line);
30
+ if (group && uri && (!audio[group] || (isDefault && !defaultAudio.has(group)))) {
28
31
  audio[group] = new URL(uri, baseUrl).href;
32
+ if (isDefault) defaultAudio.add(group);
29
33
  }
30
34
  } else if (line.startsWith("#EXT-X-STREAM-INF:")) {
31
35
  const target = lines[index + 1];
32
36
  if (target && !target.startsWith("#")) {
33
37
  const resolution = line.match(/RESOLUTION=(\d+)x(\d+)/);
38
+ const codecs = line.match(/CODECS="([^"]+)"/)?.[1];
34
39
  variants.push({
35
40
  width: resolution ? Number(resolution[1]) : 0,
36
41
  height: resolution ? Number(resolution[2]) : 0,
37
42
  bandwidth: Number(line.match(/BANDWIDTH=(\d+)/)?.[1] ?? 0),
38
43
  url: new URL(target, baseUrl).href,
39
44
  audioGroup: line.match(/AUDIO="([^"]+)"/)?.[1] ?? null,
45
+ ...(codecs ? { codecs } : {}),
40
46
  });
41
47
  }
42
48
  }
@@ -53,7 +59,8 @@ export async function assembleHls(net: Net, playlistUrl: string, headers: Header
53
59
  if (segments.length === 0) {
54
60
  throw new Error("HLS playlist has no segments");
55
61
  }
56
- return concat(await fetchSegments(net, segments, headers));
62
+ const bytes = await fetchSegments(net, segments, headers);
63
+ return packedAacToMp4(bytes) ?? concat(bytes);
57
64
  }
58
65
 
59
66
  function mediaSegments(text: string, baseUrl: string): Segment[] {
package/src/internal.ts CHANGED
@@ -19,8 +19,8 @@ export type MediaItem = {
19
19
  headers: HeadersInit;
20
20
  /**
21
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
22
+ * playlists rather than direct files: {@link download} assembles their fMP4
23
+ * segments (or packages packed AAC audio) before serving, and merges tracks when both
24
24
  * are present. Callers that read `url` directly get the playlist, not the media.
25
25
  */
26
26
  hls?: boolean;
@@ -0,0 +1,187 @@
1
+ import { buildBox, concat, readU32, writeU32 } from "./isobmff";
2
+
3
+ // YouTube's fMP4 HLS video is paired with packed AAC: each audio segment is
4
+ // ID3 (including a 90 kHz timestamp) followed by ADTS frames. Strip the transport
5
+ // headers and put the original AAC samples into CMAF fragments for our merger.
6
+ type Config = { frequencyIndex: number; sampleRate: number; channels: number };
7
+ type Fragment = { samples: Uint8Array[]; timestamp: number };
8
+ const sampleRates = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
9
+ const encoder = new TextEncoder();
10
+
11
+ export function packedAacToMp4(segments: Uint8Array[]): Uint8Array | null {
12
+ const first = segments[0];
13
+ if (!first || (!id3(first, 0) && !adts(first, 0))) {
14
+ return null;
15
+ }
16
+ let config: Config | undefined;
17
+ let nextTimestamp = 0;
18
+ const fragments: Fragment[] = [];
19
+ for (const segment of segments) {
20
+ let offset = 0;
21
+ let timestamp: number | undefined;
22
+ while (id3(segment, offset)) {
23
+ const tag = readId3(segment, offset);
24
+ timestamp ??= tag.timestamp;
25
+ offset = tag.end;
26
+ }
27
+ const samples: Uint8Array[] = [];
28
+ while (offset < segment.length) {
29
+ if (offset + 7 > segment.length || !adts(segment, offset)) {
30
+ throw new Error("Invalid packed AAC frame");
31
+ }
32
+ const profile = (segment[offset + 2] >> 6) + 1;
33
+ const frequencyIndex = (segment[offset + 2] >> 2) & 15;
34
+ const sampleRate = sampleRates[frequencyIndex];
35
+ const channels = ((segment[offset + 2] & 1) << 2) | (segment[offset + 3] >> 6);
36
+ if (profile !== 2 || !sampleRate || sampleRate > 65535 || channels === 0 || (segment[offset + 6] & 3) !== 0) {
37
+ throw new Error("Unsupported packed AAC configuration (expected AAC-LC)");
38
+ }
39
+ if (config && (config.frequencyIndex !== frequencyIndex || config.channels !== channels)) {
40
+ throw new Error("Packed AAC configuration changed between frames");
41
+ }
42
+ config ??= { frequencyIndex, sampleRate, channels };
43
+ const length = ((segment[offset + 3] & 3) << 11) | (segment[offset + 4] << 3) | (segment[offset + 5] >> 5);
44
+ const headerLength = segment[offset + 1] & 1 ? 7 : 9;
45
+ if (length <= headerLength || offset + length > segment.length) {
46
+ throw new Error("Truncated packed AAC frame");
47
+ }
48
+ samples.push(segment.subarray(offset + headerLength, offset + length));
49
+ offset += length;
50
+ }
51
+ if (!config || samples.length === 0) {
52
+ throw new Error("Packed AAC segment has no audio frames");
53
+ }
54
+ const start = timestamp === undefined ? nextTimestamp : Math.round(timestamp * config.sampleRate / 90000);
55
+ fragments.push({ samples, timestamp: start });
56
+ nextTimestamp = start + samples.length * 1024;
57
+ }
58
+ return concat([initSegment(config!), ...fragments.flatMap((fragment, index) => mediaFragment(fragment, index + 1))]);
59
+ }
60
+
61
+ function id3(bytes: Uint8Array, offset: number): boolean {
62
+ return bytes[offset] === 0x49 && bytes[offset + 1] === 0x44 && bytes[offset + 2] === 0x33;
63
+ }
64
+
65
+ function adts(bytes: Uint8Array, offset: number): boolean {
66
+ return bytes[offset] === 0xff && (bytes[offset + 1] & 0xf6) === 0xf0;
67
+ }
68
+
69
+ function synchsafe(bytes: Uint8Array, offset: number): number {
70
+ if (offset + 4 > bytes.length || bytes.subarray(offset, offset + 4).some((byte) => byte & 128)) {
71
+ throw new Error("Invalid packed AAC ID3 size");
72
+ }
73
+ return bytes[offset] * 2 ** 21 + bytes[offset + 1] * 2 ** 14 + bytes[offset + 2] * 128 + bytes[offset + 3];
74
+ }
75
+
76
+ function readId3(bytes: Uint8Array, offset: number): { end: number; timestamp?: number } {
77
+ const version = bytes[offset + 3];
78
+ if (offset + 10 > bytes.length || (version !== 3 && version !== 4) || (bytes[offset + 5] & 0xc0)) {
79
+ throw new Error("Unsupported packed AAC ID3 tag");
80
+ }
81
+ const end = offset + 10 + synchsafe(bytes, offset + 6);
82
+ if (end > bytes.length) {
83
+ throw new Error("Truncated packed AAC ID3 tag");
84
+ }
85
+ const owner = encoder.encode("com.apple.streaming.transportStreamTimestamp\0");
86
+ let timestamp: number | undefined;
87
+ for (let cursor = offset + 10; cursor + 10 <= end && bytes[cursor] !== 0;) {
88
+ const size = version === 4 ? synchsafe(bytes, cursor + 4) : readU32(bytes, cursor + 4);
89
+ const body = cursor + 10;
90
+ if (body + size > end) {
91
+ throw new Error("Truncated packed AAC ID3 frame");
92
+ }
93
+ if (String.fromCharCode(...bytes.subarray(cursor, cursor + 4)) === "PRIV" &&
94
+ size === owner.length + 8 && owner.every((byte, index) => bytes[body + index] === byte)) {
95
+ if (bytes[cursor + 8] !== 0 || bytes[cursor + 9] !== 0) {
96
+ throw new Error("Unsupported packed AAC timestamp encoding");
97
+ }
98
+ timestamp = Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(body + owner.length) & 0x1ffffffffn);
99
+ }
100
+ cursor = body + size;
101
+ }
102
+ const footer = version === 4 && (bytes[offset + 5] & 0x10) ? 10 : 0;
103
+ if (end + footer > bytes.length) {
104
+ throw new Error("Truncated packed AAC ID3 footer");
105
+ }
106
+ return { end: end + footer, timestamp };
107
+ }
108
+
109
+ function u32(...values: number[]): Uint8Array {
110
+ const bytes = new Uint8Array(values.length * 4);
111
+ values.forEach((value, index) => writeU32(bytes, index * 4, value));
112
+ return bytes;
113
+ }
114
+
115
+ function fullBox(type: string, version: number, flags: number, payload: Uint8Array): Uint8Array {
116
+ return buildBox(type, concat([u32(version * 0x1000000 + flags), payload]));
117
+ }
118
+
119
+ function matrix(bytes: Uint8Array, offset: number): void {
120
+ bytes.set(u32(0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000), offset);
121
+ }
122
+
123
+ function initSegment(config: Config): Uint8Array {
124
+ const mvhd = new Uint8Array(96);
125
+ writeU32(mvhd, 8, 1000);
126
+ writeU32(mvhd, 16, 0x10000);
127
+ new DataView(mvhd.buffer).setUint16(20, 0x100);
128
+ matrix(mvhd, 32);
129
+ writeU32(mvhd, 92, 2);
130
+ const tkhd = new Uint8Array(80);
131
+ writeU32(tkhd, 8, 1);
132
+ new DataView(tkhd.buffer).setUint16(32, 0x100);
133
+ matrix(tkhd, 36);
134
+ const mdhd = concat([u32(0, 0, config.sampleRate, 0), Uint8Array.of(0x55, 0xc4, 0, 0)]);
135
+ const hdlr = concat([u32(0), encoder.encode("soun"), u32(0, 0, 0), encoder.encode("SoundHandler\0")]);
136
+ const stbl = buildBox("stbl", concat([
137
+ fullBox("stsd", 0, 0, concat([u32(1), sampleEntry(config)])),
138
+ ...["stts", "stsc", "stco"].map((type) => fullBox(type, 0, 0, u32(0))),
139
+ fullBox("stsz", 0, 0, u32(0, 0)),
140
+ ]));
141
+ const minf = buildBox("minf", concat([
142
+ fullBox("smhd", 0, 0, u32(0)),
143
+ buildBox("dinf", fullBox("dref", 0, 0, concat([u32(1), fullBox("url ", 0, 1, new Uint8Array())]))),
144
+ stbl,
145
+ ]));
146
+ const trak = buildBox("trak", concat([
147
+ fullBox("tkhd", 0, 7, tkhd),
148
+ buildBox("mdia", concat([fullBox("mdhd", 0, 0, mdhd), fullBox("hdlr", 0, 0, hdlr), minf])),
149
+ ]));
150
+ return concat([
151
+ buildBox("ftyp", concat([encoder.encode("isom"), u32(512), encoder.encode("isomiso6mp41")])),
152
+ buildBox("moov", concat([
153
+ fullBox("mvhd", 0, 0, mvhd), trak,
154
+ buildBox("mvex", fullBox("trex", 0, 0, u32(1, 1, 1024, 0, 0x02000000))),
155
+ ])),
156
+ ]);
157
+ }
158
+
159
+ function descriptor(tag: number, payload: Uint8Array): Uint8Array {
160
+ // The fixed AAC-LC descriptors below are all shorter than 128 bytes.
161
+ return concat([Uint8Array.of(tag, payload.length), payload]);
162
+ }
163
+
164
+ function sampleEntry(config: Config): Uint8Array {
165
+ const entry = new Uint8Array(28);
166
+ const view = new DataView(entry.buffer);
167
+ view.setUint16(6, 1);
168
+ view.setUint16(16, config.channels === 7 ? 8 : config.channels);
169
+ view.setUint16(18, 16);
170
+ view.setUint32(24, config.sampleRate * 65536);
171
+ const asc = Uint8Array.of((2 << 3) | (config.frequencyIndex >> 1), ((config.frequencyIndex & 1) << 7) | (config.channels << 3));
172
+ const decoder = descriptor(4, concat([Uint8Array.of(0x40, 0x15, 0, 0, 0), u32(0, 0), descriptor(5, asc)]));
173
+ const esds = descriptor(3, concat([Uint8Array.of(0, 1, 0), decoder, descriptor(6, Uint8Array.of(2))]));
174
+ return buildBox("mp4a", concat([entry, fullBox("esds", 0, 0, esds)]));
175
+ }
176
+
177
+ function mediaFragment(fragment: Fragment, sequence: number): Uint8Array[] {
178
+ const timestamp = new Uint8Array(8);
179
+ new DataView(timestamp.buffer).setBigUint64(0, BigInt(fragment.timestamp));
180
+ const trun = fullBox("trun", 0, 0x201, concat([u32(fragment.samples.length, 0), u32(...fragment.samples.map((sample) => sample.length))]));
181
+ const moof = buildBox("moof", concat([
182
+ fullBox("mfhd", 0, 0, u32(sequence)),
183
+ buildBox("traf", concat([fullBox("tfhd", 0, 0x020000, u32(1)), fullBox("tfdt", 1, 0, timestamp), trun])),
184
+ ]));
185
+ writeU32(moof, moof.length - trun.length + 16, moof.length + 8);
186
+ return [moof, buildBox("mdat", concat(fragment.samples))];
187
+ }
package/src/youtube.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type MediaItem,
16
16
  } from "./internal";
17
17
  import { browserUserAgent } from "./fingerprint";
18
+ import { parseMaster } from "./hls";
18
19
 
19
20
  type YoutubeSession = {
20
21
  cookie: string;
@@ -29,6 +30,16 @@ const androidVrClient = {
29
30
  version: "1.65.10",
30
31
  };
31
32
 
33
+ const visionOsClient = {
34
+ clientName: "VISIONOS",
35
+ clientVersion: "1.02",
36
+ deviceMake: "Apple",
37
+ deviceModel: "RealityDevice17,1",
38
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
39
+ osName: "visionOS",
40
+ osVersion: "26.5.23O471",
41
+ };
42
+
32
43
  const browserCookie = "PREF=hl=en&tz=UTC; SOCS=CAI";
33
44
 
34
45
  export async function resolveYoutube(input: ResolveContext): Promise<PostfetchResult> {
@@ -36,11 +47,33 @@ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchRe
36
47
  if (!id) {
37
48
  throw new Error("YouTube video id not found");
38
49
  }
39
- const session = await youtubeSession(input.net, id);
40
- const response = await input.net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
41
- body: JSON.stringify(playerBody(id, session)),
42
- headers: playerHeaders(session),
43
- method: "POST",
50
+ let session: YoutubeSession | undefined;
51
+ try {
52
+ session = await youtubeSession(input.net, id);
53
+ const payload = await youtubePlayer(input.net, playerBody(id, session), playerHeaders(session));
54
+ const streams = selectStreams(payload, input.preferredWidth);
55
+ if (!streams) {
56
+ throw new Error("YouTube mp4 stream not found");
57
+ }
58
+ const headers = { "user-agent": androidVrClient.userAgent };
59
+ // A successful player response can still contain CDN URLs that return 403.
60
+ // Check the actual GET and cancel its body: HEAD/Range probes can succeed
61
+ // even when YouTube rejects the full download.
62
+ await Promise.all([streams.video, streams.audio].filter((url): url is string => url !== null).map((url) => probeStream(input.net, url, headers)));
63
+ return youtubeResult(id, payload, streams, headers);
64
+ } catch (primaryError) {
65
+ try {
66
+ return await resolveVisionOs(input, id, session?.visitorData);
67
+ } catch (fallbackError) {
68
+ const message = (error: unknown) => error instanceof Error ? error.message : String(error);
69
+ throw new Error(`YouTube resolution failed: ${message(primaryError)}; visionOS HLS: ${message(fallbackError)}`, { cause: fallbackError });
70
+ }
71
+ }
72
+ }
73
+
74
+ async function youtubePlayer(net: Net, body: Json, headers: HeadersInit): Promise<unknown> {
75
+ const response = await net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
76
+ body: JSON.stringify(body), headers, method: "POST",
44
77
  });
45
78
  if (!response.ok) {
46
79
  throw new Error(`YouTube player failed: ${response.status}`);
@@ -51,12 +84,16 @@ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchRe
51
84
  const reason = object(payload) && object(payload.playabilityStatus) ? string(payload.playabilityStatus.reason) : null;
52
85
  throw new Error(reason ?? "YouTube video unavailable");
53
86
  }
54
- const streams = selectStreams(payload, input.preferredWidth);
55
- if (!streams) {
56
- throw new Error("YouTube mp4 stream not found");
57
- }
87
+ return payload;
88
+ }
89
+
90
+ function youtubeResult(
91
+ id: string,
92
+ payload: unknown,
93
+ streams: { video: string; audio: string | null; hls?: boolean },
94
+ headers: HeadersInit,
95
+ ): PostfetchResult {
58
96
  const title = object(payload) && object(payload.videoDetails) ? string(payload.videoDetails.title) : null;
59
- const headers = { "user-agent": androidVrClient.userAgent };
60
97
  const media: MediaItem = {
61
98
  filename: filename(`youtube_${title ?? id}_${id}.mp4`),
62
99
  headers,
@@ -65,11 +102,67 @@ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchRe
65
102
  mime: "video/mp4",
66
103
  platform: "youtube",
67
104
  url: streams.video,
105
+ ...(streams.hls ? { hls: true } : {}),
68
106
  ...(streams.audio ? { audio: { headers, url: streams.audio } } : {}),
69
107
  };
70
108
  return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
71
109
  }
72
110
 
111
+ async function probeStream(net: Net, url: string, headers: HeadersInit): Promise<void> {
112
+ const response = await net(url, { headers }, 1);
113
+ await response.body?.cancel();
114
+ if (!response.ok) {
115
+ throw new Error(`YouTube stream failed: ${response.status}`);
116
+ }
117
+ }
118
+
119
+ async function resolveVisionOs(input: ResolveContext, id: string, visitorData?: string): Promise<PostfetchResult> {
120
+ const headers = { "user-agent": visionOsClient.userAgent };
121
+ const payload = await youtubePlayer(input.net, {
122
+ videoId: id,
123
+ contentCheckOk: true,
124
+ racyCheckOk: true,
125
+ context: { client: { ...visionOsClient, hl: "en", gl: "US", ...(visitorData ? { visitorData } : {}) } },
126
+ }, {
127
+ ...headers,
128
+ "content-type": "application/json",
129
+ "x-youtube-client-name": "101",
130
+ "x-youtube-client-version": visionOsClient.clientVersion,
131
+ ...(visitorData ? { "x-goog-visitor-id": visitorData } : {}),
132
+ });
133
+ const streaming = object(payload) && object(payload.streamingData) ? payload.streamingData : null;
134
+ const manifestUrl = streaming ? string(streaming.hlsManifestUrl) : null;
135
+ if (!manifestUrl) {
136
+ throw new Error("YouTube HLS manifest not found");
137
+ }
138
+ const response = await input.net(manifestUrl, { headers });
139
+ if (!response.ok) {
140
+ throw new Error(`YouTube HLS manifest failed: ${response.status}`);
141
+ }
142
+ const master = parseMaster(await response.text(), manifestUrl);
143
+ // YouTube's AVC HLS variants use MPEG-TS. VP9/AV1 variants use fMP4 and
144
+ // work with the bundled box-level merger. Require the paired AAC-LC track.
145
+ const variants = master.variants.filter((variant) =>
146
+ /(?:^|,)(?:vp09|av01)\./i.test(variant.codecs ?? "") &&
147
+ /(?:^|,)mp4a\.40\.2(?:,|$)/i.test(variant.codecs ?? "") &&
148
+ variant.audioGroup && master.audio[variant.audioGroup]
149
+ ).sort((left, right) =>
150
+ Math.abs(left.width - input.preferredWidth) - Math.abs(right.width - input.preferredWidth) || right.bandwidth - left.bandwidth
151
+ );
152
+ const variant = variants[0];
153
+ if (!variant?.audioGroup) {
154
+ throw new Error("YouTube fragmented MP4 HLS video with AAC audio not found");
155
+ }
156
+ const playlist = await input.net(variant.url, { headers });
157
+ if (!playlist.ok) {
158
+ throw new Error(`YouTube HLS playlist failed: ${playlist.status}`);
159
+ }
160
+ if (!(await playlist.text()).includes("#EXT-X-MAP:")) {
161
+ throw new Error("YouTube HLS video is not fragmented MP4");
162
+ }
163
+ return youtubeResult(id, payload, { video: variant.url, audio: master.audio[variant.audioGroup], hls: true }, headers);
164
+ }
165
+
73
166
  export function youtubeMetadata(payload: unknown): PostMetadata & { extra?: YoutubeExtra } {
74
167
  const details = object(payload) && object(payload.videoDetails) ? payload.videoDetails : null;
75
168
  const renderer =