Compare commits
No commits in common. "e7d8a4b73707cac0952cd2b00df15be8736714d1" and "a119e41075c126d2f092c80b2690edd113c9fd31" have entirely different histories.
e7d8a4b737
...
a119e41075
5
.vscode/launch.json
vendored
5
.vscode/launch.json
vendored
@ -14,10 +14,7 @@
|
||||
"program": "${workspaceFolder}\\index.js",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js"
|
||||
],
|
||||
"env": {
|
||||
"D":"BUG"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -13,7 +13,7 @@ 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. THIS MUST BE THE LAST QUERY PARAMETER AS ALL CHARS AFTER IT ARE CAPTURED VERBATIM (so you can type & etc without encoding)
|
||||
- `input`: youtube search query. All chars up to and including this exact unicode char `→` are ignored, and then whitespace is trimmed.
|
||||
|
||||
### 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/"`
|
||||
- `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 (playlist results don't have this)
|
||||
- `shortViewCountText`?: (string) i.e. `"2.5M views"` (streams don't have this)
|
||||
- `viewCountText`: (string) i.e. `"2,552,243 views"` or `"575 watching"` for live streams
|
||||
- `shortViewCountText`?: (string) i.e. `"2.5M views"`
|
||||
- `uploaded`: (string) i.e. `"12 years ago"`
|
||||
- `channel`: (object)
|
||||
- `name`: (string) i.e. `"NyanCat"`
|
||||
- `id`: (string) i.e. `"UCsW85RAS2_Twg_lEPyv7G8A"`
|
||||
- `icon`?: (object) (playlist results don't have this)
|
||||
- `icon`?: (object)
|
||||
- `x`: (integer) px from left
|
||||
- `y`: (integer) px from top
|
||||
- `width`: (integer)
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { searchYouTubeVideos, continueYouTubeVideoSearch, getYouTubePlaylist, continueYouTubePlaylist, getTrending } from "./youtube.js";
|
||||
import { searchYouTubeVideos, continueYouTubeVideoSearch } from "./simpleYoutubeSearch.js";
|
||||
import { putVrcUrl } from "./vrcurl.js";
|
||||
import { makeImageSheetVrcUrl } from "./imagesheet.js";
|
||||
import { makeImageSheetVrcUrl, thumbnailWidth, thumbnailHeight, iconWidth, iconHeight } from "./imagesheet.js";
|
||||
import { getTrending } from "./trending.js";
|
||||
|
||||
var cache = {};
|
||||
|
||||
@ -21,7 +22,7 @@ export async function cachedVRCYoutubeSearch(pool, query, options) {
|
||||
|
||||
|
||||
async function VRCYoutubeSearch(pool, query, options = {}) {
|
||||
console.log("search:", JSON.stringify(query));
|
||||
console.debug("search:", JSON.stringify(query));
|
||||
var data = {results: []};
|
||||
|
||||
if (typeof query == "object") {
|
||||
@ -37,50 +38,28 @@ async function VRCYoutubeSearch(pool, query, options = {}) {
|
||||
}
|
||||
break;
|
||||
case "continuation":
|
||||
//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);
|
||||
}
|
||||
var {videos, continuationData} = await continueYouTubeVideoSearch(query.continuationData);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
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 {videos, continuationData} = await searchYouTubeVideos(query);
|
||||
}
|
||||
|
||||
var images = [];
|
||||
|
||||
if (options.thumbnails) {
|
||||
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);
|
||||
});
|
||||
var thumbnailUrls = videos.map(video => video.thumbnailUrl);
|
||||
}
|
||||
|
||||
if (options.icons) {
|
||||
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
|
||||
}));
|
||||
var iconUrls = new Set();
|
||||
for (let video of videos) {
|
||||
iconUrls.add(video.channel.iconUrl);
|
||||
}
|
||||
iconUrls = [...iconUrls];
|
||||
}
|
||||
|
||||
if (images.length) {
|
||||
if (thumbnailUrls?.length || iconUrls?.length) {
|
||||
try {
|
||||
var {vrcurl: imagesheet_vrcurl} = await makeImageSheetVrcUrl(pool, images);
|
||||
var {vrcurl: imagesheet_vrcurl, thumbnails, icons} = await makeImageSheetVrcUrl(pool, thumbnailUrls, iconUrls);
|
||||
data.imagesheet_vrcurl = imagesheet_vrcurl;
|
||||
} catch (error) {
|
||||
console.error(error.stack);
|
||||
@ -89,30 +68,34 @@ async function VRCYoutubeSearch(pool, query, options = {}) {
|
||||
|
||||
for (let video of videos) {
|
||||
video.vrcurl = await putVrcUrl(pool, {type: "redirect", url: `https://www.youtube.com/watch?v=${video.id}`});
|
||||
let thumbnail = images.find(image => image.url == video.thumbnail.url);
|
||||
video.thumbnail = thumbnail ? {
|
||||
x: thumbnail?.x,
|
||||
y: thumbnail?.y,
|
||||
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: icon?.width,
|
||||
height: icon?.height
|
||||
} : undefined;
|
||||
if (thumbnails?.length) {
|
||||
let thumbnail = thumbnails.find(x => x.url == video.thumbnailUrl);
|
||||
video.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 = {
|
||||
x: icon?.x,
|
||||
y: icon?.y,
|
||||
width: iconWidth,
|
||||
height: iconHeight
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
if (continuationData) data.nextpage_vrcurl = await putVrcUrl(pool, {
|
||||
type: "continuation",
|
||||
for: query.for || (playlistId ? "playlist" : "search"),
|
||||
type: "ytContinuation",
|
||||
continuationData,
|
||||
options
|
||||
});
|
||||
|
123
app.js
Normal file
123
app.js
Normal file
@ -0,0 +1,123 @@
|
||||
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());
|
@ -1,42 +1,76 @@
|
||||
import { createCanvas, loadImage } from 'canvas';
|
||||
import potpack from 'potpack';
|
||||
import { putVrcUrl } from './vrcurl.js';
|
||||
|
||||
var store = {};
|
||||
|
||||
async function createImageSheet(images /*[{width, height, url}]*/) {
|
||||
images.forEach(image => {
|
||||
image.w = image.width;
|
||||
image.h = image.height;
|
||||
|
||||
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};
|
||||
});
|
||||
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);
|
||||
|
||||
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};
|
||||
});
|
||||
|
||||
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 ctx = canvas.getContext('2d');
|
||||
|
||||
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))));
|
||||
var promises = [];
|
||||
|
||||
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 {
|
||||
imagesheet: canvas.toBuffer("image/png"),
|
||||
images
|
||||
thumbnails, icons
|
||||
};
|
||||
}
|
||||
|
||||
export async function makeImageSheetVrcUrl(pool, images) {
|
||||
|
||||
export async function makeImageSheetVrcUrl(pool, thumbnailUrls, iconUrls) {
|
||||
var num = await putVrcUrl(pool, {type: "imagesheet"});
|
||||
var key = `${pool}:${num}`;
|
||||
var promise = createImageSheet(images);
|
||||
var promise = createImageSheet(thumbnailUrls, iconUrls);
|
||||
store[key] = promise;
|
||||
promise.then(() => {
|
||||
setTimeout(() => {
|
||||
|
130
index.js
130
index.js
@ -1,132 +1,4 @@
|
||||
if (process.env.D!="BUG") console.debug = () => {};
|
||||
import "./util.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());
|
||||
import { app } from "./app.js";
|
||||
|
||||
app.listen(process.env.PORT || 8142, process.env.ADDRESS);
|
8
package-lock.json
generated
8
package-lock.json
generated
@ -11,8 +11,7 @@
|
||||
"fast-xml-parser": "^4.3.4",
|
||||
"keyv": "^4.5.4",
|
||||
"koa": "^2.14.2",
|
||||
"koa-send": "^5.0.1",
|
||||
"potpack": "^2.0.0"
|
||||
"koa-send": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@ -1263,11 +1262,6 @@
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
|
||||
|
@ -6,8 +6,7 @@
|
||||
"fast-xml-parser": "^4.3.4",
|
||||
"keyv": "^4.5.4",
|
||||
"koa": "^2.14.2",
|
||||
"koa-send": "^5.0.1",
|
||||
"potpack": "^2.0.0"
|
||||
"koa-send": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
47
simpleYoutubeSearch.js
Normal file
47
simpleYoutubeSearch.js
Normal file
@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
42
trending.js
Normal file
42
trending.js
Normal file
@ -0,0 +1,42 @@
|
||||
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
23
util.js
@ -26,3 +26,26 @@ export function recursiveFind(object, fn) {
|
||||
})(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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
192
youtube.js
192
youtube.js
@ -1,192 +0,0 @@
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user