Compare commits

...

45 Commits

Author SHA1 Message Date
lamp e5189b4066 youtube antibotted the captions
gives blank file now without special code added to url
2026-03-10 21:11:14 -07:00
lamp 842ca82a0e Merge branch 'master' of ssh.gitea.moe:lamp/vrchat-youtube-search-api 2025-10-31 18:27:34 -07:00
lamp 2ff0100f2a commit no-referrer tweak
don't think it changed anything but might as well keep it
2025-10-31 18:26:25 -07:00
lamp 5a1e38a782 fix playlist pagination
- update for youtube changes
- fix wrong thumbnail size used for playlist continuations
- add new playlist id format to regex
2025-10-31 18:21:37 -07:00
lamp dd422dfaa5 " 2025-04-20 13:11:47 -07:00
lamp fbf3cb1819 log http
i bypassed caddy
2025-04-20 13:04:49 -07:00
lamp a5e6cf1c4d update canvas for node 22 2025-04-20 12:48:38 -07:00
lamp b89d97201b fix pool validation regex 2024-09-22 12:19:38 -07:00
lamp c157080a00 npm audit fix 2024-09-05 21:04:53 -07:00
lamp d21afd24da consolidate imagesheet cache with search cache 2024-09-05 20:54:35 -07:00
lamp 08612e431b asdf 2024-09-05 20:35:10 -07:00
lamp b3109968cf wait what? 2024-09-05 20:24:07 -07:00
lamp a9442bfa96 fix dum
search does not need to await imagesheet
2024-09-05 19:17:23 -07:00
lamp cf03ae3ba7 cache imagesheet 10min
dumbass, results are cached 10min, imagesheet has to be at least 10min, todo refactor
2024-09-02 03:09:44 -05:00
lamp f630a02378 cache imagesheet 5min 2024-09-02 02:45:58 -05:00
lamp b4d6548639 fix caption track with one line 2024-07-07 01:19:01 -07:00
lamp 638b74601a . 2024-07-07 00:30:49 -07:00
lamp 1c288ee4dc cache imagesheet 1 min 2024-07-06 11:20:21 -07:00
lamp d7d914c9ca log failed image url 2024-07-06 11:18:02 -07:00
lamp fb14df1ad3 help debug occasional caption error
also caption cache time changed from 6hr to 10min
2024-07-06 11:15:03 -07:00
lamp 7a32e5792e FUCK 2024-07-06 10:59:51 -07:00
lamp 2e3081b0cc handle duplicate query parameter 2024-07-06 00:47:36 -07:00
lamp 0f92513d85 label imageload error 2024-07-06 00:28:50 -07:00
lamp d51dc2c301 use got and random ips 2024-07-06 00:22:31 -07:00
lamp 3a3db9899d retry fetch 2024-07-05 22:15:36 -07:00
lamp 3289183420 search log more arg 2024-06-27 23:19:29 -07:00
lamp 78c124d784 stop using experimental shit
v22 doesnt have import assertions, import attributes was added in v20.10.0 but nodesource is still on v20.5.1, bruh
2024-06-27 23:15:22 -07:00
lamp 5c3fd0c599 add option to include "Latest from..." videos
also broke up long ass lines
2024-06-27 22:55:32 -07:00
lamp 1c6f64e042 update json import 2024-06-24 12:00:52 -07:00
lamp d08d8ac2c3 use same vrcurl for video metadata/captions 2024-06-23 22:29:00 -07:00
lamp 988edf9116 update readme 2024-06-23 14:58:33 -07:00
lamp 4c9a8e95c1 use simple thumbnail packing algorithm for regular search without icons
someone built on assumptions so we have to maintain identical output. nobody's using icons yet so don't need to include those
2024-06-23 14:44:38 -07:00
lamp 07be7f5165 force thumbnails to 360x202 2024-06-22 12:43:43 -07:00
lamp 3b82db830f filter u200b 2024-06-20 00:31:22 -07:00
lamp e7d8a4b737 manual input parsing 2024-06-19 23:25:30 -07:00
lamp 8ac07acf3b reorganize youtube stuff 2024-06-17 16:24:26 -07:00
lamp 393c073072 combine app.js into index.js 2024-06-17 16:15:01 -07:00
lamp 40c63daccd debug 2024-06-17 16:04:19 -07:00
lamp 7c25cfb6a4 use smaller thumbnails for playlist 2024-06-17 15:50:03 -07:00
lamp ff6ff97889 use mqdefault thumbnail 2024-06-17 15:37:16 -07:00
lamp 1e743aa59a potpack 2024-06-17 15:33:03 -07:00
lamp d3ff95957c handle playlist...
regression in imagesheet need bin packing
2024-06-17 14:42:11 -07:00
lamp a119e41075 short urls 2024-05-22 15:37:06 -07:00
lamp 4b7f526902 fix trending vrcurl options 2024-05-19 12:20:09 -07:00
lamp 9f143cb84e update test.html 2024-05-19 12:19:49 -07:00
17 changed files with 1405 additions and 488 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
node_modules
*.sqlite
*.json
*.sqlite-journal
ips.txt
+4 -1
View File
@@ -14,7 +14,10 @@
"program": "${workspaceFolder}\\index.js",
"outFiles": [
"${workspaceFolder}/**/*.js"
]
],
"env": {
"D":"BUG"
}
}
]
}
+15 -13
View File
@@ -13,13 +13,14 @@ Get YouTube videos for a search query.
### Required query parameters
- `pool`: id of the VRCUrl pool, only letters numbers hyphens or underscores, optionally followed by an integer for pool size.
- `input`: youtube search query. All chars up to and including this exact unicode char `→` are ignored, and then whitespace is trimmed.
- `input`: youtube search query. All chars up to and including this exact unicode char `→` are ignored, and then whitespace is trimmed. THIS MUST BE THE LAST QUERY PARAMETER AS ALL CHARS AFTER IT ARE CAPTURED VERBATIM (so you can type & etc without encoding). If this contains a url with a playlist ID, playlist results will be loaded instead (which is a bit different, up to 100 results and no descriptions).
### Optional query parameters
- `thumbnails`: set to `1`, `true`, `yes`, `on` or whatever to load thumbnails
- `icons`: set to `1`, `true`, `yes`, `on` or whatever to load channel icons
- `captions`: set to `1`, `true`, `yes`, `on` or whatever if you need access to closed captioning data
- `mode`: If set to `latestontop` will search without filter to include the "Latest from ..." section (if it exists). Otherwise it uses search filter for Videos only sorted by Relevance.
- `bp`: Custom YouTube search filter. Go to youtube search site and set some filters and you will see the corresponding `bp` value in the URL. Overrides `mode` option. Use at your own risk (experimental).
### Example URL
@@ -32,20 +33,20 @@ https://api.u2b.cx/search?pool=example10000&input= Type YouTube search query h
JSON object:
- `results`: Array of Object
- `vrcurl`: (integer) index of VRCUrl that will redirect to the youtube url
- `vrcurl`: (integer) index of VRCUrl that will redirect to the youtube url, or serve JSON with captions if used with string loader.
- `live`: (boolean) whether it's a live stream
- `title`: (string) i.e. `"Nyan Cat! [Official]"`
- `id`: (string) YouTube video id i.e. `"2yJgwwDcgV8"`
- `description`: (string) short truncated description snippet i.e. `"http://nyan.cat/ Original song : http://momolabo.lolipop.jp/nyancatsong/Nyan/"`
- `description`?: (string) short truncated description snippet i.e. `"http://nyan.cat/ Original song : http://momolabo.lolipop.jp/nyancatsong/Nyan/"` (playlist results don't have this)
- `lengthText`?: (string) i.e. `"3:37"`
- `longLengthText`?: (string) i.e. `"3 minutes, 37 seconds"`
- `viewCountText`: (string) i.e. `"2,552,243 views"` or `"575 watching"` for live streams
- `shortViewCountText`?: (string) i.e. `"2.5M views"`
- `viewCountText`?: (string) i.e. `"2,552,243 views"` or `"575 watching"` for live streams (playlist results don't have this)
- `shortViewCountText`?: (string) i.e. `"2.5M views"` (streams don't have this)
- `uploaded`: (string) i.e. `"12 years ago"`
- `channel`: (object)
- `name`: (string) i.e. `"NyanCat"`
- `id`: (string) i.e. `"UCsW85RAS2_Twg_lEPyv7G8A"`
- `icon`?: (object)
- `icon`?: (object) (playlist results don't have this)
- `x`: (integer) px from left
- `y`: (integer) px from top
- `width`: (integer)
@@ -55,7 +56,6 @@ JSON object:
- `y`: (integer) px from top
- `width`: (integer)
- `height`: (integer)
- `captions_vrcurl`?: (integer) index of vrcurl to get the caption data json
- `imagesheet_vrcurl`?: (integer) index of the vrcurl for the collage of thumbnails and/or icons
- `nextpage_vrcurl`: (integer) index of the vrcurl that will serve the JSON for the next page of results
@@ -71,13 +71,15 @@ Gets Trending YouTube videos. Identical to `/search` but without `input` paramet
## GET `/vrcurl/{pool}/{index}`
- `{pool}`: must be same as pool param in search endpoint.
- `{index}`: vrcurl index number
- `{index}`: vrcurl index number as specified in response data
Response may be 302 redirect to youtube url, `image/png` for imagesheet, `application/json` for next page (see response format above) or trending tab or caption data (below).
For youtube videos, if `User-Agent` header includes `UnityWebRequest`, then response is `application/json` for video metadata (captions). Otherwise, it is 302 redirect to youtube URL.
### Caption JSON format
For image sheet, response is `image/png`. For next page or trending tab, response is `application/json`.
- Array of Object
### Video metadata JSON format
- `captions`: Array of Object
- `name`: (string) caption track name like "English" or "English (auto-generated)"
- `id`: (string) id like `.en` or `a.en`
- `lines`: Array of Object
@@ -108,4 +110,4 @@ All resources (youtube urls etc) referenced in the search results will be substi
Video thumbnails and channel icons are collated together into one image and served at a VRCUrl to be loaded by VRCImageDownloader.
Use the x, y, width and height values from the json to crop the image from the sheet.
Use the x, y, width and height values from the json to crop the image from the sheet. Do not make any assumptions about these values as the server could arrange the images wherever it wants.
+67 -45
View File
@@ -1,104 +1,126 @@
import { searchYouTubeVideos, continueYouTubeVideoSearch } from "./simpleYoutubeSearch.js";
import { searchYouTubeVideos, continueYouTubeVideoSearch, getYouTubePlaylist, continueYouTubePlaylist, getTrending } from "./youtube.js";
import { putVrcUrl } from "./vrcurl.js";
import { makeImageSheetVrcUrl, thumbnailWidth, thumbnailHeight, iconWidth, iconHeight } from "./imagesheet.js";
import { getTrending } from "./trending.js";
import { createImageSheet } from "./imagesheet.js";
var cache = {};
export async function cachedVRCYoutubeSearch(pool, query, options) {
var key = JSON.stringify([pool, query, options]);
if (!cache[key]) {
cache[key] = VRCYoutubeSearch(pool, query, options);
cache[key] = VRCYoutubeSearch(pool, query, options, key);
setTimeout(() => {
delete cache[key];
}, 1000*60*10); // 10 mins
}
return await cache[key];
return (await cache[key])?.response;
}
export async function getImageSheet(key) {
return await (await cache[key])?.imagesheet
}
async function VRCYoutubeSearch(pool, query, options = {}) {
console.debug("search:", JSON.stringify(query));
var data = {results: []};
async function VRCYoutubeSearch(pool, query, options = {}, key) {
console.log("search", pool, JSON.stringify(query), JSON.stringify(options));
var response = {results: []};
if (typeof query == "object") {
switch (query.type) {
case "trending":
var {videos, tabs} = await getTrending(query.bp);
data.tabs = [];
response.tabs = [];
for (let tab of tabs) {
data.tabs.push({
response.tabs.push({
name: tab.name,
vrcurl: await putVrcUrl(pool, {type: "trending", bp: tab.bp})
vrcurl: await putVrcUrl(pool, {type: "trending", bp: tab.bp, options})
});
}
break;
case "continuation":
if (query.for == "playlist") {
var {videos, continuationData} = await continueYouTubePlaylist(query.continuationData);
} else {
var {videos, continuationData} = await continueYouTubeVideoSearch(query.continuationData);
}
break;
}
} else {
var {videos, continuationData} = await searchYouTubeVideos(query);
var playlistId = query.match(/list=(PL(?:[a-zA-Z0-9-_]{32}|[0-9A-F]{16}))/)?.[1];
if (playlistId) console.log("playlistId:", playlistId);
var {videos, continuationData} = playlistId ? await getYouTubePlaylist(playlistId) : await searchYouTubeVideos(query, options.bp || (options.mode == "latestontop" ? null : undefined));
}
var images = [];
if (options.thumbnails) {
var thumbnailUrls = videos.map(video => video.thumbnailUrl);
videos.forEach(video => {
if (playlistId || query?.for == "playlist") video.thumbnail = {
url: `https://i.ytimg.com/vi/${video.id}/default.jpg`,
width: 120,
height: 90
};
else {
video.thumbnail ||= {
url: `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`,
width: 320,
height: 180
};
console.debug(video.thumbnail);
video.thumbnail.width = 360;
video.thumbnail.height = 202;
}
images.push(video.thumbnail);
});
}
if (options.icons) {
var iconUrls = new Set();
for (let video of videos) {
iconUrls.add(video.channel.iconUrl);
}
iconUrls = [...iconUrls];
let iconUrls = new Set();
videos.forEach(video => video.channel.iconUrl && iconUrls.add(video.channel.iconUrl));
iconUrls.forEach(url => images.push({
width: 68,//todo pass from yt data not hardcode
height: 68,
url
}));
}
if (thumbnailUrls?.length || iconUrls?.length) {
if (images.length) {
try {
var {vrcurl: imagesheet_vrcurl, thumbnails, icons} = await makeImageSheetVrcUrl(pool, thumbnailUrls, iconUrls);
data.imagesheet_vrcurl = imagesheet_vrcurl;
response.imagesheet_vrcurl = await putVrcUrl(pool, {type: "imagesheet", key});
var imagesheet = createImageSheet(images, !playlistId && !options.icons);
} catch (error) {
console.error(error.stack);
}
}
for (let video of videos) {
video.vrcurl = await putVrcUrl(pool, {type: "redirect", url: `https://www.youtube.com/watch?v=${video.id}`});
if (thumbnails?.length) {
let thumbnail = thumbnails.find(x => x.url == video.thumbnailUrl);
video.thumbnail = {
video.vrcurl = await putVrcUrl(pool, {type: "video", id: video.id});
let thumbnail = images.find(image => image.url == video.thumbnail.url);
video.thumbnail = thumbnail ? {
x: thumbnail?.x,
y: thumbnail?.y,
width: thumbnailWidth,
height: thumbnailHeight
};
}
if (icons?.length) {
let icon = icons.find(x => x.url == video.channel.iconUrl);
video.channel.icon = {
width: thumbnail?.width,
height: thumbnail?.height
} : undefined;
let icon = images.find(image => image.url == video.channel.iconUrl);
video.channel.icon = icon ? {
x: icon?.x,
y: icon?.y,
width: iconWidth,
height: iconHeight
};
}
width: icon?.width,
height: icon?.height
} : undefined;
if (options.captions) {
video.captions_vrcurl = await putVrcUrl(pool, {type: "captions", videoId: video.id});
}
delete video.thumbnailUrl;
delete video.channel.iconUrl;
data.results.push(video);
response.results.push(video);
}
if (continuationData) data.nextpage_vrcurl = await putVrcUrl(pool, {
type: "ytContinuation",
if (continuationData) response.nextpage_vrcurl = await putVrcUrl(pool, {
type: "continuation",
for: query.for || (playlistId ? "playlist" : "search"),
continuationData,
options
});
return data;
return {response, imagesheet};
}
-105
View File
@@ -1,105 +0,0 @@
import Koa from "koa";
import Router from "@koa/router";
import send from "koa-send";
import { cachedVRCYoutubeSearch } from "./VRCYoutubeSearch.js"
import { getImageSheet } from "./imagesheet.js";
import { resolveVrcUrl } from "./vrcurl.js";
import { getVideoCaptionsCached } from "./captions.js";
import { stringToBoolean } from "./util.js";
export var app = new Koa();
var router = new Router();
router.get(["/search", "/trending"], async ctx => {
var query = ctx.path == "/trending" ? {"type":"trending"} : ctx.query.input?.replace(/^.*→/, '').trim();
if (!query) {
ctx.status = 400;
ctx.body = "missing search query";
return;
}
if (!ctx.query.pool || !/^[a-z-_]+\d*$/.test(ctx.query.pool)) {
ctx.status = 400;
ctx.body = "invalid pool";
return;
}
var options = {
thumbnails: stringToBoolean(ctx.query.thumbnails),
icons: stringToBoolean(ctx.query.icons),
captions: stringToBoolean(ctx.query.captions)
};
ctx.body = await cachedVRCYoutubeSearch(ctx.query.pool, query, options);
});
router.get("/vrcurl/:pool/:num", async ctx => {
var dest = await resolveVrcUrl(ctx.params.pool, ctx.params.num);
if (!dest) {
ctx.status = 404;
return;
}
switch (dest.type) {
case "redirect":
ctx.redirect(dest.url);
break;
case "imagesheet":
let buf = await getImageSheet(ctx.params.pool, ctx.params.num);
if (!buf) {
ctx.status = 404;
return;
}
ctx.body = buf;
ctx.type = "image/png";
break;
case "ytContinuation":
ctx.body = await cachedVRCYoutubeSearch(ctx.params.pool, {type: "continuation", continuationData: dest.continuationData}, dest.options);
break;
case "trending":
ctx.body = await cachedVRCYoutubeSearch(ctx.params.pool, {type: "trending", bp: dest.bp}, dest.options);
break;
case "captions":
ctx.body = await getVideoCaptionsCached(dest.videoId);
break;
default:
console.error("unknown vrcurl type", dest.type);
ctx.status = 500;
}
});
router.get("/robots.txt", ctx => {
ctx.body = `User-agent: *\nDisallow: /`;
});
router.get("/test.html", async ctx => {
await send(ctx, "test.html");
});
router.get("/", ctx => {
ctx.redirect("https://www.u2b.cx/");
});
// work around vrchat json parser bug https://feedback.vrchat.com/udon/p/braces-inside-strings-in-vrcjson-can-fail-to-deserialize
app.use(async (ctx, next) => {
await next();
if (ctx.type != "application/json") return;
ctx.body = structuredClone(ctx.body);
(function iterateObject(obj) {
for (var key in obj) {
if (typeof obj[key] == "string") {
obj[key] = obj[key].replace(/[\[\]{}]/g, chr => "\\u" + chr.charCodeAt(0).toString(16).padStart(4, '0'));
} else if (typeof obj[key] == "object") {
iterateObject(obj[key]);
}
}
})(ctx.body);
ctx.body = JSON.stringify(ctx.body).replaceAll("\\\\u", "\\u");
});
app.use(router.routes());
app.use(router.allowedMethods());
-43
View File
@@ -1,43 +0,0 @@
import { XMLParser } from "fast-xml-parser";
var xmlParser = new XMLParser({
ignoreAttributes: false
});
async function getVideoData(videoId) {
var html = await fetch(`https://www.youtube.com/watch?v=${videoId}`).then(res => res.text());
var ytInitialPlayerResponse = html.match(/var ytInitialPlayerResponse = ({.*});/)[1];
ytInitialPlayerResponse = JSON.parse(ytInitialPlayerResponse);
return ytInitialPlayerResponse;
}
async function getVideoCaptions(videoId) {
var ytInitialPlayerResponse = await getVideoData(videoId);
if (!ytInitialPlayerResponse.captions) return [];
var captionTracks = ytInitialPlayerResponse.captions.playerCaptionsTracklistRenderer.captionTracks;
captionTracks = await Promise.all(captionTracks.map(captionTrack => (async () => {
var xml = await fetch(captionTrack.baseUrl).then(res => res.text());
var parsed = xmlParser.parse(xml);
var lines = parsed.transcript.text.map(({ "#text": text, "@_start": start, "@_dur": dur }) => ({ start: Number(start), dur: Number(dur), text }));
return {
name: captionTrack.name.simpleText,
id: captionTrack.vssId,
lines
};
})().catch(error => console.error(error.stack))));
return captionTracks;
}
var cache = {};
export async function getVideoCaptionsCached(videoId) {
if (!cache[videoId]) {
cache[videoId] = getVideoCaptions(videoId);
setTimeout(() => {
delete cache[videoId];
}, 1000*60*60*6); // 6 hours
}
return await cache[videoId];
}
+32 -82
View File
@@ -1,92 +1,42 @@
import { createCanvas, loadImage } from 'canvas';
import { putVrcUrl } from './vrcurl.js';
import potpack from 'potpack';
var store = {};
export const thumbnailWidth = 360;
export const thumbnailHeight = 202;
export const iconWidth = 68;
export const iconHeight = 68;
const maxSheetWidth = 2048;
const maxSheetHeight = 2048;
const maxThumbnailRowLen = Math.floor(maxSheetWidth / thumbnailWidth); // 5
//const maxThumbnailColLen = Math.floor(maxSheetHeight / thumbnailHeight); // 10
//const maxIconRowLen = Math.floor(maxSheetWidth / iconWidth); // 30
const maxIconRowLen = 3;
//const maxIconColLen = Math.floor(maxSheetHeight / iconHeight); // 30
async function createImageSheet(thumbnailUrls = [], iconUrls = []) {
var thumbnails = thumbnailUrls.map((url, index) => {
const x = index % maxThumbnailRowLen * thumbnailWidth;
const y = Math.floor(index / maxThumbnailRowLen) * thumbnailHeight;
return {x, y, url};
export async function createImageSheet(images /*[{width, height, url}]*/, legacyMode) {
images.forEach(image => {
image.w = image.width;
image.h = image.height;
});
const iconStartX = thumbnailWidth * Math.min(maxThumbnailRowLen, thumbnails.length);
var icons = iconUrls.map((url, index) => {
const x = iconStartX + index % maxIconRowLen * iconWidth;
const y = Math.floor(index / maxIconRowLen) * iconHeight;
return {x, y, url};
if (legacyMode) {
images.forEach((image, index) => {
image.x = index % 5 * 360;
image.y = Math.floor(index / 5) * 202;
});
const canvasWidth = Math.max(
Math.min(thumbnails.length, maxThumbnailRowLen) * thumbnailWidth,
iconStartX + Math.min(icons.length, maxIconRowLen) * iconWidth
);
const canvasHeight = Math.max(thumbnails.length ? thumbnails.at(-1).y + thumbnailHeight : 0, icons.length ? icons.at(-1)?.y + iconHeight : 0);
var canvas = createCanvas(Math.min(maxSheetWidth, canvasWidth), Math.min(maxSheetHeight, canvasHeight));
var w = Math.min(images.length, 5) * 360;
var h = images.at(-1).y + 202;
} else {
var {w, h, fill} = potpack(images);
}
if (w > 2048) {
console.warn("Imagesheet exceeded max width");
w = 2048;
}
if (h > 2048) {
console.warn("Imagesheet exceeded max height");
h = 2048;
}
var canvas = createCanvas(w, h);
var ctx = canvas.getContext('2d');
var promises = [];
if (thumbnails.length) {
promises = promises.concat(thumbnails.map(({x, y, url}) => (async function(){
await Promise.all(images.map(({x, y, w, h, url}) => (async function(){
if (!url) return;
try {
var image = await loadImage(url);
ctx.drawImage(image, x, y, thumbnailWidth, thumbnailHeight);
})().catch(error => console.error(error.stack))));
} catch (error) {
console.error("failed to load image", url, error.message);
return;
}
ctx.drawImage(image, x, y, w, h);
})().catch(error => console.error("imageload", error.stack))));
if (icons.length) {
promises = promises.concat(icons.map(({x, y, url}) => (async function(){
var image = await loadImage(url);
ctx.drawImage(image, x, y, iconWidth, iconHeight);
})().catch(error => console.error(error.stack))));
}
await Promise.all(promises);
return {
imagesheet: canvas.toBuffer("image/png"),
thumbnails, icons
};
}
export async function makeImageSheetVrcUrl(pool, thumbnailUrls, iconUrls) {
var num = await putVrcUrl(pool, {type: "imagesheet"});
var key = `${pool}:${num}`;
var promise = createImageSheet(thumbnailUrls, iconUrls);
store[key] = promise;
promise.then(() => {
setTimeout(() => {
if (store[key] === promise) delete store[key];
}, 1000*60*10); // 10 mins;
});
promise.catch(error => {
console.error(error.stack);
});
var {thumbnails, icons} = await promise;
return {
vrcurl: num,
thumbnails, icons
}
}
export async function getImageSheet(pool, num) {
return (await store[`${pool}:${num}`])?.imagesheet;
return canvas.toBuffer("image/png");
}
+158 -1
View File
@@ -1,4 +1,161 @@
if (process.env.D!="BUG") console.debug = () => {};
else console.debug(process.env);
import "./util.js";
import { app } from "./app.js";
import Koa from "koa";
import Router from "@koa/router";
import send from "koa-send";
import qs from "qs";
import { cachedVRCYoutubeSearch, getImageSheet } from "./VRCYoutubeSearch.js"
import { resolveVrcUrl } from "./vrcurl.js";
import { getVideoCaptionsCached } from "./youtube-captions.js";
import { stringToBoolean } from "./util.js";
import { readFileSync } from "fs";
var shorturlmap = JSON.parse(readFileSync("./shorturlmap.json", "utf8"));
var app = new Koa();
var router = new Router();
router.get(["/search", "/trending"], async ctx => {
if (ctx.path == "/trending") {
var input = {"type":"trending"};
} else {
var input = ctx.querystring.match(/[?&]input=(.*)/i)?.[1];
if (!input) {
ctx.status = 400;
ctx.body = "missing search query";
return;
}
input = decodeURIComponent(input).replace(/^.*→/, '').replaceAll("\u200b", '').trim();
}
var pqs = qs.parse(ctx.querystring, {duplicates: 'first'});
if (!pqs.pool || /[^a-z-_0-9]/.test(pqs.pool)) {
ctx.status = 400;
ctx.body = "invalid pool";
return;
}
var options = {
thumbnails: stringToBoolean(pqs.thumbnails),
icons: stringToBoolean(pqs.icons),
captions: stringToBoolean(pqs.captions),
mode: pqs.mode,
bp: pqs.bp
};
ctx.body = await cachedVRCYoutubeSearch(pqs.pool, input, options);
});
router.get("/vrcurl/:pool/:num", async ctx => {
var dest = await resolveVrcUrl(ctx.params.pool, ctx.params.num);
if (!dest) {
ctx.status = 404;
return;
}
switch (dest.type) {
case "redirect":
ctx.set("Referrer-Policy", "no-referrer");
ctx.redirect(dest.url);
break;
case "video":
if (ctx.get("User-Agent").includes("UnityWebRequest")) {
ctx.body = {captions: await getVideoCaptionsCached(dest.id)};
} else {
ctx.set("Referrer-Policy", "no-referrer");
ctx.redirect(`https://www.youtube.com/watch?v=${dest.id}`);
}
break;
case "imagesheet":
let buf = await getImageSheet(dest.key);
if (!buf) {
ctx.status = 404;
return;
}
ctx.body = buf;
ctx.type = "image/png";
break;
case "continuation":
ctx.body = await cachedVRCYoutubeSearch(ctx.params.pool, {type: "continuation", for: dest.for, continuationData: dest.continuationData}, dest.options);
break;
case "trending":
ctx.body = await cachedVRCYoutubeSearch(ctx.params.pool, {type: "trending", bp: dest.bp}, dest.options);
break;
case "captions":
ctx.body = await getVideoCaptionsCached(dest.videoId);
break;
default:
console.error("unknown vrcurl type", dest.type);
ctx.status = 500;
}
});
router.get("/robots.txt", ctx => {
ctx.body = `User-agent: *\nDisallow: /`;
});
router.get("/test.html", async ctx => {
await send(ctx, "test.html");
});
router.get("/", ctx => {
ctx.redirect("https://www.u2b.cx/");
});
app.use(async (ctx, next) => {
console.log("http", ctx.socket.remoteAddress, ctx.get("X-Forwarded-For"), ctx.get("Host") + ctx.originalUrl, '"'+ctx.get("User-Agent")+'"', ctx.get("Referer"));
try {
await next();
} catch (error) {
console.error(ctx.url, error.stack);
ctx.status = 500;
ctx.type = "text";
ctx.body = error.stack;
}
});
// short urls to work around https://feedback.vrchat.com/udon/p/vrcurlinputfield-incorrect-focus-issue-on-quest
app.use(async (ctx, next) => {
var subdomain = ctx.hostname.match(/(.*).u2b.cx$/i)?.[1];
if (subdomain && !["api","api2","dev"].includes(subdomain)) {
if (shorturlmap[subdomain]) {
ctx.url = shorturlmap[subdomain] + ctx.url.slice(1);
} else {
ctx.status = 404;
return;
}
}
await next();
});
// work around vrchat json parser bug https://feedback.vrchat.com/udon/p/braces-inside-strings-in-vrcjson-can-fail-to-deserialize
app.use(async (ctx, next) => {
await next();
if (ctx.type != "application/json") return;
ctx.body = structuredClone(ctx.body);
(function iterateObject(obj) {
for (var key in obj) {
if (typeof obj[key] == "string") {
obj[key] = obj[key].replace(/[\[\]{}]/g, chr => "\\u" + chr.charCodeAt(0).toString(16).padStart(4, '0'));
} else if (typeof obj[key] == "object") {
iterateObject(obj[key]);
}
}
})(ctx.body);
ctx.body = JSON.stringify(ctx.body).replaceAll("\\\\u", "\\u");
});
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(process.env.PORT || 8142, process.env.ADDRESS);
+734 -55
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -2,11 +2,14 @@
"dependencies": {
"@keyv/sqlite": "^3.6.6",
"@koa/router": "^12.0.1",
"canvas": "^2.11.2",
"canvas": "^3.1.0",
"fast-xml-parser": "^4.3.4",
"got": "^14.4.1",
"keyv": "^4.5.4",
"koa": "^2.14.2",
"koa-send": "^5.0.1"
"koa-send": "^5.0.1",
"potpack": "^2.0.0",
"qs": "^6.12.2"
},
"engines": {
"node": ">=18.0.0"
+3
View File
@@ -0,0 +1,3 @@
{
"hal": "/search?pool=hdays10000&thumbnails=yes&input="
}
-47
View File
@@ -1,47 +0,0 @@
import { parseVideoRendererData } from "./util.js";
export async function searchYouTubeVideos(query) {
var url = `https://www.youtube.com/results?search_query=${encodeURIComponent(query.replaceAll(' ', '+'))}&sp=EgIQAQ%253D%253D`;
var html = await fetch(url).then(res => res.text());
var ytInitialData = html.match(/ytInitialData = ({.*});<\/script>/)[1];
ytInitialData = JSON.parse(ytInitialData);
var videos = ytInitialData?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents?.find(x => x.itemSectionRenderer?.contents?.find(x => x.videoRenderer))?.itemSectionRenderer?.contents?.filter(x => x.videoRenderer).map(parseVideoRendererData);
if (!videos) return {videos: []};
try {
var ytcfg = html.match(/ytcfg.set\(({.*})\);/)[1];
ytcfg = JSON.parse(ytcfg);
var continuationData = {
context: ytcfg.INNERTUBE_CONTEXT,
continuation: ytInitialData.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents.find(x => x.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token
}
} catch (error) {
console.error(error.stack);
}
return {videos, continuationData};
}
export async function continueYouTubeVideoSearch(continuationData) {
var data = await fetch("https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(continuationData)
}).then(res => res.json());
var videos = data.onResponseReceivedCommands[0].appendContinuationItemsAction.continuationItems.find(x => x.itemSectionRenderer?.contents.find(x => x.videoRenderer)).itemSectionRenderer.contents.filter(x => x.videoRenderer).map(parseVideoRendererData);
var continuationToken = data.onResponseReceivedCommands[0].appendContinuationItemsAction.continuationItems.find(x => x.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token
return {
videos,
continuationData: {
context: continuationData.context,
continuation: continuationToken
}
}
}
+17 -9
View File
@@ -9,34 +9,36 @@
</head><body>
<div>
<label>search: <input id="input" type="text" value="nyan cat" /></label>
<button id="trending">trending</button>
<label><button id="search">search:</button> <input id="input" type="text" value="nyan cat" /></label>
<br /><button id="trending">trending</button>
<br />
<label><input id="thumbnails" type="checkbox" checked>thumbnails</label>
<label><input id="icons" type="checkbox" checked>icons</label>
<label><input id="captions" type="checkbox" checked>captions</label>
<button id="start">start</button>
<label><input id="latestontop" type="checkbox">latestontop</label>
</div>
<div id="output"></div>
<button id="nextpage">next page</button>
<div id="buttons"></div>
<button id="nextpage" style="display: none">next page</button>
<script>
var num = 0;
var lastData;
start.onclick = () => {
search.onclick = () => {
output.innerHTML = "";
loadData(`/search?pool=test1000&thumbnails=${thumbnails.checked}&icons=${icons.checked}&captions=${captions.checked}&input=${encodeURIComponent(input.value)}`);
loadData(`/search?pool=test1000&thumbnails=${thumbnails.checked}&icons=${icons.checked}&mode=${latestontop.checked&&"latestontop"}&input=${encodeURIComponent(input.value)}`);
};
trending.onclick = () => {
output.innerHTML = "";
loadData(`/trending?pool=test1000&thumbnails=${thumbnails.checked}&icons=${icons.checked}&captions=${captions.checked}`);
loadData(`/trending?pool=test1000&thumbnails=${thumbnails.checked}&icons=${icons.checked}`);
}
nextpage.onclick = () => loadData(`/vrcurl/test1000/${lastData.nextpage_vrcurl}`);
async function loadData(url) {
buttons.innerHTML = '';
var data = await fetch(url).then(res => res.json());
var pre = document.createElement("pre");
pre.innerHTML = hljs.highlight(JSON.stringify(data, null, 4), {language: "json"}).value;
@@ -44,6 +46,12 @@ async function loadData(url) {
var img = document.createElement("img");
img.src = `/vrcurl/test1000/${data.imagesheet_vrcurl}`;
output.appendChild(img);
if (data.nextpage_vrcurl) {
buttons.innerHTML = `<button onclick="loadData('/vrcurl/test1000/${data.nextpage_vrcurl}')">next page</button>`;
}
if (data.tabs) {
buttons.innerHTML = data.tabs.map(t => `<button onclick="loadData('/vrcurl/test1000/${t.vrcurl}')">${t.name}</button>`).join('');
}
lastData = data;
}
-42
View File
@@ -1,42 +0,0 @@
import { parseVideoRendererData } from "./util.js";
export async function getTrending(bp) {
var url = `https://www.youtube.com/feed/trending`;
if (bp) url += `?bp=${bp}`;
var html = await fetch(url).then(res => res.text());
var ytInitialData = html.match(/ytInitialData = ({.*});<\/script>/)[1];
ytInitialData = JSON.parse(ytInitialData);
var tabs = ytInitialData.contents.twoColumnBrowseResultsRenderer.tabs.map(t => {
return {
name: t.tabRenderer.title,
//url: `https://www.youtube.com` + t.tabRenderer.endpoint.commandMetadata.webCommandMetadata.url
bp: t.tabRenderer.endpoint.browseEndpoint.params
}
});
var videos = ytInitialData
.contents
.twoColumnBrowseResultsRenderer
.tabs
.find(tab => tab.tabRenderer.selected)
.tabRenderer
.content
.sectionListRenderer
.contents
// regular trending in sections with shelfRenderer without title
.filterMap(x => {
var shelfRenderer = x.itemSectionRenderer.contents.find(x => x.shelfRenderer)?.shelfRenderer;
if (shelfRenderer && !shelfRenderer.title) {
return shelfRenderer
.content
.expandedShelfContentsRenderer
.items
.map(parseVideoRendererData)
};
})
.flat();
return {tabs, videos};
}
+30 -32
View File
@@ -1,3 +1,6 @@
import {readFileSync} from "fs";
import got from "got";
Array.prototype.filterMap = function(fn) {
var newarray = [];
for (var item of this) {
@@ -7,6 +10,23 @@ Array.prototype.filterMap = function(fn) {
return newarray;
};
Array.prototype.findMap = function(fn) {
for (var item of this) {
var ret = fn(item);
if (ret) return ret;
}
}
Object.prototype.deepFind = function (fn) {
return (function crawlObject(object) {
for (var key in object) {
var value = object[key];
if (fn(value)) return value;
if (typeof value == "object") crawlObject(value);
}
})(this);
};
export function stringToBoolean(str) {
if (str) {
if (!["0", "false", "off", "no", "null", "undefined", "nan"].includes(str.toLowerCase())) return true;
@@ -14,38 +34,16 @@ export function stringToBoolean(str) {
return false;
}
export function recursiveFind(object, fn) {
var results = [];
(function crawlObject(object) {
for (var key in object) {
var value = object[key];
var test = fn(value);
if (test) results.push(test);
if (typeof value == "object") crawlObject(value);
}
})(object);
return results;
}
export function parseVideoRendererData(data) {
data = data.videoRenderer;
return {
id: data.videoId,
live: Boolean(data.badges?.find(x => x.metadataBadgeRenderer?.style == "BADGE_STYLE_TYPE_LIVE_NOW")),
title: data.title?.runs?.[0]?.text,
description: data.detailedMetadataSnippets?.[0]?.snippetText?.runs?.reduce((str, obj) => str += obj.text, "")
|| data.descriptionSnippet?.runs?.reduce((str, obj) => str += obj.text, ""),
thumbnailUrl: data.thumbnail?.thumbnails?.find(x => x.width == 360 && x.height == 202)?.url || data.thumbnail?.thumbnails?.[0]?.url,
uploaded: data.publishedTimeText?.simpleText,
lengthText: data.lengthText?.simpleText,
longLengthText: data.lengthText?.accessibility?.accessibilityData?.label,
viewCountText: data.viewCountText?.runs ? data.viewCountText.runs.reduce((str, obj) => str += obj.text, "") : data.viewCountText?.simpleText,
shortViewCountText: data.shortViewCountText?.simpleText,
channel: {
name: data.ownerText?.runs?.[0]?.text,
id: data.ownerText?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId,
iconUrl: data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail?.thumbnails?.[0]?.url
}
};
try {
var ips = readFileSync("ips.txt", "utf8").trim().split("\n");
console.log("using ips", ips);
} catch (e) {}
export function gotw(url, options = {}) {
if (ips) options.localAddress = ips[Math.floor(Math.random() * ips.length)];
//options.timeout = {request: 3000}; //"RequestError: Expected values which are `number` or `undefined`. Received values of type `Function`." what the fuck?
return got(url, options);
}
+54
View File
@@ -0,0 +1,54 @@
import { XMLParser } from "fast-xml-parser";
import { gotw } from "./util.js";
var xmlParser = new XMLParser({
ignoreAttributes: false
});
async function getVideoData(videoId) {
var res = await gotw(`https://www.youtube.com/watch?v=${videoId}`);
var ytInitialPlayerResponse = res.body.match(/var ytInitialPlayerResponse = ({.*?});/)[1];
ytInitialPlayerResponse = JSON.parse(ytInitialPlayerResponse);
return ytInitialPlayerResponse;
}
async function getVideoCaptions(videoId) {
var ytInitialPlayerResponse = await getVideoData(videoId);
if (!ytInitialPlayerResponse.captions) return [];
var captionTracks = ytInitialPlayerResponse.captions.playerCaptionsTracklistRenderer.captionTracks;
captionTracks = await Promise.all(captionTracks.map(captionTrack => (async () => {
try {
var promise = gotw(captionTrack.baseUrl);
var res = await promise;
if (res.statusCode != 200) throw new Error("unexpected status " + res.statusCode);
var xml = await promise.text();
if (!xml) throw new Error("empty response for caption track");
var parsed = xmlParser.parse(xml);
var lines = parsed.transcript.text;
if (!Array.isArray(lines)) lines = [lines];
lines = lines.map(({ "#text": text, "@_start": start, "@_dur": dur }) => ({ start: Number(start), dur: Number(dur), text }));
return {
name: captionTrack.name.simpleText,
id: captionTrack.vssId,
lines
};
} catch (error) {
console.error("caption track error", error.stack, videoId, captionTrack, xml, parsed, lines);
}
})()));
return captionTracks;
}
var cache = {};
export async function getVideoCaptionsCached(videoId) {
if (!cache[videoId]) {
cache[videoId] = getVideoCaptions(videoId);
setTimeout(() => {
delete cache[videoId];
}, 1000*60*10); // 10 minutes
}
return await cache[videoId];
}
+274
View File
@@ -0,0 +1,274 @@
import { gotw } from "./util.js";
export async function searchYouTubeVideos(query, sp = "EgIQAQ%253D%253D") {
console.debug("sp", sp);
var url = `https://www.youtube.com/results?search_query=${encodeURIComponent(query.replaceAll(' ', '+'))}${sp ? `$sp=${sp}` : ''}`;
var res = await gotw(url), html = res.body;
var ytInitialData = html.match(/ytInitialData = ({.*});<\/script>/)?.[1];
if (!ytInitialData) {
console.error("missing ytInitialData", query, res.status, html);
}
ytInitialData = JSON.parse(ytInitialData);
console.debug(ytInitialData);
var sectionListRendererContents = ytInitialData
.contents
.twoColumnSearchResultsRenderer
.primaryContents
.sectionListRenderer
.contents;
var videos = sectionListRendererContents
?.find(x => x.itemSectionRenderer?.contents?.find(x => x.videoRenderer))
?.itemSectionRenderer
.contents
.filterMap(x => x.videoRenderer)
.map(parseVideoRendererData);
if (!videos) return {videos: []};
var latest = sectionListRendererContents
.find(x => x.itemSectionRenderer?.contents?.find(x => x.shelfRenderer))
?.itemSectionRenderer
.contents
.find(x => x.shelfRenderer?.title?.simpleText?.startsWith("Latest from"))
?.shelfRenderer
.content
.verticalListRenderer
.items
.filterMap(x => x.videoRenderer)
.map(parseVideoRendererData);
console.debug("latest", latest);
if (latest) videos = [...latest, ...videos];
console.debug(videos.length, "results");
try {
var ytcfg = html.match(/ytcfg.set\(({.*})\);/)[1];
ytcfg = JSON.parse(ytcfg);
var continuationData = {
context: ytcfg.INNERTUBE_CONTEXT,
continuation: sectionListRendererContents.findMap(x => x.continuationItemRenderer).continuationEndpoint.continuationCommand.token
}
} catch (error) {
console.error(error.stack);
}
return {videos, continuationData};
}
export async function continueYouTubeVideoSearch(continuationData) {
var res = await gotw("https://www.youtube.com/youtubei/v1/search?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false", {
method: "POST",
json: continuationData,
responseType: "json"
});
var data = res.body;
console.debug(data);
var continuationItems = data
.onResponseReceivedCommands[0]
.appendContinuationItemsAction
.continuationItems;
var videos = continuationItems
.find(x => x.itemSectionRenderer?.contents.find(x => x.videoRenderer))
.itemSectionRenderer
.contents
.filterMap(x => x.videoRenderer)
.map(parseVideoRendererData);
var continuationToken = continuationItems
.findMap(x => x.continuationItemRenderer)
?.continuationEndpoint
.continuationCommand
.token;
console.debug(videos.length, "results");
return {
videos,
continuationData: continuationToken ? {
context: continuationData.context,
continuation: continuationToken
} : null
}
}
export async function getYouTubePlaylist(playlistId) {
var res = await gotw("https://www.youtube.com/playlist?list=" + playlistId);
var html = res.body;
var ytInitialData = html.match(/ytInitialData = ({.*});<\/script>/)[1];
ytInitialData = JSON.parse(ytInitialData);
console.debug(ytInitialData);
var playlistVideoRendererContents = ytInitialData
.contents
.twoColumnBrowseResultsRenderer
.tabs
.find(tab => tab.tabRenderer.selected)
.tabRenderer
.content
.sectionListRenderer
.contents
.findMap(x => x.itemSectionRenderer?.contents)
.findMap(x => x.playlistVideoListRenderer?.contents);
var videos = playlistVideoRendererContents
.filterMap(x => x.playlistVideoRenderer)
.map(parseVideoRendererData);
if (!videos) return {videos: []};
console.debug(videos.length, "results");
try {
var ytcfg = html.match(/ytcfg.set\(({.*})\);/)[1];
ytcfg = JSON.parse(ytcfg);
var continuationData = {
context: ytcfg.INNERTUBE_CONTEXT,
continuation: playlistVideoRendererContents.findMap(x => x.continuationItemRenderer).continuationEndpoint.commandExecutorCommand.commands.findMap(x=>x.continuationCommand).token
}
} catch (error) {
console.error(error.stack);
}
return {videos, continuationData};
}
export async function continueYouTubePlaylist(continuationData) {
var res = await gotw("https://www.youtube.com/youtubei/v1/browse?prettyPrint=false", {
method: "POST",
json: continuationData,
responseType: "json"
});
var data = res.body;
console.debug(data);
if (!data.onResponseReceivedActions) return {videos:[]};
var continuationItems = data
.onResponseReceivedActions[0]
.appendContinuationItemsAction
.continuationItems;
var videos = continuationItems
.filterMap(x => x.playlistVideoRenderer)
.map(parseVideoRendererData);
var continuationToken = continuationItems
.findMap(x => x.continuationItemRenderer)
?.continuationEndpoint
.continuationCommand
.token;
console.debug(videos.length, "results");
return {
videos,
continuationData: continuationToken ? {
context: continuationData.context,
continuation: continuationToken
} : null
}
}
export async function getTrending(bp) {
var url = `https://www.youtube.com/feed/trending`;
if (bp) url += `?bp=${bp}`;
var res = await gotw(url);
var html = res.body;
var ytInitialData = html.match(/ytInitialData = ({.*});<\/script>/)[1];
ytInitialData = JSON.parse(ytInitialData);
var tabs = ytInitialData.contents.twoColumnBrowseResultsRenderer.tabs.map(t => {
return {
name: t.tabRenderer.title,
//url: `https://www.youtube.com` + t.tabRenderer.endpoint.commandMetadata.webCommandMetadata.url
bp: t.tabRenderer.endpoint.browseEndpoint.params
}
});
var videos = ytInitialData
.contents
.twoColumnBrowseResultsRenderer
.tabs
.find(tab => tab.tabRenderer.selected)
.tabRenderer
.content
.sectionListRenderer
.contents
// regular trending in sections with shelfRenderer without title
.filterMap(x => {
var shelfRenderer = x.itemSectionRenderer.contents.findMap(x => x.shelfRenderer);
if (shelfRenderer && !shelfRenderer.title) {
return shelfRenderer
.content
.expandedShelfContentsRenderer
.items
.filterMap(x => x.videoRenderer)
.map(parseVideoRendererData)
};
})
.flat();
return {tabs, videos};
}
Object.prototype.concatRunsText = function concatRunsText() {
return this.reduce((str, obj) => str += obj.text, "");
};
function parseVideoRendererData(data) {
return {
id: data.videoId,
live: Boolean(data.badges?.find(x => x.metadataBadgeRenderer?.style == "BADGE_STYLE_TYPE_LIVE_NOW")),
title: data.title?.runs?.concatRunsText(),
description: data.detailedMetadataSnippets?.[0]?.snippetText?.runs?.concatRunsText()
|| data.descriptionSnippet?.runs?.concatRunsText(),
//thumbnailUrl: data.thumbnail?.thumbnails?.find(x => (x.width == 360 && x.height == 202) || (x.width == 246 && x.height == 138))?.url || data.thumbnail?.thumbnails?.[0]?.url,
thumbnail: data.thumbnail?.thumbnails?.find(x => (x.width == 360 && x.height == 202) || (x.width == 246 && x.height == 138)) || data.thumbnail?.thumbnails?.[0],
/*thumbnail: {
url: `https://i.ytimg.com/vi/${data.videoId}/mqdefault.jpg`,
width: 320,
height: 180
},*/
uploaded: data.publishedTimeText?.simpleText || data.videoInfo?.runs?.[2]?.text,
lengthText: data.lengthText?.simpleText,
longLengthText: data.lengthText?.accessibility?.accessibilityData?.label,
viewCountText: data.viewCountText?.runs ? data.viewCountText.runs.concatRunsText() : data.viewCountText?.simpleText,
shortViewCountText: data.shortViewCountText?.simpleText || data.videoInfo?.runs?.[0]?.text,
channel: {
name: (data.ownerText || data.shortBylineText)?.runs?.concatRunsText(),
id: (data.ownerText || data.shortBylineText)?.runs?.[0]?.navigationEndpoint?.browseEndpoint?.browseId,
iconUrl: data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail?.thumbnails?.[0]?.url
}
};
}