api/rutube: support SRT transcript sources

This commit is contained in:
lmdev 2026-09-04 00:45:34 -04:00
parent 4b17927f90
commit 13431a1501
6 changed files with 66 additions and 10 deletions

View file

@ -61,6 +61,30 @@ export const parseVtt = input => {
return cues;
};
export const parseSrt = input => {
const normalized = input.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
const cues = [];
for (const block of normalized.split(/\n{2,}/)) {
const lines = block.split("\n").map(line => line.trimEnd());
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+/).map(value =>
value.replace(",", ".")
);
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) {
@ -83,14 +107,21 @@ const srtTimestamp = timestamp => {
const escapeMarkdown = value => value.replace(/[\\`*_{}\[\]<>#+.!|~-]/g, "\\$&");
export const convertCaptions = (vtt, format, metadata = {}) => {
if (format === "vtt") {
return vtt.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
export const convertCaptions = (input, format, metadata = {}, sourceFormat = "vtt") => {
if (sourceFormat === "vtt" && format === "vtt") {
return input.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
}
const cues = parseVtt(vtt);
const cues = sourceFormat === "srt" ? parseSrt(input) : parseVtt(input);
if (!cues.length) throw new Error("caption file has no cues");
if (format === "vtt") {
return "WEBVTT\n\n" + cues.map(cue => [
`${cue.start} --> ${cue.end}`,
cue.text,
].join("\n")).join("\n\n") + "\n";
}
if (format === "srt") {
return cues.map((cue, index) => [
index + 1,

View file

@ -12,6 +12,7 @@ export const matchesCaptionLanguage = (language, requested) => {
export const createCaptionResponse = ({
url,
format,
sourceFormat = "vtt",
language,
service,
id,
@ -27,6 +28,7 @@ export const createCaptionResponse = ({
urls: url,
headers,
captionFormat: format,
captionSourceFormat: sourceFormat,
captionLanguage: language || "unknown",
filenameAttributes: {
service,

View file

@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { selectCaptionTrack } from "../captions.js";
import { convertCaptions, parseVtt } from "../caption-formats.js";
import { convertCaptions, parseSrt, parseVtt } from "../caption-formats.js";
import { apiSchema } from "../../schema.js";
const sample = `\uFEFFWEBVTT\r
@ -21,6 +21,15 @@ Hello &amp; welcome back\r
<v Speaker>Final line</v>\r
`;
const sampleSrt = `1\r
00:00:00,000 --> 00:00:02,500\r
Hello &amp; welcome\r
\r
2\r
00:00:02,500 --> 00:00:04,000\r
Final line\r
`;
test("parseVtt extracts and cleans cues", () => {
assert.deepEqual(parseVtt(sample), [
{ start: "00:00.000", end: "00:02.500", text: "Hello & welcome" },
@ -29,6 +38,18 @@ test("parseVtt extracts and cleans cues", () => {
]);
});
test("SRT input converts to transcripts and VTT", () => {
assert.equal(parseSrt(sampleSrt).length, 2);
assert.equal(
convertCaptions(sampleSrt, "txt", {}, "srt"),
"Hello & welcome\n\nFinal line\n"
);
assert.match(
convertCaptions(sampleSrt, "vtt", {}, "srt"),
/^WEBVTT\n\n00:00:00\.000 --> 00:00:02\.500/
);
});
test("plain text collapses growing automatic-caption cues", () => {
assert.equal(
convertCaptions(sample, "txt"),

View file

@ -48,7 +48,7 @@ export default async function(obj) {
if (obj.isCaptionOnly) {
const caption = play.captions?.find(caption =>
caption.format === "webvtt"
["webvtt", "srt"].includes(caption.format)
&& matchesCaptionLanguage(caption.code, obj.captionLanguage)
);
if (!caption) return { error: "fetch.empty" };
@ -56,6 +56,7 @@ export default async function(obj) {
return createCaptionResponse({
url: caption.file,
format: obj.captionFormat,
sourceFormat: caption.format === "webvtt" ? "vtt" : caption.format,
language: caption.code,
service: "rutube",
id: obj.id,

View file

@ -55,7 +55,8 @@ export default async function captions(streamInfo, res) {
const body = convertCaptions(
Buffer.concat(chunks).toString("utf8"),
streamInfo.captionFormat,
streamInfo.captionMetadata
streamInfo.captionMetadata,
streamInfo.captionSourceFormat
);
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");

View file

@ -10,11 +10,11 @@
},
{
"name": "standalone transcript",
"url": "https://rutube.ru/video/b2f6c27649907c2fde0af411b03825eb/",
"canFail": true,
"url": "https://rutube.ru/video/76ee456fa0fe79a8e0013b6110cb0168/",
"params": {
"downloadMode": "captions",
"captionFormat": "txt"
"captionFormat": "txt",
"captionLanguage": "ru"
},
"expected": {
"code": 200,