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": [
|
"unstable": [
|
||||||
"cron",
|
"cron",
|
||||||
"kv",
|
"kv",
|
||||||
"http"
|
"http",
|
||||||
|
"temporal"
|
||||||
],
|
],
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"indentWidth": 4
|
"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 youtubeApiPlayer from "./youtube_api_routes/player.ts";
|
||||||
import invidiousRouteLatestVersion from "./invidious_routes/latestVersion.ts";
|
import invidiousRouteLatestVersion from "./invidious_routes/latestVersion.ts";
|
||||||
import invidiousRouteDashManifest from "./invidious_routes/dashManifest.ts";
|
import invidiousRouteDashManifest from "./invidious_routes/dashManifest.ts";
|
||||||
|
import invidiousCaptionsApi from "./invidious_routes/captions.ts";
|
||||||
import videoPlaybackProxy from "./videoPlaybackProxy.ts";
|
import videoPlaybackProxy from "./videoPlaybackProxy.ts";
|
||||||
import health from "./health.ts";
|
import health from "./health.ts";
|
||||||
|
|
||||||
|
@ -26,6 +27,7 @@ export const routes = (
|
||||||
app.route("/youtubei/v1", youtubeApiPlayer);
|
app.route("/youtubei/v1", youtubeApiPlayer);
|
||||||
app.route("/latest_version", invidiousRouteLatestVersion);
|
app.route("/latest_version", invidiousRouteLatestVersion);
|
||||||
app.route("/api/manifest/dash/id", invidiousRouteDashManifest);
|
app.route("/api/manifest/dash/id", invidiousRouteDashManifest);
|
||||||
|
app.route("/api/v1/captions", invidiousCaptionsApi);
|
||||||
app.route("/videoplayback", videoPlaybackProxy);
|
app.route("/videoplayback", videoPlaybackProxy);
|
||||||
app.route("/healthz", health);
|
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