diff --git a/README.md b/README.md index c1ff87d..8303866 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,12 @@ completeness is a separate concern: the sync remains additive and does not claim complete. Each appearance keeps its own placement and status, while source evidence and working artifacts stay on the configured Media store and readable derivatives stay beside the numbered item. Kaltura, YouTube, and direct video/audio links are classified without retaining expiring query strings; -opaque embedded or launch players are reported as unsupported rather than silently omitted. Session -material and expiring provider addresses are never persisted. A transcript is complete only when both +opaque embedded or launch players are reported as unsupported rather than silently omitted. Known +FeedbackFruits, Cengage, Blackboard placement, Padlet, and Turnitin shapes keep their provider name, +stable reference, retryability, and limitation in the status; provider-specific acquisition is an +injected adapter seam. NTULearn file-shaped non-media references remain visible as retryable +non-recordings. Session material and expiring provider addresses are never persisted. A transcript +is complete only when both a validated source and formatted Markdown derivative exist. Recording completeness remains independent of sync and follows [ADR-0014](docs/adr/0014-recordings-use-a-separate-media-workflow.md); source provenance and status remain visible with the course artifacts. The routine job never diff --git a/src/media/addresses.mjs b/src/media/addresses.mjs new file mode 100644 index 0000000..a696a33 --- /dev/null +++ b/src/media/addresses.mjs @@ -0,0 +1,15 @@ +export const MEDIA_ADDRESS_KEYS = Object.freeze([ + "resourceUrl", + "viewerUrl", + "url", + "href", + "src", + "launchUrl", + "launchLink", + "permanentUrl", + "mediaUrl", + "videoUrl", + "audioUrl", + "playbackUrl", + "downloadUrl", +]); diff --git a/src/media/classification.mjs b/src/media/classification.mjs index b7a5095..e17545b 100644 --- a/src/media/classification.mjs +++ b/src/media/classification.mjs @@ -1,9 +1,14 @@ -import { absoluteUrl } from "../ntulearn/urls.mjs"; import { directMediaKindOf, directMediaReferenceOf } from "./direct.mjs"; +import { externalRecordingAdapters, stableProviderReference } from "./external.mjs"; import { kalturaReferenceOf } from "./kaltura.mjs"; import { youtubeReferenceOf } from "./youtube.mjs"; -export function classifyRecordingCandidate({ value, sourceKind, attachment = null }) { +export function classifyRecordingCandidate({ + value, + sourceKind, + attachment = null, + adapters = externalRecordingAdapters, +}) { const values = [value, attachment].filter(Boolean); for (const candidate of values) { const providerReference = kalturaReferenceOf(candidate); @@ -21,45 +26,18 @@ export function classifyRecordingCandidate({ value, sourceKind, attachment = nul } } + for (const adapter of adapters ?? []) { + const classification = adapter?.classify?.({ value, sourceKind, attachment }); + if (classification) return classification; + } + if (sourceKind === "embedded-player" || sourceKind === "launch-link") { return { provider: "unsupported", - providerReference: unsupportedReferenceOf(value), + providerReference: stableProviderReference("unsupported", value), retryable: true, limitation: `Unsupported recording provider shape from ${sourceKind}; media acquisition is unavailable.`, }; } return null; } - -function unsupportedReferenceOf(value) { - const address = addressOf(value); - if (address) { - try { - const parsed = new URL(absoluteUrl(address)); - const identity = `${parsed.hostname}${parsed.pathname}`.replace(/\/+$/, ""); - if (identity) return `unsupported:${safeIdentity(identity)}`; - } catch { - // Fall through to a stable value for a malformed provider address. - } - } - return `unsupported:${safeIdentity(String(value ?? "opaque"))}`; -} - -function addressOf(value) { - if (typeof value === "string") return value; - if (!value || typeof value !== "object") return null; - return ["resourceUrl", "viewerUrl", "url", "href", "src", "launchUrl", "launchLink"] - .map((key) => value[key]) - .find((candidate) => typeof candidate === "string" && candidate); -} - -function safeIdentity(value) { - return ( - String(value) - .normalize("NFKC") - .replace(/[^A-Za-z0-9._/-]/g, "_") - .replace(/\/+/g, "/") - .replace(/^\/+|\/+$/g, "") || "opaque" - ); -} diff --git a/src/media/direct.mjs b/src/media/direct.mjs index 5fe7cc4..356c5e0 100644 --- a/src/media/direct.mjs +++ b/src/media/direct.mjs @@ -4,8 +4,9 @@ import { acquireWithAudioFallback, chooseRepresentation, } from "./acquisition.mjs"; +import { MEDIA_ADDRESS_KEYS } from "./addresses.mjs"; -const URL_KEYS = ["resourceUrl", "viewerUrl", "url", "href", "src", "launchUrl", "launchLink"]; +const URL_KEYS = MEDIA_ADDRESS_KEYS; const NAME_KEYS = ["fileName", "linkName", "displayName", "filename", "name"]; const VIDEO_EXTENSIONS = new Set([ ".avi", diff --git a/src/media/discovery.mjs b/src/media/discovery.mjs index 88211f7..aa7f1af 100644 --- a/src/media/discovery.mjs +++ b/src/media/discovery.mjs @@ -1,4 +1,4 @@ -import { attachmentName, externalLinkOf, isFolder } from "../ntulearn/content.mjs"; +import { attachmentName, isFolder } from "../ntulearn/content.mjs"; import { attachmentPlacement, placedFile, placementsIn } from "../placement.mjs"; import { orderedName } from "../paths.mjs"; import { classifyRecordingCandidate } from "./classification.mjs"; @@ -10,7 +10,12 @@ const JSON_ATTRIBUTE = /\bdata-bbfile\s*=\s*(["'])(.*?)\1/i; const VIDEO_EXTENSIONS = new Set([".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".webm"]); const AUDIO_EXTENSIONS = new Set([".aac", ".m4a", ".mp3", ".ogg", ".wav"]); -export function discoverContentRecordings({ course, snapshot, attachmentsByItem = new Map() }) { +export function discoverContentRecordings({ + course, + snapshot, + attachmentsByItem = new Map(), + adapters, +}) { const placements = placementsIn(snapshot.items ?? []); const recordings = []; @@ -21,12 +26,12 @@ export function discoverContentRecordings({ course, snapshot, attachmentsByItem const candidates = [ ...attachmentCandidates(attachmentsByItem.get(item.id) ?? []), ...bodyCandidates(item), - ...externalCandidate(item), + ...externalCandidates(item), ]; const seen = new Set(); for (const candidate of candidates) { - const classification = classifyRecordingCandidate(candidate); + const classification = classifyRecordingCandidate({ ...candidate, adapters }); if (!classification) continue; const identity = `${classification.provider}:${classification.providerReference}`; if (seen.has(identity)) continue; @@ -54,6 +59,8 @@ function appearance({ provider, providerReference, mediaType, + providerName, + providerShape, retryable, limitation, sourceKind, @@ -71,6 +78,8 @@ function appearance({ provider, providerReference, mediaType: mediaType ?? null, + ...(providerName ? { providerName } : {}), + ...(providerShape ? { providerShape } : {}), ...(retryable !== undefined ? { retryable } : {}), ...(limitation ? { limitation } : {}), sourceKind, @@ -139,15 +148,30 @@ function bodyCandidates(item) { return candidates; } -function externalCandidate(item) { - const link = externalLinkOf(item); - if (!link) return []; +function externalCandidates(item) { + const links = Object.values(item.contentDetail ?? {}).flatMap((detail) => + detailLinks(detail).map(({ value, sourceKind }) => ({ value, sourceKind })), + ); + const byValue = new Map(); + for (const link of links) { + const previous = byValue.get(link.value); + if ( + !previous || + (link.sourceKind === "launch-link" && previous.sourceKind === "external-link") + ) { + byValue.set(link.value, link); + } + } + return [...byValue.values()]; +} + +function detailLinks(detail) { return [ - { - value: link, - sourceKind: isLaunchLink(item) ? "launch-link" : "external-link", - }, - ]; + { value: detail?.url, sourceKind: "external-link" }, + { value: detail?.launchUrl, sourceKind: "launch-link" }, + { value: detail?.launchLink, sourceKind: "launch-link" }, + { value: detail?.placement?.launchLink, sourceKind: "launch-link" }, + ].filter(({ value }) => typeof value === "string" && value); } function attributeValues(attributes) { @@ -164,12 +188,6 @@ function embeddedValue(attributes) { } } -function isLaunchLink(item) { - return Object.values(item.contentDetail ?? {}).some( - (detail) => detail?.launchUrl || detail?.launchLink || detail?.placement?.launchLink, - ); -} - function isVideoOrAudio(attachment) { return isVideo(attachment) || isAudio(attachment); } diff --git a/src/media/errors.mjs b/src/media/errors.mjs index d6ef5eb..2dc73bc 100644 --- a/src/media/errors.mjs +++ b/src/media/errors.mjs @@ -11,7 +11,10 @@ export const GLOBAL_MEDIA_ERROR_CODES = Object.freeze([ export function publicMediaError(error) { return String(error?.message ?? error ?? "unknown error") .replace(/https?:\/\/[^\s)]+/gi, "[provider address omitted]") - .replace(/\b(ks|token|session|signature)=[^\s&]+/gi, "$1=[redacted]"); + .replace( + /\b(ks|access_token|id_token|launch_token|launch|token|session|signature|cookie|state|sig)=[^\s&]+/gi, + "$1=[redacted]", + ); } export function isGlobalMediaSafetyFailure(error) { diff --git a/src/media/external.mjs b/src/media/external.mjs new file mode 100644 index 0000000..683bd5c --- /dev/null +++ b/src/media/external.mjs @@ -0,0 +1,435 @@ +import { createHash } from "node:crypto"; +import { absoluteUrl } from "../ntulearn/urls.mjs"; +import { MEDIA_ADDRESS_KEYS } from "./addresses.mjs"; +import { publicMediaError } from "./errors.mjs"; + +const ADDRESS_KEYS = MEDIA_ADDRESS_KEYS; +const FILE_SHAPE_KEYS = Object.freeze([ + "fileName", + "filename", + "mimeType", + "contentType", + "fileSize", + "fileType", + "permanentUrl", + "uploadId", +]); +const CAPTION_MIME_TYPES = new Set(["application/ttml+xml", "text/srt", "text/vtt"]); +const CAPTION_EXTENSIONS = new Set([".srt", ".ttml", ".vtt"]); +const ID_KEYS = Object.freeze([ + "recordingId", + "recording_id", + "mediaId", + "media_id", + "videoId", + "video_id", + "entryId", + "entry_id", + "activityId", + "activity_id", + "placementId", + "placement_id", + "resourceId", + "resource_id", + "contentId", + "content_id", + "id", +]); +const PROVIDER_KEYS = Object.freeze([ + "provider", + "providerName", + "platform", + "vendor", + "tool", + "toolName", + "service", + "application", + "ltiProvider", + "contentHandler", +]); +const STABLE_QUERY_KEYS = new Set(ID_KEYS); +const EPHEMERAL_PARAMETER_PATTERN = + /\b(ks|access_token|id_token|launch_token|launch|token|session|signature|cookie|state|sig)\s*=\s*[^\s&]+/gi; +const PROVIDER_NAMES = Object.freeze({ + blackboard: "Blackboard", + cengage: "Cengage", + feedbackfruits: "FeedbackFruits", + "ntulearn-file": "NTULearn file", + padlet: "Padlet", + turnitin: "Turnitin", +}); + +export function createExternalShapeAdapter({ + provider, + matches, + referenceOf = ({ value }) => stableProviderReference(provider, value), + outputProvider = provider, + providerName = displayName(provider), + limitation = `The ${providerName} recording adapter cannot acquire this appearance yet.`, + resolve = null, + transcript = () => null, + media = null, +}) { + assertProviderName(provider); + if (typeof matches !== "function") throw new Error(`${provider} adapter needs matches.`); + if (typeof referenceOf !== "function") { + throw new Error(`${provider} adapter needs referenceOf.`); + } + if (resolve !== null && typeof resolve !== "function") { + throw new Error(`${provider} adapter resolve must be a function or null.`); + } + if (typeof transcript !== "function") { + throw new Error(`${provider} adapter transcript must be a function.`); + } + if (media !== null && typeof media !== "function") { + throw new Error(`${provider} adapter media must be a function or null.`); + } + const safeLimitation = publicMediaError(limitation); + + return Object.freeze({ + provider, + classify(candidate) { + if (!matches(candidate)) return null; + const providerReference = normalizeProviderReference(provider, referenceOf(candidate)); + if (typeof providerReference !== "string" || !providerReference.trim()) return null; + + const classification = { + provider: outputProvider, + providerName, + providerShape: provider, + providerReference: + outputProvider === "unsupported" + ? unsupportedReference(provider, providerReference) + : providerReference, + }; + if (outputProvider === "unsupported") { + classification.retryable = true; + classification.limitation = safeLimitation; + } + return classification; + }, + createProvider() { + return createExternalMediaProvider({ + name: provider, + resolve: + resolve ?? + (() => { + throw new Error(`${providerName} recording acquisition is not configured.`); + }), + transcript, + media: + media ?? + (() => ({ + kind: "unavailable", + limitation: safeLimitation, + retryable: true, + })), + }); + }, + }); +} + +export function createExternalMediaProvider({ name, resolve, transcript = () => null, media }) { + assertProviderName(name); + if (typeof resolve !== "function") throw new Error(`${name} provider needs resolve.`); + if (typeof transcript !== "function") throw new Error(`${name} provider needs transcript.`); + if (typeof media !== "function") throw new Error(`${name} provider needs media.`); + + return { + name, + resolve(appearance, context = {}) { + return resolve({ + appearance, + reference: appearance?.providerReference, + fresh: true, + ...(context.signal ? { signal: context.signal } : {}), + }); + }, + transcript(resolved, context = {}) { + return transcript(resolved, context); + }, + media(resolved, context = {}) { + return media(resolved, context); + }, + }; +} + +export const externalRecordingAdapters = Object.freeze([ + ntulearnFileAdapter(), + knownProviderAdapter("feedbackfruits", ["feedbackfruits"]), + knownProviderAdapter("cengage", ["cengage", "webassign"]), + knownProviderAdapter("blackboard", ["blackboard", "blti"]), + knownProviderAdapter("padlet", ["padlet"]), + knownProviderAdapter("turnitin", ["turnitin"]), +]); + +export function providerForRecording({ appearance, adapters = externalRecordingAdapters }) { + const providerKey = appearance?.providerShape ?? appearance?.provider; + const adapter = (adapters ?? []).find((candidate) => candidate?.provider === providerKey); + if (adapter?.createProvider) return adapter.createProvider(); + + const limitation = `No recording provider adapter is registered for ${providerKey ?? "unknown"}.`; + return createExternalMediaProvider({ + name: "unsupported", + resolve: () => { + throw new Error(limitation); + }, + media: () => ({ kind: "unavailable", limitation, retryable: true }), + }); +} + +export function stableProviderReference(provider, value) { + assertProviderName(provider); + const prefix = provider === "unsupported" ? "unsupported" : safeIdentity(provider); + const id = stableIdOf(value); + if (id) return `${prefix}:id:${safeIdentity(id)}`; + + const address = addressOf(value); + const addressParts = stableAddressParts(address); + if (addressParts) { + return `${prefix}:${safeIdentity(addressParts.identity)}${ + addressParts.query ? `?${addressParts.query}` : "" + }`; + } + + return `${prefix}:opaque:${shapeDigest(value)}`; +} + +function knownProviderAdapter(provider, signals) { + return createExternalShapeAdapter({ + provider, + outputProvider: "unsupported", + matches: (candidate) => + ["embedded-player", "launch-link"].includes(candidate?.sourceKind) && + matchesKnownProvider(candidate, signals), + limitation: `${displayName(provider)} content is visible but its recording acquisition path is unavailable.`, + }); +} + +function ntulearnFileAdapter() { + return createExternalShapeAdapter({ + provider: "ntulearn-file", + outputProvider: "unsupported", + matches: ({ value, sourceKind }) => + ["attachment", "embedded-player"].includes(sourceKind) && isFileShape(value), + limitation: "NTULearn file-shaped reference is visible but is not a recording.", + }); +} + +function isFileShape(value, depth = 0) { + if (!value || typeof value !== "object" || Array.isArray(value) || depth > 2) return false; + const hasAddress = ADDRESS_KEYS.some( + (key) => typeof value[key] === "string" && value[key].trim(), + ); + if ( + hasAddress && + FILE_SHAPE_KEYS.some((key) => value[key] !== undefined) && + !isCaptionFile(value) + ) { + return true; + } + return isFileShape(value.file, depth + 1); +} + +function isCaptionFile(value) { + if (CAPTION_MIME_TYPES.has(String(value.mimeType ?? value.contentType ?? "").toLowerCase())) { + return true; + } + const name = value.fileName ?? value.filename ?? value.name; + if (typeof name !== "string") return false; + const extension = name.slice(name.lastIndexOf(".")).toLowerCase(); + return CAPTION_EXTENSIONS.has(extension); +} + +function matchesKnownProvider(candidate, signals) { + const values = candidateValues(candidate?.value); + for (const value of values) { + if (typeof value !== "string") continue; + const text = value.trim().toLowerCase(); + if (!text) continue; + if (signals.some((signal) => providerSignalMatches(text, signal))) return true; + } + return false; +} + +function providerSignalMatches(value, signal) { + if (value === signal || value.includes(signal)) { + if (!value.includes("://") && !value.startsWith("/")) return true; + } + if (!addressLike(value)) return false; + try { + const parsed = new URL(absoluteUrl(value)); + return parsed.hostname.includes(signal) || parsed.pathname.toLowerCase().includes(signal); + } catch { + return false; + } +} + +function candidateValues(value, depth = 0) { + if (typeof value === "string") return [value]; + if (!value || typeof value !== "object" || depth > 2) return []; + + const values = []; + for (const key of [...ADDRESS_KEYS, ...PROVIDER_KEYS]) { + const nested = value[key]; + if (typeof nested === "string") values.push(nested); + else if (nested && typeof nested === "object") + values.push(...candidateValues(nested, depth + 1)); + } + return values; +} + +function stableIdOf(value, depth = 0) { + if (!value || typeof value !== "object" || Array.isArray(value) || depth > 2) return null; + for (const key of ID_KEYS) { + const candidate = value[key]; + if ( + typeof candidate === "string" && + candidate.trim() && + !addressLike(candidate) && + !containsEphemeralParameter(candidate) + ) { + return candidate.trim(); + } + } + for (const key of [...ADDRESS_KEYS, ...PROVIDER_KEYS]) { + const id = stableIdOf(value[key], depth + 1); + if (id) return id; + } + return null; +} + +function addressOf(value, depth = 0) { + if (typeof value === "string") return value; + if (!value || typeof value !== "object" || Array.isArray(value) || depth > 2) return null; + for (const key of ADDRESS_KEYS) { + if (typeof value[key] === "string" && value[key].trim()) return value[key]; + } + for (const key of [...PROVIDER_KEYS, "placement", "launch"]) { + const address = addressOf(value[key], depth + 1); + if (address) return address; + } + return null; +} + +function stableQuery(parsed) { + return [...parsed.searchParams.entries()] + .filter(([key, value]) => STABLE_QUERY_KEYS.has(key) && value) + .sort(([first], [second]) => first.localeCompare(second)) + .map(([key, value]) => `${safeIdentity(key)}=${safeIdentity(value)}`) + .join("&"); +} + +function unsupportedReference(provider, reference) { + const prefix = `${safeIdentity(provider)}:`; + const body = reference.startsWith(prefix) + ? reference.slice(prefix.length) + : safeIdentity(reference); + return `unsupported:${safeIdentity(provider)}:${body}`; +} + +function normalizeProviderReference(provider, reference) { + if (typeof reference !== "string" || !reference.trim()) return null; + const text = reference.trim(); + if (addressLike(text) || text.includes("://")) { + return stableProviderReference(provider, text); + } + const prefix = `${safeIdentity(provider)}:`; + const body = text.startsWith(prefix) ? text.slice(prefix.length) : text; + if (EPHEMERAL_PARAMETER_PATTERN.test(body)) { + EPHEMERAL_PARAMETER_PATTERN.lastIndex = 0; + return `${prefix}opaque:${shapeDigest(body)}`; + } + EPHEMERAL_PARAMETER_PATTERN.lastIndex = 0; + return `${prefix}${safeIdentity(body)}`; +} + +function shapeDigest(value) { + return createHash("sha256").update(shapeOf(value)).digest("hex").slice(0, 16); +} + +function shapeOf(value, seen = new WeakSet(), key = "") { + if (isEphemeralKey(key)) return "sensitive"; + if (Array.isArray(value)) { + if (seen.has(value)) return "circular"; + seen.add(value); + const result = `array:${value.length}:${value + .map((item) => shapeOf(item, seen, key)) + .join(",")}`; + seen.delete(value); + return result; + } + if (value && typeof value === "object") { + if (seen.has(value)) return "circular"; + seen.add(value); + const result = Object.keys(value) + .sort() + .map((childKey) => `${childKey}:${shapeOf(value[childKey], seen, childKey)}`) + .join("|"); + seen.delete(value); + return result; + } + if (typeof value === "string") { + const addressParts = stableAddressParts(value); + if (addressParts) { + return `address:${addressParts.identity}${ + addressParts.query ? `?${addressParts.query}` : "" + }`; + } + return `text:${redactEphemeralParameters(value)}`; + } + return `${typeof value}:${JSON.stringify(value)}`; +} + +function stableAddressParts(value) { + if (!addressLike(value)) return null; + try { + const parsed = new URL(absoluteUrl(value)); + const identity = `${parsed.hostname}${parsed.pathname}`.replace(/\/+$/, ""); + return identity ? { identity, query: stableQuery(parsed) } : null; + } catch { + return null; + } +} + +function redactEphemeralParameters(value) { + return value.replace(EPHEMERAL_PARAMETER_PATTERN, "$1=[redacted]").trim(); +} + +function isEphemeralKey(value) { + if (!value) return false; + const normalized = value.replace(/[A-Z]/g, (letter) => `_${letter}`).toLowerCase(); + return /^(?:ks|access_token|id_token|launch_token|token|session|session_id|signature|cookie|state|sig)$/.test( + normalized, + ); +} + +function addressLike(value) { + return typeof value === "string" && /^(?:https?:\/\/|\/)/i.test(value.trim()); +} + +function containsEphemeralParameter(value) { + return /(?:^|[?&])(?:ks|access_token|id_token|launch_token|launch|token|session|signature|cookie|state|sig)\s*=/i.test( + value, + ); +} + +function displayName(provider) { + return PROVIDER_NAMES[provider] ?? String(provider); +} + +function safeIdentity(value) { + return ( + String(value) + .normalize("NFKC") + .replace(/[^A-Za-z0-9._:/-]/g, "_") + .replace(/\/{2,}/g, "/") + .replace(/^[/.:]+|[/.:]+$/g, "") || "opaque" + ); +} + +function assertProviderName(value) { + if (typeof value !== "string" || !/^[A-Za-z0-9._-]+$/.test(value)) { + throw new Error("Media provider names must be simple identifiers."); + } +} diff --git a/src/media/gallery-browser.mjs b/src/media/gallery-browser.mjs index 7e8a6c6..c6c3d8d 100644 --- a/src/media/gallery-browser.mjs +++ b/src/media/gallery-browser.mjs @@ -34,7 +34,7 @@ export async function readKalturaMediaGallery({ page, course }) { if (!surface) return absentGallery(); const pages = await collectMediaGalleryPages({ readPage: () => readGalleryPage(surface), - clickLoadMore: () => clickGalleryMore(surface), + clickLoadMore: (page) => clickGalleryMore(surface, page), }); const enrichedPages = await enrichGalleryDates({ page, @@ -61,6 +61,7 @@ export async function collectMediaGalleryPages({ const pages = []; let nextPaginationMode = "append"; + let previousPage = null; for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) { const read = await readPage(); const page = @@ -69,17 +70,32 @@ export async function collectMediaGalleryPages({ : read; pages.push(page); if (page?.hasMore !== true) return pages; + if (previousPage && appendPageReachedDisplayedTotal(page)) { + pages[pages.length - 1] = { ...page, hasMore: false }; + return pages; + } const advance = await clickLoadMore(page); if (!advance) { throw new Error( "Media Gallery pagination advertised another page but its control was unavailable.", ); } + previousPage = page; nextPaginationMode = advance.mode ?? "append"; } throw new Error(`Media Gallery pagination exceeded the ${maxPages}-page safety limit.`); } +function appendPageReachedDisplayedTotal(page) { + return ( + page?.paginationMode === "append" && + Number.isSafeInteger(page.displayedCount) && + page.displayedCount >= 0 && + Array.isArray(page.entries) && + page.entries.length >= page.displayedCount + ); +} + async function openGallerySurface(page, courseId) { if (!page || typeof page.goto !== "function") { throw new Error("Media Gallery needs the signed-in browser page."); @@ -268,13 +284,13 @@ function mediaHour(value, meridiem) { return String(hour).padStart(2, "0"); } -async function clickGalleryMore(surface) { +async function clickGalleryMore(surface, previousPage) { for (const role of ["button", "link"]) { const control = await firstEnabledControl(surface.getByRole(role, { name: MORE_CONTROL })); if (control) { const label = await controlLabel(control); await control.click(); - await waitForGalleryUpdate(surface); + if (!(await waitForGalleryUpdate(surface, previousPage))) return false; return { mode: paginationMode(label) }; } } @@ -296,7 +312,7 @@ async function clickGalleryMore(surface) { if ((await pageNumber(control)) !== currentPage + 1) continue; if (!(await isEnabledControl(control))) continue; await control.click(); - await waitForGalleryUpdate(surface); + if (!(await waitForGalleryUpdate(surface, previousPage))) return false; return { mode: "replace" }; } } @@ -360,7 +376,18 @@ async function pageNumber(control) { return match ? Number(match[1]) : null; } -async function waitForGalleryUpdate(surface) { +async function waitForGalleryUpdate(surface, previousPage = null) { + if (previousPage && typeof surface.evaluate === "function") { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const current = await readGalleryPage(surface).catch(() => null); + if (galleryPageAdvanced(previousPage, current)) return true; + if (typeof surface.waitForTimeout !== "function") break; + await surface.waitForTimeout(250); + } + return false; + } + let previous = null; if (typeof surface.locator === "function") { const body = surface.locator("body"); @@ -377,9 +404,25 @@ async function waitForGalleryUpdate(surface) { timeout: 5_000, }) .catch(() => {}); - return; + return true; } if (typeof surface.waitForTimeout === "function") await surface.waitForTimeout(250); + return true; +} + +function galleryPageAdvanced(previous, current) { + if (!current || !Array.isArray(current.entries)) return false; + if (!Array.isArray(previous?.entries)) return false; + if (current.displayedCount !== previous.displayedCount) return true; + if (current.hasMore !== previous.hasMore) return true; + if (current.entries.length !== previous.entries?.length) return true; + return current.entries.some( + (entry, index) => galleryEntryIdentity(entry) !== galleryEntryIdentity(previous.entries[index]), + ); +} + +function galleryEntryIdentity(entry) { + return entry?.id ?? entry?.providerReference ?? entry?.href ?? null; } /* global document */ diff --git a/src/media/gallery.mjs b/src/media/gallery.mjs index e71d2d6..db7c664 100644 --- a/src/media/gallery.mjs +++ b/src/media/gallery.mjs @@ -1,5 +1,6 @@ import { safeSegment } from "../paths.mjs"; import { absoluteUrl } from "../ntulearn/urls.mjs"; +import { positiveDuration } from "./duration.mjs"; import { kalturaReferenceOf } from "./kaltura.mjs"; const HIDDEN_STATUSES = new Set(["hidden", "unpublished", "withdrawn"]); @@ -132,7 +133,7 @@ function placeEntries(course, entries) { storageSurface: "media-gallery", createdAt: entry.createdAt, mediaType: entry.mediaType ?? null, - duration: Number.isFinite(Number(entry.duration)) ? Number(entry.duration) : null, + duration: positiveDuration(Number(entry.duration)), placement: { destination: course.destination, directorySegments: [directory], diff --git a/src/media/job.mjs b/src/media/job.mjs index 9c2ab7b..b368a7b 100644 --- a/src/media/job.mjs +++ b/src/media/job.mjs @@ -1,6 +1,7 @@ import { Buffer } from "node:buffer"; import { createMediaArtifacts, restoreMedia } from "./artifacts.mjs"; import { isGlobalMediaSafetyFailure, publicMediaError } from "./errors.mjs"; +import { providerForRecording } from "./external.mjs"; import { createMediaOutcome } from "./outcome.mjs"; import { parseProviderTranscript, validateTranscript } from "./transcript.mjs"; import { positiveDuration } from "./duration.mjs"; @@ -12,7 +13,8 @@ const REGENERATION_LIMITATION = "Formatted transcript needs explicit regeneratio // artifact metadata. export async function runMediaJob({ appearance, - provider, + provider = null, + adapters, playbackCapture = null, storage, formatter, @@ -23,9 +25,10 @@ export async function runMediaJob({ }) { throwIfInterrupted(signal); const limitations = mediaLimitations(appearance); + const activeProvider = provider ?? providerForRecording({ appearance, adapters }); let retryable = appearance.retryable === true; const artifacts = {}; - let providerName = provider?.name ?? appearance.provider; + let providerName = appearance.providerName ?? activeProvider.name; const formatterVersion = nonEmpty(formatter?.version); let media = { video: unavailableMedia(), audio: unavailableMedia() }; let source = null; @@ -63,7 +66,7 @@ export async function runMediaJob({ if (!existing || existing.replaceRawTranscript || !acquiredMedia) { try { - resolved = await provider.resolve(appearance, { signal }); + resolved = await activeProvider.resolve(appearance, { signal }); duration = positiveDuration(resolved?.duration) ?? duration; speechDuration = positiveDuration(resolved?.speechDuration) ?? speechDuration; throwIfInterrupted(signal); @@ -76,7 +79,7 @@ export async function runMediaJob({ if (!source) { try { - nativeTranscript = await provider.transcript(resolved, { signal }); + nativeTranscript = await activeProvider.transcript(resolved, { signal }); throwIfInterrupted(signal); } catch (error) { throwIfCheckpointed(signal); @@ -124,7 +127,7 @@ export async function runMediaJob({ if (!acquiredMedia) { try { - const acquired = await provider.media(resolved, { signal }); + const acquired = await activeProvider.media(resolved, { signal }); throwIfInterrupted(signal); if (acquired?.kind === "video" || acquired?.kind === "audio") { retryable ||= acquired.retryable === true; @@ -353,7 +356,11 @@ function nativeBody(value) { function assertSafeProviderTranscript(body) { const text = Buffer.isBuffer(body) ? body.toString("utf8") : String(body); - if (/\b(?:ks|token|session|signature)\s*=/i.test(text)) { + if ( + /\b(?:ks|access_token|id_token|launch_token|launch|token|session|signature|cookie|state|sig)\s*=/i.test( + text, + ) + ) { throw new Error("provider transcript contains a session-bound address"); } } diff --git a/src/media/queue.mjs b/src/media/queue.mjs index b4c299d..df37ee3 100644 --- a/src/media/queue.mjs +++ b/src/media/queue.mjs @@ -206,7 +206,7 @@ function preservedState(job) { job[field], ]), ); - return sanitizeJobState(state); + return sanitizeJobState(state, { dropInvalidDurations: true }); } function queueJson(record) { @@ -219,7 +219,7 @@ function stripEphemeralFields(job) { ); } -function sanitizeJobState(update) { +function sanitizeJobState(update, { dropInvalidDurations = false } = {}) { const unknown = Object.keys(update).filter((field) => !JOB_STATE_FIELDS.includes(field)); if (unknown.length) { throw new Error(`Media queue job update contains unsupported fields: ${unknown.join(", ")}.`); @@ -255,6 +255,7 @@ function sanitizeJobState(update) { safe[field] = value === null ? null : String(value).toLowerCase(); } else if (["duration", "speechDuration"].includes(field)) { if (value !== null && !positiveDuration(value)) { + if (dropInvalidDurations) continue; throw new Error(`Media queue ${field} must be a positive number.`); } safe[field] = value; diff --git a/src/media/workflow.mjs b/src/media/workflow.mjs index 90eb70d..dce4127 100644 --- a/src/media/workflow.mjs +++ b/src/media/workflow.mjs @@ -6,12 +6,13 @@ export async function discoverCourseMedia({ client, course, readGallery = readKalturaMediaGallery, + adapters, }) { if (!isMediaCourseEnabled(course)) { return discoverMediaGallery({ course, pages: null }); } - const contentRecordings = await discoverCourseContent({ client, course }); + const contentRecordings = await discoverCourseContent({ client, course, adapters }); const gallery = await discoverCourseMediaGallery({ client, course, readGallery }); const galleryRecordings = gallery.complete === true ? gallery.recordings : []; const queue = [...contentRecordings, ...galleryRecordings]; @@ -46,7 +47,7 @@ export async function discoverCourseMediaGallery({ return discovery; } -async function discoverCourseContent({ client, course }) { +async function discoverCourseContent({ client, course, adapters }) { if (typeof client?.readCourse !== "function") { throw new Error("Content recording discovery needs the signed-in NTULearn client."); } @@ -57,5 +58,5 @@ async function discoverCourseContent({ client, course }) { attachmentsByItem.set(item.id, (await client.readAttachments(course.courseId, item)) ?? []); } } - return discoverContentRecordings({ course, snapshot, attachmentsByItem }); + return discoverContentRecordings({ course, snapshot, attachmentsByItem, adapters }); } diff --git a/test/media-classification.test.mjs b/test/media-classification.test.mjs new file mode 100644 index 0000000..898b508 --- /dev/null +++ b/test/media-classification.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classifyRecordingCandidate } from "../src/media/classification.mjs"; + +test("classifies observed external shapes with stable redacted references", () => { + const cases = [ + [ + "feedbackfruits", + "FeedbackFruits", + "https://app.feedbackfruits.com/activity/act-42?token=secret", + "launch-link", + ], + [ + "cengage", + "Cengage", + "https://ng.cengage.com/activity/assignment-42?session=secret", + "launch-link", + ], + [ + "blackboard", + "Blackboard", + "https://ntulearn.ntu.edu.sg/webapps/blackboard/execute/blti/launch?content_id=place-42&signature=secret", + "launch-link", + ], + ["padlet", "Padlet", "https://padlet.com/course/lecture-42?token=secret", "embedded-player"], + [ + "turnitin", + "Turnitin", + "https://www.turnitin.com/assignment/42?launch_token=secret", + "launch-link", + ], + ]; + + for (const [provider, providerName, value, sourceKind] of cases) { + const result = classifyRecordingCandidate({ value, sourceKind }); + assert.equal(result.provider, "unsupported"); + assert.equal(result.providerName, providerName); + assert.equal(result.providerShape, provider); + assert.equal(result.retryable, true); + assert.match(result.providerReference, new RegExp(`^unsupported:${provider}:`)); + assert.doesNotMatch(JSON.stringify(result), /https?:\/\//); + assert.doesNotMatch(JSON.stringify(result), /secret/); + } +}); + +test("does not turn an ordinary external tool link into a recording", () => { + assert.equal( + classifyRecordingCandidate({ + value: "https://www.turnitin.com/help/article-42", + sourceKind: "external-link", + }), + null, + ); +}); + +test("keeps an opaque unsupported reference stable without serializing its value", () => { + const value = { providerPayload: { launchToken: "secret", fields: ["opaque"] } }; + const first = classifyRecordingCandidate({ value, sourceKind: "embedded-player" }); + const second = classifyRecordingCandidate({ value, sourceKind: "embedded-player" }); + + assert.equal(first.provider, "unsupported"); + assert.equal(first.providerReference, second.providerReference); + assert.match(first.providerReference, /^unsupported:opaque:/); + assert.doesNotMatch(JSON.stringify(first), /secret|providerPayload|launchToken/); + + const sameSecrets = classifyRecordingCandidate({ + value: { providerPayload: { launchToken: "different", fields: ["opaque"] } }, + sourceKind: "embedded-player", + }); + assert.equal(first.providerReference, sameSecrets.providerReference); + + const different = classifyRecordingCandidate({ + value: { providerPayload: { launchToken: "different", fields: ["changed"] } }, + sourceKind: "embedded-player", + }); + assert.notEqual(first.providerReference, different.providerReference); +}); + +test("keeps NTULearn file-shaped non-media references retryable without saving their address", () => { + const result = classifyRecordingCandidate({ + value: { + resourceUrl: "/bbcswebdav/readings/week-1.pdf?signature=secret", + fileName: "week-1.pdf", + mimeType: "application/pdf", + }, + sourceKind: "attachment", + }); + + assert.equal(result.provider, "unsupported"); + assert.equal(result.providerName, "NTULearn file"); + assert.equal(result.providerShape, "ntulearn-file"); + assert.equal(result.retryable, true); + assert.match(result.providerReference, /^unsupported:ntulearn/); + assert.doesNotMatch(JSON.stringify(result), /https?:\/\/|signature=secret/); +}); + +test("unsupported malformed links use a redacted stable shape reference", () => { + const result = classifyRecordingCandidate({ + value: "not a URL?token=secret&launch=opaque", + sourceKind: "launch-link", + }); + + assert.match(result.providerReference, /^unsupported:opaque:/); + assert.doesNotMatch(JSON.stringify(result), /secret|launch=opaque|not a URL/); +}); + +test("accepts a safe direct media field inside an opaque provider object", () => { + const result = classifyRecordingCandidate({ + value: { + provider: "Padlet", + videoUrl: "https://cdn.example.test/lecture.mp4?signature=secret", + }, + sourceKind: "embedded-player", + }); + + assert.deepEqual(result, { + provider: "direct", + providerReference: "direct:cdn.example.test/lecture.mp4", + mediaType: "video", + }); +}); diff --git a/test/media-discovery.test.mjs b/test/media-discovery.test.mjs index 84947bb..c8f3579 100644 --- a/test/media-discovery.test.mjs +++ b/test/media-discovery.test.mjs @@ -274,3 +274,113 @@ test("keeps repeated YouTube appearances as separate recordings", () => { ], ); }); + +test("keeps known external-tool shapes visible as retryable appearances", () => { + const course = { key: "ML0004-TUT", courseId: "_2711874_1", destination: "/courses/ML0004-TUT" }; + const recordings = discoverContentRecordings({ + course, + snapshot: { + items: [ + { + id: "external-tools", + position: 0, + title: "External tools", + contentHandler: "resource/x-bb-document", + contentDetail: { + feedback: { + launchLink: "https://app.feedbackfruits.com/activity/feedback-1?token=secret", + }, + turnitin: { + launchLink: "https://www.turnitin.com/assignment/turnitin-1?state=secret", + }, + ordinary: { url: "https://www.turnitin.com/help/article-42" }, + }, + }, + ], + }, + }); + + assert.deepEqual( + recordings.map(({ provider, providerName, providerShape, retryable, sourceKind }) => [ + provider, + providerName, + providerShape, + retryable, + sourceKind, + ]), + [ + ["unsupported", "FeedbackFruits", "feedbackfruits", true, "launch-link"], + ["unsupported", "Turnitin", "turnitin", true, "launch-link"], + ], + ); + assert.doesNotMatch(JSON.stringify(recordings), /https?:\/\/|secret/); +}); + +test("keeps file-shaped attachments visible as retryable non-recordings", () => { + const recordings = discoverContentRecordings({ + course: { key: "CC0015", courseId: "_9_1", destination: "/courses/CC0015" }, + snapshot: { + items: [ + { + id: "reading", + position: 0, + title: "Reading", + contentHandler: "resource/x-bb-document", + }, + ], + }, + attachmentsByItem: new Map([ + [ + "reading", + [ + { + fileName: "week-1.pdf", + mimeType: "application/pdf", + resourceUrl: "/bbcswebdav/week-1.pdf?signature=secret", + }, + ], + ], + ]), + }); + + assert.deepEqual( + recordings.map(({ provider, providerName, providerShape, retryable, sourceKind }) => [ + provider, + providerName, + providerShape, + retryable, + sourceKind, + ]), + [["unsupported", "NTULearn file", "ntulearn-file", true, "attachment"]], + ); + assert.doesNotMatch(JSON.stringify(recordings), /https?:\/\/|signature=secret/); +}); + +test("prefers a launch shape when detail fields repeat one provider address", () => { + const recordings = discoverContentRecordings({ + course: { key: "CC0015", courseId: "_9_1", destination: "/courses/CC0015" }, + snapshot: { + items: [ + { + id: "repeated-link", + position: 0, + title: "Peer feedback", + contentHandler: "resource/x-bb-document", + contentDetail: { + ordinary: { + url: "https://app.feedbackfruits.com/activity/feedback-1?token=secret", + }, + launch: { + launchLink: "https://app.feedbackfruits.com/activity/feedback-1?token=secret", + }, + }, + }, + ], + }, + }); + + assert.deepEqual( + recordings.map(({ providerName, sourceKind }) => [providerName, sourceKind]), + [["FeedbackFruits", "launch-link"]], + ); +}); diff --git a/test/media-errors.test.mjs b/test/media-errors.test.mjs new file mode 100644 index 0000000..d01b680 --- /dev/null +++ b/test/media-errors.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { publicMediaError } from "../src/media/errors.mjs"; + +test("redacts provider launch parameters from relative diagnostics", () => { + const message = publicMediaError( + new Error( + "launch_token=secret&state=csrf&cookie=session-cookie&token=another-secret&access_token=secret&sig=secret", + ), + ); + + assert.equal( + message, + "launch_token=[redacted]&state=[redacted]&cookie=[redacted]&token=[redacted]&access_token=[redacted]&sig=[redacted]", + ); + assert.doesNotMatch(message, /secret|csrf|session-cookie/); +}); diff --git a/test/media-external.test.mjs b/test/media-external.test.mjs new file mode 100644 index 0000000..8b4ae4c --- /dev/null +++ b/test/media-external.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import test from "node:test"; +import { createExternalShapeAdapter, providerForRecording } from "../src/media/external.mjs"; + +test("a provider adapter exposes one stable discovery and acquisition seam", async () => { + const adapter = createExternalShapeAdapter({ + provider: "fixture-media", + matches: ({ value }) => value?.fixture === true, + referenceOf: () => "fixture-media:id:lecture-1", + resolve: async ({ reference, fresh }) => ({ reference, fresh, duration: 2 }), + transcript: async (resolved) => ({ + body: JSON.stringify({ + language: "en", + segments: [{ start: 0, end: resolved.duration, text: "Hello." }], + }), + }), + media: async () => ({ kind: "audio", body: Buffer.from("audio"), filename: "lecture.m4a" }), + }); + + const classification = adapter.classify({ + value: { fixture: true, launchUrl: "https://fixture.example.test/launch?token=secret" }, + sourceKind: "launch-link", + }); + assert.deepEqual(classification, { + provider: "fixture-media", + providerName: "fixture-media", + providerShape: "fixture-media", + providerReference: "fixture-media:id:lecture-1", + }); + + const provider = adapter.createProvider(); + const resolved = await provider.resolve({ providerReference: classification.providerReference }); + assert.deepEqual(resolved, { + reference: "fixture-media:id:lecture-1", + fresh: true, + duration: 2, + }); + assert.equal((await provider.transcript(resolved)).body.includes("Hello"), true); + assert.deepEqual((await provider.media(resolved)).body, Buffer.from("audio")); +}); + +test("selects the production adapter for a classified appearance", async () => { + const provider = providerForRecording({ + appearance: { provider: "unsupported", providerShape: "feedbackfruits" }, + }); + + assert.equal(provider.name, "feedbackfruits"); + assert.deepEqual(await provider.media(null), { + kind: "unavailable", + limitation: + "FeedbackFruits content is visible but its recording acquisition path is unavailable.", + retryable: true, + }); +}); + +test("redacts a provider adapter that accidentally returns a launch URL", () => { + const adapter = createExternalShapeAdapter({ + provider: "fixture-media", + matches: () => true, + referenceOf: () => "https://fixture.example.test/lecture?token=secret", + }); + + const result = adapter.classify({ value: "fixture", sourceKind: "embedded-player" }); + assert.match(result.providerReference, /^fixture-media:fixture\.example\.test\/lecture$/); + assert.doesNotMatch(JSON.stringify(result), /https?:\/\/|secret/); +}); + +test("redacts ephemeral fields from a custom provider reference", () => { + const adapter = createExternalShapeAdapter({ + provider: "fixture-media", + matches: () => true, + referenceOf: () => "state=secret&access_token=secret&sig=secret", + }); + + const result = adapter.classify({ value: "fixture", sourceKind: "launch-link" }); + assert.match(result.providerReference, /^fixture-media:opaque:/); + assert.doesNotMatch(JSON.stringify(result), /state=secret|access_token=secret|sig=secret/); +}); diff --git a/test/media-gallery-browser.test.mjs b/test/media-gallery-browser.test.mjs index 049cd4d..b3c1a0a 100644 --- a/test/media-gallery-browser.test.mjs +++ b/test/media-gallery-browser.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { setTimeout } from "node:timers/promises"; import { runInNewContext } from "node:vm"; import test from "node:test"; import { @@ -36,6 +37,28 @@ test("collects every gallery page through the visible load-more control", async assert.deepEqual(clicks, ["load-more"]); }); +test("stops cumulative Load More pagination when the displayed total is reached", async () => { + let clicks = 0; + const first = { id: "gallery-1" }; + const second = { id: "gallery-2" }; + + const result = await collectMediaGalleryPages({ + async readPage() { + return clicks === 0 + ? { displayedCount: 2, entries: [first], hasMore: true } + : { displayedCount: 2, entries: [first, second], hasMore: true }; + }, + async clickLoadMore() { + clicks += 1; + return clicks === 1 ? { mode: "append" } : false; + }, + }); + + assert.equal(clicks, 1); + assert.equal(result.at(-1).hasMore, false); + assert.equal(result.at(-1).entries.length, 2); +}); + test("fails when a gallery advertises more pages but its control cannot advance", async () => { await assert.rejects( collectMediaGalleryPages({ @@ -321,6 +344,49 @@ test("advances a numbered Gallery page from the identified current page", async assert.equal(currentPage, 2); }); +test("waits for a Gallery page to advance before reading its next state", async () => { + let currentPage = 1; + let clicks = 0; + const pageOne = pageEntry("gallery-1", "entry:one", true); + const pageTwo = pageEntry("gallery-2", "entry:two", false); + const current = pageControl("Page 1", { "aria-current": "page" }); + const next = pageControl("Go to page 2"); + const child = { + locator: () => locator({ count: 1 }), + evaluate: async () => + currentPage === 1 + ? { displayedCount: 2, entries: [pageOne], hasMore: true } + : { displayedCount: 2, entries: [pageTwo], hasMore: false }, + getByRole(role, { name }) { + if (role === "button" && name.test("Page 1")) { + return collectionLocator([current, next], () => { + clicks += 1; + setTimeout(1_000).then(() => { + currentPage = 2; + }); + }); + } + return locator({ count: 0 }); + }, + async waitForTimeout(delay) { + await setTimeout(delay); + }, + }; + const outer = { locator: () => locator({ count: 0 }) }; + const page = { + async goto() {}, + frames: () => [outer, child], + mainFrame: () => outer, + getByRole: () => locator({ count: 1, click: async () => {} }), + getByText: () => locator({ count: 1, click: async () => {} }), + }; + + const result = await readKalturaMediaGallery({ page, course: COURSE }); + + assert.equal(result.complete, true); + assert.equal(clicks, 1); +}); + test("turns an identity-provider stall into an actionable session limitation", async () => { const page = { async goto() { diff --git a/test/media-gallery.test.mjs b/test/media-gallery.test.mjs index d10ea90..3045589 100644 --- a/test/media-gallery.test.mjs +++ b/test/media-gallery.test.mjs @@ -176,6 +176,21 @@ test("normalizes a raw gallery entry id without treating the appearance id as pr assert.equal(result.recordings[0].providerReference, "entry:entry-one"); }); +test("does not persist a zero duration when the Gallery omits duration metadata", () => { + const result = discoverMediaGallery({ + course: COURSE, + pages: [ + { + displayedCount: 1, + entries: [galleryEntry("gallery-1", "entry:one", "Lecture", "2026-08-10T09:00:00+08:00")], + hasMore: false, + }, + ], + }); + + assert.equal(result.recordings[0].duration, null); +}); + test("does not queue a subset when the gallery count does not reconcile", () => { const result = discoverMediaGallery({ course: COURSE, diff --git a/test/media-job.test.mjs b/test/media-job.test.mjs index 9553ce2..6344df2 100644 --- a/test/media-job.test.mjs +++ b/test/media-job.test.mjs @@ -1372,7 +1372,11 @@ test("does not preserve provider transcript bytes that contain session material" body: JSON.stringify({ language: "en", segments: [ - { start: 0, end: 10, text: "Caption https://video.test/caption?ks=session-secret" }, + { + start: 0, + end: 10, + text: "Caption https://video.test/caption?ks=session-secret&access_token=secret&sig=secret", + }, ], }), filename: "captions.json", @@ -1401,7 +1405,7 @@ test("does not preserve provider transcript bytes that contain session material" writes.map(({ kind }) => kind), ["media", "state", "status"], ); - assert.doesNotMatch(JSON.stringify(writes), /session-secret|ks=/); + assert.doesNotMatch(JSON.stringify(writes), /session-secret|ks=|access_token=|sig=/); }); test("rejects formatted output that loses a number or timestamp", async () => { diff --git a/test/media-queue.test.mjs b/test/media-queue.test.mjs index d92e78c..6b188c7 100644 --- a/test/media-queue.test.mjs +++ b/test/media-queue.test.mjs @@ -155,6 +155,25 @@ test("keeps prior job state on red rediscovery and merges it on the next green r assert.equal(restored.queue[0].withdrawn, true); }); +test("drops invalid retained durations during green queue reconciliation", async () => { + const root = await mkdtemp(join(tmpdir(), "ntulearn-media-queue-")); + const statePath = join(root, "state.json"); + await writeMediaQueue({ + statePath, + course: COURSE, + discovery: { complete: true, queue: [{ recordingId: "gallery-1", duration: 0 }] }, + }); + + const saved = await writeMediaQueue({ + statePath, + course: COURSE, + discovery: { complete: true, queue: [{ recordingId: "gallery-1" }] }, + }); + + const persisted = JSON.parse(await readFile(saved.path, "utf8")); + assert.equal(Object.hasOwn(persisted.queue[0], "duration"), false); +}); + test("keeps a withdrawn tombstone when the next green discovery omits it", async () => { const root = await mkdtemp(join(tmpdir(), "ntulearn-media-queue-")); const statePath = join(root, "state.json"); diff --git a/test/media-status.test.mjs b/test/media-status.test.mjs index 140fd79..c508412 100644 --- a/test/media-status.test.mjs +++ b/test/media-status.test.mjs @@ -107,6 +107,26 @@ test("marks discovery and attempted incomplete work red while retaining retry ev assert.match(summary.recordings[0].lastError, /provider transcript/i); }); +test("keeps the known external provider name in unsupported status", () => { + const appearance = { + recordingId: "content-tree:_9_1:item-1:unsupported:feedbackfruits:activity-1", + title: "Peer feedback", + provider: "unsupported", + providerName: "FeedbackFruits", + providerShape: "feedbackfruits", + sourceKind: "launch-link", + }; + + const summary = mediaCourseStatus({ + course: COURSE, + discovery: { complete: true, verdict: "green" }, + queue: [appearance], + }); + + assert.equal(summary.recordings[0].provider, "FeedbackFruits"); + assert.equal(summary.recordings[0].retryable, true); +}); + test("writes a per-recording status with the complete media contract", async () => { const root = await mkdtemp(join(tmpdir(), "ntulearn-recording-status-")); const appearance = { diff --git a/test/media-workflow.test.mjs b/test/media-workflow.test.mjs index ce0c4fb..0b03083 100644 --- a/test/media-workflow.test.mjs +++ b/test/media-workflow.test.mjs @@ -85,3 +85,51 @@ test("combines content-tree and Media Gallery appearances into one enabled-cours ["content-tree:_9_1:lecture-item:youtube:lecture123", "media-gallery:gallery-1"], ); }); + +test("passes injected content adapters through the discovery workflow", async () => { + let classified = 0; + const adapter = { + classify({ value, sourceKind }) { + if (value !== "https://fixture.example.test/player" || sourceKind !== "embedded-player") { + return null; + } + classified += 1; + return { + provider: "fixture-media", + providerReference: "fixture-media:id:lecture-1", + }; + }, + }; + const result = await discoverCourseMedia({ + client: { + async readCourse() { + return { + items: [ + { + id: "fixture-item", + parentId: null, + position: 0, + title: "Fixture lecture", + contentHandler: "resource/x-bb-document", + body: { displayText: '' }, + }, + ], + }; + }, + async readAttachments() { + return []; + }, + async withBrowserPage(read) { + return read({ signedIn: true }); + }, + }, + course: COURSE, + adapters: [adapter], + async readGallery() { + return { complete: true, recordings: [], queue: [], discoveredCount: 0 }; + }, + }); + + assert.equal(classified, 1); + assert.equal(result.contentRecordings[0].providerReference, "fixture-media:id:lecture-1"); +});