Compare commits

..

No commits in common. "5c00a0444b611a88057e0a81a59600d49b2cf477" and "d413e7d1b8aaf83b375112d7f196cd4b6e8759fc" have entirely different histories.

9 changed files with 38 additions and 114 deletions

3
.gitignore vendored
View File

@ -1,3 +1,2 @@
node_modules node_modules
*.sqlite vrcurl.sqlite
*.json

View File

@ -1,7 +1,6 @@
import { searchYouTubeVideos, continueYouTubeVideoSearch } from "./simpleYoutubeSearch.js"; import { searchYouTubeVideos, continueYouTubeVideoSearch } from "./simpleYoutubeSearch.js";
import { putVrcUrl } from "./vrcurl.js"; import { putVrcUrl } from "./vrcurl.js";
import { makeImageSheetVrcUrl, thumbnailWidth, thumbnailHeight, iconWidth, iconHeight } from "./imagesheet.js"; import { makeImageSheetVrcUrl, thumbnailWidth, thumbnailHeight, iconWidth, iconHeight } from "./imagesheet.js";
import { getTrending } from "./trending.js";
var cache = {}; var cache = {};
@ -25,18 +24,7 @@ async function VRCYoutubeSearch(pool, query, options = {}) {
console.debug("search:", JSON.stringify(query)); console.debug("search:", JSON.stringify(query));
var data = {results: []}; var data = {results: []};
if (query == "trending") {
var {videos, tabs} = await getTrending();
data.tabs = [];
for (let tab of tabs) {
data.tabs.push({
name: tab.name,
vrcurl: await putVrcUrl(pool, {type: "trending", url: tab.url})
});
}
} else {
var {videos, continuationData} = typeof query == "object" ? await continueYouTubeVideoSearch(query) : await searchYouTubeVideos(query); var {videos, continuationData} = typeof query == "object" ? await continueYouTubeVideoSearch(query) : await searchYouTubeVideos(query);
}
if (options.thumbnails) { if (options.thumbnails) {
var thumbnailUrls = videos.map(video => video.thumbnailUrl); var thumbnailUrls = videos.map(video => video.thumbnailUrl);

4
app.js
View File

@ -11,8 +11,8 @@ export var app = new Koa();
var router = new Router(); var router = new Router();
router.get(["/search", "/trending"], async ctx => { router.get("/search", async ctx => {
var query = ctx.path == "/trending" ? "trending" : ctx.query.input?.replace(/^.*→/, '').trim(); var query = ctx.query.input?.replace(/^.*→/, '').trim();
if (!query) { if (!query) {
ctx.status = 400; ctx.status = 400;
ctx.body = "missing search query"; ctx.body = "missing search query";

View File

@ -11,10 +11,9 @@ export const iconHeight = 68;
const maxSheetWidth = 2048; const maxSheetWidth = 2048;
const maxSheetHeight = 2048; const maxSheetHeight = 2048;
const maxThumbnailRowLen = Math.floor(maxSheetWidth / thumbnailWidth); // 5 const maxThumbnailRowLen = Math.floor(maxSheetWidth / thumbnailWidth); // 5
//const maxThumbnailColLen = Math.floor(maxSheetHeight / thumbnailHeight); // 10 const maxThumbnailColLen = Math.floor(maxSheetHeight / thumbnailHeight); // 10
//const maxIconRowLen = Math.floor(maxSheetWidth / iconWidth); // 30 const maxIconRowLen = Math.floor(maxSheetWidth / iconWidth); // 30
const maxIconRowLen = 3; const maxIconColLen = Math.floor(maxSheetHeight / iconHeight); // 30
//const maxIconColLen = Math.floor(maxSheetHeight / iconHeight); // 30
async function createImageSheet(thumbnailUrls = [], iconUrls = []) { async function createImageSheet(thumbnailUrls = [], iconUrls = []) {
@ -26,19 +25,19 @@ async function createImageSheet(thumbnailUrls = [], iconUrls = []) {
return {x, y, url}; return {x, y, url};
}); });
const iconStartX = thumbnailWidth * Math.min(maxThumbnailRowLen, thumbnails.length); const iconStartY = thumbnails.length ? thumbnails.at(-1).y + thumbnailHeight : 0;
var icons = iconUrls.map((url, index) => { var icons = iconUrls.map((url, index) => {
const x = iconStartX + index % maxIconRowLen * iconWidth; const x = index % maxIconRowLen * iconWidth;
const y = Math.floor(index / maxIconRowLen) * iconHeight; const y = iconStartY + Math.floor(index / maxIconRowLen);
return {x, y, url}; return {x, y, url};
}); });
const canvasWidth = Math.max( const canvasWidth = Math.max(
Math.min(thumbnails.length, maxThumbnailRowLen) * thumbnailWidth, Math.min(thumbnails.length, maxThumbnailRowLen) * thumbnailWidth,
iconStartX + Math.min(icons.length, maxIconRowLen) * iconWidth 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); const canvasHeight = icons.length ? icons.at(-1).y + iconHeight : thumbnails.length ? thumbnails.at(-1).y + thumbnailHeight : 0;
var canvas = createCanvas(Math.min(maxSheetWidth, canvasWidth), Math.min(maxSheetHeight, canvasHeight)); var canvas = createCanvas(Math.min(maxSheetWidth, canvasWidth), Math.min(maxSheetHeight, canvasHeight));
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');

View File

@ -1,4 +1,3 @@
import "./util.js";
import { app } from "./app.js"; import { app } from "./app.js";
app.listen(process.env.PORT || 8142, process.env.ADDRESS); app.listen(process.env.PORT || 8142, process.env.ADDRESS);

1
package-lock.json generated
View File

@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vrchat-youtube-search-api",
"dependencies": { "dependencies": {
"@keyv/sqlite": "^3.6.6", "@keyv/sqlite": "^3.6.6",
"@koa/router": "^12.0.1", "@koa/router": "^12.0.1",

View File

@ -1,4 +1,3 @@
import { parseVideoRendererData } from "./util.js";
export async function searchYouTubeVideos(query) { export async function searchYouTubeVideos(query) {
var url = `https://www.youtube.com/results?search_query=${encodeURIComponent(query.replaceAll(' ', '+'))}&sp=EgIQAQ%253D%253D`; var url = `https://www.youtube.com/results?search_query=${encodeURIComponent(query.replaceAll(' ', '+'))}&sp=EgIQAQ%253D%253D`;
@ -45,3 +44,26 @@ export async function continueYouTubeVideoSearch(continuationData) {
} }
} }
} }
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, ""),
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
}
};
}

View File

@ -1,39 +0,0 @@
import { parseVideoRendererData } from "./util.js";
export async function getTrending() {
var url = `https://www.youtube.com/feed/trending`;
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
}
});
var videos = ytInitialData
.contents
.twoColumnBrowseResultsRenderer
.tabs[0] //Now
.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};
}

45
util.js
View File

@ -1,51 +1,6 @@
Array.prototype.filterMap = function(fn) {
var newarray = [];
for (var item of this) {
var ret = fn(item);
if (ret) newarray.push(ret);
}
return newarray;
};
export function stringToBoolean(str) { export function stringToBoolean(str) {
if (str) { if (str) {
if (!["0", "false", "off", "no", "null", "undefined", "nan"].includes(str.toLowerCase())) return true; if (!["0", "false", "off", "no", "null", "undefined", "nan"].includes(str.toLowerCase())) return true;
} }
return false; 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
}
};
}