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:
parent
59fdecf3c7
commit
a6e758cd64
4 changed files with 153 additions and 1 deletions
|
@ -24,7 +24,8 @@
|
|||
"unstable": [
|
||||
"cron",
|
||||
"kv",
|
||||
"http"
|
||||
"http",
|
||||
"temporal"
|
||||
],
|
||||
"fmt": {
|
||||
"indentWidth": 4
|
||||
|
|
63
src/lib/helpers/youtubeTranscriptsHandling.ts
Normal file
63
src/lib/helpers/youtubeTranscriptsHandling.ts
Normal 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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\u200E": "‎",
|
||||
"\u200F": "‏",
|
||||
"\u00A0": " ",
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
|
|
86
src/routes/invidious_routes/captions.ts
Normal file
86
src/routes/invidious_routes/captions.ts
Normal 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;
|
Reference in a new issue