Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/media/addresses.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const MEDIA_ADDRESS_KEYS = Object.freeze([
"resourceUrl",
"viewerUrl",
"url",
"href",
"src",
"launchUrl",
"launchLink",
"permanentUrl",
"mediaUrl",
"videoUrl",
"audioUrl",
"playbackUrl",
"downloadUrl",
]);
48 changes: 13 additions & 35 deletions src/media/classification.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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"
);
}
3 changes: 2 additions & 1 deletion src/media/direct.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 36 additions & 18 deletions src/media/discovery.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = [];

Expand All @@ -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;
Expand Down Expand Up @@ -54,6 +59,8 @@ function appearance({
provider,
providerReference,
mediaType,
providerName,
providerShape,
retryable,
limitation,
sourceKind,
Expand All @@ -71,6 +78,8 @@ function appearance({
provider,
providerReference,
mediaType: mediaType ?? null,
...(providerName ? { providerName } : {}),
...(providerShape ? { providerShape } : {}),
...(retryable !== undefined ? { retryable } : {}),
...(limitation ? { limitation } : {}),
sourceKind,
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
5 changes: 4 additions & 1 deletion src/media/errors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading