@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 +1 -0
- package/dist/index.js +263 -14
- package/dist/internal.d.ts +2 -2
- package/dist/packed-aac.d.ts +1 -0
- package/package.json +1 -1
- package/src/hls.ts +12 -5
- package/src/internal.ts +2 -2
- package/src/packed-aac.ts +187 -0
- package/src/youtube.ts +103 -10
package/dist/hls.d.ts
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
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
|
|
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 =
|
|
3103
|
+
const name = encoder2.encode(entry.name);
|
|
2855
3104
|
const crc = crc32(entry.data);
|
|
2856
3105
|
return { ...entry, crc, name };
|
|
2857
3106
|
});
|
package/dist/internal.d.ts
CHANGED
|
@@ -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
|
|
24
|
-
*
|
|
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.
|
|
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)
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 =
|