Add support for Invidious captions endpoint (#55)

* Add basic skeletons for Invidious captions api

* Add logic to fetch and convert transcripts to vtt

* Use transcripts logic in captions route

* Format

* Fix lint
This commit is contained in:
syeopite 2025-03-08 17:11:58 +00:00 committed by GitHub
parent 59fdecf3c7
commit a6e758cd64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 153 additions and 1 deletions

View file

@ -24,7 +24,8 @@
"unstable": [
"cron",
"kv",
"http"
"http",
"temporal"
],
"fmt": {
"indentWidth": 4

View file

@ -0,0 +1,63 @@
import { Innertube } from "youtubei.js";
function createTemporalDuration(milliseconds) {
return new Temporal.Duration(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
milliseconds,
);
}
const ESCAPE_SUBSTITUTIONS = {
"&": "&",
"<": "&lt;",
">": "&gt;",
"\u200E": "&lrm;",
"\u200F": "&rlm;",
"\u00A0": "&nbsp;",
};
export async function handleTranscripts(
innertubeClient: Innertube,
videoId: string,
selectedCaption,
) {
const lines: string[] = ["WEBVTT"];
const info = await innertubeClient.getInfo(videoId);
const transcriptInfo = await (await info.getTranscript()).selectLanguage(
selectedCaption.name.simpleText,
);
const rawTranscriptLines =
transcriptInfo.transcript.content.body.initial_segments;
rawTranscriptLines.forEach((line) => {
const timestampFormatOptions = {
style: "digital",
minutesDisplay: "always",
fractionalDigits: 3,
};
const start_ms = createTemporalDuration(line.start_ms).round({
largestUnit: "year",
}).toLocaleString(undefined, timestampFormatOptions);
const end_ms = createTemporalDuration(line.end_ms).round({
largestUnit: "year",
}).toLocaleString(undefined, timestampFormatOptions);
const timestamp = `${start_ms} --> ${end_ms}`;
const text = line.snippet.text.replace(
/[&<>\u200E\u200F\u00A0]/g,
(match) => ESCAPE_SUBSTITUTIONS[match],
);
lines.push(`${timestamp}\n${text}`);
});
return lines.join("\n\n");
}

View file

@ -6,6 +6,7 @@ import { bearerAuth } from "hono/bearer-auth";
import youtubeApiPlayer from "./youtube_api_routes/player.ts";
import invidiousRouteLatestVersion from "./invidious_routes/latestVersion.ts";
import invidiousRouteDashManifest from "./invidious_routes/dashManifest.ts";
import invidiousCaptionsApi from "./invidious_routes/captions.ts";
import videoPlaybackProxy from "./videoPlaybackProxy.ts";
import health from "./health.ts";
@ -26,6 +27,7 @@ export const routes = (
app.route("/youtubei/v1", youtubeApiPlayer);
app.route("/latest_version", invidiousRouteLatestVersion);
app.route("/api/manifest/dash/id", invidiousRouteDashManifest);
app.route("/api/v1/captions", invidiousCaptionsApi);
app.route("/videoplayback", videoPlaybackProxy);
app.route("/healthz", health);
};

View file

@ -0,0 +1,86 @@
import { Hono } from "hono";
import { HonoVariables } from "../../lib/types/HonoVariables.ts";
import { Store } from "@willsoto/node-konfig-core";
import { verifyRequest } from "../../lib/helpers/verifyRequest.ts";
import { youtubePlayerParsing } from "../../lib/helpers/youtubePlayerHandling.ts";
import { handleTranscripts } from "../../lib/helpers/youtubeTranscriptsHandling.ts";
import { HTTPException } from "hono/http-exception";
interface AvailableCaption {
label: string;
languageCode: string;
url: string;
}
const captionsHandler = new Hono<{ Variables: HonoVariables }>();
captionsHandler.get("/:videoId", async (c) => {
const { videoId } = c.req.param();
const konfigStore = await c.get("konfigStore") as Store<
Record<string, unknown>
>;
const check = c.req.query("check");
if (konfigStore.get("server.verify_requests") && check == undefined) {
throw new HTTPException(400, {
res: new Response("No check ID."),
});
} else if (konfigStore.get("server.verify_requests") && check) {
if (verifyRequest(check, videoId, konfigStore) === false) {
throw new HTTPException(400, {
res: new Response("ID incorrect."),
});
}
}
const innertubeClient = await c.get("innertubeClient") as Innertube;
const playerJson = await youtubePlayerParsing(
innertubeClient,
videoId,
konfigStore,
);
const captionsTrackArray =
playerJson.captions.playerCaptionsTracklistRenderer.captionTracks;
const label = c.req.query("label");
const lang = c.req.query("lang");
// Show all available captions when a specific one is not selected
if (label == undefined && lang == undefined) {
const invidiousAvailableCaptionsArr: AvailableCaption[] = [];
captionsTrackArray.forEach((captions) => {
invidiousAvailableCaptionsArr.push({
label: captions.name.simpleText,
languageCode: captions.languageCode,
url: `/api/v1/captions/${videoId}?label=${
encodeURIComponent(captions.name.simpleText)
}`,
});
});
return c.json({ captions: invidiousAvailableCaptionsArr });
}
// Extract selected caption
let caption;
if (lang) {
caption = captionsTrackArray.filter((c) => c.languageCode === lang);
} else {
caption = captionsTrackArray.filter((c) => c.name.simpleText === label);
}
if (caption.length == 0) {
throw new HTTPException(404);
} else {
caption = caption[0];
}
c.header("Content-Type", "text/vtt; charset=UTF-8");
return c.body(await handleTranscripts(innertubeClient, videoId, caption));
});
export default captionsHandler;