Compare commits

...

4 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
7 changed files with 22 additions and 36 deletions
+4 -2
View File
@@ -71,9 +71,11 @@ Gets Trending YouTube videos. Identical to `/search` but without `input` paramet
## GET `/vrcurl/{pool}/{index}` ## GET `/vrcurl/{pool}/{index}`
- `{pool}`: must be same as pool param in search endpoint. - `{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 video json data (see 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.
For image sheet, response is `image/png`. For next page or trending tab, response is `application/json`.
### Video metadata JSON format ### Video metadata JSON format
+2 -3
View File
@@ -37,7 +37,6 @@ async function VRCYoutubeSearch(pool, query, options = {}, key) {
} }
break; break;
case "continuation": case "continuation":
//var {videos, continuationData} = await [query.for == "playlist" ? continueYouTubePlaylist : continueYouTubeVideoSearch](query.continuationData);
if (query.for == "playlist") { if (query.for == "playlist") {
var {videos, continuationData} = await continueYouTubePlaylist(query.continuationData); var {videos, continuationData} = await continueYouTubePlaylist(query.continuationData);
} else { } else {
@@ -46,7 +45,7 @@ async function VRCYoutubeSearch(pool, query, options = {}, key) {
break; break;
} }
} else { } else {
var playlistId = query.match(/list=(PL[a-zA-Z0-9-_]{32})/)?.[1]; var playlistId = query.match(/list=(PL(?:[a-zA-Z0-9-_]{32}|[0-9A-F]{16}))/)?.[1];
if (playlistId) console.log("playlistId:", playlistId); if (playlistId) console.log("playlistId:", playlistId);
var {videos, continuationData} = playlistId ? await getYouTubePlaylist(playlistId) : await searchYouTubeVideos(query, options.bp || (options.mode == "latestontop" ? null : undefined)); var {videos, continuationData} = playlistId ? await getYouTubePlaylist(playlistId) : await searchYouTubeVideos(query, options.bp || (options.mode == "latestontop" ? null : undefined));
} }
@@ -55,7 +54,7 @@ async function VRCYoutubeSearch(pool, query, options = {}, key) {
if (options.thumbnails) { if (options.thumbnails) {
videos.forEach(video => { videos.forEach(video => {
if (playlistId) video.thumbnail = { if (playlistId || query?.for == "playlist") video.thumbnail = {
url: `https://i.ytimg.com/vi/${video.id}/default.jpg`, url: `https://i.ytimg.com/vi/${video.id}/default.jpg`,
width: 120, width: 120,
height: 90 height: 90
+3
View File
@@ -1,5 +1,6 @@
if (process.env.D!="BUG") console.debug = () => {}; if (process.env.D!="BUG") console.debug = () => {};
else console.debug(process.env); else console.debug(process.env);
import "./util.js"; import "./util.js";
import Koa from "koa"; import Koa from "koa";
import Router from "@koa/router"; import Router from "@koa/router";
@@ -57,12 +58,14 @@ router.get("/vrcurl/:pool/:num", async ctx => {
} }
switch (dest.type) { switch (dest.type) {
case "redirect": case "redirect":
ctx.set("Referrer-Policy", "no-referrer");
ctx.redirect(dest.url); ctx.redirect(dest.url);
break; break;
case "video": case "video":
if (ctx.get("User-Agent").includes("UnityWebRequest")) { if (ctx.get("User-Agent").includes("UnityWebRequest")) {
ctx.body = {captions: await getVideoCaptionsCached(dest.id)}; ctx.body = {captions: await getVideoCaptionsCached(dest.id)};
} else { } else {
ctx.set("Referrer-Policy", "no-referrer");
ctx.redirect(`https://www.youtube.com/watch?v=${dest.id}`); ctx.redirect(`https://www.youtube.com/watch?v=${dest.id}`);
} }
break; break;
-21
View File
@@ -647,27 +647,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+1 -1
View File
@@ -14,7 +14,7 @@
<br /> <br />
<label><input id="thumbnails" type="checkbox" checked>thumbnails</label> <label><input id="thumbnails" type="checkbox" checked>thumbnails</label>
<label><input id="icons" type="checkbox" checked>icons</label> <label><input id="icons" type="checkbox" checked>icons</label>
<label><input id="latestontop" type="checkbox" checked>latestontop</label> <label><input id="latestontop" type="checkbox">latestontop</label>
</div> </div>
<div id="output"></div> <div id="output"></div>
+6 -2
View File
@@ -8,7 +8,7 @@ var xmlParser = new XMLParser({
async function getVideoData(videoId) { async function getVideoData(videoId) {
var res = await gotw(`https://www.youtube.com/watch?v=${videoId}`); var res = await gotw(`https://www.youtube.com/watch?v=${videoId}`);
var ytInitialPlayerResponse = res.body.match(/var ytInitialPlayerResponse = ({.*});/)[1]; var ytInitialPlayerResponse = res.body.match(/var ytInitialPlayerResponse = ({.*?});/)[1];
ytInitialPlayerResponse = JSON.parse(ytInitialPlayerResponse); ytInitialPlayerResponse = JSON.parse(ytInitialPlayerResponse);
return ytInitialPlayerResponse; return ytInitialPlayerResponse;
@@ -20,7 +20,11 @@ async function getVideoCaptions(videoId) {
var captionTracks = ytInitialPlayerResponse.captions.playerCaptionsTracklistRenderer.captionTracks; var captionTracks = ytInitialPlayerResponse.captions.playerCaptionsTracklistRenderer.captionTracks;
captionTracks = await Promise.all(captionTracks.map(captionTrack => (async () => { captionTracks = await Promise.all(captionTracks.map(captionTrack => (async () => {
try { try {
var xml = await gotw(captionTrack.baseUrl, {resolveBodyOnly: true}); 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 parsed = xmlParser.parse(xml);
var lines = parsed.transcript.text; var lines = parsed.transcript.text;
if (!Array.isArray(lines)) lines = [lines]; if (!Array.isArray(lines)) lines = [lines];
+6 -7
View File
@@ -114,7 +114,7 @@ export async function getYouTubePlaylist(playlistId) {
ytInitialData = JSON.parse(ytInitialData); ytInitialData = JSON.parse(ytInitialData);
console.debug(ytInitialData); console.debug(ytInitialData);
var sectionListRendererContents = ytInitialData var playlistVideoRendererContents = ytInitialData
.contents .contents
.twoColumnBrowseResultsRenderer .twoColumnBrowseResultsRenderer
.tabs .tabs
@@ -122,10 +122,10 @@ export async function getYouTubePlaylist(playlistId) {
.tabRenderer .tabRenderer
.content .content
.sectionListRenderer .sectionListRenderer
.contents; .contents
var videos = sectionListRendererContents
.findMap(x => x.itemSectionRenderer?.contents) .findMap(x => x.itemSectionRenderer?.contents)
.findMap(x => x.playlistVideoListRenderer?.contents) .findMap(x => x.playlistVideoListRenderer?.contents);
var videos = playlistVideoRendererContents
.filterMap(x => x.playlistVideoRenderer) .filterMap(x => x.playlistVideoRenderer)
.map(parseVideoRendererData); .map(parseVideoRendererData);
if (!videos) return {videos: []}; if (!videos) return {videos: []};
@@ -136,7 +136,7 @@ export async function getYouTubePlaylist(playlistId) {
ytcfg = JSON.parse(ytcfg); ytcfg = JSON.parse(ytcfg);
var continuationData = { var continuationData = {
context: ytcfg.INNERTUBE_CONTEXT, context: ytcfg.INNERTUBE_CONTEXT,
continuation: sectionListRendererContents.findMap(x => x.continuationItemRenderer).continuationEndpoint.continuationCommand.token continuation: playlistVideoRendererContents.findMap(x => x.continuationItemRenderer).continuationEndpoint.commandExecutorCommand.commands.findMap(x=>x.continuationCommand).token
} }
} catch (error) { } catch (error) {
console.error(error.stack); console.error(error.stack);
@@ -162,8 +162,7 @@ export async function continueYouTubePlaylist(continuationData) {
.appendContinuationItemsAction .appendContinuationItemsAction
.continuationItems; .continuationItems;
var videos = continuationItems var videos = continuationItems
.findMap(x => x.itemSectionRenderer?.contents) .filterMap(x => x.playlistVideoRenderer)
.filterMap(x => x.playlistVideoListRenderer)
.map(parseVideoRendererData); .map(parseVideoRendererData);
var continuationToken = continuationItems var continuationToken = continuationItems
.findMap(x => x.continuationItemRenderer) .findMap(x => x.continuationItemRenderer)