mirror of
https://github.com/imputnet/cobalt.git
synced 2026-10-24 19:11:16 +00:00
api & web/captions: unify transcript downloads
This commit is contained in:
parent
13431a1501
commit
99bcc3ad7b
29 changed files with 548 additions and 254 deletions
|
|
@ -85,17 +85,134 @@ export const parseSrt = input => {
|
|||
return cues;
|
||||
};
|
||||
|
||||
const timestampSeconds = timestamp => timestamp.split(":").reduce(
|
||||
(seconds, part) => seconds * 60 + Number(part), 0
|
||||
);
|
||||
|
||||
const vttTimestamp = seconds => {
|
||||
const milliseconds = Math.round(seconds * 1000);
|
||||
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
|
||||
throw new Error("invalid caption timestamp");
|
||||
}
|
||||
const hours = Math.floor(milliseconds / 3600000);
|
||||
const minutes = Math.floor(milliseconds / 60000) % 60;
|
||||
const remaining = milliseconds % 60000;
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:`
|
||||
+ `${(remaining / 1000).toFixed(3).padStart(6, "0")}`;
|
||||
};
|
||||
|
||||
// HLS repeats boundary cues and may use a different local clock per segment.
|
||||
// Keep the first segment's timeline while applying subsequent timestamp maps.
|
||||
// https://www.rfc-editor.org/rfc/rfc8216#section-3.5
|
||||
export const mergeCaptionSegments = segments => {
|
||||
const blocks = new Set();
|
||||
const metadata = new Set();
|
||||
const clockWrap = 2 ** 33;
|
||||
let firstOffset, previousMpeg;
|
||||
let adjustment;
|
||||
let wraps = 0;
|
||||
|
||||
for (const segment of segments) {
|
||||
const normalized = segment.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
||||
const hasHeader = /^WEBVTT(?:\s|$)/.test(normalized);
|
||||
if (hasHeader) {
|
||||
const header = normalized.split(/\n{2,}/)[0];
|
||||
const map = header.match(/^X-TIMESTAMP-MAP=(.+)$/m)?.[1];
|
||||
const local = map?.match(/LOCAL:([\d:.]+)/)?.[1];
|
||||
const mpeg = map?.match(/MPEGTS:(\d+)/)?.[1];
|
||||
if (map && (!local || !mpeg)) throw new Error("invalid caption timestamp map");
|
||||
const timestamp = Number(mpeg);
|
||||
if (map && previousMpeg !== undefined
|
||||
&& timestamp - previousMpeg < -clockWrap / 2) wraps++;
|
||||
if (map) previousMpeg = timestamp;
|
||||
const offset = map
|
||||
? (timestamp + wraps * clockWrap) / 90000 - timestampSeconds(local)
|
||||
: 0;
|
||||
firstOffset ??= offset;
|
||||
adjustment = offset - firstOffset;
|
||||
} else if (adjustment === undefined) {
|
||||
throw new Error("invalid VTT segment");
|
||||
}
|
||||
|
||||
const segmentBlocks = normalized.split(/\n{2,}/).slice(hasHeader ? 1 : 0);
|
||||
for (const block of segmentBlocks) {
|
||||
if (/^(STYLE|REGION)(?:\s|$)/.test(block)) {
|
||||
metadata.add(block.trimEnd());
|
||||
continue;
|
||||
}
|
||||
if (/^NOTE(?:\s|$)/.test(block)) continue;
|
||||
const lines = block.trimEnd().split("\n");
|
||||
const timingIndex = lines.findIndex(line => line.includes(" --> "));
|
||||
if (timingIndex < 0) continue;
|
||||
const timing = lines[timingIndex].match(/^(\S+)\s+-->\s+(\S+)(.*)$/);
|
||||
if (!timing) throw new Error("invalid caption cue");
|
||||
const start = vttTimestamp(timestampSeconds(timing[1]) + adjustment);
|
||||
const end = vttTimestamp(timestampSeconds(timing[2]) + adjustment);
|
||||
// Identifiers can change across segments; timing and payload identify a cue.
|
||||
blocks.add([
|
||||
`${start} --> ${end}${timing[3]}`,
|
||||
...lines.slice(timingIndex + 1),
|
||||
].join("\n"));
|
||||
}
|
||||
}
|
||||
return `WEBVTT\n\n${[...metadata, ...blocks].join("\n\n")}\n`;
|
||||
};
|
||||
|
||||
const collapseRollingCues = cues => {
|
||||
const result = [];
|
||||
let run = [], rows = [], rolling = false;
|
||||
const flush = () => {
|
||||
if (rolling) {
|
||||
result.push({ ...run[0], end: run.at(-1).end, text: rows.join("\n") });
|
||||
} else {
|
||||
for (const cue of run) result.push(cue);
|
||||
}
|
||||
run = [];
|
||||
rows = [];
|
||||
rolling = false;
|
||||
};
|
||||
|
||||
for (const cue of cues) {
|
||||
const nextRows = cue.text.split("\n");
|
||||
const previous = run.at(-1);
|
||||
let shared = 0;
|
||||
if (previous && timestampSeconds(cue.start) >= timestampSeconds(previous.start)
|
||||
&& timestampSeconds(cue.start) <= timestampSeconds(previous.end)) {
|
||||
const previousRows = previous.text.split("\n");
|
||||
for (let count = Math.min(previousRows.length, nextRows.length); count > 0; count--) {
|
||||
if (previousRows.slice(-count).every((row, index) => row === nextRows[index])) {
|
||||
shared = count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A changing multiline window distinguishes rolling text from repeated speech.
|
||||
if (shared && previous.text !== cue.text
|
||||
&& (previousRows.length > 1 || nextRows.length > 1)) rolling = true;
|
||||
}
|
||||
if (!shared) flush();
|
||||
for (const row of nextRows.slice(shared)) rows.push(row);
|
||||
run.push(cue);
|
||||
}
|
||||
flush();
|
||||
return result;
|
||||
};
|
||||
|
||||
const transcriptLines = cues => {
|
||||
const lines = [];
|
||||
for (const cue of cues) {
|
||||
let previous;
|
||||
for (const cue of collapseRollingCues(cues)) {
|
||||
const text = cue.text.replace(/\s*\n\s*/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!text || lines.at(-1) === text) continue;
|
||||
if (!text) continue;
|
||||
|
||||
if (lines.length && text.startsWith(`${lines.at(-1)} `)) {
|
||||
const overlaps = previous
|
||||
&& timestampSeconds(cue.start) >= timestampSeconds(previous.start)
|
||||
&& timestampSeconds(cue.start) < timestampSeconds(previous.end);
|
||||
if (overlaps && (text === lines.at(-1) || text.startsWith(`${lines.at(-1)} `))) {
|
||||
lines[lines.length - 1] = text;
|
||||
} else {
|
||||
lines.push(text);
|
||||
}
|
||||
previous = cue;
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
|
@ -108,17 +225,18 @@ const srtTimestamp = timestamp => {
|
|||
const escapeMarkdown = value => value.replace(/[\\`*_{}\[\]<>#+.!|~-]/g, "\\$&");
|
||||
|
||||
export const convertCaptions = (input, format, metadata = {}, sourceFormat = "vtt") => {
|
||||
if (!["vtt", "srt"].includes(sourceFormat)) throw new Error("unsupported caption source format");
|
||||
const cues = sourceFormat === "srt" ? parseSrt(input) : parseVtt(input);
|
||||
if (!cues.length) throw new Error("caption file has no cues");
|
||||
|
||||
if (sourceFormat === "vtt" && format === "vtt") {
|
||||
return input.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
||||
}
|
||||
|
||||
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,
|
||||
cue.text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"),
|
||||
].join("\n")).join("\n\n") + "\n";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,50 @@
|
|||
const normalizedLanguage = language => language?.toLowerCase().replace("_", "-");
|
||||
import { convertLanguageCode } from "../../misc/language-codes.js";
|
||||
|
||||
export const matchesCaptionLanguage = (language, requested) => {
|
||||
if (!requested || !language) return true;
|
||||
// Services use both ISO 639-1 and ISO 639-2, sometimes with a script or region.
|
||||
export const normalizeCaptionLanguage = language => {
|
||||
if (!language) return;
|
||||
const [base, ...parts] = language.replaceAll("_", "-").split("-");
|
||||
const normalized = [
|
||||
base.length === 3 ? convertLanguageCode(base) || base : base,
|
||||
...parts,
|
||||
].join("-");
|
||||
try {
|
||||
return new Intl.Locale(normalized).toString();
|
||||
} catch {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
const normalized = normalizedLanguage(language);
|
||||
const normalizedRequested = normalizedLanguage(requested);
|
||||
return normalized === normalizedRequested
|
||||
|| normalized?.split("-")[0] === normalizedRequested?.split("-")[0];
|
||||
const languageRank = (language, requested) => {
|
||||
if (!requested) return 0;
|
||||
if (!language) return Infinity;
|
||||
if (language === requested) return 0;
|
||||
|
||||
try {
|
||||
const track = new Intl.Locale(language);
|
||||
const target = new Intl.Locale(requested);
|
||||
if (track.language !== target.language) return Infinity;
|
||||
// Bare languages accept any script. Explicit scripts or regions must
|
||||
// not fall back to a different writing system, e.g. zh-Hant -> zh-Hans.
|
||||
if ((target.script || target.region)
|
||||
&& track.maximize().script !== target.maximize().script) return Infinity;
|
||||
return 1;
|
||||
} catch {
|
||||
return Infinity;
|
||||
}
|
||||
};
|
||||
|
||||
export const captionSelectionError = tracks => {
|
||||
const available = tracks.filter(track => track.url);
|
||||
if (!available.length) return { error: "captions.unavailable" };
|
||||
return {
|
||||
error: "captions.language_unavailable",
|
||||
context: {
|
||||
languages: [...new Set(available.map(track =>
|
||||
normalizeCaptionLanguage(track.language)
|
||||
).filter(Boolean))],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createCaptionResponse = ({
|
||||
|
|
@ -45,24 +83,50 @@ export const createCaptionResponse = ({
|
|||
};
|
||||
};
|
||||
|
||||
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 captionResource = (resource, playlistURL) => {
|
||||
const output = {
|
||||
url: new URL(resource.uri, playlistURL).toString(),
|
||||
};
|
||||
if (resource.byterange) {
|
||||
const { offset, length } = resource.byterange;
|
||||
output.headers = { range: `bytes=${offset}-${offset + length - 1}` };
|
||||
}
|
||||
|
||||
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];
|
||||
return output;
|
||||
};
|
||||
|
||||
export const captionSegmentResources = (segments, playlistURL) => {
|
||||
const resources = [];
|
||||
let previousMap;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.map) {
|
||||
const map = captionResource(segment.map, playlistURL);
|
||||
const mapKey = `${map.url}:${map.headers?.range || ""}`;
|
||||
if (mapKey !== previousMap) {
|
||||
resources.push(map);
|
||||
previousMap = mapKey;
|
||||
}
|
||||
} else {
|
||||
previousMap = undefined;
|
||||
}
|
||||
resources.push(captionResource(segment, playlistURL));
|
||||
}
|
||||
return resources;
|
||||
};
|
||||
|
||||
// Tracks share { url, language, automatic, sourceFormat }; adapters may add fields.
|
||||
// Preserve source order when language rank and manual/automatic status are equal.
|
||||
export const selectCaptionTrack = (tracks, language, allowAutomatic = true) => {
|
||||
const requested = normalizeCaptionLanguage(language);
|
||||
let selected, bestRank = Infinity;
|
||||
for (const track of tracks) {
|
||||
if (!track.url || (!allowAutomatic && track.automatic)) continue;
|
||||
const rank = languageRank(normalizeCaptionLanguage(track.language), requested) * 2
|
||||
+ Number(!!track.automatic);
|
||||
if (rank < bestRank) {
|
||||
selected = track;
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { selectCaptionTrack } from "../captions.js";
|
||||
import { convertCaptions, parseSrt, parseVtt } from "../caption-formats.js";
|
||||
import { captionSegmentResources, selectCaptionTrack } from "../captions.js";
|
||||
import { convertCaptions, mergeCaptionSegments, parseSrt, parseVtt } from "../caption-formats.js";
|
||||
import { apiSchema } from "../../schema.js";
|
||||
|
||||
const sample = `\uFEFFWEBVTT\r
|
||||
|
|
@ -52,7 +52,7 @@ test("SRT input converts to transcripts and VTT", () => {
|
|||
|
||||
test("plain text collapses growing automatic-caption cues", () => {
|
||||
assert.equal(
|
||||
convertCaptions(sample, "txt"),
|
||||
convertCaptions(sample.replace("00:02.500 -->", "00:01.500 -->"), "txt"),
|
||||
"Hello & welcome back\n\nFinal line\n"
|
||||
);
|
||||
});
|
||||
|
|
@ -83,14 +83,14 @@ test("VTT output is passed through with normalized newlines", () => {
|
|||
|
||||
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" },
|
||||
{ url: "auto", language: "en-US", automatic: true },
|
||||
{ url: "manual", language: "en-GB" },
|
||||
{ url: "french", language: "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, "en-US", false), tracks[1]);
|
||||
assert.equal(selectCaptionTrack(tracks, undefined, true), tracks[1]);
|
||||
assert.equal(selectCaptionTrack(tracks, "de", true), undefined);
|
||||
});
|
||||
|
|
@ -116,3 +116,61 @@ test("caption API requests default to plain text and reject unknown formats", ()
|
|||
captionFormat: "json",
|
||||
}).success, false);
|
||||
});
|
||||
|
||||
test("adjoining rolling windows collapse but separate repeated speech survives", () => {
|
||||
const input = "WEBVTT\n\n"
|
||||
+ "00:00.000 --> 00:00.010\nA new game\n\n"
|
||||
+ "00:00.010 --> 00:02.000\nA new game\n\n"
|
||||
+ "00:02.000 --> 00:04.000\nA new game\nwith a large world\n\n"
|
||||
+ "00:04.000 --> 00:06.000\nwith a large world\n\n"
|
||||
+ "00:10.000 --> 00:11.000\nNo.\n\n"
|
||||
+ "00:11.000 --> 00:12.000\nNo.\n";
|
||||
assert.equal(convertCaptions(input, "txt"), "A new game with a large world\n\nNo.\n\nNo.\n");
|
||||
assert.equal(convertCaptions(input, "md"), "# Transcript\n\nA new game with a large world\n\nNo\\.\n\nNo\\.\n");
|
||||
assert.equal(parseVtt(convertCaptions(input, "vtt")).length, 6);
|
||||
assert.equal(parseSrt(convertCaptions(input, "srt")).length, 6);
|
||||
});
|
||||
|
||||
test("HLS merging preserves cue settings and markup and removes repeated boundary cues", () => {
|
||||
const cue = "00:00:09.000 --> 00:00:12.000 align:start\n<v Speaker>Hello & welcome</v>";
|
||||
const merged = mergeCaptionSegments([
|
||||
`WEBVTT\n\nSTYLE\n::cue { color: white; }\n\nfirst-id\n${cue}\n`,
|
||||
`WEBVTT\n\nnew-id\n${cue}\n\n00:00:12.000 --> 00:00:14.000\nSecond segment\n`,
|
||||
"WEBVTT\n",
|
||||
]);
|
||||
assert.equal(parseVtt(merged).length, 2);
|
||||
assert.ok(merged.includes(cue));
|
||||
assert.ok(merged.includes("STYLE\n::cue { color: white; }"));
|
||||
assert.equal(convertCaptions(merged, "txt"), "Hello & welcome\n\nSecond segment\n");
|
||||
});
|
||||
|
||||
test("HLS merging accepts media fragments after an initialization section", () => {
|
||||
const merged = mergeCaptionSegments([
|
||||
"WEBVTT\nX-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000\n",
|
||||
"00:00:00.000 --> 00:00:02.000\nFirst fragment\n",
|
||||
"00:00:02.000 --> 00:00:04.000\nSecond fragment\n",
|
||||
]);
|
||||
assert.equal(convertCaptions(merged, "txt"), "First fragment\n\nSecond fragment\n");
|
||||
});
|
||||
|
||||
test("HLS resources include changed initialization sections and byte ranges", () => {
|
||||
const map = { uri: "captions.vtt", byterange: { offset: 0, length: 100 } };
|
||||
const resources = captionSegmentResources([
|
||||
{ uri: "captions.vtt", byterange: { offset: 100, length: 50 }, map },
|
||||
{ uri: "captions.vtt", byterange: { offset: 150, length: 60 }, map },
|
||||
{
|
||||
uri: "captions.vtt",
|
||||
byterange: { offset: 310, length: 70 },
|
||||
map: { uri: "captions.vtt", byterange: { offset: 210, length: 100 } },
|
||||
},
|
||||
], "https://example.com/path/playlist.m3u8");
|
||||
|
||||
assert.deepEqual(resources.map(resource => resource.headers.range), [
|
||||
"bytes=0-99",
|
||||
"bytes=100-149",
|
||||
"bytes=150-209",
|
||||
"bytes=210-309",
|
||||
"bytes=310-379",
|
||||
]);
|
||||
assert.ok(resources.every(resource => resource.url === "https://example.com/path/captions.vtt"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default function({
|
|||
originalRequest: r.originalRequest,
|
||||
subtitles: r.subtitles,
|
||||
captionFormat: r.captionFormat,
|
||||
captionSourceFormat: r.captionSourceFormat,
|
||||
captionMetadata: !disableMetadata ? r.captionMetadata : {},
|
||||
cover: !disableMetadata ? r.cover : false,
|
||||
cropCover: !disableMetadata ? r.cropCover : false,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { genericUserAgent } from "../../config.js";
|
||||
import {
|
||||
createCaptionResponse,
|
||||
matchesCaptionLanguage,
|
||||
selectCaptionTrack,
|
||||
captionSelectionError,
|
||||
} from "../helpers/captions.js";
|
||||
|
||||
const craftHeaders = id => ({
|
||||
|
|
@ -102,8 +103,9 @@ export default async function({
|
|||
}) {
|
||||
if (isCaptionOnly) {
|
||||
const transcript = await getTranscript(id);
|
||||
if (!transcript || !matchesCaptionLanguage(transcript.language, captionLanguage)) {
|
||||
return { error: "fetch.empty" };
|
||||
const tracks = transcript ? [transcript] : [];
|
||||
if (!selectCaptionTrack(tracks, captionLanguage)) {
|
||||
return captionSelectionError(tracks);
|
||||
}
|
||||
|
||||
return createCaptionResponse({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import HLS from "hls-parser";
|
|||
import { env } from "../../config.js";
|
||||
import {
|
||||
createCaptionResponse,
|
||||
matchesCaptionLanguage,
|
||||
selectCaptionTrack,
|
||||
captionSelectionError,
|
||||
} from "../helpers/captions.js";
|
||||
|
||||
async function requestJSON(url) {
|
||||
|
|
@ -16,7 +17,7 @@ const delta = (a, b) => Math.abs(a - b);
|
|||
|
||||
export default async function(obj) {
|
||||
if (obj.yappyId) {
|
||||
if (obj.isCaptionOnly) return { error: "fetch.empty" };
|
||||
if (obj.isCaptionOnly) return captionSelectionError([]);
|
||||
|
||||
const yappy = await requestJSON(
|
||||
`https://rutube.ru/pangolin/api/web/yappy/v4/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=1`
|
||||
|
|
@ -47,17 +48,21 @@ export default async function(obj) {
|
|||
if (play.live_streams?.hls) return { error: "content.video.live" };
|
||||
|
||||
if (obj.isCaptionOnly) {
|
||||
const caption = play.captions?.find(caption =>
|
||||
["webvtt", "srt"].includes(caption.format)
|
||||
&& matchesCaptionLanguage(caption.code, obj.captionLanguage)
|
||||
);
|
||||
if (!caption) return { error: "fetch.empty" };
|
||||
const tracks = (play.captions || [])
|
||||
.filter(caption => ["webvtt", "srt"].includes(caption.format))
|
||||
.map(caption => ({
|
||||
url: caption.file,
|
||||
language: caption.code,
|
||||
sourceFormat: caption.format === "webvtt" ? "vtt" : "srt",
|
||||
}));
|
||||
const caption = selectCaptionTrack(tracks, obj.captionLanguage);
|
||||
if (!caption) return captionSelectionError(tracks);
|
||||
|
||||
return createCaptionResponse({
|
||||
url: caption.file,
|
||||
url: caption.url,
|
||||
format: obj.captionFormat,
|
||||
sourceFormat: caption.format === "webvtt" ? "vtt" : caption.format,
|
||||
language: caption.code,
|
||||
sourceFormat: caption.sourceFormat,
|
||||
language: caption.language,
|
||||
service: "rutube",
|
||||
id: obj.id,
|
||||
title: play.title?.trim(),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { createStream } from "../../stream/manage.js";
|
|||
import { convertLanguageCode } from "../../misc/language-codes.js";
|
||||
import {
|
||||
createCaptionResponse,
|
||||
matchesCaptionLanguage,
|
||||
selectCaptionTrack,
|
||||
captionSelectionError,
|
||||
normalizeCaptionLanguage,
|
||||
} from "../helpers/captions.js";
|
||||
|
||||
const shortDomain = "https://vt.tiktok.com/";
|
||||
|
|
@ -77,20 +79,19 @@ export default async function(obj) {
|
|||
}
|
||||
|
||||
if (obj.isCaptionOnly) {
|
||||
const requestedLanguage = convertLanguageCode(obj.captionLanguage)
|
||||
|| obj.captionLanguage;
|
||||
const subtitle = detail.video?.subtitleInfos?.find(subtitle =>
|
||||
subtitle.Format === "webvtt"
|
||||
&& matchesCaptionLanguage(subtitle.LanguageCodeName, requestedLanguage)
|
||||
);
|
||||
if (!subtitle) return { error: "fetch.empty" };
|
||||
const tracks = (detail.video?.subtitleInfos || [])
|
||||
.filter(subtitle => subtitle.Format === "webvtt")
|
||||
.map(subtitle => ({
|
||||
url: subtitle.Url,
|
||||
language: normalizeCaptionLanguage(subtitle.LanguageCodeName),
|
||||
}));
|
||||
const subtitle = selectCaptionTrack(tracks, obj.captionLanguage);
|
||||
if (!subtitle) return captionSelectionError(tracks);
|
||||
|
||||
const language = convertLanguageCode(subtitle.LanguageCodeName)
|
||||
|| subtitle.LanguageCodeName;
|
||||
return createCaptionResponse({
|
||||
url: subtitle.Url,
|
||||
url: subtitle.url,
|
||||
format: obj.captionFormat,
|
||||
language,
|
||||
language: subtitle.language,
|
||||
service: "tiktok",
|
||||
id: postId,
|
||||
title: detail.desc?.trim(),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { genericUserAgent } from "../../config.js";
|
|||
import { createStream } from "../../stream/manage.js";
|
||||
import { getCookie, updateCookie } from "../cookie/manager.js";
|
||||
import {
|
||||
captionSegmentResources,
|
||||
createCaptionResponse,
|
||||
matchesCaptionLanguage,
|
||||
selectCaptionTrack,
|
||||
captionSelectionError,
|
||||
} from "../helpers/captions.js";
|
||||
|
||||
const graphqlURL = 'https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail';
|
||||
|
|
@ -274,54 +276,54 @@ export default async function({
|
|||
url, filename,
|
||||
});
|
||||
|
||||
const extractSubtitles = async (hlsUrl, requestedLanguage) => {
|
||||
const getSubtitleTracks = async hlsUrl => {
|
||||
const mainHls = await fetch(hlsUrl).then(r => r.text()).catch(() => {});
|
||||
if (!mainHls) return;
|
||||
if (!mainHls) return [];
|
||||
return (HLS.parse(mainHls)?.variants?.[0]?.subtitles || []).map(subtitle => ({
|
||||
url: new URL(subtitle.uri, hlsUrl).toString(),
|
||||
language: subtitle.language,
|
||||
}));
|
||||
};
|
||||
|
||||
const subtitle = HLS.parse(mainHls)?.variants[0]?.subtitles?.find(
|
||||
subtitle => matchesCaptionLanguage(subtitle.language, requestedLanguage)
|
||||
);
|
||||
const resolveSubtitle = async (subtitle, allSegments = false) => {
|
||||
if (!subtitle) return;
|
||||
|
||||
const subtitleUrl = new URL(subtitle.uri, hlsUrl).toString();
|
||||
const subtitleHls = await fetch(subtitleUrl).then(r => r.text());
|
||||
const subtitleHls = await fetch(subtitle.url).then(r => r.text()).catch(() => {});
|
||||
if (!subtitleHls) return;
|
||||
|
||||
const finalSubtitlePath = HLS.parse(subtitleHls)?.segments?.[0].uri;
|
||||
if (!finalSubtitlePath) return;
|
||||
|
||||
const finalSubtitleUrl = new URL(finalSubtitlePath, subtitleUrl).toString();
|
||||
|
||||
const segments = HLS.parse(subtitleHls)?.segments;
|
||||
if (!segments?.length) return;
|
||||
const urls = allSegments
|
||||
? captionSegmentResources(segments, subtitle.url)
|
||||
: new URL(segments[0].uri, subtitle.url).toString();
|
||||
return {
|
||||
url: finalSubtitleUrl,
|
||||
url: urls,
|
||||
language: subtitle.language,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (isCaptionOnly) {
|
||||
const tracks = [];
|
||||
for (const mediaItem of media) {
|
||||
if (mediaItem.type !== "video") continue;
|
||||
|
||||
const hlsVariant = mediaItem.video_info?.variants?.find(
|
||||
variant => variant.content_type === "application/x-mpegURL"
|
||||
);
|
||||
if (!hlsVariant) continue;
|
||||
|
||||
const subtitles = await extractSubtitles(hlsVariant.url, captionLanguage);
|
||||
if (!subtitles) continue;
|
||||
|
||||
return createCaptionResponse({
|
||||
url: subtitles.url,
|
||||
format: captionFormat,
|
||||
language: subtitles.language,
|
||||
service: "twitter",
|
||||
id,
|
||||
source: `https://x.com/i/status/${id}`,
|
||||
headers: { "user-agent": genericUserAgent },
|
||||
});
|
||||
if (hlsVariant) tracks.push(...await getSubtitleTracks(hlsVariant.url));
|
||||
}
|
||||
const selected = selectCaptionTrack(tracks, captionLanguage);
|
||||
if (!selected) return captionSelectionError(tracks);
|
||||
const subtitles = await resolveSubtitle(selected, true);
|
||||
if (!subtitles) return { error: "captions.unavailable" };
|
||||
|
||||
return { error: "fetch.empty" };
|
||||
return createCaptionResponse({
|
||||
url: subtitles.url,
|
||||
format: captionFormat,
|
||||
language: subtitles.language,
|
||||
service: "twitter",
|
||||
id,
|
||||
source: `https://x.com/i/status/${id}`,
|
||||
headers: { "user-agent": genericUserAgent },
|
||||
});
|
||||
}
|
||||
|
||||
switch (media?.length) {
|
||||
|
|
@ -348,10 +350,9 @@ export default async function({
|
|||
v => v.content_type === "application/x-mpegURL"
|
||||
);
|
||||
if (hlsVariant) {
|
||||
const { url, language } = await extractSubtitles(
|
||||
hlsVariant.url,
|
||||
subtitleLang
|
||||
) || {};
|
||||
const { url, language } = await resolveSubtitle(selectCaptionTrack(
|
||||
await getSubtitleTracks(hlsVariant.url), subtitleLang
|
||||
)) || {};
|
||||
subtitles = url;
|
||||
if (language) fileMetadata = { sublanguage: language };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { merge } from '../../misc/utils.js';
|
|||
import { getCookie } from "../cookie/manager.js";
|
||||
import {
|
||||
createCaptionResponse,
|
||||
matchesCaptionLanguage,
|
||||
selectCaptionTrack,
|
||||
captionSelectionError,
|
||||
} from "../helpers/captions.js";
|
||||
|
||||
const resolutionMatch = {
|
||||
|
|
@ -200,16 +201,18 @@ const getHLS = async (configURL, obj) => {
|
|||
export default async function(obj) {
|
||||
if (obj.isCaptionOnly) {
|
||||
const config = await getPlayerConfig(obj.id, obj.password);
|
||||
const track = config?.request?.text_tracks?.find(track =>
|
||||
track.url
|
||||
&& matchesCaptionLanguage(track.lang, obj.captionLanguage)
|
||||
);
|
||||
if (!track) return { error: "fetch.empty" };
|
||||
if (!config) return { error: "fetch.fail" };
|
||||
const tracks = (config.request?.text_tracks || []).map(track => ({
|
||||
url: track.url,
|
||||
language: track.lang,
|
||||
}));
|
||||
const track = selectCaptionTrack(tracks, obj.captionLanguage);
|
||||
if (!track) return captionSelectionError(tracks);
|
||||
|
||||
return createCaptionResponse({
|
||||
url: new URL(track.url, "https://player.vimeo.com/").toString(),
|
||||
format: obj.captionFormat,
|
||||
language: track.lang,
|
||||
language: track.language,
|
||||
service: "vimeo",
|
||||
id: obj.id,
|
||||
title: config.video?.title,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { env } from "../../config.js";
|
||||
import {
|
||||
createCaptionResponse,
|
||||
matchesCaptionLanguage,
|
||||
selectCaptionTrack,
|
||||
captionSelectionError,
|
||||
} from "../helpers/captions.js";
|
||||
|
||||
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"];
|
||||
|
|
@ -117,17 +118,17 @@ export default async function ({
|
|||
}
|
||||
|
||||
if (isCaptionOnly) {
|
||||
const subtitle = video.subtitles?.find(subtitle =>
|
||||
subtitle.title?.endsWith(".vtt")
|
||||
&& matchesCaptionLanguage(subtitle.lang, captionLanguage)
|
||||
);
|
||||
if (!subtitle) return { error: "fetch.empty" };
|
||||
const tracks = (video.subtitles || [])
|
||||
.filter(subtitle => subtitle.title?.endsWith(".vtt"))
|
||||
.map(subtitle => ({ url: subtitle.url, language: subtitle.lang }));
|
||||
const subtitle = selectCaptionTrack(tracks, captionLanguage);
|
||||
if (!subtitle) return captionSelectionError(tracks);
|
||||
|
||||
const id = `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`;
|
||||
return createCaptionResponse({
|
||||
url: subtitle.url,
|
||||
format: captionFormat,
|
||||
language: subtitle.lang,
|
||||
language: subtitle.language,
|
||||
service: "vk",
|
||||
id,
|
||||
title: video.title?.trim(),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ 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";
|
||||
import {
|
||||
captionSegmentResources,
|
||||
createCaptionResponse,
|
||||
selectCaptionTrack,
|
||||
captionSelectionError,
|
||||
} from "../helpers/captions.js";
|
||||
|
||||
// https://github.com/LuanRT/YouTube.js/pull/1052
|
||||
Platform.shim.eval = async (data) => {
|
||||
|
|
@ -136,21 +141,14 @@ const getHlsVariants = async (hlsManifest, dispatcher) => {
|
|||
return variants;
|
||||
}
|
||||
|
||||
const normalizedLanguage = language => language?.toLowerCase().replace("_", "-");
|
||||
const captionTracks = info => (info.captions?.caption_tracks || []).map(track => ({
|
||||
url: track.base_url,
|
||||
language: track.language_code,
|
||||
automatic: track.kind === "asr",
|
||||
}));
|
||||
|
||||
const getSubtitles = async (
|
||||
info,
|
||||
dispatcher,
|
||||
subtitleLang,
|
||||
allowAutomatic = false
|
||||
) => {
|
||||
const preferredCap = selectCaptionTrack(
|
||||
info.captions.caption_tracks,
|
||||
subtitleLang,
|
||||
allowAutomatic
|
||||
);
|
||||
|
||||
const captionsUrl = preferredCap?.base_url;
|
||||
const getSubtitles = async (info, dispatcher, preferredCap, allSegments = false) => {
|
||||
const captionsUrl = preferredCap?.url;
|
||||
if (!captionsUrl) return;
|
||||
|
||||
if (!captionsUrl.includes("exp=xpe")) {
|
||||
|
|
@ -159,7 +157,7 @@ const getSubtitles = async (
|
|||
|
||||
return {
|
||||
url: url.toString(),
|
||||
language: preferredCap.language_code,
|
||||
language: preferredCap.language,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +166,7 @@ const getSubtitles = async (
|
|||
// so instead we just use subtitles from HLS
|
||||
|
||||
const hlsVariants = await getHlsVariants(
|
||||
info.streaming_data.hls_manifest_url,
|
||||
info.streaming_data?.hls_manifest_url,
|
||||
dispatcher
|
||||
);
|
||||
if (hlsVariants?.error) return;
|
||||
|
|
@ -177,23 +175,28 @@ const getSubtitles = async (
|
|||
const hlsSubtitles = hlsVariants[0]?.subtitles;
|
||||
if (!hlsSubtitles?.length) return;
|
||||
|
||||
const preferredHls = hlsSubtitles.find(subtitle =>
|
||||
normalizedLanguage(subtitle.language)
|
||||
?.startsWith(normalizedLanguage(preferredCap.language_code))
|
||||
const preferredHls = selectCaptionTrack(
|
||||
hlsSubtitles.map(subtitle => ({ ...subtitle, url: subtitle.uri })),
|
||||
preferredCap.language
|
||||
);
|
||||
|
||||
if (!preferredHls) return;
|
||||
|
||||
const playlistURL = new URL(preferredHls.uri, info.streaming_data.hls_manifest_url).toString();
|
||||
const fetchedHlsSubs =
|
||||
await fetch(preferredHls.uri, { dispatcher })
|
||||
await fetch(playlistURL, { dispatcher })
|
||||
.then(r => r.status === 200 ? r.text() : undefined)
|
||||
.catch(() => {});
|
||||
|
||||
if (!fetchedHlsSubs) return;
|
||||
const parsedSubs = HLS.parse(fetchedHlsSubs);
|
||||
if (!parsedSubs) return;
|
||||
if (!parsedSubs?.segments?.length) return;
|
||||
const urls = allSegments
|
||||
? captionSegmentResources(parsedSubs.segments, playlistURL)
|
||||
: new URL(parsedSubs.segments[0].uri, playlistURL).toString();
|
||||
|
||||
return {
|
||||
url: parsedSubs.segments[0]?.uri,
|
||||
url: urls,
|
||||
language: preferredHls.language,
|
||||
}
|
||||
}
|
||||
|
|
@ -349,54 +352,23 @@ export default async function (o) {
|
|||
}
|
||||
|
||||
if (o.isCaptionOnly) {
|
||||
const captionTracks = info.captions?.caption_tracks || [];
|
||||
if (!captionTracks.length) {
|
||||
return { error: "youtube.captions_unavailable" };
|
||||
}
|
||||
const tracks = captionTracks(info);
|
||||
const selectedTrack = selectCaptionTrack(tracks, o.captionLanguage || o.subtitleLang);
|
||||
if (!selectedTrack) return captionSelectionError(tracks);
|
||||
|
||||
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, true);
|
||||
if (!captions?.url) return { error: "captions.unavailable" };
|
||||
|
||||
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}`,
|
||||
},
|
||||
};
|
||||
return createCaptionResponse({
|
||||
url: captions.url,
|
||||
format: o.captionFormat,
|
||||
language: captions.language,
|
||||
service: "youtube",
|
||||
id: o.id,
|
||||
title: basicInfo.title.trim(),
|
||||
author: basicInfo.author.replace("- Topic", "").trim(),
|
||||
source: `https://www.youtube.com/watch?v=${o.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizeQuality = res => {
|
||||
|
|
@ -566,7 +538,9 @@ export default async function (o) {
|
|||
}
|
||||
|
||||
if (o.subtitleLang && !o.isAudioOnly && info.captions?.caption_tracks?.length) {
|
||||
const videoSubtitles = await getSubtitles(info, o.dispatcher, o.subtitleLang);
|
||||
const videoSubtitles = await getSubtitles(
|
||||
info, o.dispatcher, selectCaptionTrack(captionTracks(info), o.subtitleLang, false)
|
||||
);
|
||||
if (videoSubtitles) {
|
||||
subtitles = videoSubtitles;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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";
|
||||
import { convertCaptions, mergeCaptionSegments } from "../processing/helpers/caption-formats.js";
|
||||
|
||||
const defaultAgent = new Agent();
|
||||
const maxCaptionSize = 8 * 1024 * 1024;
|
||||
|
|
@ -17,43 +17,50 @@ const contentTypes = {
|
|||
|
||||
export default async function captions(streamInfo, res) {
|
||||
const abortController = new AbortController();
|
||||
const shutdown = () => (
|
||||
closeRequest(abortController),
|
||||
closeResponse(res),
|
||||
destroyInternalStream(streamInfo.urls)
|
||||
);
|
||||
const urls = [streamInfo.urls].flat();
|
||||
const cleanup = () => {
|
||||
closeRequest(abortController);
|
||||
urls.forEach(destroyInternalStream);
|
||||
};
|
||||
res.once("close", cleanup);
|
||||
res.once("error", cleanup);
|
||||
|
||||
try {
|
||||
const response = await request(streamInfo.urls, {
|
||||
signal: abortController.signal,
|
||||
maxRedirections: 4,
|
||||
dispatcher: defaultAgent,
|
||||
headers: streamInfo.headers,
|
||||
});
|
||||
|
||||
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 = [];
|
||||
if (res.destroyed) return;
|
||||
const segments = [];
|
||||
let size = 0;
|
||||
for await (const chunk of response.body) {
|
||||
size += chunk.length;
|
||||
if (size > maxCaptionSize) {
|
||||
res.status(413).end();
|
||||
return shutdown();
|
||||
for (const url of urls) {
|
||||
const response = await request(url, {
|
||||
signal: abortController.signal,
|
||||
maxRedirections: 4,
|
||||
dispatcher: defaultAgent,
|
||||
headers: streamInfo.headers,
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode > 299) {
|
||||
return closeResponse(res);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
|
||||
const declaredSize = Number(response.headers["content-length"]);
|
||||
if (size + declaredSize > maxCaptionSize) {
|
||||
res.status(413).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of response.body) {
|
||||
size += chunk.length;
|
||||
if (size > maxCaptionSize) {
|
||||
res.status(413).end();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
segments.push(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
const body = convertCaptions(
|
||||
Buffer.concat(chunks).toString("utf8"),
|
||||
segments.length === 1 ? segments[0] : mergeCaptionSegments(segments),
|
||||
streamInfo.captionFormat,
|
||||
streamInfo.captionMetadata,
|
||||
streamInfo.captionSourceFormat
|
||||
|
|
@ -64,8 +71,11 @@ export default async function captions(streamInfo, res) {
|
|||
res.setHeader("Content-Type", contentTypes[streamInfo.captionFormat]);
|
||||
res.setHeader("Content-Length", Buffer.byteLength(body));
|
||||
res.status(200).send(body);
|
||||
destroyInternalStream(streamInfo.urls);
|
||||
} catch {
|
||||
shutdown();
|
||||
if (!res.destroyed) closeResponse(res);
|
||||
} finally {
|
||||
cleanup();
|
||||
res.off("close", cleanup);
|
||||
res.off("error", cleanup);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function createStream(obj) {
|
|||
subtitles: obj.subtitles,
|
||||
|
||||
captionFormat: obj.captionFormat,
|
||||
captionSourceFormat: obj.captionSourceFormat,
|
||||
captionMetadata: obj.captionMetadata,
|
||||
};
|
||||
|
||||
|
|
@ -262,21 +263,32 @@ const transplantTunnel = async function (dispatcher) {
|
|||
function wrapStream(streamInfo) {
|
||||
const url = streamInfo.urls;
|
||||
|
||||
const createWrappedStream = resource => {
|
||||
if (typeof resource === "string") {
|
||||
return createInternalStream(resource, streamInfo, streamInfo.type === "captions");
|
||||
}
|
||||
if (streamInfo.type !== "captions" || typeof resource?.url !== "string") {
|
||||
throw "invalid url";
|
||||
}
|
||||
return createInternalStream(
|
||||
resource.url,
|
||||
{
|
||||
...streamInfo,
|
||||
headers: { ...streamInfo.headers, ...resource.headers },
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
if (streamInfo.originalRequest) {
|
||||
streamInfo.transplant = transplantTunnel.bind(streamInfo);
|
||||
}
|
||||
|
||||
if (typeof url === 'string') {
|
||||
streamInfo.urls = createInternalStream(
|
||||
url,
|
||||
streamInfo,
|
||||
streamInfo.type === "captions"
|
||||
);
|
||||
streamInfo.urls = createWrappedStream(url);
|
||||
} else if (Array.isArray(url)) {
|
||||
for (const idx in streamInfo.urls) {
|
||||
streamInfo.urls[idx] = createInternalStream(
|
||||
streamInfo.urls[idx], streamInfo
|
||||
);
|
||||
streamInfo.urls[idx] = createWrappedStream(streamInfo.urls[idx]);
|
||||
}
|
||||
} else throw 'invalid urls';
|
||||
|
||||
|
|
|
|||
15
docs/api.md
15
docs/api.md
|
|
@ -102,10 +102,17 @@ downloading video or audio. supported services are listed in the
|
|||
[api readme](/api/README.md#supported-services).
|
||||
|
||||
`captionLanguage` selects the caption language, falling back to `subtitleLang` when
|
||||
omitted. if both are omitted, cobalt prefers the first manually provided track and
|
||||
falls back to an automatic caption track. when a language is provided, cobalt matches
|
||||
the full language code first and then its base language. manually provided captions
|
||||
take priority over automatic captions.
|
||||
omitted. if both are omitted, cobalt chooses an available track. when a service
|
||||
identifies automatic captions, manually provided tracks take priority.
|
||||
|
||||
when a language is provided, an exact match takes priority over compatible regional
|
||||
variants, with manual captions preferred within each tier. explicitly requested
|
||||
writing systems are preserved, so `zh-Hant` will not fall back to `zh-Hans`.
|
||||
|
||||
if captions exist but none match, the api returns
|
||||
`error.api.captions.language_unavailable`. omit both language fields to retry with an
|
||||
available track. if no downloadable tracks exist, the error is
|
||||
`error.api.captions.unavailable`.
|
||||
|
||||
`txt` and `md` produce readable transcripts without timestamps. `vtt` preserves the
|
||||
original webvtt cues, while `srt` converts those cues to the subrip format.
|
||||
|
|
|
|||
|
|
@ -26,5 +26,6 @@
|
|||
"clear_cache": "clear cache",
|
||||
"remove": "remove",
|
||||
"retry": "retry",
|
||||
"delete": "delete"
|
||||
"delete": "delete",
|
||||
"download.transcript_any_language": "download anyway"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,5 +62,7 @@
|
|||
"youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
|
||||
"youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!",
|
||||
"youtube.captions_unavailable": "this video doesn't have any captions i can download.",
|
||||
"youtube.caption_language_unavailable": "this video doesn't have captions in the selected language. available languages: {{ languages }}."
|
||||
"youtube.caption_language_unavailable": "this video doesn't have captions in the selected language. available languages: {{ languages }}.",
|
||||
"captions.unavailable": "this video doesn't have any captions i can download.",
|
||||
"captions.language_unavailable": "this video doesn't have captions in your preferred language. you can download an available transcript instead."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@
|
|||
"captions.method": "transcript saving method",
|
||||
"captions.method.description": "how to save transcripts from the save button. \"ask\" shows both options every time.",
|
||||
"captions.language": "preferred transcript language",
|
||||
"captions.language.description": "cobalt will pick the transcript in this language when the video has multiple caption tracks. otherwise it falls back to the video's default track.",
|
||||
"captions.language.description": "pick a preferred transcript language, or let cobalt choose an available track automatically. if your preferred language isn't available, cobalt will ask before downloading another language.",
|
||||
|
||||
"audio.youtube.better_audio": "youtube audio quality",
|
||||
"audio.youtube.better_audio.title": "prefer better quality",
|
||||
|
|
@ -168,5 +168,6 @@
|
|||
|
||||
"tabs": "navigation",
|
||||
"tabs.hide_remux": "hide the remux tab",
|
||||
"tabs.hide_remux.description": "if you don't use the remux tool, you can hide it from the navigation bar."
|
||||
"tabs.hide_remux.description": "if you don't use the remux tool, you can hide it from the navigation bar.",
|
||||
"captions.language.automatic": "automatic"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@
|
|||
|
||||
.popup-button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
height: auto;
|
||||
padding-block: 8px;
|
||||
transition: 0.2s opacity;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
// check if dialog parent is still present
|
||||
if (dialogParent) {
|
||||
dialogParent.close();
|
||||
killDialog();
|
||||
killDialog(id);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
-->
|
||||
<div id="dialog-holder">
|
||||
<NoScriptDialog />
|
||||
{#each $dialogs as dialog}
|
||||
{#each $dialogs as dialog (dialog.id)}
|
||||
{#if dialog.type === "small"}
|
||||
<SmallDialog {...dialog} />
|
||||
{:else if dialog.type === "picker"}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
</script>
|
||||
|
||||
{#if !browser}
|
||||
<noscript style="display: contents">
|
||||
<noscript>
|
||||
<div id="nojs-ack">
|
||||
<SmallDialog
|
||||
id="nojs-dialog"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { downloadButtonState } from "$lib/state/omnibox";
|
|||
import { createSavePipeline } from "$lib/task-manager/queue";
|
||||
|
||||
import type { CobaltSaveRequestBody } from "$lib/types/api";
|
||||
import type { DialogButton } from "$lib/types/dialog";
|
||||
|
||||
type SavingHandlerArgs = {
|
||||
url?: string,
|
||||
|
|
@ -18,18 +19,19 @@ type SavingHandlerArgs = {
|
|||
oldTaskId?: string
|
||||
}
|
||||
|
||||
export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerArgs) => {
|
||||
export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerArgs): Promise<void> => {
|
||||
downloadButtonState.set("think");
|
||||
|
||||
const error = (errorText: string) => {
|
||||
const error = (errorText: string, retry?: DialogButton) => {
|
||||
return createDialog({
|
||||
id: "save-error",
|
||||
type: "small",
|
||||
meowbalt: "error",
|
||||
buttons: [
|
||||
...(retry ? [retry] : []),
|
||||
{
|
||||
text: get(t)("button.gotit"),
|
||||
main: true,
|
||||
main: !retry,
|
||||
action: () => {},
|
||||
},
|
||||
],
|
||||
|
|
@ -41,7 +43,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||
|
||||
if (!request && !url) return;
|
||||
|
||||
const selectedRequest = request || {
|
||||
const selectedRequest = request ? { ...request } : {
|
||||
url: url!,
|
||||
|
||||
// not lazy cuz default depends on device capabilities
|
||||
|
|
@ -70,7 +72,9 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||
convertGif: getSetting("save", "convertGif"),
|
||||
}
|
||||
|
||||
if (selectedRequest.downloadMode === "captions") {
|
||||
if (!request && selectedRequest.downloadMode === "captions") {
|
||||
// Transcript preferences are independent of embedded media subtitles.
|
||||
delete selectedRequest.subtitleLang;
|
||||
const captionLang = getSetting("save", "captionLang");
|
||||
if (captionLang && captionLang !== "none") {
|
||||
selectedRequest.captionLanguage = captionLang;
|
||||
|
|
@ -87,8 +91,23 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||
if (response.status === "error") {
|
||||
downloadButtonState.set("error");
|
||||
|
||||
const canRetry = selectedRequest.downloadMode === "captions"
|
||||
&& [
|
||||
"error.api.captions.language_unavailable",
|
||||
"error.api.youtube.caption_language_unavailable",
|
||||
].includes(response.error.code);
|
||||
return error(
|
||||
get(t)(response.error.code, response?.error?.context)
|
||||
get(t)(response.error.code, response?.error?.context),
|
||||
canRetry ? {
|
||||
text: get(t)("button.download.transcript_any_language"),
|
||||
main: true,
|
||||
action: () => {
|
||||
const retry = { ...selectedRequest };
|
||||
delete retry.captionLanguage;
|
||||
delete retry.subtitleLang;
|
||||
return savingHandler({ request: retry, oldTaskId });
|
||||
},
|
||||
} : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +131,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
|
|||
return downloadFile({
|
||||
url: response.url,
|
||||
method: selectedRequest.downloadMode === "captions"
|
||||
? getSetting("save", "transcriptMethod")
|
||||
? get(settings).save.transcriptMethod
|
||||
: undefined,
|
||||
copyTextURL: selectedRequest.downloadMode === "captions"
|
||||
? response.text || response.url
|
||||
|
|
|
|||
|
|
@ -81,9 +81,10 @@ export const copyURL = async (url: string) => {
|
|||
}
|
||||
|
||||
export const copyTextFromURL = async (url: string) => {
|
||||
if (!navigator?.clipboard?.writeText) throw new Error("clipboard unavailable");
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("couldn't fetch transcript");
|
||||
return await navigator?.clipboard?.writeText(await response.text());
|
||||
return await navigator.clipboard.writeText(await response.text());
|
||||
}
|
||||
|
||||
export const downloadFile = async ({ url, file, urlType, copyTextURL, method }: DownloadFileParams) => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
transcriptMethodOptions,
|
||||
type PartialSettings,
|
||||
} from '$lib/types/settings';
|
||||
import { youtubeDubLanguages } from '$lib/settings/audio-sub-language';
|
||||
import { subtitleLanguages, youtubeDubLanguages } from '$lib/settings/audio-sub-language';
|
||||
|
||||
function validateTypes(input: unknown, reference = defaultSettings as unknown) {
|
||||
if (typeof input === 'undefined')
|
||||
|
|
@ -80,6 +80,7 @@ export function validateSettings(settings: PartialSettings) {
|
|||
[ settings?.appearance?.theme , themeOptions ],
|
||||
[ settings?.save?.downloadMode , downloadModeOptions ],
|
||||
[ settings?.save?.captionFormat , captionFormatOptions ],
|
||||
[ settings?.save?.captionLang , subtitleLanguages ],
|
||||
[ settings?.save?.transcriptMethod , transcriptMethodOptions ],
|
||||
[ settings?.save?.filenameStyle , filenameStyleOptions ],
|
||||
[ settings?.save?.videoQuality , videoQualityOptions ],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { readable, type Updater } from "svelte/store";
|
|||
import type { DialogInfo } from "$lib/types/dialog";
|
||||
|
||||
let update: (_: Updater<DialogInfo[]>) => void;
|
||||
let nextId = 0;
|
||||
|
||||
export default readable<DialogInfo[]>(
|
||||
[],
|
||||
|
|
@ -10,14 +11,15 @@ export default readable<DialogInfo[]>(
|
|||
|
||||
export function createDialog(newData: DialogInfo) {
|
||||
update((popups) => {
|
||||
popups.push(newData);
|
||||
popups.push({ ...newData, id: `${newData.id}-${nextId++}` });
|
||||
return popups;
|
||||
});
|
||||
}
|
||||
|
||||
export function killDialog() {
|
||||
export function killDialog(id?: string) {
|
||||
update((popups) => {
|
||||
popups.pop()
|
||||
const index = id ? popups.findIndex(popup => popup.id === id) : popups.length - 1;
|
||||
if (index >= 0) popups.splice(index, 1);
|
||||
return popups;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export * from "$lib/types/settings/v4";
|
|||
export * from "$lib/types/settings/v5";
|
||||
export * from "$lib/types/settings/v6";
|
||||
export * from "$lib/types/settings/v7";
|
||||
export { downloadModeOptions } from "$lib/types/settings/v7";
|
||||
|
||||
export type CobaltSettings = CobaltSettingsV7;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import languages from "$i18n/languages.json";
|
|||
export const themeOptions = ["auto", "light", "dark"] as const;
|
||||
export const audioBitrateOptions = ["320", "256", "128", "96", "64", "8"] as const;
|
||||
export const audioFormatOptions = ["best", "mp3", "ogg", "wav", "opus"] as const;
|
||||
export const downloadModeOptions = ["auto", "audio", "mute", "captions"] as const;
|
||||
export const downloadModeOptions = ["auto", "audio", "mute"] as const;
|
||||
export const filenameStyleOptions = ["classic", "basic", "pretty", "nerdy"] as const;
|
||||
export const videoQualityOptions = ["max", "2160", "1440", "1080", "720", "480", "360", "240", "144"] as const;
|
||||
export const youtubeVideoCodecOptions = ["h264", "av1", "vp9"] as const;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import type { SubtitleLang } from "$lib/settings/audio-sub-language";
|
||||
import type { CobaltSettingsV6 } from "$lib/types/settings/v6";
|
||||
import { downloadModeOptions as previousDownloadModes } from "$lib/types/settings/v2";
|
||||
|
||||
export const downloadModeOptions = [...previousDownloadModes, "captions"] as const;
|
||||
export const captionFormatOptions = ["txt", "vtt", "srt", "md"] as const;
|
||||
export const transcriptMethodOptions = ["ask", "download", "copy"] as const;
|
||||
|
||||
export type CobaltSettingsV7 = Omit<CobaltSettingsV6, "schemaVersion" | "save"> & {
|
||||
schemaVersion: 7,
|
||||
save: CobaltSettingsV6["save"] & {
|
||||
save: Omit<CobaltSettingsV6["save"], "downloadMode"> & {
|
||||
downloadMode: typeof downloadModeOptions[number],
|
||||
captionFormat: typeof captionFormatOptions[number],
|
||||
captionLang: SubtitleLang,
|
||||
transcriptMethod: typeof transcriptMethodOptions[number],
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@
|
|||
import SettingsDropdown from "$components/settings/SettingsDropdown.svelte";
|
||||
|
||||
const displayLangs = namedSubtitleLanguages($t);
|
||||
const displayCaptionLangs: Record<string, string> = {
|
||||
...displayLangs,
|
||||
none: $t("settings.captions.language.automatic"),
|
||||
};
|
||||
const displayCaptionFormats = Object.fromEntries(
|
||||
captionFormatOptions.map(format => [format, $t(`settings.captions.format.${format}`)])
|
||||
);
|
||||
|
|
@ -71,11 +75,11 @@
|
|||
<SettingsDropdown
|
||||
title={$t("settings.captions.language")}
|
||||
description={$t("settings.captions.language.description")}
|
||||
items={displayLangs}
|
||||
items={displayCaptionLangs}
|
||||
settingContext="save"
|
||||
settingId="captionLang"
|
||||
selectedOption={$settings.save.captionLang}
|
||||
selectedTitle={displayLangs[$settings.save.captionLang]}
|
||||
selectedTitle={displayCaptionLangs[$settings.save.captionLang]}
|
||||
/>
|
||||
</SettingsCategory>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue