@postfetch/core 0.9.2 → 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/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
  });
@@ -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.2",
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/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 =