mirror of
https://github.com/imputnet/cobalt.git
synced 2026-10-24 02:52:18 +00:00
api/youtube: add standalone caption downloads
This commit is contained in:
parent
a636575b09
commit
d63deb94ac
14 changed files with 473 additions and 15 deletions
1
.github/test.sh
vendored
1
.github/test.sh
vendored
|
|
@ -12,6 +12,7 @@ waitport() {
|
|||
}
|
||||
|
||||
test_api() {
|
||||
pnpm run --prefix api test:unit
|
||||
waitport 3000
|
||||
curl -m 3 http://localhost:3000/
|
||||
API_RESPONSE=$(curl -m 10 http://localhost:3000/ \
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"scripts": {
|
||||
"start": "node src/cobalt",
|
||||
"test": "node src/util/test",
|
||||
"test:unit": "node --test src/processing/helpers/tests/*.test.js",
|
||||
"token:jwt": "node src/util/generate-jwt-secret"
|
||||
},
|
||||
"repository": {
|
||||
|
|
|
|||
117
api/src/processing/helpers/caption-formats.js
Normal file
117
api/src/processing/helpers/caption-formats.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
const namedEntities = {
|
||||
amp: "&",
|
||||
apos: "'",
|
||||
gt: ">",
|
||||
lt: "<",
|
||||
nbsp: " ",
|
||||
quot: '"',
|
||||
};
|
||||
|
||||
const decodeEntities = text => text.replace(
|
||||
/&(#(?:x[0-9a-f]+|\d+)|[a-z]+);/gi,
|
||||
(entity, value) => {
|
||||
if (value[0] !== "#") {
|
||||
return namedEntities[value.toLowerCase()] ?? entity;
|
||||
}
|
||||
|
||||
const hex = value[1]?.toLowerCase() === "x";
|
||||
const codePoint = Number.parseInt(value.slice(hex ? 2 : 1), hex ? 16 : 10);
|
||||
try {
|
||||
return String.fromCodePoint(codePoint);
|
||||
} catch {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cleanCueText = text => decodeEntities(
|
||||
text
|
||||
.replace(/<\d{2}:\d{2}(?::\d{2})?\.\d{3}>/g, "")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
).split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
export const parseVtt = input => {
|
||||
const normalized = input.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
||||
const blocks = normalized.split(/\n{2,}/);
|
||||
const cues = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split("\n").map(line => line.trimEnd());
|
||||
if (!lines.length || /^(WEBVTT|NOTE|STYLE|REGION)(?:\s|$)/.test(lines[0])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timingIndex = lines.findIndex(line => line.includes(" --> "));
|
||||
if (timingIndex < 0) continue;
|
||||
|
||||
const timing = lines[timingIndex].match(
|
||||
/^(\d{2}:)?\d{2}:\d{2}\.\d{3}\s+-->\s+(\d{2}:)?\d{2}:\d{2}\.\d{3}/
|
||||
)?.[0];
|
||||
if (!timing) continue;
|
||||
|
||||
const [ start, end ] = timing.split(/\s+-->\s+/);
|
||||
const text = cleanCueText(lines.slice(timingIndex + 1).join("\n"));
|
||||
if (text) cues.push({ start, end, text });
|
||||
}
|
||||
|
||||
return cues;
|
||||
};
|
||||
|
||||
const transcriptLines = cues => {
|
||||
const lines = [];
|
||||
for (const cue of cues) {
|
||||
const text = cue.text.replace(/\s*\n\s*/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!text || lines.at(-1) === text) continue;
|
||||
|
||||
if (lines.length && text.startsWith(`${lines.at(-1)} `)) {
|
||||
lines[lines.length - 1] = text;
|
||||
} else {
|
||||
lines.push(text);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const srtTimestamp = timestamp => {
|
||||
const withHours = timestamp.split(":").length === 2 ? `00:${timestamp}` : timestamp;
|
||||
return withHours.replace(".", ",");
|
||||
};
|
||||
|
||||
const escapeMarkdown = value => value.replace(/[\\`*_{}\[\]<>#+.!|~-]/g, "\\$&");
|
||||
|
||||
export const convertCaptions = (vtt, format, metadata = {}) => {
|
||||
if (format === "vtt") {
|
||||
return vtt.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
||||
}
|
||||
|
||||
const cues = parseVtt(vtt);
|
||||
if (!cues.length) throw new Error("caption file has no cues");
|
||||
|
||||
if (format === "srt") {
|
||||
return cues.map((cue, index) => [
|
||||
index + 1,
|
||||
`${srtTimestamp(cue.start)} --> ${srtTimestamp(cue.end)}`,
|
||||
cue.text,
|
||||
].join("\n")).join("\n\n") + "\n";
|
||||
}
|
||||
|
||||
const transcript = transcriptLines(cues);
|
||||
if (format === "txt") return transcript.join("\n\n") + "\n";
|
||||
|
||||
if (format === "md") {
|
||||
const heading = `# ${escapeMarkdown(metadata.title || "Transcript")}`;
|
||||
const details = [
|
||||
metadata.author && `author: ${escapeMarkdown(metadata.author)}`,
|
||||
metadata.language && `language: ${escapeMarkdown(metadata.language)}`,
|
||||
metadata.source && `source: <${metadata.source}>`,
|
||||
].filter(Boolean);
|
||||
|
||||
return [ heading, ...details, ...transcript.map(escapeMarkdown) ].join("\n\n") + "\n";
|
||||
}
|
||||
|
||||
throw new Error("unsupported caption format");
|
||||
};
|
||||
23
api/src/processing/helpers/captions.js
Normal file
23
api/src/processing/helpers/captions.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const normalizedLanguage = language => language?.toLowerCase().replace("_", "-");
|
||||
|
||||
export const selectCaptionTrack = (tracks, subtitleLang, allowAutomatic = false) => {
|
||||
const allowed = tracks.filter(track => allowAutomatic || track.kind !== "asr");
|
||||
if (!allowed.length) return;
|
||||
|
||||
const requested = normalizedLanguage(subtitleLang);
|
||||
if (!requested) {
|
||||
return allowed.find(track => track.kind !== "asr") || allowed[0];
|
||||
}
|
||||
|
||||
const matching = allowed.filter(track => {
|
||||
const language = normalizedLanguage(track.language_code);
|
||||
const baseLanguage = language?.split("-")[0];
|
||||
const requestedBase = requested.split("-")[0];
|
||||
return language === requested
|
||||
|| language?.startsWith(`${requested}-`)
|
||||
|| requested.startsWith(`${language}-`)
|
||||
|| (allowAutomatic && baseLanguage === requestedBase);
|
||||
});
|
||||
|
||||
return matching.find(track => track.kind !== "asr") || matching[0];
|
||||
};
|
||||
97
api/src/processing/helpers/tests/captions.test.js
Normal file
97
api/src/processing/helpers/tests/captions.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { selectCaptionTrack } from "../captions.js";
|
||||
import { convertCaptions, parseVtt } from "../caption-formats.js";
|
||||
import { apiSchema } from "../../schema.js";
|
||||
|
||||
const sample = `\uFEFFWEBVTT\r
|
||||
\r
|
||||
NOTE generated fixture\r
|
||||
ignored\r
|
||||
\r
|
||||
first cue\r
|
||||
00:00.000 --> 00:02.500 align:start position:0%\r
|
||||
<c.colorE5E5E5>Hello & welcome</c>\r
|
||||
\r
|
||||
00:02.500 --> 00:04.000\r
|
||||
Hello & welcome back\r
|
||||
\r
|
||||
00:04.000 --> 00:06.250\r
|
||||
<v Speaker>Final line</v>\r
|
||||
`;
|
||||
|
||||
test("parseVtt extracts and cleans cues", () => {
|
||||
assert.deepEqual(parseVtt(sample), [
|
||||
{ start: "00:00.000", end: "00:02.500", text: "Hello & welcome" },
|
||||
{ start: "00:02.500", end: "00:04.000", text: "Hello & welcome back" },
|
||||
{ start: "00:04.000", end: "00:06.250", text: "Final line" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("plain text collapses growing automatic-caption cues", () => {
|
||||
assert.equal(
|
||||
convertCaptions(sample, "txt"),
|
||||
"Hello & welcome back\n\nFinal line\n"
|
||||
);
|
||||
});
|
||||
|
||||
test("SRT output numbers cues and converts timestamps", () => {
|
||||
assert.match(convertCaptions(sample, "srt"), /^1\n00:00:00,000 --> 00:00:02,500/);
|
||||
assert.match(convertCaptions(sample, "srt"), /3\n00:00:04,000 --> 00:00:06,250\nFinal line\n$/);
|
||||
});
|
||||
|
||||
test("Markdown output includes escaped metadata and transcript", () => {
|
||||
const markdown = convertCaptions(sample, "md", {
|
||||
title: "A *small* test",
|
||||
author: "cobalt",
|
||||
language: "en",
|
||||
source: "https://www.youtube.com/watch?v=abc",
|
||||
});
|
||||
|
||||
assert.match(markdown, /^# A \\\*small\\\* test/);
|
||||
assert.match(markdown, /language: en/);
|
||||
assert.match(markdown, /source: <https:\/\/www\.youtube\.com\/watch\?v=abc>/);
|
||||
});
|
||||
|
||||
test("VTT output is passed through with normalized newlines", () => {
|
||||
const output = convertCaptions(sample, "vtt");
|
||||
assert.ok(output.startsWith("WEBVTT\n"));
|
||||
assert.ok(!output.includes("\r"));
|
||||
});
|
||||
|
||||
test("caption selection prefers manual tracks and matches base languages", () => {
|
||||
const tracks = [
|
||||
{ language_code: "en-US", kind: "asr" },
|
||||
{ language_code: "en-GB" },
|
||||
{ language_code: "fr" },
|
||||
];
|
||||
|
||||
assert.equal(selectCaptionTrack(tracks, "en", true), tracks[1]);
|
||||
assert.equal(selectCaptionTrack(tracks, "en-AU", true), tracks[1]);
|
||||
assert.equal(selectCaptionTrack(tracks, "en-US", false), undefined);
|
||||
assert.equal(selectCaptionTrack(tracks, undefined, true), tracks[1]);
|
||||
assert.equal(selectCaptionTrack(tracks, "de", true), undefined);
|
||||
});
|
||||
|
||||
test("empty and unknown caption formats fail", () => {
|
||||
assert.throws(() => convertCaptions("WEBVTT\n", "txt"));
|
||||
assert.throws(() => convertCaptions(sample, "json"));
|
||||
});
|
||||
|
||||
test("caption API requests default to plain text and reject unknown formats", () => {
|
||||
const parsed = apiSchema.safeParse({
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
downloadMode: "captions",
|
||||
});
|
||||
|
||||
assert.equal(parsed.success, true);
|
||||
assert.equal(parsed.data.captionFormat, "txt");
|
||||
assert.equal(parsed.data.downloadMode, "captions");
|
||||
|
||||
assert.equal(apiSchema.safeParse({
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
downloadMode: "captions",
|
||||
captionFormat: "json",
|
||||
}).success, false);
|
||||
});
|
||||
|
|
@ -34,12 +34,15 @@ export default function({
|
|||
requestIP,
|
||||
originalRequest: r.originalRequest,
|
||||
subtitles: r.subtitles,
|
||||
captionFormat: r.captionFormat,
|
||||
captionMetadata: !disableMetadata ? r.captionMetadata : {},
|
||||
cover: !disableMetadata ? r.cover : false,
|
||||
cropCover: !disableMetadata ? r.cropCover : false,
|
||||
},
|
||||
params = {};
|
||||
|
||||
if (r.isPhoto) action = "photo";
|
||||
if (r.isCaptionOnly) action = "captions";
|
||||
else if (r.isPhoto) action = "photo";
|
||||
else if (r.picker) action = "picker"
|
||||
else if (r.isGif && convertGif) action = "gif";
|
||||
else if (isAudioOnly) action = "audio";
|
||||
|
|
@ -70,6 +73,37 @@ export default function({
|
|||
params = { type: "proxy" };
|
||||
break;
|
||||
|
||||
case "captions": {
|
||||
const extension = r.captionFormat;
|
||||
const language = r.captionLanguage;
|
||||
const baseName = createFilename(
|
||||
{
|
||||
...r.filenameAttributes,
|
||||
extension,
|
||||
qualityLabel: language,
|
||||
},
|
||||
filenameStyle,
|
||||
false,
|
||||
false
|
||||
);
|
||||
const textName = createFilename(
|
||||
{
|
||||
...r.filenameAttributes,
|
||||
extension: "txt",
|
||||
qualityLabel: language,
|
||||
},
|
||||
filenameStyle,
|
||||
false,
|
||||
false
|
||||
);
|
||||
params = {
|
||||
type: "captions",
|
||||
filename: baseName,
|
||||
captionTextFilename: textName,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "gif":
|
||||
params = { type: "gif" };
|
||||
break;
|
||||
|
|
@ -252,7 +286,7 @@ export default function({
|
|||
|
||||
// TODO: add support for HLS
|
||||
// (very painful)
|
||||
if (!params.isHLS && responseType !== "picker") {
|
||||
if (action !== "captions" && !params.isHLS && responseType !== "picker") {
|
||||
const isPreferredWithExtra =
|
||||
localProcessing === "preferred" && extraProcessingTypes.has(params.type);
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ export default async function({ host, patternMatch, params, authType }) {
|
|||
try {
|
||||
let r,
|
||||
isAudioOnly = params.downloadMode === "audio",
|
||||
isAudioMuted = params.downloadMode === "mute";
|
||||
isAudioMuted = params.downloadMode === "mute",
|
||||
isCaptionOnly = params.downloadMode === "captions";
|
||||
|
||||
if (!testers[host]) {
|
||||
return createResponse("error", {
|
||||
|
|
@ -65,6 +66,12 @@ export default async function({ host, patternMatch, params, authType }) {
|
|||
});
|
||||
}
|
||||
|
||||
if (isCaptionOnly && host !== "youtube") {
|
||||
return createResponse("error", {
|
||||
code: "error.api.service.captions_not_supported"
|
||||
});
|
||||
}
|
||||
|
||||
// youtubeHLS will be fully removed in the future
|
||||
let youtubeHLS = params.youtubeHLS;
|
||||
const hlsEnv = env.enableDeprecatedYoutubeHls;
|
||||
|
|
@ -121,9 +128,12 @@ export default async function({ host, patternMatch, params, authType }) {
|
|||
dubLang: params.youtubeDubLang,
|
||||
youtubeHLS,
|
||||
subtitleLang,
|
||||
isCaptionOnly,
|
||||
captionLanguage: params.captionLanguage,
|
||||
captionFormat: params.captionFormat,
|
||||
}
|
||||
|
||||
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
||||
if (!isCaptionOnly && (url.hostname === "music.youtube.com" || isAudioOnly)) {
|
||||
fetchInfo.quality = "1080";
|
||||
fetchInfo.codec = "vp9";
|
||||
fetchInfo.isAudioOnly = true;
|
||||
|
|
@ -284,7 +294,7 @@ export default async function({ host, patternMatch, params, authType }) {
|
|||
}
|
||||
|
||||
if (r.error) {
|
||||
let context;
|
||||
let context = r.context;
|
||||
switch(r.error) {
|
||||
case "content.too_long":
|
||||
context = {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,15 @@ export function createResponse(responseType, responseData) {
|
|||
url: createStream(responseData),
|
||||
filename: responseData?.filename
|
||||
}
|
||||
if (responseData?.type === "captions") {
|
||||
response.text = responseData.captionFormat === "txt"
|
||||
? response.url
|
||||
: createStream({
|
||||
...responseData,
|
||||
captionFormat: "txt",
|
||||
filename: responseData.captionTextFilename,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "local-processing":
|
||||
|
|
|
|||
|
|
@ -15,9 +15,13 @@ export const apiSchema = z.object({
|
|||
).default("mp3"),
|
||||
|
||||
downloadMode: z.enum(
|
||||
["auto", "audio", "mute"]
|
||||
["auto", "audio", "mute", "captions"]
|
||||
).default("auto"),
|
||||
|
||||
captionFormat: z.enum(
|
||||
["txt", "vtt", "srt", "md"]
|
||||
).default("txt"),
|
||||
|
||||
filenameStyle: z.enum(
|
||||
["classic", "pretty", "basic", "nerdy"]
|
||||
).default("basic"),
|
||||
|
|
@ -50,6 +54,12 @@ export const apiSchema = z.object({
|
|||
.regex(/^[0-9a-zA-Z\-]+$/)
|
||||
.optional(),
|
||||
|
||||
captionLanguage: z.string()
|
||||
.min(2)
|
||||
.max(8)
|
||||
.regex(/^[0-9a-zA-Z\-]+$/)
|
||||
.optional(),
|
||||
|
||||
disableMetadata: z.boolean().default(false),
|
||||
|
||||
allowH265: z.boolean().default(false),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Innertube, Platform, Session } from "youtubei.js";
|
|||
import { env } from "../../config.js";
|
||||
import { getCookie } from "../cookie/manager.js";
|
||||
import { getYouTubeSession } from "../helpers/youtube-session.js";
|
||||
import { selectCaptionTrack } from "../helpers/captions.js";
|
||||
|
||||
// https://github.com/LuanRT/YouTube.js/pull/1052
|
||||
Platform.shim.eval = async (data) => {
|
||||
|
|
@ -135,9 +136,18 @@ const getHlsVariants = async (hlsManifest, dispatcher) => {
|
|||
return variants;
|
||||
}
|
||||
|
||||
const getSubtitles = async (info, dispatcher, subtitleLang) => {
|
||||
const preferredCap = info.captions.caption_tracks.find(caption =>
|
||||
caption.kind !== 'asr' && caption.language_code.startsWith(subtitleLang)
|
||||
const normalizedLanguage = language => language?.toLowerCase().replace("_", "-");
|
||||
|
||||
const getSubtitles = async (
|
||||
info,
|
||||
dispatcher,
|
||||
subtitleLang,
|
||||
allowAutomatic = false
|
||||
) => {
|
||||
const preferredCap = selectCaptionTrack(
|
||||
info.captions.caption_tracks,
|
||||
subtitleLang,
|
||||
allowAutomatic
|
||||
);
|
||||
|
||||
const captionsUrl = preferredCap?.base_url;
|
||||
|
|
@ -167,8 +177,9 @@ const getSubtitles = async (info, dispatcher, subtitleLang) => {
|
|||
const hlsSubtitles = hlsVariants[0]?.subtitles;
|
||||
if (!hlsSubtitles?.length) return;
|
||||
|
||||
const preferredHls = hlsSubtitles.find(
|
||||
subtitle => subtitle.language.startsWith(subtitleLang)
|
||||
const preferredHls = hlsSubtitles.find(subtitle =>
|
||||
normalizedLanguage(subtitle.language)
|
||||
?.startsWith(normalizedLanguage(preferredCap.language_code))
|
||||
);
|
||||
|
||||
if (!preferredHls) return;
|
||||
|
|
@ -217,7 +228,7 @@ export default async function (o) {
|
|||
);
|
||||
|
||||
// we can get subtitles reliably only from the iOS client
|
||||
if (o.subtitleLang) {
|
||||
if (o.subtitleLang || o.isCaptionOnly) {
|
||||
innertubeClient = "IOS";
|
||||
useSession = false;
|
||||
}
|
||||
|
|
@ -324,7 +335,7 @@ export default async function (o) {
|
|||
return { error: "content.video.live" };
|
||||
}
|
||||
|
||||
if (basicInfo.duration > env.durationLimit) {
|
||||
if (!o.isCaptionOnly && basicInfo.duration > env.durationLimit) {
|
||||
return { error: "content.too_long" };
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +348,57 @@ export default async function (o) {
|
|||
}
|
||||
}
|
||||
|
||||
if (o.isCaptionOnly) {
|
||||
const captionTracks = info.captions?.caption_tracks || [];
|
||||
if (!captionTracks.length) {
|
||||
return { error: "youtube.captions_unavailable" };
|
||||
}
|
||||
|
||||
const selectedTrack = selectCaptionTrack(
|
||||
captionTracks,
|
||||
o.captionLanguage || o.subtitleLang,
|
||||
true
|
||||
);
|
||||
if (!selectedTrack) {
|
||||
return {
|
||||
error: "youtube.caption_language_unavailable",
|
||||
context: {
|
||||
languages: [...new Set(captionTracks.map(track => track.language_code))],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const captions = await getSubtitles(
|
||||
info,
|
||||
o.dispatcher,
|
||||
selectedTrack.language_code,
|
||||
true
|
||||
);
|
||||
if (!captions?.url) {
|
||||
return { error: "youtube.captions_unavailable" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: "captions",
|
||||
isCaptionOnly: true,
|
||||
urls: captions.url,
|
||||
captionFormat: o.captionFormat,
|
||||
captionLanguage: captions.language,
|
||||
filenameAttributes: {
|
||||
service: "youtube",
|
||||
id: o.id,
|
||||
title: basicInfo.title.trim(),
|
||||
author: basicInfo.author.replace("- Topic", "").trim(),
|
||||
},
|
||||
captionMetadata: {
|
||||
title: basicInfo.title.trim(),
|
||||
author: basicInfo.author.replace("- Topic", "").trim(),
|
||||
language: captions.language,
|
||||
source: `https://www.youtube.com/watch?v=${o.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const normalizeQuality = res => {
|
||||
const shortestSide = Math.min(res.height, res.width);
|
||||
return videoQualities.find(qual => qual >= shortestSide);
|
||||
|
|
|
|||
69
api/src/stream/captions.js
Normal file
69
api/src/stream/captions.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Agent, request } from "undici";
|
||||
import { create as contentDisposition } from "content-disposition-header";
|
||||
|
||||
import { closeRequest, closeResponse } from "./shared.js";
|
||||
import { destroyInternalStream } from "./manage.js";
|
||||
import { convertCaptions } from "../processing/helpers/caption-formats.js";
|
||||
|
||||
const defaultAgent = new Agent();
|
||||
const maxCaptionSize = 8 * 1024 * 1024;
|
||||
|
||||
const contentTypes = {
|
||||
md: "text/markdown; charset=utf-8",
|
||||
srt: "application/x-subrip; charset=utf-8",
|
||||
txt: "text/plain; charset=utf-8",
|
||||
vtt: "text/vtt; charset=utf-8",
|
||||
};
|
||||
|
||||
export default async function captions(streamInfo, res) {
|
||||
const abortController = new AbortController();
|
||||
const shutdown = () => (
|
||||
closeRequest(abortController),
|
||||
closeResponse(res),
|
||||
destroyInternalStream(streamInfo.urls)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await request(streamInfo.urls, {
|
||||
signal: abortController.signal,
|
||||
maxRedirections: 4,
|
||||
dispatcher: defaultAgent,
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode > 299) {
|
||||
return shutdown();
|
||||
}
|
||||
|
||||
const declaredSize = Number(response.headers["content-length"]);
|
||||
if (declaredSize > maxCaptionSize) {
|
||||
res.status(413).end();
|
||||
return shutdown();
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of response.body) {
|
||||
size += chunk.length;
|
||||
if (size > maxCaptionSize) {
|
||||
res.status(413).end();
|
||||
return shutdown();
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const body = convertCaptions(
|
||||
Buffer.concat(chunks).toString("utf8"),
|
||||
streamInfo.captionFormat,
|
||||
streamInfo.captionMetadata
|
||||
);
|
||||
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Content-Disposition", contentDisposition(streamInfo.filename));
|
||||
res.setHeader("Content-Type", contentTypes[streamInfo.captionFormat]);
|
||||
res.setHeader("Content-Length", Buffer.byteLength(body));
|
||||
res.status(200).send(body);
|
||||
destroyInternalStream(streamInfo.urls);
|
||||
} catch {
|
||||
shutdown();
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,9 @@ export function createStream(obj) {
|
|||
|
||||
// url to a subtitle file
|
||||
subtitles: obj.subtitles,
|
||||
|
||||
captionFormat: obj.captionFormat,
|
||||
captionMetadata: obj.captionMetadata,
|
||||
};
|
||||
|
||||
// FIXME: this is now a Promise, but it is not awaited
|
||||
|
|
@ -264,7 +267,11 @@ function wrapStream(streamInfo) {
|
|||
}
|
||||
|
||||
if (typeof url === 'string') {
|
||||
streamInfo.urls = createInternalStream(url, streamInfo);
|
||||
streamInfo.urls = createInternalStream(
|
||||
url,
|
||||
streamInfo,
|
||||
streamInfo.type === "captions"
|
||||
);
|
||||
} else if (Array.isArray(url)) {
|
||||
for (const idx in streamInfo.urls) {
|
||||
streamInfo.urls[idx] = createInternalStream(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import proxy from "./proxy.js";
|
||||
import ffmpeg from "./ffmpeg.js";
|
||||
import captions from "./captions.js";
|
||||
|
||||
import { closeResponse } from "./shared.js";
|
||||
import { internalStream } from "./internal.js";
|
||||
|
|
@ -10,6 +11,9 @@ export default async function(res, streamInfo) {
|
|||
case "proxy":
|
||||
return await proxy(streamInfo, res);
|
||||
|
||||
case "captions":
|
||||
return await captions(streamInfo, res);
|
||||
|
||||
case "internal":
|
||||
return await internalStream(streamInfo.data, res);
|
||||
|
||||
|
|
|
|||
|
|
@ -240,5 +240,19 @@
|
|||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "standalone English transcript",
|
||||
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
|
||||
"canFail": true,
|
||||
"params": {
|
||||
"downloadMode": "captions",
|
||||
"captionFormat": "txt",
|
||||
"subtitleLang": "en"
|
||||
},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue