brightspace-mcp-server 3.13.7 → 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.
@@ -25,9 +25,9 @@ const AUTH_TIMEOUT_MS = 8 * 60 * 1000;
25
25
  const KILL_GRACE_MS = 5000;
26
26
  /**
27
27
  * How long a caller keeps waiting for the background sign-in once the
28
- * challenge is known: long enough to find a phone and approve. Applies both
29
- * to a caller told the challenge mid-call (see run()'s onChallenge) and to a
30
- * retry that joins the background child after an early answer. The retry
28
+ * challenge is known: long enough to find a phone and approve. Applies to a
29
+ * retry that joins the background child after the answer carrying the
30
+ * challenge (the first answer never waits; see attend). The retry
31
31
  * used to wait only 5 s, so on clients that send no progress token (Claude
32
32
  * Desktop) every retry re-answered "enter the number" and the model asked
33
33
  * the user to confirm by hand instead of the server polling for the approval.
@@ -289,9 +289,9 @@ export class AuthRunner {
289
289
  * challenge appears (an mfaPending error carrying the number to enter: the
290
290
  * user cannot see it any other way), and the next call joins the background
291
291
  * sign-in and polls it for up to MFA_POLL_MS, never past CALL_BUDGET_MS from
292
- * that call. With onChallenge, the caller is handed the challenge mid-call
293
- * and the first call itself keeps waiting the same way. Either way an
294
- * approval within the window completes the caller's original request; past
292
+ * that call. With onChallenge, the caller is also handed the challenge
293
+ * mid-call, but is still answered at once: a client may accept progress
294
+ * notices without showing them. An approval within the window completes the caller's original request; past
295
295
  * it, the caller gets the same mfaPending answer again and is expected to
296
296
  * call once more. No caller waits past CALL_BUDGET_MS: a sign-in still
297
297
  * short of its challenge by then answers inProgress and keeps running for
@@ -355,21 +355,24 @@ export class AuthRunner {
355
355
  const childDone = this.childDone;
356
356
  if (!childDone || !(error instanceof AuthProcessError))
357
357
  throw error;
358
- // An mfaPending is only worth waiting on when the caller can relay the
359
- // number mid-call. An automaticPending needs no relay at all — nobody is
360
- // being asked for anything — so keep waiting on it either way.
361
- const joinable = error.kind === "automaticPending" || (Boolean(onChallenge) && error.kind === "mfaPending");
362
- if (!joinable)
363
- throw error;
364
- const windowMs = pollWindowMs(Date.now() - startedAt);
365
- if (windowMs <= 0)
366
- throw error;
358
+ // A new challenge always goes back in the answer, announced or not. A
359
+ // progress token says a client CAN receive mid-call notices, not that it
360
+ // shows them: Claude Desktop sends one and displays nothing, so holding
361
+ // the call open after announcing kept the number from the user for up
362
+ // to 45 seconds. The retry is what joins and waits for the approval.
363
+ // An automaticPending asks nothing of anyone, so keep waiting on it.
367
364
  if (error.kind === "mfaPending") {
368
365
  try {
369
366
  onChallenge?.(error.numberMatch);
370
367
  }
371
368
  catch { /* Announcing must not interrupt authentication. */ }
369
+ throw error;
372
370
  }
371
+ if (error.kind !== "automaticPending")
372
+ throw error;
373
+ const windowMs = pollWindowMs(Date.now() - startedAt);
374
+ if (windowMs <= 0)
375
+ throw error;
373
376
  return this.awaitBackgroundChild(childDone, windowMs);
374
377
  }
375
378
  }
@@ -379,32 +382,30 @@ export class AuthRunner {
379
382
  * exited, so unlike awaitBackgroundChild there is nothing here to join:
380
383
  * the lock is the only thing to watch.
381
384
  *
382
- * The first answer is still immediate for a caller that cannot be told
383
- * mid-call, because the number is what the user needs and every second
385
+ * The first answer is immediate, because the number is what the user needs and every second
384
386
  * before it is a second the other sign-in's window runs down. A RETRY of the
385
387
  * same challenge (already reported to a caller in this process) waits, like
386
388
  * a retry joining a local child, instead of re-answering at once: answering
387
389
  * a loop of retries in milliseconds made a model conclude the approval
388
390
  * window had closed while the other sign-in was still waiting. A caller
389
- * that can be told mid-call is told and waits at once, and an owner
390
- * answering its own code has nothing to relay, so every caller waits.
391
+ * that can be told mid-call is told too, but is answered at once all the
392
+ * same (see attend), and an owner answering its own code has nothing to
393
+ * relay, so every caller waits.
391
394
  */
392
395
  attendRelay(error, startedAt, onChallenge, toldBefore) {
393
396
  const retry = toldBefore.has(relayKey(error.kind, error.numberMatch));
394
- const waits = error.kind === "automaticPending"
395
- || (error.kind === "mfaPending" && (Boolean(onChallenge) || retry));
396
- if (!waits)
397
- throw error;
398
- const windowMs = pollWindowMs(Date.now() - startedAt);
399
- if (windowMs <= 0)
400
- throw error;
401
- if (error.kind === "mfaPending" && onChallenge) {
397
+ if (error.kind === "mfaPending" && onChallenge && !retry) {
402
398
  try {
403
399
  onChallenge(error.numberMatch);
404
400
  }
405
401
  catch { /* Announcing must not interrupt authentication. */ }
406
- this.noteRelayReported(error);
407
402
  }
403
+ const waits = error.kind === "automaticPending" || (error.kind === "mfaPending" && retry);
404
+ if (!waits)
405
+ throw error;
406
+ const windowMs = pollWindowMs(Date.now() - startedAt);
407
+ if (windowMs <= 0)
408
+ throw error;
408
409
  return this.awaitRelayedOwner(error, windowMs);
409
410
  }
410
411
  /**
@@ -110,7 +110,9 @@ const AUTH_FAILURE_GUIDANCE = {
110
110
  "Call this tool again right away: the next call joins the same sign-in and returns the result " +
111
111
  "as soon as it completes.",
112
112
  mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo). " +
113
- "Tell the user that, then call this tool again right away without waiting for them to " +
113
+ "Tell the user that now, as text in your reply, before you make any other tool call: they " +
114
+ "can't see tool results, and some apps show nothing until you write it. Then call this tool " +
115
+ "again right away without waiting for them to " +
114
116
  "confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
115
117
  "as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
116
118
  MFA_SOLO_CALL,
@@ -141,7 +143,9 @@ function authFailureMessage(error) {
141
143
  return AUTOMATIC_ALREADY_REPORTED;
142
144
  if (error.kind === "mfaPending" && error.numberMatch) {
143
145
  return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes. ` +
144
- "Tell the user that number, then call this tool again right away without waiting for them " +
146
+ "Show the user that number now, as text in your reply, before you make any other tool call: " +
147
+ "they can't see tool results, and some apps show nothing until you write it. Then call this " +
148
+ "tool again right away without waiting for them " +
145
149
  "to confirm: each call waits up to 45 seconds for the approval and returns the result as " +
146
150
  "soon as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
147
151
  MFA_SOLO_CALL;
@@ -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 `&#39;` and `&amp;`, 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 format = /^WEBVTT/.test(normalized.trimStart()) ? "vtt" : "srt";
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
- .slice(timingIndex + 1)
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
- if (data.error) {
46
- throw new TranscriptFetchError(`Kaltura rejected the caption list request: ${data.error.message ?? "unknown error"}.`);
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
- return res.text();
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
- const asset = assets.find((a) => a.isDefault) ?? assets[0];
80
- const raw = await serveCaption(ks, asset.id, fetchImpl);
81
- const { format, cues } = parseCaptions(raw);
82
- if (cues.length === 0) {
83
- throw new NoTranscriptError("This Kaltura video has a caption track, but it contained no readable cues.");
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") || isDomainOrSubdomain(host, "youtu.be"))
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
- const embedMatch = parsed.pathname.match(/\/embed\/([\w-]+)/);
106
- if (embedMatch)
107
- return embedMatch[1];
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 via its public timedtext endpoint. No
8
- * authentication is needed — this is the same caption track a signed-out
9
- * browser can fetch — so unlike Kaltura there is no session to reuse or fail
10
- * to obtain.
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 TIMEDTEXT_BASE = "https://www.youtube.com/api/timedtext";
15
- async function listCaptionTracks(videoId, fetchImpl) {
16
- const res = await fetchImpl(`${TIMEDTEXT_BASE}?type=list&v=${encodeURIComponent(videoId)}`);
17
- if (!res.ok) {
18
- throw new TranscriptFetchError(`YouTube caption list request failed (HTTP ${res.status}).`);
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
- const xml = await res.text();
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 fetchTitle(videoId, fetchImpl) {
24
- try {
25
- const watchUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
26
- const res = await fetchImpl(`https://www.youtube.com/oembed?url=${encodeURIComponent(watchUrl)}&format=json`);
27
- if (!res.ok)
28
- return null;
29
- const data = (await res.json());
30
- return data.title ?? null;
31
- }
32
- catch {
33
- return null;
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 tracks = await listCaptionTracks(videoId, fetchImpl);
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 lang = tracks.find((t) => t.startsWith("en")) ?? tracks[0];
42
- const res = await fetchImpl(`${TIMEDTEXT_BASE}?lang=${encodeURIComponent(lang)}&v=${encodeURIComponent(videoId)}&fmt=vtt`);
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 vtt = await res.text();
47
- if (!vtt.trim()) {
48
- throw new NoTranscriptError("This YouTube video has no caption tracks available.");
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(vtt);
157
+ const { format, cues } = parseCaptions(body);
51
158
  if (cues.length === 0) {
52
- throw new NoTranscriptError("This YouTube video has a caption track, but it contained no readable cues.");
159
+ throw new TranscriptFetchError("YouTube served a caption track for this video in a format that could not be read.");
53
160
  }
54
- const title = await fetchTitle(videoId, fetchImpl);
55
- return { cues, format, language: lang, title, durationSeconds: null };
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.7",
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",