Compare commits

..

8 Commits

Author SHA1 Message Date
e7d8a4b737 manual input parsing 2024-06-19 23:25:30 -07:00
8ac07acf3b reorganize youtube stuff 2024-06-17 16:24:26 -07:00
393c073072 combine app.js into index.js 2024-06-17 16:15:01 -07:00
40c63daccd debug 2024-06-17 16:04:19 -07:00
7c25cfb6a4 use smaller thumbnails for playlist 2024-06-17 15:50:03 -07:00
ff6ff97889 use mqdefault thumbnail 2024-06-17 15:37:16 -07:00
1e743aa59a potpack 2024-06-17 15:33:03 -07:00
d3ff95957c handle playlist...
regression in imagesheet need bin packing
2024-06-17 14:42:11 -07:00
13 changed files with 412 additions and 334 deletions

5
.vscode/launch.json vendored
View File

@ -14,7 +14,10 @@
"program": "${workspaceFolder}\\index.js", "program": "${workspaceFolder}\\index.js",
"outFiles": [ "outFiles": [
"${workspaceFolder}/**/*.js" "${workspaceFolder}/**/*.js"
] ],
"env": {
"D":"BUG"
}
} }
] ]
} }

View File

@ -13,7 +13,7 @@ Get YouTube videos for a search query.
### Required query parameters ### Required query parameters
- `pool`: id of the VRCUrl pool, only letters numbers hyphens or underscores, optionally followed by an integer for pool size. - `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)
### Optional query parameters ### Optional query parameters
@ -39,13 +39,13 @@ JSON object:
- `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/"`
- `lengthText`?: (string) i.e. `"3:37"` - `lengthText`?: (string) i.e. `"3:37"`
- `longLengthText`?: (string) i.e. `"3 minutes, 37 seconds"` - `longLengthText`?: (string) i.e. `"3 minutes, 37 seconds"`
- `viewCountText`: (string) i.e. `"2,552,243 views"` or `"575 watching"` for live streams - `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"` - `shortViewCountText`?: (string) i.e. `"2.5M views"` (streams don't have this)
- `uploaded`: (string) i.e. `"12 years ago"` - `uploaded`: (string) i.e. `"12 years ago"`
- `channel`: (object) - `channel`: (object)
- `name`: (string) i.e. `"NyanCat"` - `name`: (string) i.e. `"NyanCat"`
- `id`: (string) i.e. `"UCsW85RAS2_Twg_lEPyv7G8A"` - `id`: (string) i.e. `"UCsW85RAS2_Twg_lEPyv7G8A"`
- `icon`?: (object) - `icon`?: (object) (playlist results don't have this)
- `x`: (integer) px from left - `x`: (integer) px from left
- `y`: (integer) px from top - `y`: (integer) px from top
- `width`: (integer) - `width`: (integer)

View File

@ -1,7 +1,6 @@
import { searchYouTubeVideos, continueYouTubeVideoSearch } from "./simpleYoutubeSearch.js"; import { searchYouTubeVideos, continueYouTubeVideoSearch, getYouTubePlaylist, continueYouTubePlaylist, getTrending } from "./youtube.js";
import { putVrcUrl } from "./vrcurl.js"; import { putVrcUrl } from "./vrcurl.js";
import { makeImageSheetVrcUrl, thumbnailWidth, thumbnailHeight, iconWidth, iconHeight } from "./imagesheet.js"; import { makeImageSheetVrcUrl } from "./imagesheet.js";
import { getTrending } from "./trending.js";
var cache = {}; var cache = {};
@ -22,7 +21,7 @@ export async function cachedVRCYoutubeSearch(pool, query, options) {
async function VRCYoutubeSearch(pool, query, options = {}) { async function VRCYoutubeSearch(pool, query, options = {}) {
console.debug("search:", JSON.stringify(query)); console.log("search:", JSON.stringify(query));
var data = {results: []}; var data = {results: []};
if (typeof query == "object") { if (typeof query == "object") {
@ -38,28 +37,50 @@ async function VRCYoutubeSearch(pool, query, options = {}) {
} }
break; break;
case "continuation": case "continuation":
var {videos, continuationData} = await continueYouTubeVideoSearch(query.continuationData); //var {videos, continuationData} = await [query.for == "playlist" ? continueYouTubePlaylist : continueYouTubeVideoSearch](query.continuationData);
if (query.for == "playlist") {
var {videos, continuationData} = await continueYouTubePlaylist(query.continuationData);
} else {
var {videos, continuationData} = await continueYouTubeVideoSearch(query.continuationData);
}
break; break;
} }
} else { } else {
var {videos, continuationData} = await searchYouTubeVideos(query); var playlistId = query.match(/list=(PL[a-zA-Z0-9-_]{32})/)?.[1];
if (playlistId) console.log("playlistId:", playlistId);
var {videos, continuationData} = playlistId ? await getYouTubePlaylist(playlistId) : await searchYouTubeVideos(query);
} }
var images = [];
if (options.thumbnails) { if (options.thumbnails) {
var thumbnailUrls = videos.map(video => video.thumbnailUrl); videos.forEach(video => {
video.thumbnail = playlistId ? {
url: `https://i.ytimg.com/vi/${video.id}/default.jpg`,
width: 120,
height: 90
} : {
url: `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`,
width: 320,
height: 180
};
images.push(video.thumbnail);
});
} }
if (options.icons) { if (options.icons) {
var iconUrls = new Set(); let iconUrls = new Set();
for (let video of videos) { videos.forEach(video => video.channel.iconUrl && iconUrls.add(video.channel.iconUrl));
iconUrls.add(video.channel.iconUrl); iconUrls.forEach(url => images.push({
} width: 68,//todo pass from yt data not hardcode
iconUrls = [...iconUrls]; height: 68,
url
}));
} }
if (thumbnailUrls?.length || iconUrls?.length) { if (images.length) {
try { try {
var {vrcurl: imagesheet_vrcurl, thumbnails, icons} = await makeImageSheetVrcUrl(pool, thumbnailUrls, iconUrls); var {vrcurl: imagesheet_vrcurl} = await makeImageSheetVrcUrl(pool, images);
data.imagesheet_vrcurl = imagesheet_vrcurl; data.imagesheet_vrcurl = imagesheet_vrcurl;
} catch (error) { } catch (error) {
console.error(error.stack); console.error(error.stack);
@ -68,34 +89,30 @@ async function VRCYoutubeSearch(pool, query, options = {}) {
for (let video of videos) { for (let video of videos) {
video.vrcurl = await putVrcUrl(pool, {type: "redirect", url: `https://www.youtube.com/watch?v=${video.id}`}); video.vrcurl = await putVrcUrl(pool, {type: "redirect", url: `https://www.youtube.com/watch?v=${video.id}`});
if (thumbnails?.length) { let thumbnail = images.find(image => image.url == video.thumbnail.url);
let thumbnail = thumbnails.find(x => x.url == video.thumbnailUrl); video.thumbnail = thumbnail ? {
video.thumbnail = { x: thumbnail?.x,
x: thumbnail?.x, y: thumbnail?.y,
y: thumbnail?.y, width: thumbnail?.width,
width: thumbnailWidth, height: thumbnail?.height
height: thumbnailHeight } : undefined;
}; let icon = images.find(image => image.url == video.channel.iconUrl);
} video.channel.icon = icon ? {
if (icons?.length) { x: icon?.x,
let icon = icons.find(x => x.url == video.channel.iconUrl); y: icon?.y,
video.channel.icon = { width: icon?.width,
x: icon?.x, height: icon?.height
y: icon?.y, } : undefined;
width: iconWidth,
height: iconHeight
};
}
if (options.captions) { if (options.captions) {
video.captions_vrcurl = await putVrcUrl(pool, {type: "captions", videoId: video.id}); video.captions_vrcurl = await putVrcUrl(pool, {type: "captions", videoId: video.id});
} }
delete video.thumbnailUrl;
delete video.channel.iconUrl; delete video.channel.iconUrl;
data.results.push(video); data.results.push(video);
} }
if (continuationData) data.nextpage_vrcurl = await putVrcUrl(pool, { if (continuationData) data.nextpage_vrcurl = await putVrcUrl(pool, {
type: "ytContinuation", type: "continuation",
for: query.for || (playlistId ? "playlist" : "search"),
continuationData, continuationData,
options options
}); });

123
app.js
View File

@ -1,123 +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";
import shorturlmap from "./shorturlmap.json" assert { type: "json" };
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/");
});
// 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());

View File

@ -1,76 +1,42 @@
import { createCanvas, loadImage } from 'canvas'; import { createCanvas, loadImage } from 'canvas';
import potpack from 'potpack';
import { putVrcUrl } from './vrcurl.js'; import { putVrcUrl } from './vrcurl.js';
var store = {}; var store = {};
async function createImageSheet(images /*[{width, height, url}]*/) {
export const thumbnailWidth = 360; images.forEach(image => {
export const thumbnailHeight = 202; image.w = image.width;
export const iconWidth = 68; image.h = image.height;
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};
}); });
var {w, h, fill} = potpack(images);
const iconStartX = thumbnailWidth * Math.min(maxThumbnailRowLen, thumbnails.length); if (w > 2048) {
console.warn("Imagesheet exceeded max width");
var icons = iconUrls.map((url, index) => { w = 2048;
const x = iconStartX + index % maxIconRowLen * iconWidth; }
const y = Math.floor(index / maxIconRowLen) * iconHeight; if (h > 2048) {
return {x, y, url}; console.warn("Imagesheet exceeded max height");
}); h = 2048;
}
const canvasWidth = Math.max( var canvas = createCanvas(w, h);
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 ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
var promises = []; await Promise.all(images.map(({x, y, w, h, url}) => (async function(){
if (!url) return;
var image = await loadImage(url);
ctx.drawImage(image, x, y, w, h);
})().catch(error => console.error(error.stack))));
if (thumbnails.length) {
promises = promises.concat(thumbnails.map(({x, y, url}) => (async function(){
var image = await loadImage(url);
ctx.drawImage(image, x, y, thumbnailWidth, thumbnailHeight);
})().catch(error => console.error(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 { return {
imagesheet: canvas.toBuffer("image/png"), imagesheet: canvas.toBuffer("image/png"),
thumbnails, icons images
}; };
} }
export async function makeImageSheetVrcUrl(pool, images) {
export async function makeImageSheetVrcUrl(pool, thumbnailUrls, iconUrls) {
var num = await putVrcUrl(pool, {type: "imagesheet"}); var num = await putVrcUrl(pool, {type: "imagesheet"});
var key = `${pool}:${num}`; var key = `${pool}:${num}`;
var promise = createImageSheet(thumbnailUrls, iconUrls); var promise = createImageSheet(images);
store[key] = promise; store[key] = promise;
promise.then(() => { promise.then(() => {
setTimeout(() => { setTimeout(() => {

130
index.js
View File

@ -1,4 +1,132 @@
if (process.env.D!="BUG") console.debug = () => {};
import "./util.js"; import "./util.js";
import { app } from "./app.js"; 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 "./youtube-captions.js";
import { stringToBoolean } from "./util.js";
import shorturlmap from "./shorturlmap.json" assert { type: "json" };
var app = new Koa();
var router = new Router();
router.get(["/search", "/trending"], async ctx => {
if (ctx.path == "/trending") {
var query = {"type":"trending"};
} else {
var query = ctx.querystring.match(/[?&]input=(.*)/i)?.[1];
if (!query) {
ctx.status = 400;
ctx.body = "missing search query";
return;
}
query = decodeURIComponent(query).replace(/^.*→/, '').trim();
}
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 "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/");
});
// 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); app.listen(process.env.PORT || 8142, process.env.ADDRESS);

8
package-lock.json generated
View File

@ -11,7 +11,8 @@
"fast-xml-parser": "^4.3.4", "fast-xml-parser": "^4.3.4",
"keyv": "^4.5.4", "keyv": "^4.5.4",
"koa": "^2.14.2", "koa": "^2.14.2",
"koa-send": "^5.0.1" "koa-send": "^5.0.1",
"potpack": "^2.0.0"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@ -1262,6 +1263,11 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
"integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw=="
}, },
"node_modules/potpack": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz",
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw=="
},
"node_modules/promise-inflight": { "node_modules/promise-inflight": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",

View File

@ -6,7 +6,8 @@
"fast-xml-parser": "^4.3.4", "fast-xml-parser": "^4.3.4",
"keyv": "^4.5.4", "keyv": "^4.5.4",
"koa": "^2.14.2", "koa": "^2.14.2",
"koa-send": "^5.0.1" "koa-send": "^5.0.1",
"potpack": "^2.0.0"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"

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
}
}
}

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};
}

23
util.js
View File

@ -26,26 +26,3 @@ export function recursiveFind(object, fn) {
})(object); })(object);
return results; 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
}
};
}

192
youtube.js Normal file
View File

@ -0,0 +1,192 @@
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);
console.debug(ytInitialData);
var videos = ytInitialData?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents?.find(x => x.itemSectionRenderer?.contents?.find(x => x.videoRenderer))?.itemSectionRenderer?.contents?.filterMap(x => x.videoRenderer)?.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: 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());
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.find(x => x.continuationItemRenderer)?.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 html = await fetch("https://www.youtube.com/playlist?list=" + playlistId).then(res => res.text());
var ytInitialData = html.match(/ytInitialData = ({.*});<\/script>/)[1];
ytInitialData = JSON.parse(ytInitialData);
console.debug(ytInitialData);
var sectionListRendererContents = ytInitialData.contents.twoColumnBrowseResultsRenderer.tabs.find(tab => tab.tabRenderer.selected).tabRenderer.content.sectionListRenderer.contents;
var videos = sectionListRendererContents.find(x => x.itemSectionRenderer).itemSectionRenderer.contents.find(x => x.playlistVideoListRenderer).playlistVideoListRenderer.contents.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: sectionListRendererContents.find(x => x.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token
}
} catch (error) {
console.error(error.stack);
}
return {videos, continuationData};
}
export async function continueYouTubePlaylist(continuationData) {
var data = await fetch("https://www.youtube.com/youtubei/v1/browse?prettyPrint=false", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(continuationData)
}).then(res => res.json());
console.debug(data);
if (!data.onResponseReceivedActions) return {videos:[]};
var continuationItems = data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems;
var videos = continuationItems.find(x => x.itemSectionRenderer).itemSectionRenderer.contents.filterMap(x => x.playlistVideoListRenderer).map(parseVideoRendererData);
var continuationToken = continuationItems.find(x => x.continuationItemRenderer)?.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 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
.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
}
};
}