Chrunos commited on
Commit
ca46324
·
verified ·
1 Parent(s): 652a86e

Update api/src/processing/services/youtube.js

Browse files
Files changed (1) hide show
  1. api/src/processing/services/youtube.js +297 -122
api/src/processing/services/youtube.js CHANGED
@@ -1,16 +1,16 @@
1
- import { fetch } from "undici";
2
 
 
3
  import { Innertube, Session } from "youtubei.js";
4
 
5
  import { env } from "../../config.js";
6
- import { cleanString } from "../../misc/utils.js";
7
  import { getCookie, updateCookieValues } from "../cookie/manager.js";
8
 
9
  const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
10
 
11
  let innertube, lastRefreshedAt;
12
 
13
- const codecMatch = {
14
  h264: {
15
  videoCodec: "avc1",
16
  audioCodec: "mp4a",
@@ -28,6 +28,21 @@ const codecMatch = {
28
  }
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  const transformSessionData = (cookie) => {
32
  if (!cookie)
33
  return;
@@ -53,7 +68,8 @@ const cloneInnertube = async (customFetch) => {
53
  const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
54
  if (!innertube || shouldRefreshPlayer) {
55
  innertube = await Innertube.create({
56
- fetch: customFetch
 
57
  });
58
  lastRefreshedAt = +new Date();
59
  }
@@ -108,7 +124,7 @@ export default async function(o) {
108
  dispatcher: o.dispatcher
109
  })
110
  );
111
- } catch(e) {
112
  if (e.message?.endsWith("decipher algorithm")) {
113
  return { error: "youtube.decipher" }
114
  } else if (e.message?.includes("refresh access token")) {
@@ -116,29 +132,33 @@ export default async function(o) {
116
  } else throw e;
117
  }
118
 
119
- const quality = o.quality === "max" ? "9000" : o.quality;
120
-
121
- let info, isDubbed,
122
- format = o.format || "h264";
123
-
124
- function qual(i) {
125
- if (!i.quality_label) {
126
- return;
127
- }
128
 
129
- return i.quality_label.split('p')[0].split('s')[0]
 
 
130
  }
131
 
 
132
  try {
133
- info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS');
134
- } catch(e) {
135
- if (e?.info?.reason === "This video is private") {
136
- return { error: "content.video.private" };
137
- } else if (e?.message === "This video is unavailable") {
 
 
 
 
 
 
 
 
 
138
  return { error: "content.video.unavailable" };
139
- } else {
140
- return { error: "fetch.fail" };
141
  }
 
 
142
  }
143
 
144
  if (!info) return { error: "fetch.fail" };
@@ -146,37 +166,47 @@ export default async function(o) {
146
  const playability = info.playability_status;
147
  const basicInfo = info.basic_info;
148
 
149
- if (playability.status === "LOGIN_REQUIRED") {
150
- if (playability.reason.endsWith("bot")) {
151
- return { error: "youtube.login" }
152
- }
153
- if (playability.reason.endsWith("age")) {
154
- return { error: "content.video.age" }
155
- }
156
- if (playability?.error_screen?.reason?.text === "Private video") {
157
- return { error: "content.video.private" }
158
- }
159
- }
 
160
 
161
- if (playability.status === "UNPLAYABLE") {
162
- if (playability?.reason?.endsWith("request limit.")) {
163
- return { error: "fetch.rate" }
164
- }
165
- if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
166
- return { error: "content.video.region" }
167
- }
168
- if (playability?.error_screen?.reason?.text === "Private video") {
169
- return { error: "content.video.private" }
170
- }
 
 
 
 
171
  }
172
 
173
  if (playability.status !== "OK") {
174
  return { error: "content.video.unavailable" };
175
  }
 
176
  if (basicInfo.is_live) {
177
  return { error: "content.video.live" };
178
  }
179
 
 
 
 
 
180
  // return a critical error if returned video is "Video Not Available"
181
  // or a similar stub by youtube
182
  if (basicInfo.id !== o.id) {
@@ -186,64 +216,200 @@ export default async function(o) {
186
  }
187
  }
188
 
189
- const filterByCodec = (formats) =>
190
- formats
191
- .filter(e =>
192
- e.mime_type.includes(codecMatch[format].videoCodec)
193
- || e.mime_type.includes(codecMatch[format].audioCodec)
194
- )
195
- .sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
196
 
197
- let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats);
198
-
199
- if (adaptive_formats.length === 0 && format === "vp9") {
200
- format = "h264"
201
- adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
202
  }
203
 
204
- let bestQuality;
 
 
 
 
205
 
206
- const bestVideo = adaptive_formats.find(i => i.has_video && i.content_length);
207
- const hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
 
208
 
209
- if (bestVideo) bestQuality = qual(bestVideo);
 
 
 
 
 
 
 
 
210
 
211
- if ((!bestQuality && !o.isAudioOnly) || !hasAudio)
212
- return { error: "youtube.codec" };
 
213
 
214
- if (basicInfo.duration > env.durationLimit)
215
- return { error: "content.too_long" };
 
216
 
217
- const checkBestAudio = (i) => (i.has_audio && !i.has_video);
 
 
218
 
219
- let audio = adaptive_formats.find(i =>
220
- checkBestAudio(i) && i.is_original
221
- );
 
 
 
 
 
 
222
 
223
- if (o.dubLang) {
224
- let dubbedAudio = adaptive_formats.find(i =>
225
- checkBestAudio(i)
226
- && i.language === o.dubLang
227
- && i.audio_track
228
- )
229
 
230
- if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
231
- audio = dubbedAudio;
232
- isDubbed = true;
233
  }
234
- }
235
 
