brightspace-mcp-server 3.13.8 → 3.13.10
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.
- package/build/auth/auth-lock.js +18 -0
- package/build/auth/auth-runner.js +149 -23
- package/build/auth/browser-auth.js +4 -2
- package/build/auth/mfa-challenge.js +20 -0
- package/build/auth-cli.js +7 -6
- package/build/tools/get-announcement-files.js +1 -0
- package/build/tools/get-announcements.js +1 -0
- package/build/tools/get-assignment-files.js +1 -0
- package/build/tools/get-assignment-rubric.js +1 -0
- package/build/tools/get-assignments.js +1 -0
- package/build/tools/get-calendar-events.js +1 -0
- package/build/tools/get-classlist-emails.js +1 -0
- package/build/tools/get-course-content.js +1 -0
- package/build/tools/get-discussions.js +1 -0
- package/build/tools/get-dropbox-feedback.js +1 -0
- package/build/tools/get-dropbox-folders.js +1 -0
- package/build/tools/get-dropbox-submissions.js +1 -0
- package/build/tools/get-dropbox-user-submissions.js +1 -0
- package/build/tools/get-my-courses.js +1 -0
- package/build/tools/get-my-grades.js +1 -0
- package/build/tools/get-my-groups.js +1 -0
- package/build/tools/get-roster.js +1 -0
- package/build/tools/get-rubrics-for-object.js +1 -0
- package/build/tools/get-server-info.js +1 -0
- package/build/tools/get-upcoming-due-dates.js +1 -0
- package/build/tools/get-video-transcript.js +1 -0
- package/build/tools/search-course.js +1 -0
- package/build/tools/tool-helpers.js +3 -3
- package/build/utils/transcript/captions.js +125 -6
- package/build/utils/transcript/kaltura.js +51 -9
- package/build/utils/transcript/platform.js +12 -4
- package/build/utils/transcript/youtube.js +146 -33
- package/package.json +1 -1
package/build/auth/auth-lock.js
CHANGED
|
@@ -52,6 +52,12 @@ export class AuthenticationInProgressError extends Error {
|
|
|
52
52
|
* CHALLENGE_FILE from this one.
|
|
53
53
|
*/
|
|
54
54
|
challenge;
|
|
55
|
+
/**
|
|
56
|
+
* How the auth lock's owner holds it, when the caller looked it up (see
|
|
57
|
+
* BrowserAuth.authenticate). Set only for the auth lock, never for the
|
|
58
|
+
* other locks that throw this same error.
|
|
59
|
+
*/
|
|
60
|
+
ownerMode;
|
|
55
61
|
constructor() {
|
|
56
62
|
super("Authentication already in progress. Retry after the current authentication finishes.");
|
|
57
63
|
this.name = "AuthenticationInProgressError";
|
|
@@ -98,6 +104,18 @@ export const lockOps = {
|
|
|
98
104
|
kill: (pid, signal) => { process.kill(pid, signal); },
|
|
99
105
|
isDead: isDeadImpl,
|
|
100
106
|
};
|
|
107
|
+
/**
|
|
108
|
+
* What a contender can tell about a lock's owner: how it holds the lock
|
|
109
|
+
* ("exclusive" reads back as "explicit", see readOwner) and whether that
|
|
110
|
+
* process is gone. undefined when there is no readable owner record, which
|
|
111
|
+
* is not proof the owner is gone (it may still be writing it).
|
|
112
|
+
*/
|
|
113
|
+
export async function inspectLockOwner(lockPath) {
|
|
114
|
+
const owner = await readOwner(lockPath);
|
|
115
|
+
if (!owner)
|
|
116
|
+
return undefined;
|
|
117
|
+
return { mode: owner.mode === "automatic" ? "automatic" : "explicit", gone: lockOps.isDead(owner) };
|
|
118
|
+
}
|
|
101
119
|
/** Remove only our fixed metadata names, after the directory was retired. */
|
|
102
120
|
async function cleanRetired(lockPath) {
|
|
103
121
|
await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
|
|
@@ -12,7 +12,8 @@ import { AuthError } from "../utils/errors.js";
|
|
|
12
12
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
13
13
|
import { devActivity } from "../utils/dev-activity.js";
|
|
14
14
|
import { parsePhaseMarker } from "./auth-phases.js";
|
|
15
|
-
import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
|
|
15
|
+
import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
|
|
16
|
+
import { inspectLockOwner } from "./auth-lock.js";
|
|
16
17
|
/**
|
|
17
18
|
* Timeout for the auth process. It has to outlast the child's own MFA wait,
|
|
18
19
|
* which is five minutes: a person has to find their phone, unlock it, and read
|
|
@@ -68,6 +69,27 @@ const RELAY_POLL_MS = 1000;
|
|
|
68
69
|
* is over; the same digits showing up again are a new challenge.
|
|
69
70
|
*/
|
|
70
71
|
const RELAY_TOLD_MS = 5 * 60 * 1000;
|
|
72
|
+
/**
|
|
73
|
+
* A call that arrives this soon after another call was answered with an MFA
|
|
74
|
+
* challenge is that call's sibling, not a retry. Claude Code runs tool calls
|
|
75
|
+
* that are not marked read-only one at a time, so a batch the model sent
|
|
76
|
+
* together arrives as a queue: each call lands moments after the previous one
|
|
77
|
+
* answered. Treated as retries, they each waited up to 45 seconds for an
|
|
78
|
+
* approval the user could not give yet, because the model cannot show the
|
|
79
|
+
* number until the whole batch is done. A real retry comes after the model
|
|
80
|
+
* has read the answer and written to the user, which takes longer than this.
|
|
81
|
+
* Measured from when the call arrived, and from the previous answer of either
|
|
82
|
+
* kind, so a queue of siblings chains (see SIBLING_CHAIN_MS).
|
|
83
|
+
*/
|
|
84
|
+
const SIBLING_GRACE_MS = 1500;
|
|
85
|
+
/**
|
|
86
|
+
* How long a chain of sibling answers may run after the last answer that was
|
|
87
|
+
* not a sibling's. A queue of a few tool calls fits easily; a loop of calls
|
|
88
|
+
* that never stops to wait is cut off here and has to wait like a retry.
|
|
89
|
+
*/
|
|
90
|
+
const SIBLING_CHAIN_MS = 20000;
|
|
91
|
+
/** What a call is told when the sign-in it waited on is still short of its challenge. */
|
|
92
|
+
const STILL_SIGNING_IN = "Sign-in is still running in the background. Try again.";
|
|
71
93
|
/**
|
|
72
94
|
* Calls arriving within this long of a batch's first call count as that batch
|
|
73
95
|
* (issue #212). A client running tool calls in parallel sends them within
|
|
@@ -152,7 +174,7 @@ export class AuthProcessError extends AuthError {
|
|
|
152
174
|
function withinCallBudget(work, startedAt) {
|
|
153
175
|
return new Promise((resolve, reject) => {
|
|
154
176
|
const budgetTimer = setTimeout(() => {
|
|
155
|
-
reject(new AuthProcessError("inProgress",
|
|
177
|
+
reject(new AuthProcessError("inProgress", STILL_SIGNING_IN));
|
|
156
178
|
}, Math.max(0, CALL_BUDGET_MS - (Date.now() - startedAt)));
|
|
157
179
|
budgetTimer.unref?.();
|
|
158
180
|
work.then((value) => { clearTimeout(budgetTimer); resolve(value); }, (error) => { clearTimeout(budgetTimer); reject(error); });
|
|
@@ -223,6 +245,13 @@ function dedupeChallenge(error, wave) {
|
|
|
223
245
|
}
|
|
224
246
|
return new AuthProcessError(error.kind, error.message, error.numberMatch, true, error.relayed);
|
|
225
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Answers given to a queued sibling (see SIBLING_GRACE_MS). They extend the
|
|
250
|
+
* chain (Telling.last) but never restart it (Telling.anchor).
|
|
251
|
+
*/
|
|
252
|
+
const siblingAnswers = new WeakSet();
|
|
253
|
+
/** Whether a call arriving at arrivedAt is a queued sibling of that telling. */
|
|
254
|
+
const isQueuedSibling = (told, arrivedAt) => told !== undefined && arrivedAt - told.last < SIBLING_GRACE_MS && arrivedAt - told.anchor < SIBLING_CHAIN_MS;
|
|
226
255
|
/** Identifies a challenge for "was this already reported to a caller". */
|
|
227
256
|
const relayKey = (kind, numberMatch) => `${kind}:${numberMatch ?? ""}`;
|
|
228
257
|
export class AuthRunner {
|
|
@@ -269,6 +298,18 @@ export class AuthRunner {
|
|
|
269
298
|
* that sign-in instead of repeating the answer (see attendRelay).
|
|
270
299
|
*/
|
|
271
300
|
relayTold = new Map();
|
|
301
|
+
/**
|
|
302
|
+
* When callers here were answered with this process's own pending
|
|
303
|
+
* challenge. A call arriving just after is a queued sibling and is
|
|
304
|
+
* answered at once (see SIBLING_GRACE_MS).
|
|
305
|
+
*/
|
|
306
|
+
challengeTold = null;
|
|
307
|
+
/**
|
|
308
|
+
* What the current child reported when it only relayed another process's
|
|
309
|
+
* sign-in. A later call answers from it instead of joining that child,
|
|
310
|
+
* which can linger for seconds after it has said everything it will say.
|
|
311
|
+
*/
|
|
312
|
+
relayProbe = null;
|
|
272
313
|
scriptPath;
|
|
273
314
|
timeoutMs;
|
|
274
315
|
onProgress;
|
|
@@ -298,6 +339,9 @@ export class AuthRunner {
|
|
|
298
339
|
* the next call to join.
|
|
299
340
|
*/
|
|
300
341
|
async run(onChallenge) {
|
|
342
|
+
const sibling = this.queuedSiblingAnswer(Date.now());
|
|
343
|
+
if (sibling)
|
|
344
|
+
throw sibling;
|
|
301
345
|
// Synchronous, before any await: calls in one tick cannot split a batch.
|
|
302
346
|
const wave = this.joinWave();
|
|
303
347
|
// Also synchronous: what was already told BEFORE this call, so siblings in
|
|
@@ -310,31 +354,61 @@ export class AuthRunner {
|
|
|
310
354
|
}
|
|
311
355
|
catch (error) {
|
|
312
356
|
this.noteRelayReported(error);
|
|
313
|
-
|
|
357
|
+
const answer = dedupeChallenge(error, wave);
|
|
358
|
+
if (answer instanceof AuthProcessError && answer.kind === "mfaPending" && !answer.duplicate && !answer.relayed) {
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
this.challengeTold = { anchor: now, last: now };
|
|
361
|
+
}
|
|
362
|
+
throw answer;
|
|
314
363
|
}
|
|
315
364
|
finally {
|
|
316
365
|
this.waiting -= 1;
|
|
317
366
|
this.lastAttendedAt = Date.now();
|
|
318
367
|
}
|
|
319
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* The answer for a call queued right behind one that was just answered
|
|
371
|
+
* with this process's pending challenge
|
|
372
|
+
* (see SIBLING_GRACE_MS), or null when this call should run normally.
|
|
373
|
+
*/
|
|
374
|
+
queuedSiblingAnswer(arrivedAt) {
|
|
375
|
+
const pending = this.pendingChallenge;
|
|
376
|
+
const told = this.challengeTold;
|
|
377
|
+
if (!this.childDone || pending?.kind !== "mfaPending" || !told || !isQueuedSibling(told, arrivedAt))
|
|
378
|
+
return null;
|
|
379
|
+
told.last = arrivedAt;
|
|
380
|
+
// In full, not the short sibling line: if the answer before it was lost,
|
|
381
|
+
// this one still carries the digits (#201). It never reaches catch in
|
|
382
|
+
// run(), so it extends the chain without restarting it.
|
|
383
|
+
return new AuthProcessError(...mfaPendingFailure(pending.numberMatch), pending.numberMatch);
|
|
384
|
+
}
|
|
385
|
+
/** Relayed challenges already told to a caller here, and when (a snapshot). */
|
|
320
386
|
relayChallengesTold() {
|
|
321
387
|
const now = Date.now();
|
|
322
|
-
const told = new
|
|
388
|
+
const told = new Map();
|
|
323
389
|
for (const [key, at] of this.relayTold) {
|
|
324
|
-
if (now - at > RELAY_TOLD_MS)
|
|
390
|
+
if (now - at.last > RELAY_TOLD_MS)
|
|
325
391
|
this.relayTold.delete(key);
|
|
326
392
|
else
|
|
327
|
-
told.
|
|
393
|
+
told.set(key, { ...at });
|
|
328
394
|
}
|
|
329
395
|
return told;
|
|
330
396
|
}
|
|
331
397
|
/** A relayed challenge that went back to a caller counts as told. */
|
|
332
398
|
noteRelayReported(error) {
|
|
333
|
-
if (!(error instanceof AuthProcessError) || !error.relayed)
|
|
399
|
+
if (!(error instanceof AuthProcessError) || !error.relayed || error.duplicate)
|
|
334
400
|
return;
|
|
335
401
|
if (error.kind !== "mfaPending" && error.kind !== "automaticPending")
|
|
336
402
|
return;
|
|
337
|
-
|
|
403
|
+
const key = relayKey(error.kind, error.numberMatch);
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
const told = this.relayTold.get(key);
|
|
406
|
+
// A sibling's answer extends the chain but does not restart it, or a loop
|
|
407
|
+
// of quick calls could keep every one of them from waiting.
|
|
408
|
+
if (told && siblingAnswers.has(error))
|
|
409
|
+
told.last = now;
|
|
410
|
+
else
|
|
411
|
+
this.relayTold.set(key, { anchor: now, last: now });
|
|
338
412
|
}
|
|
339
413
|
joinWave() {
|
|
340
414
|
const now = Date.now();
|
|
@@ -393,14 +467,25 @@ export class AuthRunner {
|
|
|
393
467
|
* relay, so every caller waits.
|
|
394
468
|
*/
|
|
395
469
|
attendRelay(error, startedAt, onChallenge, toldBefore) {
|
|
396
|
-
const
|
|
470
|
+
const told = toldBefore.get(relayKey(error.kind, error.numberMatch));
|
|
471
|
+
// startedAt is when this call arrived, not when its child finished: a
|
|
472
|
+
// queued call is a sibling however long the lock check took.
|
|
473
|
+
if (error.kind === "mfaPending" && isQueuedSibling(told, startedAt)) {
|
|
474
|
+
// Queued right behind the call that was told (see SIBLING_GRACE_MS):
|
|
475
|
+
// answer in full at once.
|
|
476
|
+
const sibling = new AuthProcessError(error.kind, error.message, error.numberMatch, false, true);
|
|
477
|
+
siblingAnswers.add(sibling);
|
|
478
|
+
throw sibling;
|
|
479
|
+
}
|
|
480
|
+
const retry = told !== undefined;
|
|
397
481
|
if (error.kind === "mfaPending" && onChallenge && !retry) {
|
|
398
482
|
try {
|
|
399
483
|
onChallenge(error.numberMatch);
|
|
400
484
|
}
|
|
401
485
|
catch { /* Announcing must not interrupt authentication. */ }
|
|
402
486
|
}
|
|
403
|
-
|
|
487
|
+
// inProgress: the owner has not reached a challenge yet. Wait for one.
|
|
488
|
+
const waits = error.kind === "automaticPending" || error.kind === "inProgress" || (error.kind === "mfaPending" && retry);
|
|
404
489
|
if (!waits)
|
|
405
490
|
throw error;
|
|
406
491
|
const windowMs = pollWindowMs(Date.now() - startedAt);
|
|
@@ -426,21 +511,30 @@ export class AuthRunner {
|
|
|
426
511
|
async awaitRelayedOwner(error, windowMs) {
|
|
427
512
|
const lockPath = authLockPath(this.sessionDir);
|
|
428
513
|
const deadline = Date.now() + windowMs;
|
|
429
|
-
let latest = {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
};
|
|
514
|
+
let latest = error.kind === "inProgress" ? { phase: "starting" }
|
|
515
|
+
: error.kind === "automaticPending" ? { phase: "automatic" }
|
|
516
|
+
: { phase: "approval", numberMatch: error.numberMatch };
|
|
433
517
|
for (;;) {
|
|
434
518
|
const held = await fs.access(lockPath).then(() => true, () => false);
|
|
435
519
|
if (!held) {
|
|
436
520
|
this.relayTold.clear();
|
|
437
521
|
return true;
|
|
438
522
|
}
|
|
523
|
+
// The owner died without releasing its lock. Waiting longer only runs
|
|
524
|
+
// the clock down; the next call's sign-in reclaims the dead lock.
|
|
525
|
+
if ((await inspectLockOwner(lockPath))?.gone)
|
|
526
|
+
throw new AuthProcessError("inProgress", STILL_SIGNING_IN);
|
|
439
527
|
const relayed = await relayChallenge(lockPath);
|
|
440
528
|
if (relayed) {
|
|
441
|
-
|
|
442
|
-
? {
|
|
443
|
-
: {
|
|
529
|
+
const next = relayed.kind === "automatic"
|
|
530
|
+
? { phase: "automatic" }
|
|
531
|
+
: { phase: "approval", numberMatch: relayed.numberMatch ?? (latest.phase === "approval" ? latest.numberMatch : undefined) };
|
|
532
|
+
// A challenge appearing while this caller waited on a sign-in that had
|
|
533
|
+
// none yet is what the user needs right now: answer with it at once.
|
|
534
|
+
if (next.phase === "approval" && latest.phase !== "approval") {
|
|
535
|
+
throw new AuthProcessError(...mfaPendingFailure(next.numberMatch), next.numberMatch, false, true);
|
|
536
|
+
}
|
|
537
|
+
latest = next;
|
|
444
538
|
}
|
|
445
539
|
const remaining = deadline - Date.now();
|
|
446
540
|
if (remaining <= 0)
|
|
@@ -450,7 +544,9 @@ export class AuthRunner {
|
|
|
450
544
|
timer.unref?.();
|
|
451
545
|
});
|
|
452
546
|
}
|
|
453
|
-
if (latest.
|
|
547
|
+
if (latest.phase === "starting")
|
|
548
|
+
throw new AuthProcessError("inProgress", STILL_SIGNING_IN, undefined, false, true);
|
|
549
|
+
if (latest.phase === "automatic")
|
|
454
550
|
throw automaticPendingFailure(true);
|
|
455
551
|
throw new AuthProcessError(...mfaPendingFailure(latest.numberMatch), latest.numberMatch, false, true);
|
|
456
552
|
}
|
|
@@ -463,6 +559,12 @@ export class AuthRunner {
|
|
|
463
559
|
// running in the background (waiting on the user's phone). Join it
|
|
464
560
|
// instead of spawning a second child, which would only hit the
|
|
465
561
|
// cross-process lock and return "busy".
|
|
562
|
+
// That child only relayed another process's sign-in, and has said all it
|
|
563
|
+
// will: answer from its report rather than wait for it to exit.
|
|
564
|
+
if (this.childDone && this.relayProbe) {
|
|
565
|
+
const probe = this.relayProbe;
|
|
566
|
+
return Promise.reject(new AuthProcessError(probe.kind, probe.message, probe.numberMatch, false, true));
|
|
567
|
+
}
|
|
466
568
|
if (this.childDone) {
|
|
467
569
|
log("DEBUG", "Joining the background sign-in still running from an earlier call");
|
|
468
570
|
return this.joinBackgroundChild(this.childDone, startedAt);
|
|
@@ -633,11 +735,14 @@ export class AuthRunner {
|
|
|
633
735
|
this.pendingChallenge = null;
|
|
634
736
|
this.challengeSignal = null;
|
|
635
737
|
this.wave = null;
|
|
738
|
+
this.challengeTold = null;
|
|
739
|
+
this.relayProbe = null;
|
|
636
740
|
}
|
|
637
741
|
});
|
|
638
742
|
trackedCompletion.catch(() => { });
|
|
639
743
|
this.childDone = trackedCompletion;
|
|
640
744
|
this.pendingChallenge = null;
|
|
745
|
+
this.relayProbe = null;
|
|
641
746
|
let resolveChallengeSignal;
|
|
642
747
|
this.challengeSignal = new Promise((res) => { resolveChallengeSignal = res; });
|
|
643
748
|
// Records the challenge (number or not) and wakes a joiner waiting in
|
|
@@ -683,9 +788,11 @@ export class AuthRunner {
|
|
|
683
788
|
}
|
|
684
789
|
if (firstChallenge)
|
|
685
790
|
resolveChallengeSignal();
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
791
|
+
const answer = new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch, false, relayed);
|
|
792
|
+
if (relayed)
|
|
793
|
+
this.relayProbe = answer;
|
|
794
|
+
if (!callerSettled)
|
|
795
|
+
settleCaller(answer);
|
|
689
796
|
};
|
|
690
797
|
/**
|
|
691
798
|
* The child is typing its own verification code. Watched for abandonment
|
|
@@ -700,7 +807,10 @@ export class AuthRunner {
|
|
|
700
807
|
this.pendingChallenge = { kind: "automaticPending" };
|
|
701
808
|
startWatching();
|
|
702
809
|
resolveChallengeSignal();
|
|
703
|
-
|
|
810
|
+
const answer = automaticPendingFailure(relayed);
|
|
811
|
+
if (relayed)
|
|
812
|
+
this.relayProbe = answer;
|
|
813
|
+
settleCaller(answer);
|
|
704
814
|
};
|
|
705
815
|
// Runs once the child is actually done. Settles the caller too, if an
|
|
706
816
|
// early answer had not already done so; otherwise this is just the
|
|
@@ -768,6 +878,18 @@ export class AuthRunner {
|
|
|
768
878
|
// marker that follows it settles the caller.
|
|
769
879
|
relayed = true;
|
|
770
880
|
}
|
|
881
|
+
else if (line === RELAYED_STARTING_MARKER) {
|
|
882
|
+
// Another process's background sign-in holds the lock and has no
|
|
883
|
+
// challenge yet. With a lock to watch, answer now so the caller can
|
|
884
|
+
// wait on it (attendRelay); the child may linger a few seconds more.
|
|
885
|
+
// Without one, the close handler answers "busy" as it always has.
|
|
886
|
+
relayed = true;
|
|
887
|
+
if (this.sessionDir) {
|
|
888
|
+
const answer = new AuthProcessError("inProgress", STILL_SIGNING_IN, undefined, false, true);
|
|
889
|
+
this.relayProbe = answer;
|
|
890
|
+
settleCaller(answer);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
771
893
|
else {
|
|
772
894
|
log("DEBUG", line);
|
|
773
895
|
}
|
|
@@ -807,9 +929,13 @@ export class AuthRunner {
|
|
|
807
929
|
// nothing to approve, so say that rather than "busy".
|
|
808
930
|
else if (code === 2 && automaticSeen)
|
|
809
931
|
failures[2] = ["automaticPending", automaticPendingFailure().message];
|
|
932
|
+
// Busy because another process's sign-in has not reached a
|
|
933
|
+
// challenge yet: the caller can wait on it (see attendRelay).
|
|
934
|
+
else if (code === 2 && relayed && this.sessionDir)
|
|
935
|
+
failures[2] = ["inProgress", STILL_SIGNING_IN];
|
|
810
936
|
const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
|
|
811
937
|
kill("SIGKILL");
|
|
812
|
-
const challenged = kind === "mfaPending" || kind === "automaticPending";
|
|
938
|
+
const challenged = kind === "mfaPending" || kind === "automaticPending" || kind === "inProgress";
|
|
813
939
|
finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined, false, relayed && challenged));
|
|
814
940
|
}
|
|
815
941
|
});
|
|
@@ -10,7 +10,7 @@ import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError, Automa
|
|
|
10
10
|
import { isDuoPrompt } from "./duo-mfa.js";
|
|
11
11
|
import { BrowserStateStore } from "./browser-state-store.js";
|
|
12
12
|
import { hasNewerEntraState, recordMicrosoftSession } from "./microsoft-session.js";
|
|
13
|
-
import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
|
|
13
|
+
import { acquireProcessLock, AuthenticationInProgressError, inspectLockOwner } from "./auth-lock.js";
|
|
14
14
|
import { authLockPath, publishAutomaticProgress, publishChallenge, relayChallenge } from "./mfa-challenge.js";
|
|
15
15
|
import { AuthCooldown } from "./auth-cooldown.js";
|
|
16
16
|
import { mintAccessToken } from "./token-mint.js";
|
|
@@ -126,8 +126,10 @@ export class BrowserAuth {
|
|
|
126
126
|
const release = await acquireProcessLock(this.lockPath, { mode: options.automatic ? "automatic" : "explicit" }).catch(async (error) => {
|
|
127
127
|
// Hand back the number the owner is showing, so this caller's user can
|
|
128
128
|
// approve that sign-in instead of being told only to wait for it.
|
|
129
|
-
if (error instanceof AuthenticationInProgressError)
|
|
129
|
+
if (error instanceof AuthenticationInProgressError) {
|
|
130
130
|
error.challenge = await relayChallenge(this.lockPath);
|
|
131
|
+
error.ownerMode = (await inspectLockOwner(this.lockPath))?.mode;
|
|
132
|
+
}
|
|
131
133
|
throw error;
|
|
132
134
|
});
|
|
133
135
|
try {
|
|
@@ -86,6 +86,26 @@ export const AUTOMATIC_PENDING_MARKER = "AUTH_AUTOMATIC_PENDING";
|
|
|
86
86
|
* needed.
|
|
87
87
|
*/
|
|
88
88
|
export const RELAYED_CHALLENGE_MARKER = "AUTH_RELAYED";
|
|
89
|
+
/**
|
|
90
|
+
* Printed alone, instead of the relay marker and a challenge, when the lock
|
|
91
|
+
* belongs to another server's background sign-in that has not reached a
|
|
92
|
+
* challenge yet. The parent can wait on that sign-in (AuthRunner.attendRelay).
|
|
93
|
+
*/
|
|
94
|
+
export const RELAYED_STARTING_MARKER = "AUTH_RELAYED_STARTING";
|
|
95
|
+
/**
|
|
96
|
+
* What an automatic child prints when its sign-in found the auth lock held:
|
|
97
|
+
* the owner's challenge after the relay marker; or, when a background sign-in
|
|
98
|
+
* (mode "automatic") holds the lock with no challenge yet, the starting
|
|
99
|
+
* marker. Nothing for anything else, so the parent answers "busy": an
|
|
100
|
+
* explicit `auth` run in a terminal never publishes a challenge, and an owner
|
|
101
|
+
* that cannot be identified may be a stuck lock someone has to clear.
|
|
102
|
+
*/
|
|
103
|
+
export function relayedLockLines(challenge, ownerMode) {
|
|
104
|
+
if (challenge) {
|
|
105
|
+
return [RELAYED_CHALLENGE_MARKER, challenge.kind === "automatic" ? AUTOMATIC_PENDING_MARKER : challengeMarker(challenge.numberMatch)];
|
|
106
|
+
}
|
|
107
|
+
return ownerMode === "automatic" ? [RELAYED_STARTING_MARKER] : [];
|
|
108
|
+
}
|
|
89
109
|
/** The stdout marker AuthRunner parses for a challenge. */
|
|
90
110
|
export function challengeMarker(numberMatch) {
|
|
91
111
|
return numberMatch ? `MFA_NUMBER:${numberMatch}` : "MFA_PENDING";
|
package/build/auth-cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { NativeCredentialStoreError } from "./auth/credential-store.js";
|
|
|
12
12
|
import { retireLegacyProfile } from "./auth/legacy-profile.js";
|
|
13
13
|
import { formatPhaseMarker } from "./auth/auth-phases.js";
|
|
14
14
|
import { AuthenticationInProgressError } from "./auth/auth-lock.js";
|
|
15
|
-
import { AUTOMATIC_PENDING_MARKER,
|
|
15
|
+
import { AUTOMATIC_PENDING_MARKER, challengeMarker, relayedLockLines } from "./auth/mfa-challenge.js";
|
|
16
16
|
import { runLogout } from "./auth/logout.js";
|
|
17
17
|
import { configureDevActivity } from "./utils/dev-activity.js";
|
|
18
18
|
import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
|
|
@@ -111,11 +111,12 @@ async function main() {
|
|
|
111
111
|
}
|
|
112
112
|
// Another process's sign-in holds the lock and is showing a challenge:
|
|
113
113
|
// pass it on, so this caller can tell its user what to approve.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
// Before it has one, say that it is starting, so the caller can wait on
|
|
115
|
+
// it. Only the auth lock's own contention sets challenge/ownerMode
|
|
116
|
+
// (BrowserAuth.authenticate); any other lock stays a plain "busy".
|
|
117
|
+
if (automatic && error instanceof AuthenticationInProgressError) {
|
|
118
|
+
for (const line of relayedLockLines(error.challenge, error.ownerMode))
|
|
119
|
+
console.log(line);
|
|
119
120
|
}
|
|
120
121
|
process.exitCode = error instanceof NativeCredentialStoreError ? 5
|
|
121
122
|
: code === "AUTH_IN_PROGRESS" ? 2
|
|
@@ -27,6 +27,7 @@ async function listNews(apiClient, courseId, newsId) {
|
|
|
27
27
|
export function registerGetAnnouncementFiles(server, apiClient) {
|
|
28
28
|
server.registerTool("get_announcement_files", {
|
|
29
29
|
title: "Get Announcement Files",
|
|
30
|
+
annotations: { readOnlyHint: true },
|
|
30
31
|
description: "Read the files an instructor attached to an announcement: prompt questions, a rubric, an updated schedule, slides. Call it with just courseId to see which announcements have attachments, then with newsId and fileId to read one. Use this when the user asks what a file attached to an announcement says. Returns the text itself. Use download_file (newsId + fileId) instead when the user wants the file saved to disk.",
|
|
31
32
|
inputSchema: GetAnnouncementFilesSchema,
|
|
32
33
|
}, async (args) => {
|
|
@@ -99,6 +99,7 @@ export function mapNewsItem(item) {
|
|
|
99
99
|
export function registerGetAnnouncements(server, apiClient, config) {
|
|
100
100
|
server.registerTool("get_announcements", {
|
|
101
101
|
title: "Get Announcements",
|
|
102
|
+
annotations: { readOnlyHint: true },
|
|
102
103
|
description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
|
|
103
104
|
inputSchema: GetAnnouncementsSchema,
|
|
104
105
|
}, async (args) => {
|
|
@@ -26,6 +26,7 @@ async function listFolders(apiClient, courseId, folderId) {
|
|
|
26
26
|
export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
|
|
27
27
|
server.registerTool("get_assignment_files", {
|
|
28
28
|
title: "Get Assignment Files",
|
|
29
|
+
annotations: { readOnlyHint: true },
|
|
29
30
|
description: "Read the files an instructor attached to an assignment: the spec or instructions PDF, a starter workbook, a rubric document. Call it with just courseId to see which assignments have attachments, then with folderId and fileId to read one. Use this when the user asks what an assignment requires, what the instructions say, or to summarize a handout. Returns the text itself. Use download_file instead when the user wants the file saved to disk.",
|
|
30
31
|
inputSchema: GetAssignmentFilesSchema,
|
|
31
32
|
}, async (args) => {
|
|
@@ -256,6 +256,7 @@ export async function fetchAssignmentRubrics(apiClient, courseId, assignmentId)
|
|
|
256
256
|
export function registerGetAssignmentRubric(server, apiClient) {
|
|
257
257
|
server.registerTool("get_assignment_rubric", {
|
|
258
258
|
title: "Get Assignment Rubric",
|
|
259
|
+
annotations: { readOnlyHint: true },
|
|
259
260
|
description: "Fetch the full grading rubric for a dropbox assignment: every criteria group, criterion, and achievement level with its points and description, plus the student's own graded outcome per criterion once it has been released. Use this when the user asks what a rubric wants, how an assignment will be graded, or why they got a particular score on a criterion.",
|
|
260
261
|
inputSchema: GetAssignmentRubricSchema,
|
|
261
262
|
}, async (args) => {
|
|
@@ -425,6 +425,7 @@ function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
|
|
|
425
425
|
export function registerGetAssignments(server, apiClient, config) {
|
|
426
426
|
server.registerTool("get_assignments", {
|
|
427
427
|
title: "Get Assignments",
|
|
428
|
+
annotations: { readOnlyHint: true },
|
|
428
429
|
description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics. " +
|
|
429
430
|
"Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace.",
|
|
430
431
|
inputSchema: GetAssignmentsSchema,
|
|
@@ -15,6 +15,7 @@ const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
15
15
|
export function registerGetCalendarEvents(server, apiClient, config) {
|
|
16
16
|
server.registerTool("get_calendar_events", {
|
|
17
17
|
title: "Get Calendar Events",
|
|
18
|
+
annotations: { readOnlyHint: true },
|
|
18
19
|
description: "Fetch course calendar events — exams, midterms, labs, recitations, review sessions, schedule changes, and deadlines instructors typed straight onto the calendar — across all your courses or one course, in a time window (default: the next 7 days). Use this when the user asks when an exam is, what's on their calendar, or what's happening this week.",
|
|
19
20
|
inputSchema: GetCalendarEventsSchema,
|
|
20
21
|
}, async (args) => {
|
|
@@ -14,6 +14,7 @@ import { log } from "../utils/logger.js";
|
|
|
14
14
|
export function registerGetClasslistEmails(server, apiClient) {
|
|
15
15
|
server.registerTool("get_classlist_emails", {
|
|
16
16
|
title: "Get Classlist Emails",
|
|
17
|
+
annotations: { readOnlyHint: true },
|
|
17
18
|
description: "Fetch all email addresses for everyone in a course — instructors, TAs, and students. " +
|
|
18
19
|
"Use this when the user wants a list of emails for a class, needs to email the whole class, " +
|
|
19
20
|
"or wants contact info for everyone enrolled.",
|
|
@@ -228,6 +228,7 @@ function countModules(tree) {
|
|
|
228
228
|
export function registerGetCourseContent(server, apiClient) {
|
|
229
229
|
server.registerTool("get_course_content", {
|
|
230
230
|
title: "Get Course Content",
|
|
231
|
+
annotations: { readOnlyHint: true },
|
|
231
232
|
description: "Fetch the content tree for a course showing modules, topics, files, and links. Use this when the user asks about course materials, lecture slides, uploaded files, content structure, or what's in a course module. Use moduleTitle to filter to a specific module (e.g. 'Labs', 'Staff', 'Homeworks') instead of fetching the entire tree. Use maxDepth to limit recursion depth for a table-of-contents view. A module or topic that isn't currently available carries isAvailable/availabilityStatus/availabilityMessage (and startDate/endDate when known) explaining why; absence of these fields means it's available now.",
|
|
232
233
|
inputSchema: GetCourseContentSchema,
|
|
233
234
|
}, async (args) => {
|
|
@@ -34,6 +34,7 @@ export async function fetchForumTopics(apiClient, courseId, forumId) {
|
|
|
34
34
|
export function registerGetDiscussions(server, apiClient) {
|
|
35
35
|
server.registerTool("get_discussions", {
|
|
36
36
|
title: "Get Discussions",
|
|
37
|
+
annotations: { readOnlyHint: true },
|
|
37
38
|
description: "Fetch discussion board content for a course including forums, topics, and posts. Use this when the user asks about discussion boards, forum posts, class discussions, or wants to see what's been posted. Provide just courseId to list all forums and their topics. Add forumId to get topics and posts for a specific forum. Add both forumId and topicId to get all posts in a specific discussion topic.",
|
|
38
39
|
inputSchema: GetDiscussionsSchema,
|
|
39
40
|
}, async (args) => {
|
|
@@ -14,6 +14,7 @@ import { log } from "../utils/logger.js";
|
|
|
14
14
|
export function registerGetDropboxFeedback(server, apiClient) {
|
|
15
15
|
server.registerTool("get_dropbox_feedback", {
|
|
16
16
|
title: "Get Dropbox Feedback",
|
|
17
|
+
annotations: { readOnlyHint: true },
|
|
17
18
|
description: "Retrieve the existing feedback already saved for a specific user or group in a dropbox folder, " +
|
|
18
19
|
"for an instructor or TA — score, graded state, feedback text, and rubric assessment detail. " +
|
|
19
20
|
"Use this before drafting new feedback to see what has already been recorded. Requires " +
|
|
@@ -23,6 +23,7 @@ function submissionTypeLabel(t) {
|
|
|
23
23
|
export function registerGetDropboxFolders(server, apiClient) {
|
|
24
24
|
server.registerTool("get_dropbox_folders", {
|
|
25
25
|
title: "Get Dropbox Folders",
|
|
26
|
+
annotations: { readOnlyHint: true },
|
|
26
27
|
description: "List all assignment/dropbox folders for a course, for an instructor or TA. Returns folder ID, " +
|
|
27
28
|
"name, due date, start/end dates, submission type, visibility, and whether rubrics are attached. " +
|
|
28
29
|
"Use this to discover folderId values before calling get_dropbox_submissions, " +
|
|
@@ -27,6 +27,7 @@ async function mapWithConcurrency(items, concurrency, fn) {
|
|
|
27
27
|
export function registerGetDropboxSubmissions(server, apiClient) {
|
|
28
28
|
server.registerTool("get_dropbox_submissions", {
|
|
29
29
|
title: "Get Dropbox Submissions",
|
|
30
|
+
annotations: { readOnlyHint: true },
|
|
30
31
|
description: "List every student's (or group's) submission to a dropbox folder, for an instructor or TA — " +
|
|
31
32
|
"submitter names, submission dates, late status, file lists, and feedback/grading status. " +
|
|
32
33
|
"Use get_dropbox_folders first to find folderId. Requires instructor or TA access; a student " +
|
|
@@ -13,6 +13,7 @@ import { log } from "../utils/logger.js";
|
|
|
13
13
|
export function registerGetDropboxUserSubmissions(server, apiClient) {
|
|
14
14
|
server.registerTool("get_dropbox_user_submissions", {
|
|
15
15
|
title: "Get Dropbox User Submissions",
|
|
16
|
+
annotations: { readOnlyHint: true },
|
|
16
17
|
description: "Retrieve all submissions made by one specific student (or group) in a dropbox folder, for an " +
|
|
17
18
|
"instructor or TA — useful for reviewing a single student's work before drafting feedback. " +
|
|
18
19
|
"Use get_dropbox_submissions or get_roster to find the userId first. Requires instructor or TA " +
|
|
@@ -15,6 +15,7 @@ import { applyCourseFilter } from "../utils/course-filter.js";
|
|
|
15
15
|
export function registerGetMyCourses(server, apiClient, config) {
|
|
16
16
|
server.registerTool("get_my_courses", {
|
|
17
17
|
title: "Get My Courses",
|
|
18
|
+
annotations: { readOnlyHint: true },
|
|
18
19
|
description: "Fetch your enrolled Brightspace courses with names, codes, and IDs. Use this when the user asks about their courses, enrolled classes, what they're taking this semester, or needs a course ID for other queries.",
|
|
19
20
|
inputSchema: GetMyCoursesSchema,
|
|
20
21
|
}, async (args) => {
|
|
@@ -71,6 +71,7 @@ function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
|
|
|
71
71
|
export function registerGetMyGrades(server, apiClient, config) {
|
|
72
72
|
server.registerTool("get_my_grades", {
|
|
73
73
|
title: "Get My Grades",
|
|
74
|
+
annotations: { readOnlyHint: true },
|
|
74
75
|
description: "Fetch your grade breakdown for a specific course or all enrolled courses. Shows grade items with points, percentages, and comments. Use this when the user asks about grades, scores, marks, GPA, academic performance, or how they're doing in a class.",
|
|
75
76
|
inputSchema: GetMyGradesSchema,
|
|
76
77
|
}, async (args) => {
|
|
@@ -19,6 +19,7 @@ function isMissingOrForbidden(error) {
|
|
|
19
19
|
export function registerGetMyGroups(server, apiClient) {
|
|
20
20
|
server.registerTool("get_my_groups", {
|
|
21
21
|
title: "Get My Groups",
|
|
22
|
+
annotations: { readOnlyHint: true },
|
|
22
23
|
description: "List the current user's project/discussion groups in a course, with each group's members. " +
|
|
23
24
|
"Use this when a student asks who is in their project group, lab group, or discussion group.",
|
|
24
25
|
inputSchema: GetMyGroupsSchema,
|
|
@@ -48,6 +48,7 @@ export async function fetchClasslistUsers(apiClient, courseId, options) {
|
|
|
48
48
|
export function registerGetRoster(server, apiClient) {
|
|
49
49
|
server.registerTool("get_roster", {
|
|
50
50
|
title: "Get Course Roster",
|
|
51
|
+
annotations: { readOnlyHint: true },
|
|
51
52
|
description: "Fetch the roster for a course including instructors, TAs, and optionally students with their names, emails, and roles. Use this when the user asks about classmates, instructor contact info, TA emails, professor names, or who's in a class. By default returns only instructors and TAs for privacy. Use includeStudents to get full class list.",
|
|
52
53
|
inputSchema: GetRosterSchema,
|
|
53
54
|
}, async (args) => {
|
|
@@ -13,6 +13,7 @@ import { log } from "../utils/logger.js";
|
|
|
13
13
|
export function registerGetRubricsForObject(server, apiClient) {
|
|
14
14
|
server.registerTool("get_rubrics_for_object", {
|
|
15
15
|
title: "Get Rubrics for Dropbox Folder",
|
|
16
|
+
annotations: { readOnlyHint: true },
|
|
16
17
|
description: "Retrieve the full rubric table (criteria, levels, and point values) attached to a dropbox " +
|
|
17
18
|
"folder, for an instructor or TA. Use get_dropbox_folders first to find folderId. Like " +
|
|
18
19
|
"get_dropbox_folders, this reads the folder listing's embedded rubric data, so it is not " +
|
|
@@ -56,6 +56,7 @@ async function defaultReadSignedInIdentity(sessionDir) {
|
|
|
56
56
|
export function registerGetServerInfo(server, config, version, apiClient, readSignedInIdentity = () => defaultReadSignedInIdentity(config.sessionDir)) {
|
|
57
57
|
server.registerTool("get_server_info", {
|
|
58
58
|
title: "Get Server Info",
|
|
59
|
+
annotations: { readOnlyHint: true },
|
|
59
60
|
description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, the server's local timezone and UTC offset, (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox, (when signed in) the account's uniqueName/displayName as signedInAs, and this process's request counters (requests: responses by status class, network errors, cache hits/misses, coalesced joins, and token refreshes). Use this for troubleshooting, when the user asks which version they have, or what timezone/account dates are being computed against. Never contacts Brightspace and never returns secrets.",
|
|
60
61
|
inputSchema: GetServerInfoSchema,
|
|
61
62
|
}, async () => {
|
|
@@ -183,6 +183,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
183
183
|
export function registerGetUpcomingDueDates(server, apiClient, config) {
|
|
184
184
|
server.registerTool("get_upcoming_due_dates", {
|
|
185
185
|
title: "Get Upcoming Due Dates",
|
|
186
|
+
annotations: { readOnlyHint: true },
|
|
186
187
|
description: "Fetch upcoming due dates across all your courses, derived from the due dates on assignments (dropbox folders), quizzes, and graded discussion topics themselves, plus course calendar events such as exams and labs (type: event). Use this when the user asks about deadlines, what's due, upcoming work, or what they need to do this week.",
|
|
187
188
|
inputSchema: GetUpcomingDueDatesSchema,
|
|
188
189
|
}, async (args) => {
|
|
@@ -109,6 +109,7 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
|
|
|
109
109
|
export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch) {
|
|
110
110
|
server.registerTool("get_video_transcript", {
|
|
111
111
|
title: "Get Video Transcript",
|
|
112
|
+
annotations: { readOnlyHint: true },
|
|
112
113
|
description: "Read the transcript of a video embedded in course content, such as a recorded lecture or explainer clip. " +
|
|
113
114
|
"Call it with courseId and topicId from get_course_content (typeFilter: 'video' or 'other'), or with videoUrl " +
|
|
114
115
|
"directly if you already have the link. Returns transcript text with timestamps, plus title and duration when " +
|
|
@@ -172,6 +172,7 @@ function describeFailure(error) {
|
|
|
172
172
|
export function registerSearchCourse(server, apiClient) {
|
|
173
173
|
server.registerTool("search_course", {
|
|
174
174
|
title: "Search Course",
|
|
175
|
+
annotations: { readOnlyHint: true },
|
|
175
176
|
description: "Search a course's content (modules, topics, file names), announcements, and discussion forums/topics by keyword in a single call, instead of reading the whole content tree. Use this when the user wants to find something specific, e.g. 'find the midterm review slides' or 'did anyone post about office hours'. Results are ranked: a result matching every query term ranks above one matching only some, and within that, a match in the title ranks above one only in the body text. If one source (e.g. discussions) can't be read, it's skipped and named in `note` rather than failing the whole search. This fans out over the entire content tree, announcements, and every discussion forum, so it can be slower than calling a single tool like get_course_content directly.",
|
|
176
177
|
inputSchema: SearchCourseSchema,
|
|
177
178
|
}, async (args) => {
|
|
@@ -117,9 +117,9 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
117
117
|
"as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
|
|
118
118
|
MFA_SOLO_CALL,
|
|
119
119
|
inProgress: "Brightspace sign-in is still starting in the background (opening the browser and the " +
|
|
120
|
-
"school's login pages). Call this tool again right away: the next call
|
|
121
|
-
"sign-in
|
|
122
|
-
"
|
|
120
|
+
"school's login pages). Call this tool again right away: the next call picks up that " +
|
|
121
|
+
"sign-in and returns the number to approve as soon as one appears. Don't ask the user to " +
|
|
122
|
+
"do anything yet.",
|
|
123
123
|
};
|
|
124
124
|
/**
|
|
125
125
|
* mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
|
|
@@ -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.10",
|
|
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",
|