From 13431a1501c206b1d4e745f7ebff7eb7b20a6a17 Mon Sep 17 00:00:00 2001 From: lmdev <138343225+lmdevv@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:45:34 -0400 Subject: [PATCH] api/rutube: support SRT transcript sources --- api/src/processing/helpers/caption-formats.js | 39 +++++++++++++++++-- api/src/processing/helpers/captions.js | 2 + .../processing/helpers/tests/captions.test.js | 23 ++++++++++- api/src/processing/services/rutube.js | 3 +- api/src/stream/captions.js | 3 +- api/src/util/tests/rutube.json | 6 +-- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/api/src/processing/helpers/caption-formats.js b/api/src/processing/helpers/caption-formats.js index ee80787e..a753c5b0 100644 --- a/api/src/processing/helpers/caption-formats.js +++ b/api/src/processing/helpers/caption-formats.js @@ -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, diff --git a/api/src/processing/helpers/captions.js b/api/src/processing/helpers/captions.js index 94edef87..370429cc 100644 --- a/api/src/processing/helpers/captions.js +++ b/api/src/processing/helpers/captions.js @@ -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, diff --git a/api/src/processing/helpers/tests/captions.test.js b/api/src/processing/helpers/tests/captions.test.js index 65c62bda..1ff32e88 100644 --- a/api/src/processing/helpers/tests/captions.test.js +++ b/api/src/processing/helpers/tests/captions.test.js @@ -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 & welcome back\r Final line\r `; +const sampleSrt = `1\r +00:00:00,000 --> 00:00:02,500\r +Hello & 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"), diff --git a/api/src/processing/services/rutube.js b/api/src/processing/services/rutube.js index bdf3846f..96139ab4 100644 --- a/api/src/processing/services/rutube.js +++ b/api/src/processing/services/rutube.js @@ -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, diff --git a/api/src/stream/captions.js b/api/src/stream/captions.js index 02251d14..a137985c 100644 --- a/api/src/stream/captions.js +++ b/api/src/stream/captions.js @@ -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"); diff --git a/api/src/util/tests/rutube.json b/api/src/util/tests/rutube.json index 41370448..38173df7 100644 --- a/api/src/util/tests/rutube.json +++ b/api/src/util/tests/rutube.json @@ -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,