236
- if (!audio) {
237
- audio = adaptive_formats.find(i => checkBestAudio(i));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  }
239
 
240
- let fileMetadata = {
241
- title: cleanString(basicInfo.title.trim()),
242
- artist: cleanString(basicInfo.author.replace("- Topic", "").trim()),
243
  }
244
 
245
  if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
246
- let descItems = basicInfo.short_description.split("\n\n", 5);
 
247
  if (descItems.length === 5) {
248
  fileMetadata.album = descItems[2];
249
  fileMetadata.copyright = descItems[3];
@@ -253,61 +419,70 @@ export default async function(o) {
253
  }
254
  }
255
 
256
- let filenameAttributes = {
257
  service: "youtube",
258
  id: o.id,
259
  title: fileMetadata.title,
260
  author: fileMetadata.artist,
261
- youtubeDubName: isDubbed ? o.dubLang : false
262
  }
263
 
264
- if (audio && o.isAudioOnly) return {
265
- type: "audio",
266
- isAudioOnly: true,
267
- urls: audio.decipher(yt.session.player),
268
- filenameAttributes: filenameAttributes,
269
- fileMetadata: fileMetadata,
270
- bestAudio: format === "h264" ? "m4a" : "opus"
 
 
 
 
 
 
 
 
 
 
 
271
  }
272
 
273
- const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality,
274
- checkSingle = i =>
275
- qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].videoCodec),
276
- checkRender = i =>
277
- qual(i) === matchingQuality && i.has_video && !i.has_audio;
278
 
279
- let match, type, urls;
 
 
 
280
 
281
- // prefer good premuxed videos if available
282
- if (!o.isAudioOnly && !o.isAudioMuted && format === "h264" && bestVideo.fps <= 30) {
283
- match = info.streaming_data.formats.find(checkSingle);
284
- type = "proxy";
285
- urls = match?.decipher(yt.session.player);
286
- }
 
 
 
287
 
288
- const video = adaptive_formats.find(checkRender);
 
 
289
 
290
- if (!match && video && audio) {
291
- match = video;
292
- type = "merge";
293
- urls = [
294
- video.decipher(yt.session.player),
295
- audio.decipher(yt.session.player)
296
- ]
297
- }
298
 
299
- if (match) {
300
- filenameAttributes.qualityLabel = match.quality_label;
301
- filenameAttributes.resolution = `${match.width}x${match.height}`;
302
- filenameAttributes.extension = codecMatch[format].container;
303
- filenameAttributes.youtubeFormat = format;
304
  return {
305
- type,
306
- urls,
 
 
 
307
  filenameAttributes,
308
- fileMetadata
 
309
  }
310
  }
311
 
312
- return { error: "fetch.fail" }
313
  }
 
1
+ import HLS from "hls-parser";
2
 
3
+ import { fetch } from "undici";
4
  import { Innertube, Session } from "youtubei.js";
5
 
6
  import { env } from "../../config.js";
 
7
  import { getCookie, updateCookieValues } from "../cookie/manager.js";
8
 
9
  const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
10
 
11
  let innertube, lastRefreshedAt;
12
 
13
+ const codecList = {
14
  h264: {
15
  videoCodec: "avc1",
16
  audioCodec: "mp4a",
 
28
  }
29
  }
30
 
31
+ const hlsCodecList = {
32
+ h264: {
33
+ videoCodec: "avc1",
34
+ audioCodec: "mp4a",
35
+ container: "mp4"
36
+ },
37
+ vp9: {
38
+ videoCodec: "vp09",
39
+ audioCodec: "mp4a",
40
+ container: "webm"
41
+ }
42
+ }
43
+
44
+ const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
45
+
46
  const transformSessionData = (cookie) => {
47
  if (!cookie)
48
  return;
 
68
  const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
69
  if (!innertube || shouldRefreshPlayer) {
70
  innertube = await Innertube.create({
71
+ fetch: customFetch,
72
+ retrieve_player: false,
73
  });
74
  lastRefreshedAt = +new Date();
75
  }
 
124
  dispatcher: o.dispatcher
125
  })
126
  );
127
+ } catch (e) {
128
  if (e.message?.endsWith("decipher algorithm")) {
129
  return { error: "youtube.decipher" }
130
  } else if (e.message?.includes("refresh access token")) {
 
132
  } else throw e;
133
  }
134
 
135
+ let useHLS = o.youtubeHLS;
 
 
 
 
 
 
 
 
136
 
137
+ // HLS playlists don't contain the av1 video format, at least with the iOS client
138
+ if (useHLS && o.format === "av1") {
139
+ useHLS = false;
140
  }
141
 
142
+ let info;
143
  try {
144
+ info = await yt.getBasicInfo(o.id, useHLS ? 'IOS' : 'ANDROID');
145
+ } catch (e) {
146
+ if (e?.info) {
147
+ const errorInfo = JSON.parse(e?.info);
148
+
149
+ if (errorInfo?.reason === "This video is private") {
150
+ return { error: "content.video.private" };
151
+ }
152
+ if (["INVALID_ARGUMENT", "UNAUTHENTICATED"].includes(errorInfo?.error?.status)) {
153
+ return { error: "youtube.api_error" };
154
+ }
155
+ }
156
+
157
+ if (e?.message === "This video is unavailable") {
158
  return { error: "content.video.unavailable" };
 
 
159
  }
160
+
161
+ return { error: "fetch.fail" };
162
  }
163
 
164
  if (!info) return { error: "fetch.fail" };
 
166
  const playability = info.playability_status;
167
  const basicInfo = info.basic_info;
168
 
169
+ switch(playability.status) {
170
+ case "LOGIN_REQUIRED":
171
+ if (playability.reason.endsWith("bot")) {
172
+ return { error: "youtube.login" }
173
+ }
174
+ if (playability.reason.endsWith("age")) {
175
+ return { error: "content.video.age" }
176
+ }
177
+ if (playability?.error_screen?.reason?.text === "Private video") {
178
+ return { error: "content.video.private" }
179
+ }
180
+ break;
181
 
182
+ case "UNPLAYABLE":
183
+ if (playability?.reason?.endsWith("request limit.")) {
184
+ return { error: "fetch.rate" }
185
+ }
186
+ if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
187
+ return { error: "content.video.region" }
188
+ }
189
+ if (playability?.error_screen?.reason?.text === "Private video") {
190
+ return { error: "content.video.private" }
191
+ }
192
+ break;
193
+
194
+ case "AGE_VERIFICATION_REQUIRED":
195
+ return { error: "content.video.age" };
196
  }
197
 
198
  if (playability.status !== "OK") {
199
  return { error: "content.video.unavailable" };
200
  }
201
+
202
  if (basicInfo.is_live) {
203
  return { error: "content.video.live" };
204
  }
205
 
206
+ if (basicInfo.duration > env.durationLimit) {
207
+ return { error: "content.too_long" };
208
+ }
209
+
210
  // return a critical error if returned video is "Video Not Available"
211
  // or a similar stub by youtube
212
  if (basicInfo.id !== o.id) {
 
216
  }
217
  }
218
 
219
+ const quality = o.quality === "max" ? 9000 : Number(o.quality);
 
 
 
 
 
 
220
 
221
+ const normalizeQuality = res => {
222
+ const shortestSide = res.height > res.width ? res.width : res.height;
223
+ return videoQualities.find(qual => qual >= shortestSide);
 
 
224
  }
225
 
226
+ let video, audio, dubbedLanguage,
227
+ codec = o.format || "h264";
228
+
229
+ if (useHLS) {
230
+ const hlsManifest = info.streaming_data.hls_manifest_url;
231
 
232
+ if (!hlsManifest) {
233
+ return { error: "youtube.no_hls_streams" };
234
+ }
235
 
236
+ const fetchedHlsManifest = await fetch(hlsManifest, {
237
+ dispatcher: o.dispatcher,
238
+ }).then(r => {
239
+ if (r.status === 200) {
240
+ return r.text();
241
+ } else {
242
+ throw new Error("couldn't fetch the HLS playlist");
243
+ }
244
+ }).catch(() => {});
245
 
246
+ if (!fetchedHlsManifest) {
247
+ return { error: "youtube.no_hls_streams" };
248
+ }
249
 
250
+ const variants = HLS.parse(fetchedHlsManifest).variants.sort(
251
+ (a, b) => Number(b.bandwidth) - Number(a.bandwidth)
252
+ );
253
 
254
+ if (!variants || variants.length === 0) {
255
+ return { error: "youtube.no_hls_streams" };
256
+ }
257
 
258
+ const matchHlsCodec = codecs => (
259
+ codecs.includes(hlsCodecList[codec].videoCodec)
260
+ );
261
+
262
+ const best = variants.find(i => matchHlsCodec(i.codecs));
263
+
264
+ const preferred = variants.find(i =>
265
+ matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality
266
+ );
267
 
268
+ let selected = preferred || best;
 
 
 
 
 
269
 
270
+ if (!selected) {
271
+ codec = "h264";
272
+ selected = variants.find(i => matchHlsCodec(i.codecs));
273
  }
 
274
 
275
+ if (!selected) {
276
+ return { error: "youtube.no_matching_format" };
277
+ }
278
+
279
+ audio = selected.audio.find(i => i.isDefault);
280
+
281
+ // some videos (mainly those with AI dubs) don't have any tracks marked as default
282
+ // why? god knows, but we assume that a default track is marked as such in the title
283
+ if (!audio) {
284
+ audio = selected.audio.find(i => i.name.endsWith("- original"));
285
+ }
286
+
287
+ if (o.dubLang) {
288
+ const dubbedAudio = selected.audio.find(i =>
289
+ i.language?.startsWith(o.dubLang)
290
+ );
291
+
292
+ if (dubbedAudio && !dubbedAudio.isDefault) {
293
+ dubbedLanguage = dubbedAudio.language;
294
+ audio = dubbedAudio;
295
+ }
296
+ }
297
+
298
+ selected.audio = [];
299
+ selected.subtitles = [];
300
+ video = selected;
301
+ } else {
302
+ // i miss typescript so bad
303
+ const sorted_formats = {
304
+ h264: {
305
+ video: [],
306
+ audio: [],
307
+ bestVideo: undefined,
308
+ bestAudio: undefined,
309
+ },
310
+ vp9: {
311
+ video: [],
312
+ audio: [],
313
+ bestVideo: undefined,
314
+ bestAudio: undefined,
315
+ },
316
+ av1: {
317
+ video: [],
318
+ audio: [],
319
+ bestVideo: undefined,
320
+ bestAudio: undefined,
321
+ },
322
+ }
323
+
324
+ const checkFormat = (format, pCodec) => format.content_length &&
325
+ (format.mime_type.includes(codecList[pCodec].videoCodec)
326
+ || format.mime_type.includes(codecList[pCodec].audioCodec));
327
+
328
+ // sort formats & weed out bad ones
329
+ info.streaming_data.adaptive_formats.sort((a, b) =>
330
+ Number(b.bitrate) - Number(a.bitrate)
331
+ ).forEach(format => {
332
+ Object.keys(codecList).forEach(yCodec => {
333
+ const sorted = sorted_formats[yCodec];
334
+ const goodFormat = checkFormat(format, yCodec);
335
+ if (!goodFormat) return;
336
+
337
+ if (format.has_video) {
338
+ sorted.video.push(format);
339
+ if (!sorted.bestVideo) sorted.bestVideo = format;
340
+ }
341
+ if (format.has_audio) {
342
+ sorted.audio.push(format);
343
+ if (!sorted.bestAudio) sorted.bestAudio = format;
344
+ }
345
+ })
346
+ });
347
+
348
+ const noBestMedia = () => {
349
+ const vid = sorted_formats[codec]?.bestVideo;
350
+ const aud = sorted_formats[codec]?.bestAudio;
351
+ return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly)
352
+ };
353
+
354
+ if (noBestMedia()) {
355
+ if (codec === "av1") codec = "vp9";
356
+ else if (codec === "vp9") codec = "av1";
357
+
358
+ // if there's no higher quality fallback, then use h264
359
+ if (noBestMedia()) codec = "h264";
360
+ }
361
+
362
+ // if there's no proper combo of av1, vp9, or h264, then give up
363
+ if (noBestMedia()) {
364
+ return { error: "youtube.no_matching_format" };
365
+ }
366
+
367
+ audio = sorted_formats[codec].bestAudio;
368
+
369
+ if (audio?.audio_track && !audio?.audio_track?.audio_is_default) {
370
+ audio = sorted_formats[codec].audio.find(i =>
371
+ i?.audio_track?.audio_is_default
372
+ );
373
+ }
374
+
375
+ if (o.dubLang) {
376
+ const dubbedAudio = sorted_formats[codec].audio.find(i =>
377
+ i.language?.startsWith(o.dubLang) && i.audio_track
378
+ );
379
+
380
+ if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
381
+ audio = dubbedAudio;
382
+ dubbedLanguage = dubbedAudio.language;
383
+ }
384
+ }
385
+
386
+ if (!o.isAudioOnly) {
387
+ const qual = (i) => {
388
+ return normalizeQuality({
389
+ width: i.width,
390
+ height: i.height,
391
+ })
392
+ }
393
+
394
+ const bestQuality = qual(sorted_formats[codec].bestVideo);
395
+ const useBestQuality = quality >= bestQuality;
396
+
397
+ video = useBestQuality
398
+ ? sorted_formats[codec].bestVideo
399
+ : sorted_formats[codec].video.find(i => qual(i) === quality);
400
+
401
+ if (!video) video = sorted_formats[codec].bestVideo;
402
+ }
403
  }
404
 
405
+ const fileMetadata = {
406
+ title: basicInfo.title.trim(),
407
+ artist: basicInfo.author.replace("- Topic", "").trim()
408
  }
409
 
410
  if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
411
+ const descItems = basicInfo.short_description.split("\n\n", 5);
412
+
413
  if (descItems.length === 5) {
414
  fileMetadata.album = descItems[2];
415
  fileMetadata.copyright = descItems[3];
 
419
  }
420
  }
421
 
422
+ const filenameAttributes = {
423
  service: "youtube",
424
  id: o.id,
425
  title: fileMetadata.title,
426
  author: fileMetadata.artist,
427
+ youtubeDubName: dubbedLanguage || false,
428
  }
429
 
430
+ if (audio && o.isAudioOnly) {
431
+ let bestAudio = codec === "h264" ? "m4a" : "opus";
432
+ let urls = audio.url;
433
+
434
+ if (useHLS) {
435
+ bestAudio = "mp3";
436
+ urls = audio.uri;
437
+ }
438
+
439
+ return {
440
+ type: "audio",
441
+ isAudioOnly: true,
442
+ urls,
443
+ filenameAttributes,
444
+ fileMetadata,
445
+ bestAudio,
446
+ isHLS: useHLS,
447
+ }
448
  }
449
 
450
+ if (video && audio) {
451
+ let resolution;
 
 
 
452
 
453
+ if (useHLS) {
454
+ resolution = normalizeQuality(video.resolution);
455
+ filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;
456
+ filenameAttributes.extension = hlsCodecList[codec].container;
457
 
458
+ video = video.uri;
459
+ audio = audio.uri;
460
+ } else {
461
+ resolution = normalizeQuality({
462
+ width: video.width,
463
+ height: video.height,
464
+ });
465
+ filenameAttributes.resolution = `${video.width}x${video.height}`;
466
+ filenameAttributes.extension = codecList[codec].container;
467
 
468
+ video = video.url;
469
+ audio = audio.url;
470
+ }
471
 
472
+ filenameAttributes.qualityLabel = `${resolution}p`;
473
+ filenameAttributes.youtubeFormat = codec;
 
 
 
 
 
 
474
 
 
 
 
 
 
475
  return {
476
+ type: "merge",
477
+ urls: [
478
+ video,
479
+ audio,
480
+ ],
481
  filenameAttributes,
482
+ fileMetadata,
483
+ isHLS: useHLS,
484
  }
485
  }
486
 
487
+ return { error: "youtube.no_matching_format" };
488
  }