cursorpro commited on
Commit
161d16e
Β·
verified Β·
1 Parent(s): 5ec2e9b

Upload 43 files

Browse files
Files changed (1) hide show
  1. src/routes/invidious_routes/videos.ts +404 -428
src/routes/invidious_routes/videos.ts CHANGED
@@ -7,9 +7,12 @@ import {
7
  import { validateVideoId } from "../../lib/helpers/validateVideoId.ts";
8
  import { encryptQuery } from "../../lib/helpers/encryptQuery.ts";
9
  import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts";
 
10
 
11
  const videos = new Hono();
12
 
 
 
13
  interface Thumbnail {
14
  quality: string;
15
  url: string;
@@ -36,25 +39,20 @@ interface Storyboard {
36
  }
37
 
38
  interface AdaptiveFormat {
39
- init?: string;
40
- index?: string;
41
  bitrate: string;
42
  url: string;
43
  itag: string;
44
  type: string;
45
- clen?: string;
46
- lmt?: string;
47
  projectionType: string;
48
- fps?: number;
49
- size?: string;
50
- resolution?: string;
51
- qualityLabel?: string;
52
- container?: string;
53
- encoding?: string;
54
- audioQuality?: string;
55
- audioSampleRate?: number;
56
- audioChannels?: number;
57
- colorInfo?: object;
58
  }
59
 
60
  interface FormatStream {
@@ -63,18 +61,12 @@ interface FormatStream {
63
  type: string;
64
  quality: string;
65
  bitrate: string;
66
- fps?: number;
67
- size?: string;
68
- resolution?: string;
69
- qualityLabel?: string;
70
- container?: string;
71
- encoding?: string;
72
- }
73
-
74
- interface Caption {
75
- label: string;
76
- language_code: string;
77
- url: string;
78
  }
79
 
80
  interface RecommendedVideo {
@@ -87,165 +79,131 @@ interface RecommendedVideo {
87
  authorVerified: boolean;
88
  lengthSeconds: number;
89
  viewCountText: string;
90
- published?: string;
91
- publishedText?: string;
92
  }
93
 
94
- // Generate thumbnail URLs for a video
95
- function generateThumbnails(videoId: string, baseUrl: string): Thumbnail[] {
 
 
96
  return [
97
- { quality: "maxres", url: `${baseUrl}/vi/${videoId}/maxres.jpg`, width: 1280, height: 720 },
98
- { quality: "maxresdefault", url: `${baseUrl}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 },
99
- { quality: "sddefault", url: `${baseUrl}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 },
100
- { quality: "high", url: `${baseUrl}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 },
101
- { quality: "medium", url: `${baseUrl}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 },
102
- { quality: "default", url: `${baseUrl}/vi/${videoId}/default.jpg`, width: 120, height: 90 },
103
- { quality: "start", url: `${baseUrl}/vi/${videoId}/1.jpg`, width: 120, height: 90 },
104
- { quality: "middle", url: `${baseUrl}/vi/${videoId}/2.jpg`, width: 120, height: 90 },
105
- { quality: "end", url: `${baseUrl}/vi/${videoId}/3.jpg`, width: 120, height: 90 },
106
  ];
107
  }
108
 
109
- // Parse storyboards from YouTube response
110
- function parseStoryboards(storyboards: any, videoId: string): Storyboard[] {
111
  const result: Storyboard[] = [];
112
- if (!storyboards) return result;
113
-
114
- // Handle PlayerStoryboardSpec format
115
- if (storyboards.type === "PlayerStoryboardSpec" && storyboards.boards) {
116
- for (const board of storyboards.boards) {
117
- if (!board.template_url) continue;
118
- result.push({
119
- url: `/api/v1/storyboards/${videoId}?width=${board.thumbnail_width}&height=${board.thumbnail_height}`,
120
- templateUrl: board.template_url,
121
- width: board.thumbnail_width || 0,
122
- height: board.thumbnail_height || 0,
123
- count: board.thumbnail_count || 0,
124
- interval: board.interval || 0,
125
- storyboardWidth: board.columns || 0,
126
- storyboardHeight: board.rows || 0,
127
- storyboardCount: board.storyboard_count || 1,
128
- });
129
- }
 
 
 
 
 
 
 
 
 
 
 
 
130
  }
131
 
132
  return result;
133
  }
134
 
135
- // Convert YouTube format to Invidious adaptive format
136
- function convertAdaptiveFormat(format: any): AdaptiveFormat {
137
- const result: AdaptiveFormat = {
138
- bitrate: String(format.bitrate || "0"),
139
- url: format.url || "",
140
- itag: String(format.itag || "0"),
141
- type: format.mime_type || "",
142
- projectionType: format.projection_type || "RECTANGULAR",
143
  };
 
144
 
145
- if (format.init_range) {
146
- result.init = `${format.init_range.start}-${format.init_range.end}`;
147
- }
148
- if (format.index_range) {
149
- result.index = `${format.index_range.start}-${format.index_range.end}`;
150
- }
151
- if (format.content_length) result.clen = String(format.content_length);
152
- if (format.last_modified) result.lmt = String(format.last_modified);
153
- if (format.fps) result.fps = format.fps;
154
- if (format.width && format.height) result.size = `${format.width}x${format.height}`;
155
- if (format.quality_label) {
156
- result.qualityLabel = format.quality_label;
157
- result.resolution = format.quality_label;
158
- }
159
-
160
- // Parse container and encoding from mime type
161
- const mimeMatch = format.mime_type?.match(/^(video|audio)\/(\w+)/);
162
- if (mimeMatch) {
163
- result.container = mimeMatch[2];
164
- }
165
-
166
- const codecMatch = format.mime_type?.match(/codecs="([^"]+)"/);
167
- if (codecMatch) {
168
- result.encoding = codecMatch[1].split(",")[0].trim();
169
- }
170
-
171
- if (format.audio_quality) result.audioQuality = format.audio_quality;
172
- if (format.audio_sample_rate) result.audioSampleRate = parseInt(format.audio_sample_rate);
173
- if (format.audio_channels) result.audioChannels = format.audio_channels;
174
- if (format.color_info) result.colorInfo = format.color_info;
175
-
176
- return result;
177
  }
178
 
179
- // Convert YouTube format to Invidious format stream (combined video+audio)
180
  function convertFormatStream(format: any): FormatStream {
181
- const result: FormatStream = {
182
- url: format.url || "",
183
- itag: String(format.itag || "0"),
184
- type: format.mime_type || "",
185
- quality: format.quality || "medium",
186
- bitrate: String(format.bitrate || "0"),
 
 
 
 
 
 
 
187
  };
188
-
189
- if (format.fps) result.fps = format.fps;
190
- if (format.width && format.height) result.size = `${format.width}x${format.height}`;
191
- if (format.quality_label) {
192
- result.qualityLabel = format.quality_label;
193
- result.resolution = format.quality_label;
194
- }
195
-
196
- const mimeMatch = format.mime_type?.match(/^video\/(\w+)/);
197
- if (mimeMatch) {
198
- result.container = mimeMatch[1];
199
- }
200
-
201
- const codecMatch = format.mime_type?.match(/codecs="([^"]+)"/);
202
- if (codecMatch) {
203
- result.encoding = codecMatch[1].split(",")[0].trim();
204
- }
205
-
206
- return result;
207
  }
208
 
209
- // Convert description to HTML with links
210
  function descriptionToHtml(description: string): string {
211
  if (!description) return "";
212
-
213
- // Escape HTML entities
214
  let html = description
215
  .replace(/&/g, "&")
216
  .replace(/</g, "&lt;")
217
  .replace(/>/g, "&gt;");
218
-
219
- // Convert URLs to links
220
  html = html.replace(
221
  /(https?:\/\/[^\s]+)/g,
222
- (url) => {
223
- const displayUrl = url.replace(/^https?:\/\//, "");
224
- return `<a href="${url}">${displayUrl}</a>`;
225
- }
226
- );
227
-
228
- // Convert hashtags to links
229
- html = html.replace(
230
- /#(\w+)/g,
231
- '<a href="/hashtag/$1">#$1</a>'
232
  );
233
-
234
  return html;
235
  }
236
 
237
- // Calculate relative time string
238
  function getRelativeTimeString(date: Date): string {
239
- const now = new Date();
240
- const diffMs = now.getTime() - date.getTime();
241
- const diffSeconds = Math.floor(diffMs / 1000);
242
- const diffMinutes = Math.floor(diffSeconds / 60);
243
- const diffHours = Math.floor(diffMinutes / 60);
244
- const diffDays = Math.floor(diffHours / 24);
245
- const diffWeeks = Math.floor(diffDays / 7);
246
- const diffMonths = Math.floor(diffDays / 30);
247
  const diffYears = Math.floor(diffDays / 365);
248
-
 
 
 
249
  if (diffYears > 0) return `${diffYears} year${diffYears > 1 ? "s" : ""} ago`;
250
  if (diffMonths > 0) return `${diffMonths} month${diffMonths > 1 ? "s" : ""} ago`;
251
  if (diffWeeks > 0) return `${diffWeeks} week${diffWeeks > 1 ? "s" : ""} ago`;
@@ -255,7 +213,6 @@ function getRelativeTimeString(date: Date): string {
255
  return "just now";
256
  }
257
 
258
- // Localize URL to route through local server
259
  function localizeUrl(url: string, config: any): string {
260
  if (!url) return url;
261
  try {
@@ -265,15 +222,12 @@ function localizeUrl(url: string, config: any): string {
265
 
266
  if (config.server.encrypt_query_params) {
267
  const publicParams = [...queryParams].filter(([key]) =>
268
- ["pot", "ip"].includes(key) === false
269
  );
270
  const privateParams = [...queryParams].filter(([key]) =>
271
- ["pot", "ip"].includes(key) === true
272
- );
273
- const encryptedParams = encryptQuery(
274
- JSON.stringify(privateParams),
275
- config,
276
  );
 
277
  queryParams = new URLSearchParams(publicParams);
278
  queryParams.set("enc", "true");
279
  queryParams.set("data", encryptedParams);
@@ -285,6 +239,186 @@ function localizeUrl(url: string, config: any): string {
285
  }
286
  }
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  videos.get("/:videoId", async (c) => {
289
  const videoId = c.req.param("videoId");
290
  const { local } = c.req.query();
@@ -308,13 +442,13 @@ videos.get("/:videoId", async (c) => {
308
  const metrics = c.get("metrics");
309
  const tokenMinter = c.get("tokenMinter");
310
 
311
- // Check if tokenMinter is ready (only needed when PO token is enabled)
312
  if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) {
313
  throw new HTTPException(503, {
314
  res: new Response(JSON.stringify({ error: TOKEN_MINTER_NOT_READY_MESSAGE })),
315
  });
316
  }
317
 
 
318
  const youtubePlayerResponseJson = await youtubePlayerParsing({
319
  innertubeClient,
320
  videoId,
@@ -323,6 +457,7 @@ videos.get("/:videoId", async (c) => {
323
  metrics,
324
  }) as any;
325
 
 
326
  const videoInfo = youtubeVideoInfo(innertubeClient, youtubePlayerResponseJson);
327
 
328
  if (videoInfo.playability_status?.status !== "OK") {
@@ -334,286 +469,127 @@ videos.get("/:videoId", async (c) => {
334
  });
335
  }
336
 
337
- // Get the request origin for thumbnail URLs
338
- const origin = new URL(c.req.url).origin;
339
- const thumbnailBaseUrl = origin;
 
 
 
 
340
 
341
- // Build video details
342
- const details = videoInfo.basic_info;
343
- const streamingData = videoInfo.streaming_data;
 
 
 
 
344
 
345
- // Parse publish date
346
  let publishedTimestamp = 0;
347
  let publishedText = "";
348
- if (youtubePlayerResponseJson.microformat?.playerMicroformatRenderer?.publishDate) {
349
- const publishDate = new Date(youtubePlayerResponseJson.microformat.playerMicroformatRenderer.publishDate);
350
  publishedTimestamp = Math.floor(publishDate.getTime() / 1000);
351
  publishedText = getRelativeTimeString(publishDate);
352
  }
353
 
354
- // Build adaptive formats
355
- const adaptiveFormats: AdaptiveFormat[] = [];
356
- if (streamingData?.adaptive_formats) {
357
- for (const format of streamingData.adaptive_formats) {
358
- const converted = convertAdaptiveFormat(format);
359
- if (local) {
360
- converted.url = localizeUrl(converted.url, config);
361
- }
362
- adaptiveFormats.push(converted);
363
- }
364
- }
365
-
366
- // Build format streams (combined video+audio)
367
- const formatStreams: FormatStream[] = [];
368
- if (streamingData?.formats) {
369
- for (const format of streamingData.formats) {
370
- const converted = convertFormatStream(format);
371
- if (local) {
372
- converted.url = localizeUrl(converted.url, config);
373
- }
374
- formatStreams.push(converted);
375
- }
376
- }
377
-
378
- // Build captions
379
- const captions: Caption[] = [];
380
- if (videoInfo.captions?.caption_tracks) {
381
- for (const track of videoInfo.captions.caption_tracks) {
382
- captions.push({
383
- label: track.name?.text || track.language_code || "Unknown",
384
- language_code: track.language_code || "en",
385
- url: `/api/v1/captions/${videoId}?label=${encodeURIComponent(track.name?.text || track.language_code || "")}`,
386
- });
387
- }
388
- }
389
-
390
- // Build recommended videos
391
- const recommendedVideos: RecommendedVideo[] = [];
392
- // Note: Related videos require a separate API call to /next endpoint
393
- // For now, we return an empty array - this can be enhanced later
394
-
395
- // Build author thumbnails from raw YouTube response
396
- const authorThumbnails: AuthorThumbnail[] = [];
397
- const channelThumbnails = youtubePlayerResponseJson.videoDetails?.author?.thumbnail?.thumbnails ||
398
- youtubePlayerResponseJson.microformat?.playerMicroformatRenderer?.ownerProfileUrl ? [] : [];
399
-
400
- // Generate standard author thumbnail sizes if we have the channel ID
401
- if (details.channel_id) {
402
- const sizes = [32, 48, 76, 100, 176, 512];
403
- for (const size of sizes) {
404
- authorThumbnails.push({
405
- url: `https://yt3.ggpht.com/a/default-user=s${size}-c-k-c0x00ffffff-no-rj`,
406
- width: size,
407
- height: size,
408
- });
409
- }
410
- }
411
-
412
- // Get raw YouTube response data
413
- const videoDetails = (youtubePlayerResponseJson as any).videoDetails || {};
414
- const microformat = (youtubePlayerResponseJson as any).microformat?.playerMicroformatRenderer || {};
415
- const playabilityStatus = (youtubePlayerResponseJson as any).playabilityStatus || {};
416
- const streamingDataRaw = (youtubePlayerResponseJson as any).streamingData || {};
417
- const captionsRaw = (youtubePlayerResponseJson as any).captions || {};
418
- const storyboardsRaw = (youtubePlayerResponseJson as any).storyboards || {};
419
-
420
- // Map thumbnails directly from videoDetails
421
- const thumbnailArray = [];
422
- if (videoDetails.thumbnail?.thumbnails) {
423
- for (const thumb of videoDetails.thumbnail.thumbnails) {
424
- thumbnailArray.push({
425
- url: thumb.url,
426
- width: thumb.width,
427
- height: thumb.height,
428
- });
429
- }
430
- }
431
-
432
- // Map storyboards directly from API response
433
- const storyboardsArray = [];
434
- if (storyboardsRaw.playerStoryboardSpecRenderer?.spec) {
435
- const spec = storyboardsRaw.playerStoryboardSpecRenderer.spec;
436
- const specParts = spec.split('|');
437
-
438
- for (let i = 3; i < specParts.length; i++) {
439
- const parts = specParts[i].split('#');
440
- if (parts.length >= 8) {
441
- const baseUrl = specParts[0];
442
- const [width, height, count, columns, rows, interval, name, sigh] = parts;
443
- const storyboardCount = Math.ceil(parseInt(count) / (parseInt(columns) * parseInt(rows)));
444
-
445
- const urls = [];
446
- for (let j = 0; j < storyboardCount; j++) {
447
- let url = baseUrl.replace('$L', i - 3).replace('$N', name) + j;
448
- if (sigh) url += '&sigh=' + sigh;
449
- urls.push(url);
450
- }
451
-
452
- storyboardsArray.push({
453
- width: width,
454
- height: height,
455
- thumbsCount: count,
456
- columns: columns,
457
- rows: rows,
458
- interval: interval,
459
- storyboardCount: storyboardCount,
460
- url: urls,
461
- });
462
- }
463
- }
464
- }
465
-
466
- // Map captions directly from API response
467
- const captionTracks = [];
468
- if (captionsRaw.playerCaptionsTracklistRenderer?.captionTracks) {
469
- for (const track of captionsRaw.playerCaptionsTracklistRenderer.captionTracks) {
470
- captionTracks.push({
471
- baseUrl: track.baseUrl,
472
- name: track.name?.simpleText || track.languageCode,
473
- vssId: track.vssId || "",
474
- languageCode: track.languageCode,
475
- isTranslatable: track.isTranslatable ?? true,
476
- });
477
- }
478
- }
479
-
480
- // Map audioTracks directly from API response
481
- const audioTracks = [];
482
- if (captionsRaw.playerCaptionsTracklistRenderer?.audioTracks) {
483
- for (const track of captionsRaw.playerCaptionsTracklistRenderer.audioTracks) {
484
- audioTracks.push({
485
- languageName: track.displayName || track.id,
486
- languageCode: track.id,
487
- });
488
- }
489
- } else if (captionsRaw.playerCaptionsTracklistRenderer?.captionTracks) {
490
- // Fallback: extract unique languages from caption tracks
491
- const uniqueLangs = new Set();
492
- for (const track of captionsRaw.playerCaptionsTracklistRenderer.captionTracks) {
493
- const langCode = track.languageCode;
494
- if (!uniqueLangs.has(langCode)) {
495
- uniqueLangs.add(langCode);
496
- audioTracks.push({
497
- languageName: track.name?.simpleText || langCode,
498
- languageCode: langCode,
499
- });
500
- }
501
- }
502
- }
503
-
504
- // Map formats directly from streamingData
505
- const formatsArray = [];
506
- if (streamingDataRaw.formats) {
507
- for (const format of streamingDataRaw.formats) {
508
- const formatObj: any = {
509
- itag: format.itag,
510
- url: format.url,
511
- mimeType: format.mimeType,
512
- bitrate: format.bitrate,
513
- width: format.width || 0,
514
- height: format.height || 0,
515
- lastModified: format.lastModified,
516
- contentLength: format.contentLength,
517
- quality: format.quality,
518
- fps: format.fps,
519
- qualityLabel: format.qualityLabel,
520
- projectionType: format.projectionType || "RECTANGULAR",
521
- averageBitrate: format.averageBitrate,
522
- approxDurationMs: format.approxDurationMs,
523
- };
524
-
525
- if (format.audioQuality) formatObj.audioQuality = format.audioQuality;
526
- if (format.audioSampleRate) formatObj.audioSampleRate = format.audioSampleRate;
527
- if (format.audioChannels) formatObj.audioChannels = format.audioChannels;
528
- if (format.qualityLabel) {
529
- formatObj.qualityOrdinal = "QUALITY_ORDINAL_" + format.qualityLabel.replace(/\d+/, "").replace('p', 'P');
530
- }
531
-
532
- formatsArray.push(formatObj);
533
- }
534
- }
535
-
536
- // Map adaptiveFormats directly from streamingData
537
- const adaptiveFormatsArray = [];
538
- if (streamingDataRaw.adaptiveFormats) {
539
- for (const format of streamingDataRaw.adaptiveFormats) {
540
- const adaptiveFormat: any = {
541
- itag: format.itag,
542
- url: format.url,
543
- mimeType: format.mimeType,
544
- bitrate: format.bitrate,
545
- width: format.width || 0,
546
- height: format.height || 0,
547
- lastModified: format.lastModified,
548
- contentLength: format.contentLength,
549
- quality: format.quality,
550
- fps: format.fps,
551
- qualityLabel: format.qualityLabel,
552
- projectionType: format.projectionType || "RECTANGULAR",
553
- averageBitrate: format.averageBitrate,
554
- approxDurationMs: format.approxDurationMs,
555
- };
556
-
557
- if (format.initRange) {
558
- adaptiveFormat.initRange = {
559
- start: format.initRange.start,
560
- end: format.initRange.end,
561
- };
562
- }
563
- if (format.indexRange) {
564
- adaptiveFormat.indexRange = {
565
- start: format.indexRange.start,
566
- end: format.indexRange.end,
567
- };
568
- }
569
-
570
- if (format.audioQuality) adaptiveFormat.audioQuality = format.audioQuality;
571
- if (format.audioSampleRate) adaptiveFormat.audioSampleRate = format.audioSampleRate;
572
- if (format.audioChannels) adaptiveFormat.audioChannels = format.audioChannels;
573
- if (format.colorInfo) adaptiveFormat.colorInfo = format.colorInfo;
574
- if (format.highReplication) adaptiveFormat.highReplication = format.highReplication;
575
- if (format.loudnessDb !== undefined) adaptiveFormat.loudnessDb = format.loudnessDb;
576
-
577
- if (format.qualityLabel) {
578
- adaptiveFormat.qualityOrdinal = "QUALITY_ORDINAL_" + format.qualityLabel.replace(/\d+/, "").replace('p', 'P');
579
- } else {
580
- adaptiveFormat.qualityOrdinal = "QUALITY_ORDINAL_UNKNOWN";
581
- }
582
-
583
- adaptiveFormatsArray.push(adaptiveFormat);
584
- }
585
- }
586
-
587
- const currentTimestamp = Math.floor(Date.now() / 1000);
588
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  const response = {
590
- status: playabilityStatus.status || "OK",
591
- id: videoDetails.videoId || videoId,
592
- title: videoDetails.title || "",
593
- lengthSeconds: videoDetails.lengthSeconds || "0",
594
- keywords: videoDetails.keywords || [],
595
- channelTitle: videoDetails.author || "",
596
- channelId: videoDetails.channelId || "",
597
- description: videoDetails.shortDescription || "",
598
- thumbnail: thumbnailArray,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  allowRatings: videoDetails.allowRatings ?? true,
600
- viewCount: videoDetails.viewCount || "0",
601
- isPrivate: videoDetails.isPrivate || false,
602
- isUnpluggedCorpus: videoDetails.isUnpluggedCorpus || false,
603
- isLiveContent: videoDetails.isLiveContent || false,
604
- storyboards: storyboardsArray,
605
- captions: {
606
- captionTracks: captionTracks,
607
- },
608
- audioTracks: audioTracks,
609
- defaultVideoLanguage: microformat.defaultLanguage || "English",
610
- defaultVideoLanguageCode: microformat.defaultLanguage || "en",
611
- fetchedTS: currentTimestamp,
612
- expiresInSeconds: streamingDataRaw.expiresInSeconds || "21540",
613
- formats: formatsArray,
614
- isGCR: false,
615
- adaptiveFormats: adaptiveFormatsArray,
616
- availableAt: currentTimestamp,
617
  };
618
 
619
  return c.json(response);
 
7
  import { validateVideoId } from "../../lib/helpers/validateVideoId.ts";
8
  import { encryptQuery } from "../../lib/helpers/encryptQuery.ts";
9
  import { TOKEN_MINTER_NOT_READY_MESSAGE } from "../../constants.ts";
10
+ import { YT, YTNodes } from "youtubei.js";
11
 
12
  const videos = new Hono();
13
 
14
+ // ─── Interfaces ──────────────────────────────────────────────────────────────
15
+
16
  interface Thumbnail {
17
  quality: string;
18
  url: string;
 
39
  }
40
 
41
  interface AdaptiveFormat {
42
+ init: string;
43
+ index: string;
44
  bitrate: string;
45
  url: string;
46
  itag: string;
47
  type: string;
48
+ clen: string;
49
+ lmt: string;
50
  projectionType: string;
51
+ container: string;
52
+ encoding: string;
53
+ audioQuality: string;
54
+ audioSampleRate: number;
55
+ audioChannels: number;
 
 
 
 
 
56
  }
57
 
58
  interface FormatStream {
 
61
  type: string;
62
  quality: string;
63
  bitrate: string;
64
+ fps: number;
65
+ size: string;
66
+ resolution: string;
67
+ qualityLabel: string;
68
+ container: string;
69
+ encoding: string;
 
 
 
 
 
 
70
  }
71
 
72
  interface RecommendedVideo {
 
79
  authorVerified: boolean;
80
  lengthSeconds: number;
81
  viewCountText: string;
82
+ published: string;
83
+ publishedText: string;
84
  }
85
 
86
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
87
+
88
+ function generateThumbnails(videoId: string): Thumbnail[] {
89
+ const base = "https://i.ytimg.com";
90
  return [
91
+ { quality: "maxres", url: `${base}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 },
92
+ { quality: "maxresdefault", url: `${base}/vi/${videoId}/maxresdefault.jpg`, width: 1280, height: 720 },
93
+ { quality: "sddefault", url: `${base}/vi/${videoId}/sddefault.jpg`, width: 640, height: 480 },
94
+ { quality: "high", url: `${base}/vi/${videoId}/hqdefault.jpg`, width: 480, height: 360 },
95
+ { quality: "medium", url: `${base}/vi/${videoId}/mqdefault.jpg`, width: 320, height: 180 },
96
+ { quality: "default", url: `${base}/vi/${videoId}/default.jpg`, width: 120, height: 90 },
97
+ { quality: "start", url: `${base}/vi/${videoId}/1.jpg`, width: 120, height: 90 },
98
+ { quality: "middle", url: `${base}/vi/${videoId}/2.jpg`, width: 120, height: 90 },
99
+ { quality: "end", url: `${base}/vi/${videoId}/3.jpg`, width: 120, height: 90 },
100
  ];
101
  }
102
 
103
+ function parseStoryboards(storyboardsRaw: any, videoId: string): Storyboard[] {
 
104
  const result: Storyboard[] = [];
105
+ if (!storyboardsRaw?.playerStoryboardSpecRenderer?.spec) return result;
106
+
107
+ const spec = storyboardsRaw.playerStoryboardSpecRenderer.spec as string;
108
+ const specParts = spec.split("|");
109
+ const baseUrl = specParts[0];
110
+
111
+ for (let i = 3; i < specParts.length; i++) {
112
+ const parts = specParts[i].split("#");
113
+ if (parts.length < 8) continue;
114
+ const [width, height, count, columns, rows, interval, name, sigh] = parts;
115
+ const storyboardCount = Math.ceil(
116
+ parseInt(count) / (parseInt(columns) * parseInt(rows)),
117
+ );
118
+
119
+ let templateUrl = baseUrl
120
+ .replace("$L", String(i - 3))
121
+ .replace("$N", name) + "$M";
122
+ if (sigh) templateUrl += "&sigh=" + sigh;
123
+
124
+ result.push({
125
+ url: `/api/v1/storyboards/${videoId}?width=${width}&height=${height}`,
126
+ templateUrl,
127
+ width: parseInt(width),
128
+ height: parseInt(height),
129
+ count: parseInt(count),
130
+ interval: parseInt(interval),
131
+ storyboardWidth: parseInt(columns),
132
+ storyboardHeight: parseInt(rows),
133
+ storyboardCount,
134
+ });
135
  }
136
 
137
  return result;
138
  }
139
 
140
+ function mimeToContainerEncoding(mimeType: string): { container: string; encoding: string } {
141
+ const containerMatch = mimeType?.match(/^(?:video|audio)\/(\w+)/);
142
+ const encodingMatch = mimeType?.match(/codecs="([^"]+)"/);
143
+ return {
144
+ container: containerMatch ? containerMatch[1] : "",
145
+ encoding: encodingMatch ? encodingMatch[1].split(",")[0].trim() : "",
 
 
146
  };
147
+ }
148
 
149
+ function convertAdaptiveFormat(format: any): AdaptiveFormat {
150
+ const { container, encoding } = mimeToContainerEncoding(format.mimeType);
151
+ return {
152
+ init: format.initRange ? `${format.initRange.start}-${format.initRange.end}` : "",
153
+ index: format.indexRange ? `${format.indexRange.start}-${format.indexRange.end}` : "",
154
+ bitrate: String(format.bitrate ?? 0),
155
+ url: format.url ?? "",
156
+ itag: String(format.itag ?? 0),
157
+ type: format.mimeType ?? "",
158
+ clen: format.contentLength ? String(format.contentLength) : "",
159
+ lmt: format.lastModified ? String(format.lastModified) : "",
160
+ projectionType: format.projectionType ?? "RECTANGULAR",
161
+ container,
162
+ encoding,
163
+ audioQuality: format.audioQuality ?? "",
164
+ audioSampleRate: format.audioSampleRate ? parseInt(format.audioSampleRate) : 0,
165
+ audioChannels: format.audioChannels ?? 0,
166
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  }
168
 
 
169
  function convertFormatStream(format: any): FormatStream {
170
+ const { container, encoding } = mimeToContainerEncoding(format.mimeType);
171
+ return {
172
+ url: format.url ?? "",
173
+ itag: String(format.itag ?? 0),
174
+ type: format.mimeType ?? "",
175
+ quality: format.quality ?? "medium",
176
+ bitrate: String(format.bitrate ?? 0),
177
+ fps: format.fps ?? 0,
178
+ size: format.width && format.height ? `${format.width}x${format.height}` : "",
179
+ resolution: format.qualityLabel ?? "",
180
+ qualityLabel: format.qualityLabel ?? "",
181
+ container,
182
+ encoding,
183
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  }
185
 
 
186
  function descriptionToHtml(description: string): string {
187
  if (!description) return "";
 
 
188
  let html = description
189
  .replace(/&/g, "&amp;")
190
  .replace(/</g, "&lt;")
191
  .replace(/>/g, "&gt;");
 
 
192
  html = html.replace(
193
  /(https?:\/\/[^\s]+)/g,
194
+ (url) => `<a href="${url}">${url.replace(/^https?:\/\//, "")}</a>`,
 
 
 
 
 
 
 
 
 
195
  );
196
+ html = html.replace(/#(\w+)/g, '<a href="/hashtag/$1">#$1</a>');
197
  return html;
198
  }
199
 
 
200
  function getRelativeTimeString(date: Date): string {
201
+ const diffDays = Math.floor((Date.now() - date.getTime()) / 86400000);
 
 
 
 
 
 
 
202
  const diffYears = Math.floor(diffDays / 365);
203
+ const diffMonths = Math.floor(diffDays / 30);
204
+ const diffWeeks = Math.floor(diffDays / 7);
205
+ const diffHours = Math.floor((Date.now() - date.getTime()) / 3600000);
206
+ const diffMinutes = Math.floor((Date.now() - date.getTime()) / 60000);
207
  if (diffYears > 0) return `${diffYears} year${diffYears > 1 ? "s" : ""} ago`;
208
  if (diffMonths > 0) return `${diffMonths} month${diffMonths > 1 ? "s" : ""} ago`;
209
  if (diffWeeks > 0) return `${diffWeeks} week${diffWeeks > 1 ? "s" : ""} ago`;
 
213
  return "just now";
214
  }
215
 
 
216
  function localizeUrl(url: string, config: any): string {
217
  if (!url) return url;
218
  try {
 
222
 
223
  if (config.server.encrypt_query_params) {
224
  const publicParams = [...queryParams].filter(([key]) =>
225
+ !["pot", "ip"].includes(key)
226
  );
227
  const privateParams = [...queryParams].filter(([key]) =>
228
+ ["pot", "ip"].includes(key)
 
 
 
 
229
  );
230
+ const encryptedParams = encryptQuery(JSON.stringify(privateParams), config);
231
  queryParams = new URLSearchParams(publicParams);
232
  queryParams.set("enc", "true");
233
  queryParams.set("data", encryptedParams);
 
239
  }
240
  }
241
 
242
+ /**
243
+ * Helper to convert duration string (e.g., "3:42", "1:05:20") to seconds.
244
+ */
245
+ function durationToSeconds(text: string): number {
246
+ if (!text) return 0;
247
+ const parts = text.split(":").map(Number);
248
+ if (parts.some(isNaN)) return 0;
249
+ if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
250
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
251
+ return parts[0];
252
+ }
253
+
254
+ /**
255
+ * Extract recommended/related videos from VideoInfo.watch_next_feed.
256
+ * Handles CompactVideo, LockupView, and generic nodes with video_id.
257
+ */
258
+ function extractRecommendedVideos(videoInfo: YT.VideoInfo): RecommendedVideo[] {
259
+ const feed = videoInfo.watch_next_feed;
260
+ if (!feed || feed.length === 0) return [];
261
+
262
+ const results: RecommendedVideo[] = [];
263
+
264
+ for (const item of feed) {
265
+ const raw = item as any;
266
+
267
+ // ── Strategy 1: proper CompactVideo node ──────────────────────────
268
+ if (item.is(YTNodes.CompactVideo)) {
269
+ const vid = item as YTNodes.CompactVideo;
270
+ const vidId = vid.id ?? "";
271
+ if (!vidId) continue;
272
+
273
+ const thumbs: Thumbnail[] = (vid.thumbnails ?? []).map(
274
+ (t: any, idx: number) => ({
275
+ quality: idx === 0 ? "high" : idx === 1 ? "medium" : "default",
276
+ url: t.url ?? "",
277
+ width: t.width ?? 0,
278
+ height: t.height ?? 0,
279
+ }),
280
+ );
281
+ const publishedText: string = (vid as any).published?.text ?? "";
282
+ results.push({
283
+ videoId: vidId,
284
+ title: vid.title?.text ?? "",
285
+ videoThumbnails: thumbs.length > 0 ? thumbs : generateThumbnails(vidId),
286
+ author: vid.author?.name ?? "",
287
+ authorUrl: `/channel/${vid.author?.id ?? ""}`,
288
+ authorId: vid.author?.id ?? "",
289
+ authorVerified: (vid.author as any)?.is_verified ?? false,
290
+ lengthSeconds: vid.duration?.seconds ?? 0,
291
+ viewCountText: (vid.short_view_count as any)?.text ?? (vid as any).view_count?.text ?? "",
292
+ published: publishedText,
293
+ publishedText,
294
+ });
295
+ continue;
296
+ }
297
+
298
+ // ── Strategy 2: LockupView node ───────────────────────────────────
299
+ if (item.type === "LockupView") {
300
+ const vidId = raw.content_id;
301
+ if (!vidId) continue;
302
+
303
+ const title = raw.metadata?.title?.text ?? "";
304
+ const thumbs: Thumbnail[] = (raw.content_image?.image ?? []).map(
305
+ (t: any, idx: number) => ({
306
+ quality: idx === 0 ? "high" : idx === 1 ? "medium" : "default",
307
+ url: t.url ?? "",
308
+ width: t.width ?? 0,
309
+ height: t.height ?? 0,
310
+ }),
311
+ );
312
+
313
+ // Duration from overlay badge (e.g. "3:42")
314
+ let durationText = "";
315
+ const overlays = raw.content_image?.overlays || [];
316
+ for (const overlay of overlays) {
317
+ if (overlay.badges) {
318
+ for (const badge of overlay.badges) {
319
+ if (badge.text) {
320
+ durationText = badge.text;
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ }
326
+ const lengthSeconds = durationToSeconds(durationText);
327
+
328
+ // Metadata rows: [0] = Author, [1] = Views & Published
329
+ const rows = raw.metadata?.metadata?.metadata_rows || [];
330
+ const author = rows[0]?.metadata_parts?.[0]?.text?.text ?? "";
331
+
332
+ // Author ID from avatar navigation endpoint
333
+ const authorId = raw.metadata?.image?.renderer_context?.command_context?.on_tap?.payload?.browseId ?? "";
334
+
335
+ const viewCountText = rows[1]?.metadata_parts?.[0]?.text?.text ?? "";
336
+ const publishedText = rows[1]?.metadata_parts?.[1]?.text?.text ?? "";
337
+
338
+ results.push({
339
+ videoId: vidId,
340
+ title,
341
+ videoThumbnails: thumbs.length > 0 ? thumbs : generateThumbnails(vidId),
342
+ author,
343
+ authorUrl: authorId ? `/channel/${authorId}` : "",
344
+ authorId,
345
+ authorVerified: false, // LockupView verification status is tricky, assume false or check badge
346
+ lengthSeconds,
347
+ viewCountText,
348
+ published: publishedText,
349
+ publishedText,
350
+ });
351
+ continue;
352
+ }
353
+
354
+ // ── Strategy 3: generic fallback (any node with video_id) ─────────
355
+ const vidId: string =
356
+ raw.video_id ??
357
+ raw.videoId ??
358
+ raw.id ??
359
+ raw.content?.video_id ??
360
+ raw.content?.id ??
361
+ "";
362
+ if (!vidId || !/^[a-zA-Z0-9_-]{11}$/.test(vidId)) continue;
363
+
364
+ // Try to extract thumbnail, title, author from wherever they may be
365
+ const rawThumbs: any[] =
366
+ raw.thumbnails ??
367
+ raw.thumbnail?.thumbnails ??
368
+ raw.content?.thumbnail?.thumbnails ??
369
+ [];
370
+
371
+ const thumbs: Thumbnail[] = rawThumbs.length > 0
372
+ ? rawThumbs.map((t: any, idx: number) => ({
373
+ quality: idx === 0 ? "high" : idx === 1 ? "medium" : "default",
374
+ url: t.url ?? "",
375
+ width: t.width ?? 0,
376
+ height: t.height ?? 0,
377
+ }))
378
+ : generateThumbnails(vidId);
379
+
380
+ const title: string =
381
+ raw.title?.text ??
382
+ raw.title ??
383
+ raw.content?.title?.text ??
384
+ raw.headline?.text ??
385
+ "";
386
+ const author: string =
387
+ raw.author?.name ??
388
+ raw.author ??
389
+ raw.short_byline_text?.runs?.[0]?.text ??
390
+ "";
391
+ const authorId: string =
392
+ raw.author?.id ??
393
+ raw.channel_id ??
394
+ raw.short_byline_text?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId ??
395
+ "";
396
+ const publishedText: string =
397
+ raw.published?.text ??
398
+ raw.published_time_text?.simpleText ??
399
+ "";
400
+
401
+ results.push({
402
+ videoId: vidId,
403
+ title,
404
+ videoThumbnails: thumbs,
405
+ author,
406
+ authorUrl: authorId ? `/channel/${authorId}` : "",
407
+ authorId,
408
+ authorVerified: raw.author?.is_verified ?? false,
409
+ lengthSeconds: raw.duration?.seconds ?? raw.length_seconds ?? 0,
410
+ viewCountText: raw.short_view_count?.text ?? raw.view_count?.text ?? "",
411
+ published: publishedText,
412
+ publishedText,
413
+ });
414
+ }
415
+
416
+ return results;
417
+ }
418
+
419
+
420
+ // ─── Route ───────────────────────────────────────────────────────────────────
421
+
422
  videos.get("/:videoId", async (c) => {
423
  const videoId = c.req.param("videoId");
424
  const { local } = c.req.query();
 
442
  const metrics = c.get("metrics");
443
  const tokenMinter = c.get("tokenMinter");
444
 
 
445
  if (config.jobs.youtube_session.po_token_enabled && !tokenMinter) {
446
  throw new HTTPException(503, {
447
  res: new Response(JSON.stringify({ error: TOKEN_MINTER_NOT_READY_MESSAGE })),
448
  });
449
  }
450
 
451
+ // Fetch player data (cached, deciphered)
452
  const youtubePlayerResponseJson = await youtubePlayerParsing({
453
  innertubeClient,
454
  videoId,
 
457
  metrics,
458
  }) as any;
459
 
460
+ // Build VideoInfo from cached player response (for streaming_data / basic_info / captions)
461
  const videoInfo = youtubeVideoInfo(innertubeClient, youtubePlayerResponseJson);
462
 
463
  if (videoInfo.playability_status?.status !== "OK") {
 
469
  });
470
  }
471
 
472
+ // Fetch next (watch page) to get related videos β€” this is a lightweight call
473
+ let fullVideoInfo: YT.VideoInfo | null = null;
474
+ try {
475
+ fullVideoInfo = await innertubeClient.getInfo(videoId);
476
+ } catch (_) {
477
+ // If this fails, we continue without related videos
478
+ }
479
 
480
+ // ── Raw YouTube fields ──────────────────────────────────────────────────
481
+ const videoDetails = youtubePlayerResponseJson.videoDetails ?? {};
482
+ const microformat = youtubePlayerResponseJson.microformat?.playerMicroformatRenderer ?? {};
483
+ const streamingDataRaw = youtubePlayerResponseJson.streamingData ?? {};
484
+ const captionsRaw = youtubePlayerResponseJson.captions ?? {};
485
+ const storyboardsRaw = youtubePlayerResponseJson.storyboards ?? {};
486
+ const playabilityStatus = youtubePlayerResponseJson.playabilityStatus ?? {};
487
 
488
+ // ── Published date ──────────────────────────────────────────────────────
489
  let publishedTimestamp = 0;
490
  let publishedText = "";
491
+ if (microformat.publishDate) {
492
+ const publishDate = new Date(microformat.publishDate);
493
  publishedTimestamp = Math.floor(publishDate.getTime() / 1000);
494
  publishedText = getRelativeTimeString(publishDate);
495
  }
496
 
497
+ // ── Thumbnails ──────────────────────────────────────────────────────────
498
+ const videoThumbnails: Thumbnail[] = generateThumbnails(videoId);
499
+
500
+ // ── Storyboards ─────────────────────────────────────────────────────────
501
+ const storyboards: Storyboard[] = parseStoryboards(storyboardsRaw, videoId);
502
+
503
+ // ── Author thumbnails ────────────────────────────────────────────────────
504
+ const authorThumbnails: AuthorThumbnail[] = [32, 48, 76, 100, 176, 512].map((sz) => ({
505
+ url: `https://yt3.ggpht.com/a/default-user=s${sz}-c-k-c0x00ffffff-no-rj`,
506
+ width: sz,
507
+ height: sz,
508
+ }));
509
+
510
+ // ── Adaptive formats ─────────────────────────────────────────────────────
511
+ const adaptiveFormats: AdaptiveFormat[] = (streamingDataRaw.adaptiveFormats ?? []).map(
512
+ (f: any) => {
513
+ const fmt = convertAdaptiveFormat(f);
514
+ if (local) fmt.url = localizeUrl(fmt.url, config);
515
+ return fmt;
516
+ },
517
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
 
519
+ // ── Format streams ────────────────────────────────────────────────────────
520
+ const formatStreams: FormatStream[] = (streamingDataRaw.formats ?? []).map((f: any) => {
521
+ const fmt = convertFormatStream(f);
522
+ if (local) fmt.url = localizeUrl(fmt.url, config);
523
+ return fmt;
524
+ });
525
+
526
+ // ── Captions ──────────────────────────────────────────────────────────────
527
+ const captions: any[] = (
528
+ captionsRaw.playerCaptionsTracklistRenderer?.captionTracks ?? []
529
+ ).map((track: any) => ({
530
+ label: track.name?.simpleText ?? track.languageCode,
531
+ language_code: track.languageCode,
532
+ url: `/api/v1/captions/${videoId}?label=${encodeURIComponent(
533
+ track.name?.simpleText ?? track.languageCode ?? "",
534
+ )}`,
535
+ }));
536
+
537
+ // ── Recommended videos ─────────────────────────────────────────────────
538
+ const recommendedVideos: RecommendedVideo[] = fullVideoInfo
539
+ ? extractRecommendedVideos(fullVideoInfo)
540
+ : [];
541
+
542
+ // ── DASH manifest URL ──────────────────────────────────────────────────
543
+ const dashUrl = `${new URL(c.req.url).origin}/api/manifest/dash/id/${videoId}`;
544
+
545
+ // ── Genre / family safe ────────────────────────────────────────────────
546
+ const genre: string = microformat.category ?? "";
547
+ const isFamilyFriendly: boolean = microformat.isFamilySafe ?? true;
548
+ const allowedRegions: string[] = microformat.availableCountries ?? [];
549
+
550
+ // ── Sub count ─────────────────────────────────────────────────────────
551
+ const subCountText: string =
552
+ (fullVideoInfo as any)?.secondary_info?.owner?.subscriber_count?.text ?? "";
553
+
554
+ // ── Build final Invidious-compatible response ──────────────────────────
555
  const response = {
556
+ type: "video",
557
+ title: videoDetails.title ?? "",
558
+ videoId: videoDetails.videoId ?? videoId,
559
+ videoThumbnails,
560
+ storyboards,
561
+ description: videoDetails.shortDescription ?? "",
562
+ descriptionHtml: descriptionToHtml(videoDetails.shortDescription ?? ""),
563
+ published: publishedTimestamp,
564
+ publishedText,
565
+ keywords: videoDetails.keywords ?? [],
566
+ viewCount: parseInt(videoDetails.viewCount ?? "0"),
567
+ likeCount: 0,
568
+ dislikeCount: 0,
569
+ paid: false,
570
+ premium: false,
571
+ isFamilyFriendly,
572
+ allowedRegions,
573
+ genre,
574
+ genreUrl: null,
575
+ author: videoDetails.author ?? "",
576
+ authorId: videoDetails.channelId ?? "",
577
+ authorUrl: `/channel/${videoDetails.channelId ?? ""}`,
578
+ authorVerified: false,
579
+ authorThumbnails,
580
+ subCountText,
581
+ lengthSeconds: parseInt(videoDetails.lengthSeconds ?? "0"),
582
  allowRatings: videoDetails.allowRatings ?? true,
583
+ rating: 0,
584
+ isListed: !(videoDetails.isPrivate ?? false),
585
+ liveNow: videoDetails.isLiveContent ?? false,
586
+ isPostLiveDvr: playabilityStatus.status === "OK" && (videoDetails.isLiveContent ?? false),
587
+ isUpcoming: playabilityStatus.status === "LIVE_STREAM_OFFLINE",
588
+ dashUrl,
589
+ adaptiveFormats,
590
+ formatStreams,
591
+ captions,
592
+ recommendedVideos,
 
 
 
 
 
 
 
593
  };
594
 
595
  return c.json(response);