brightspace-mcp-server 3.13.8 → 3.13.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -12,6 +12,121 @@ function parseTimestamp(raw) {
|
|
|
12
12
|
return ((Number(hours ?? 0) * 3600 + Number(minutes) * 60 + Number(seconds)) * 1000 +
|
|
13
13
|
Number(millis));
|
|
14
14
|
}
|
|
15
|
+
const ENTITIES = {
|
|
16
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", "#39": "'", "#34": '"',
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Decode the XML/HTML entities caption payloads carry. YouTube's tracks are
|
|
20
|
+
* full of `'` and `&`, which used to reach the user verbatim.
|
|
21
|
+
*/
|
|
22
|
+
function decodeEntities(text) {
|
|
23
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, body) => {
|
|
24
|
+
const key = body.toLowerCase();
|
|
25
|
+
if (ENTITIES[key] !== undefined)
|
|
26
|
+
return ENTITIES[key];
|
|
27
|
+
if (key.startsWith("#x")) {
|
|
28
|
+
const code = Number.parseInt(key.slice(2), 16);
|
|
29
|
+
return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : whole;
|
|
30
|
+
}
|
|
31
|
+
if (key.startsWith("#")) {
|
|
32
|
+
const code = Number.parseInt(key.slice(1), 10);
|
|
33
|
+
return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : whole;
|
|
34
|
+
}
|
|
35
|
+
return whole;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/*
|
|
39
|
+
* Only strip the tags captions actually use. The old `/<[^>]+>/g` ate
|
|
40
|
+
* everything between any two angle brackets, so a lecture line like
|
|
41
|
+
* "if x < y and y > z" silently lost the middle -- and maths and inequalities
|
|
42
|
+
* are exactly what a course recording says out loud.
|
|
43
|
+
*/
|
|
44
|
+
const CAPTION_TAG = /<\/?(?:[0-9:.]+|c(?:\.[^>\s]*)?|v(?:\s[^>]*)?|b|i|u|ruby|rt|lang(?:\s[^>]*)?)>/gi;
|
|
45
|
+
function stripCaptionTags(text) {
|
|
46
|
+
return text.replace(CAPTION_TAG, "");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* YouTube's auto-generated tracks roll: each cue repeats the tail of the one
|
|
50
|
+
* before it, so a naive join says every line two or three times. Drop a cue
|
|
51
|
+
* whose text the previous cue already ended with.
|
|
52
|
+
*/
|
|
53
|
+
function dropRollingRepeats(cues) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const cue of cues) {
|
|
56
|
+
const previous = out[out.length - 1];
|
|
57
|
+
if (previous && (previous.text === cue.text || previous.text.endsWith(cue.text)))
|
|
58
|
+
continue;
|
|
59
|
+
if (previous && cue.text.startsWith(previous.text) && cue.text.length > previous.text.length) {
|
|
60
|
+
out[out.length - 1] = { ...previous, endMs: cue.endMs, text: cue.text };
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
out.push(cue);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A TTML/DFXP time expression: a clock value ("00:00:01.500", optionally with
|
|
69
|
+
* frames) or an offset ("1.5s", "150ms", "90f" treated as seconds-less).
|
|
70
|
+
*/
|
|
71
|
+
function parseTimeExpression(raw) {
|
|
72
|
+
const value = raw.trim();
|
|
73
|
+
const offset = /^(\d+(?:\.\d+)?)(h|min|m|s|ms)?$/.exec(value);
|
|
74
|
+
if (offset) {
|
|
75
|
+
const n = Number(offset[1]);
|
|
76
|
+
const unit = offset[2] ?? "s";
|
|
77
|
+
const scale = unit === "h" ? 3600_000 : unit === "min" || unit === "m" ? 60_000 : unit === "ms" ? 1 : 1000;
|
|
78
|
+
return Math.round(n * scale);
|
|
79
|
+
}
|
|
80
|
+
const clock = /^(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?$/.exec(value);
|
|
81
|
+
if (!clock)
|
|
82
|
+
return NaN;
|
|
83
|
+
const [, h, m, sec, frac] = clock;
|
|
84
|
+
const millis = frac ? Number(frac.padEnd(3, "0")) : 0;
|
|
85
|
+
return (Number(h ?? 0) * 3600 + Number(m) * 60 + Number(sec)) * 1000 + millis;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Timed-text XML. Three shapes reach us: YouTube srv3 (`<p t= d=>`), YouTube
|
|
89
|
+
* srv1 (`<text start= dur=>`), and TTML/DFXP (`<p begin= end=>`), which is
|
|
90
|
+
* what Kaltura serves for many caption assets. TTML used to parse as zero
|
|
91
|
+
* cues and be reported as a track with nothing readable in it.
|
|
92
|
+
*/
|
|
93
|
+
function parseTimedText(xml) {
|
|
94
|
+
const cues = [];
|
|
95
|
+
const element = /<(p|text)\b([^>]*)>([\s\S]*?)<\/\1>/g;
|
|
96
|
+
for (const match of xml.matchAll(element)) {
|
|
97
|
+
const attrs = match[2];
|
|
98
|
+
const numericStart = /\b(?:t|start)="([\d.]+)"/.exec(attrs)?.[1];
|
|
99
|
+
const numericDur = /\b(?:d|dur)="([\d.]+)"/.exec(attrs)?.[1];
|
|
100
|
+
const beginRaw = /\bbegin="([^"]+)"/.exec(attrs)?.[1];
|
|
101
|
+
const endRaw = /\bend="([^"]+)"/.exec(attrs)?.[1];
|
|
102
|
+
const ttmlDur = /\bdur="([^"]+)"/.exec(attrs)?.[1];
|
|
103
|
+
let startMs;
|
|
104
|
+
let durMs;
|
|
105
|
+
if (beginRaw !== undefined) {
|
|
106
|
+
startMs = parseTimeExpression(beginRaw);
|
|
107
|
+
const endMs = endRaw !== undefined ? parseTimeExpression(endRaw) : NaN;
|
|
108
|
+
durMs = Number.isFinite(endMs)
|
|
109
|
+
? endMs - startMs
|
|
110
|
+
: ttmlDur !== undefined ? parseTimeExpression(ttmlDur) : 0;
|
|
111
|
+
}
|
|
112
|
+
else if (numericStart !== undefined) {
|
|
113
|
+
// srv3 counts milliseconds, srv1 counts fractional seconds.
|
|
114
|
+
const seconds = match[1] === "text";
|
|
115
|
+
startMs = Math.round(Number(numericStart) * (seconds ? 1000 : 1));
|
|
116
|
+
durMs = numericDur === undefined ? 0 : Math.round(Number(numericDur) * (seconds ? 1000 : 1));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(durMs))
|
|
122
|
+
continue;
|
|
123
|
+
// <s> word segments inside a <p> carry the words for ASR tracks.
|
|
124
|
+
const text = decodeEntities(match[3].replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
|
|
125
|
+
if (text)
|
|
126
|
+
cues.push({ startMs, endMs: startMs + Math.max(0, durMs), text });
|
|
127
|
+
}
|
|
128
|
+
return cues;
|
|
129
|
+
}
|
|
15
130
|
/**
|
|
16
131
|
* Parse WebVTT or SRT text into cues. Format is detected from the WEBVTT
|
|
17
132
|
* header rather than taken on faith, since a caller only knows what the
|
|
@@ -19,7 +134,13 @@ function parseTimestamp(raw) {
|
|
|
19
134
|
*/
|
|
20
135
|
export function parseCaptions(raw) {
|
|
21
136
|
const normalized = raw.replace(/^/, "").replace(/\r\n?/g, "\n");
|
|
22
|
-
const
|
|
137
|
+
const head = normalized.trimStart();
|
|
138
|
+
// Dispatch on what actually arrived. Calling every non-VTT payload "srt" and
|
|
139
|
+
// then finding no cues is how an XML or HTML body got reported as a caption
|
|
140
|
+
// track with nothing readable in it.
|
|
141
|
+
if (head.startsWith("<"))
|
|
142
|
+
return { format: "timedtext", cues: dropRollingRepeats(parseTimedText(normalized)) };
|
|
143
|
+
const format = /^WEBVTT\b/.test(head) ? "vtt" : "srt";
|
|
23
144
|
const cues = [];
|
|
24
145
|
for (const block of normalized.split(/\n{2,}/)) {
|
|
25
146
|
const lines = block.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
@@ -31,15 +152,13 @@ export function parseCaptions(raw) {
|
|
|
31
152
|
const endMs = parseTimestamp((endRaw ?? "").trim().split(/\s+/)[0] ?? "");
|
|
32
153
|
if (Number.isNaN(startMs) || Number.isNaN(endMs))
|
|
33
154
|
continue;
|
|
34
|
-
const text = lines
|
|
35
|
-
.
|
|
36
|
-
.join(" ")
|
|
37
|
-
.replace(/<[^>]+>/g, "")
|
|
155
|
+
const text = decodeEntities(stripCaptionTags(lines.slice(timingIndex + 1).join(" ")))
|
|
156
|
+
.replace(/\s+/g, " ")
|
|
38
157
|
.trim();
|
|
39
158
|
if (text)
|
|
40
159
|
cues.push({ startMs, endMs, text });
|
|
41
160
|
}
|
|
42
|
-
return { format, cues };
|
|
161
|
+
return { format, cues: dropRollingRepeats(cues) };
|
|
43
162
|
}
|
|
44
163
|
/** "H:MM:SS", matching how course-length recordings are usually captioned. */
|
|
45
164
|
export function formatTimestamp(ms) {
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
import { parseCaptions } from "./captions.js";
|
|
22
22
|
import { NoTranscriptError, TranscriptFetchError } from "./errors.js";
|
|
23
23
|
const KALTURA_API_BASE = "https://cdnapisec.kaltura.com/api_v3/service";
|
|
24
|
+
/** The API's own refusal, in either envelope, or undefined when it did not refuse. */
|
|
25
|
+
function apiException(data) {
|
|
26
|
+
if (data.error)
|
|
27
|
+
return data.error.message ?? data.error.code ?? "unknown error";
|
|
28
|
+
if (data.objectType === "KalturaAPIException")
|
|
29
|
+
return data.message ?? data.code ?? "unknown error";
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
24
32
|
async function startWidgetSession(partnerId, fetchImpl) {
|
|
25
33
|
const res = await fetchImpl(`${KALTURA_API_BASE}/session/action/startWidgetSession?widgetId=_${encodeURIComponent(partnerId)}&format=1`);
|
|
26
34
|
if (!res.ok) {
|
|
@@ -42,10 +50,16 @@ async function listCaptionAssets(ks, entryId, fetchImpl) {
|
|
|
42
50
|
throw new TranscriptFetchError(`Kaltura caption list request failed (HTTP ${res.status}).`);
|
|
43
51
|
}
|
|
44
52
|
const data = (await res.json());
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
const refusal = apiException(data);
|
|
54
|
+
if (refusal) {
|
|
55
|
+
throw new TranscriptFetchError(`Kaltura rejected the caption list request: ${refusal}.`);
|
|
56
|
+
}
|
|
57
|
+
// A missing `objects` is a malformed answer, not an empty list. Only a real
|
|
58
|
+
// empty array means the entry genuinely has no caption assets.
|
|
59
|
+
if (!Array.isArray(data.objects)) {
|
|
60
|
+
throw new TranscriptFetchError("Kaltura returned an unreadable caption list for this video.");
|
|
47
61
|
}
|
|
48
|
-
return data.objects
|
|
62
|
+
return data.objects;
|
|
49
63
|
}
|
|
50
64
|
async function serveCaption(ks, captionAssetId, fetchImpl) {
|
|
51
65
|
const res = await fetchImpl(`${KALTURA_API_BASE}/caption_captionasset/action/serve?ks=${encodeURIComponent(ks)}` +
|
|
@@ -53,7 +67,17 @@ async function serveCaption(ks, captionAssetId, fetchImpl) {
|
|
|
53
67
|
if (!res.ok) {
|
|
54
68
|
throw new TranscriptFetchError(`Kaltura caption download failed (HTTP ${res.status}).`);
|
|
55
69
|
}
|
|
56
|
-
|
|
70
|
+
const body = await res.text();
|
|
71
|
+
// The asset was listed a moment ago, so nothing coming back is a refusal,
|
|
72
|
+
// not an absence. An error page arrives as JSON here rather than as cues.
|
|
73
|
+
if (!body.trim()) {
|
|
74
|
+
throw new TranscriptFetchError("Kaltura listed a caption asset for this video but served an empty response for it.");
|
|
75
|
+
}
|
|
76
|
+
const refusal = body.trimStart().startsWith("{") ? apiException(JSON.parse(body)) : undefined;
|
|
77
|
+
if (refusal) {
|
|
78
|
+
throw new TranscriptFetchError(`Kaltura refused to serve the caption asset: ${refusal}.`);
|
|
79
|
+
}
|
|
80
|
+
return body;
|
|
57
81
|
}
|
|
58
82
|
/** Best-effort title/duration — a caption-only entry still has a usable transcript without this. */
|
|
59
83
|
async function fetchMediaInfo(ks, entryId, fetchImpl) {
|
|
@@ -76,12 +100,30 @@ export async function getKalturaTranscript(partnerId, entryId, fetchImpl) {
|
|
|
76
100
|
if (assets.length === 0) {
|
|
77
101
|
throw new NoTranscriptError("This Kaltura video has no captions available.");
|
|
78
102
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
// Try the default asset first, then the rest: an entry can carry a stale or
|
|
104
|
+
// empty asset alongside a good one, and giving up on the first was enough to
|
|
105
|
+
// report a captioned lecture as having no transcript.
|
|
106
|
+
const ordered = [...assets].sort((a, b) => Number(Boolean(b.isDefault)) - Number(Boolean(a.isDefault)));
|
|
107
|
+
let asset = ordered[0];
|
|
108
|
+
let parsed;
|
|
109
|
+
let lastError;
|
|
110
|
+
for (const candidate of ordered) {
|
|
111
|
+
try {
|
|
112
|
+
const attempt = parseCaptions(await serveCaption(ks, candidate.id, fetchImpl));
|
|
113
|
+
if (attempt.cues.length > 0) {
|
|
114
|
+
asset = candidate;
|
|
115
|
+
parsed = attempt;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
lastError = error instanceof Error ? error : undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!parsed) {
|
|
124
|
+
throw lastError ?? new TranscriptFetchError("Kaltura served this video's caption assets in a format that could not be read.");
|
|
84
125
|
}
|
|
126
|
+
const { format, cues } = parsed;
|
|
85
127
|
const media = await fetchMediaInfo(ks, entryId, fetchImpl);
|
|
86
128
|
return {
|
|
87
129
|
cues,
|
|
@@ -43,7 +43,12 @@ export function detectVideoPlatform(url) {
|
|
|
43
43
|
// query params or path segments before falling back to "unknown".
|
|
44
44
|
if (hasHostLabel(host, "kaltura"))
|
|
45
45
|
return "kaltura";
|
|
46
|
-
if (isDomainOrSubdomain(host, "youtube.com")
|
|
46
|
+
if (isDomainOrSubdomain(host, "youtube.com")
|
|
47
|
+
|| isDomainOrSubdomain(host, "youtu.be")
|
|
48
|
+
// Privacy-mode embeds are the same video. extractYouTubeVideoId has always
|
|
49
|
+
// accepted this host, so leaving it out here made the tool answer
|
|
50
|
+
// "unsupported platform" for a link it could in fact read.
|
|
51
|
+
|| isDomainOrSubdomain(host, "youtube-nocookie.com"))
|
|
47
52
|
return "youtube";
|
|
48
53
|
if (hasHostLabel(host, "panopto"))
|
|
49
54
|
return "panopto";
|
|
@@ -102,9 +107,12 @@ export function extractYouTubeVideoId(url) {
|
|
|
102
107
|
const v = parsed.searchParams.get("v");
|
|
103
108
|
if (v)
|
|
104
109
|
return v;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
// /embed/, /shorts/, /live/ and the old /v/ all name the video in the path.
|
|
111
|
+
// Only /embed/ was handled, so a Shorts or a premiere link reported that no
|
|
112
|
+
// video id could be found.
|
|
113
|
+
const pathMatch = parsed.pathname.match(/\/(?:embed|shorts|live|v)\/([\w-]+)/);
|
|
114
|
+
if (pathMatch)
|
|
115
|
+
return pathMatch[1];
|
|
108
116
|
}
|
|
109
117
|
return null;
|
|
110
118
|
}
|
|
@@ -4,53 +4,166 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
/**
|
|
7
|
-
* YouTube transcript retrieval
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* YouTube transcript retrieval. Still no authentication: this is the caption
|
|
8
|
+
* track a signed-out viewer can read.
|
|
9
|
+
*
|
|
10
|
+
* It no longer uses `api/timedtext?type=list`. That endpoint now answers
|
|
11
|
+
* HTTP 200 with an empty body for every video, captioned or not, so listing
|
|
12
|
+
* tracks through it reported "this video has no captions" universally.
|
|
13
|
+
*
|
|
14
|
+
* The track list comes from YouTube's InnerTube player endpoint instead,
|
|
15
|
+
* asked as one of the mobile clients. The client identity matters: the signed
|
|
16
|
+
* caption URLs a browser page hands out are stamped `exp=xpe` and require a
|
|
17
|
+
* proof-of-origin token this server cannot mint, while the mobile clients'
|
|
18
|
+
* URLs are stamped `exp=xpo` and serve captions as-is. Both were measured;
|
|
19
|
+
* see the markers below.
|
|
11
20
|
*/
|
|
12
21
|
import { parseCaptions } from "./captions.js";
|
|
13
22
|
import { NoTranscriptError, TranscriptFetchError } from "./errors.js";
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
const PLAYER_ENDPOINT = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false";
|
|
24
|
+
/**
|
|
25
|
+
* Client versions rot: YouTube eventually refuses one outright, and the
|
|
26
|
+
* symptom is a non-OK playabilityStatus for every video at once. Bump these.
|
|
27
|
+
*/
|
|
28
|
+
const CLIENTS = [
|
|
29
|
+
{
|
|
30
|
+
name: "ANDROID",
|
|
31
|
+
userAgent: "com.google.android.youtube/20.10.38 (Linux; U; Android 14) gzip",
|
|
32
|
+
context: { clientName: "ANDROID", clientVersion: "20.10.38", androidSdkVersion: 34, hl: "en", gl: "US" },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "IOS",
|
|
36
|
+
userAgent: "com.google.ios.youtube/21.26.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)",
|
|
37
|
+
context: {
|
|
38
|
+
clientName: "IOS", clientVersion: "21.26.4", deviceMake: "Apple", deviceModel: "iPhone16,2",
|
|
39
|
+
osName: "iPhone", osVersion: "18.3.2.22D82", hl: "en", gl: "US",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
function trackLabel(track) {
|
|
44
|
+
return track.name?.runs?.[0]?.text ?? track.name?.simpleText;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* What the player said about a video that will not play for us. These are
|
|
48
|
+
* distinct from "has no captions", and saying so is the whole point: a
|
|
49
|
+
* bot check or an age gate used to be reported as an absent transcript.
|
|
50
|
+
*/
|
|
51
|
+
function playabilityFailure(status, reason) {
|
|
52
|
+
const detail = reason ? ` YouTube said: ${reason}` : "";
|
|
53
|
+
if (status === "LOGIN_REQUIRED") {
|
|
54
|
+
return new TranscriptFetchError(`YouTube would not serve this video without a sign-in.${detail}`);
|
|
55
|
+
}
|
|
56
|
+
if (status === "UNPLAYABLE" || status === "ERROR") {
|
|
57
|
+
return new TranscriptFetchError(`YouTube reports this video as unavailable.${detail}`);
|
|
19
58
|
}
|
|
20
|
-
|
|
21
|
-
return [...xml.matchAll(/<track\b[^>]*\blang_code="([^"]+)"/g)].map((m) => m[1]);
|
|
59
|
+
return new TranscriptFetchError(`YouTube refused to describe this video (${status}).${detail}`);
|
|
22
60
|
}
|
|
23
|
-
async function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
61
|
+
async function requestPlayer(videoId, fetchImpl) {
|
|
62
|
+
let lastError;
|
|
63
|
+
for (const client of CLIENTS) {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetchImpl(PLAYER_ENDPOINT, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "Content-Type": "application/json", "User-Agent": client.userAgent },
|
|
69
|
+
body: JSON.stringify({ videoId, contentCheckOk: true, racyCheckOk: true, context: { client: client.context } }),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
lastError = new TranscriptFetchError(`YouTube player request failed (HTTP ${res.status}).`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
parsed = (await res.json());
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
lastError = error instanceof Error ? error : new TranscriptFetchError("YouTube player request failed.");
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const status = parsed.playabilityStatus?.status;
|
|
82
|
+
// A client that has aged out answers non-OK for everything, so try the
|
|
83
|
+
// next one before believing what it says about the video.
|
|
84
|
+
if (status && status !== "OK") {
|
|
85
|
+
lastError = playabilityFailure(status, parsed.playabilityStatus?.reason);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
return parsed;
|
|
34
89
|
}
|
|
90
|
+
throw lastError ?? new TranscriptFetchError("YouTube did not describe this video.");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Which track to read. Auto-dubbed videos are the reason this is not simply
|
|
94
|
+
* "the first English one": dubbing adds *audio* tracks, all pointing back at
|
|
95
|
+
* the same caption list, and the dub a viewer hears decides which caption
|
|
96
|
+
* track YouTube shows by default. So the default audio track's own choice
|
|
97
|
+
* wins, then a manual track in the wanted language, then anything manual,
|
|
98
|
+
* then auto-generated. There is no transcript of synthesised dub speech --
|
|
99
|
+
* only the original language's captions, hence no attempt to find one.
|
|
100
|
+
*/
|
|
101
|
+
export function selectCaptionTrack(tracks, audio, defaultAudioIndex, preferred) {
|
|
102
|
+
if (tracks.length === 0)
|
|
103
|
+
return undefined;
|
|
104
|
+
const manual = (t) => t.kind !== "asr";
|
|
105
|
+
const wanted = (t) => (t.languageCode ?? "").toLowerCase().startsWith(preferred);
|
|
106
|
+
const chosen = audio?.[defaultAudioIndex ?? 0]?.defaultCaptionTrackIndex;
|
|
107
|
+
if (chosen !== undefined && tracks[chosen])
|
|
108
|
+
return tracks[chosen];
|
|
109
|
+
return tracks.find((t) => manual(t) && wanted(t))
|
|
110
|
+
?? tracks.find(wanted)
|
|
111
|
+
?? tracks.find(manual)
|
|
112
|
+
?? tracks[0];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Ask a signed caption URL for WebVTT. `fmt` is already present on the URL
|
|
116
|
+
* (as `srv3`), and a second one is ignored, so it has to be replaced rather
|
|
117
|
+
* than appended. The signature covers only the parameters named in
|
|
118
|
+
* `sparams`, which `fmt` is not, so rewriting it keeps the URL valid.
|
|
119
|
+
*/
|
|
120
|
+
export function captionUrlAsVtt(baseUrl) {
|
|
121
|
+
return /[?&]fmt=/.test(baseUrl)
|
|
122
|
+
? baseUrl.replace(/([?&])fmt=[^&]*/, "$1fmt=vtt")
|
|
123
|
+
: `${baseUrl}${baseUrl.includes("?") ? "&" : "?"}fmt=vtt`;
|
|
124
|
+
}
|
|
125
|
+
/** True for the browser-issued URLs that need a proof-of-origin token. */
|
|
126
|
+
function needsProofOfOrigin(baseUrl) {
|
|
127
|
+
const exp = /[?&]exp=([^&]*)/.exec(baseUrl)?.[1] ?? "";
|
|
128
|
+
return exp.split(",").some((value) => value === "xpe" || value === "xpv");
|
|
35
129
|
}
|
|
36
|
-
export async function getYouTubeTranscript(videoId, fetchImpl) {
|
|
37
|
-
const
|
|
130
|
+
export async function getYouTubeTranscript(videoId, fetchImpl, language = "en") {
|
|
131
|
+
const player = await requestPlayer(videoId, fetchImpl);
|
|
132
|
+
const renderer = player.captions?.playerCaptionsTracklistRenderer;
|
|
133
|
+
const tracks = (renderer?.captionTracks ?? []).filter((t) => typeof t?.baseUrl === "string" && t.baseUrl);
|
|
38
134
|
if (tracks.length === 0) {
|
|
39
135
|
throw new NoTranscriptError("This YouTube video has no caption tracks available.");
|
|
40
136
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
137
|
+
const track = selectCaptionTrack(tracks, renderer?.audioTracks, renderer?.defaultAudioTrackIndex, language);
|
|
138
|
+
if (!track) {
|
|
139
|
+
throw new NoTranscriptError("This YouTube video has no caption tracks available.");
|
|
140
|
+
}
|
|
141
|
+
if (needsProofOfOrigin(track.baseUrl)) {
|
|
142
|
+
throw new TranscriptFetchError("YouTube issued this caption track behind a proof-of-origin check this server cannot answer, " +
|
|
143
|
+
"so the captions could not be downloaded. The video may still have captions.");
|
|
144
|
+
}
|
|
145
|
+
const res = await fetchImpl(captionUrlAsVtt(track.baseUrl));
|
|
43
146
|
if (!res.ok) {
|
|
44
147
|
throw new TranscriptFetchError(`YouTube caption download failed (HTTP ${res.status}).`);
|
|
45
148
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
149
|
+
const body = await res.text();
|
|
150
|
+
// A caption URL that answers 200 with nothing is a refusal, not an absence:
|
|
151
|
+
// the track was listed a moment ago. Saying "no captions" here is what sent
|
|
152
|
+
// users looking for a transcript that was there all along.
|
|
153
|
+
if (!body.trim()) {
|
|
154
|
+
throw new TranscriptFetchError("YouTube listed a caption track for this video but served an empty response for it. " +
|
|
155
|
+
"This is usually a temporary block rather than a missing transcript; try again.");
|
|
49
156
|
}
|
|
50
|
-
const { format, cues } = parseCaptions(
|
|
157
|
+
const { format, cues } = parseCaptions(body);
|
|
51
158
|
if (cues.length === 0) {
|
|
52
|
-
throw new
|
|
159
|
+
throw new TranscriptFetchError("YouTube served a caption track for this video in a format that could not be read.");
|
|
53
160
|
}
|
|
54
|
-
const
|
|
55
|
-
return {
|
|
161
|
+
const lengthSeconds = Number(player.videoDetails?.lengthSeconds);
|
|
162
|
+
return {
|
|
163
|
+
cues,
|
|
164
|
+
format,
|
|
165
|
+
language: trackLabel(track) ?? track.languageCode ?? null,
|
|
166
|
+
title: player.videoDetails?.title ?? null,
|
|
167
|
+
durationSeconds: Number.isFinite(lengthSeconds) && lengthSeconds > 0 ? lengthSeconds : null,
|
|
168
|
+
};
|
|
56
169
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.9",
|
|
4
4
|
"mcpName": "io.github.RohanMuppa/brightspace",
|
|
5
5
|
"description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
|
|
6
6
|
"type": "module",
|