Compare commits

...

2 Commits

Author SHA1 Message Date
Lamp 5c00a0444b trending working, other tabs todo 2024-04-25 00:22:47 -07:00
Lamp 18f01ea323 wip trending 2024-04-24 22:12:58 -07:00
9 changed files with 114 additions and 38 deletions

3
.gitignore vendored
View File

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

View File

@ -1,6 +1,7 @@
import { searchYouTubeVideos, continueYouTubeVideoSearch } from "./simpleYoutubeSearch.js";
import { putVrcUrl } from "./vrcurl.js";
import { makeImageSheetVrcUrl, thumbnailWidth, thumbnailHeight, iconWidth, iconHeight } from "./imagesheet.js";
import { getTrending } from "./trending.js";
var cache = {};
@ -24,8 +25,19 @@ async function VRCYoutubeSearch(pool, query, options = {}) {
console.debug("search:", JSON.stringify(query));
var data = {results: []};
var {videos, continuationData} = typeof query == "object" ? await continueYouTubeVideoSearch(query) : await searchYouTubeVideos(query);
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);
}
if (options.thumbnails) {
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();
router.get("/search", async ctx => {
var query = ctx.query.input?.replace(/^.*→/, '').trim();
router.get(["/search", "/trending"], async ctx => {
var query = ctx.path == "/trending" ? "trending" : ctx.query.input?.replace(/^.*→/, '').trim();
if (!query) {
ctx.status = 400;
ctx.body = "missing search query";

View File

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

View File

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

1
package-lock.json generated
View File

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

View File

@ -1,3 +1,4 @@
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`;
@ -44,26 +45,3 @@ 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
}
};
}

39
trending.js Normal file
View File

@ -0,0 +1,39 @@
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};
}

47
util.js
View File

@ -1,6 +1,51 @@
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) {
if (str) {
if (!["0", "false", "off", "no", "null", "undefined", "nan"].includes(str.toLowerCase())) return true;
}
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
}
};
}