245 lines
8.6 KiB
TypeScript
245 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
// Learner player loader — the L3 side of the frozen-bundle contract.
|
|
//
|
|
// Flow (per courseware version):
|
|
// 1. resolve the published record from the registry (GET /api/coursewares/:id),
|
|
// 2. if the local document for `learn_<id>_v<version>` already exists, reuse
|
|
// it (offline-capable: the bundle is fetched once, then cached in the
|
|
// document store + asset pool / IndexedDB),
|
|
// 3. otherwise download the bundle, materialize audio/media, build the
|
|
// playable document (NO generation outlines — see
|
|
// `import-classroom-core`), and commit it under the learner stage id.
|
|
//
|
|
// The classroom page then loads the committed course document without running
|
|
// outline, scene, or media generation. The original teacher/assistant, dynamic
|
|
// grading, and PBL teaching runtime remain available and may make their normal
|
|
// teaching-time model calls. Progress/answers are persisted through the
|
|
// runtime store (HTTP backend when NEXT_PUBLIC_PERSISTENCE=1).
|
|
|
|
import { nanoid } from 'nanoid';
|
|
import { accessDocument, mutateDocument } from '@/lib/document-store';
|
|
import { db } from '@/lib/utils/database';
|
|
import { removeAsset } from '@/lib/media/asset-pool';
|
|
import { createLogger } from '@/lib/logger';
|
|
import {
|
|
buildImportedDocument,
|
|
materializeImportedAudio,
|
|
materializeImportedMedia,
|
|
} from '@/lib/import/import-classroom-core';
|
|
import type { ClassroomManifest } from '@/lib/export/classroom-zip-types';
|
|
import { computeBundleContentHash, readFrozenBundleDocuments } from '@/lib/bundle/packager';
|
|
|
|
const log = createLogger('LearnerLoad');
|
|
|
|
export interface LearnerCoursewareDetail {
|
|
coursewareId: string;
|
|
version: number;
|
|
title: string;
|
|
language?: string;
|
|
bundleUrl: string;
|
|
sceneCount: number;
|
|
quizSceneCount: number;
|
|
contentHash: string;
|
|
}
|
|
|
|
export interface LearnerLoadResult {
|
|
/** The classroom document id the player should open. */
|
|
stageId: string;
|
|
/** True when an already-materialized copy was reused (no network). */
|
|
cached: boolean;
|
|
detail: LearnerCoursewareDetail;
|
|
}
|
|
|
|
/** Deterministic learner stage id per courseware version. */
|
|
export function learnerStageId(
|
|
coursewareId: string,
|
|
version: number,
|
|
contentHash?: string,
|
|
): string {
|
|
return `learn_${coursewareId}_v${version}${contentHash ? `_${contentHash}` : ''}`;
|
|
}
|
|
|
|
/** Resolve the published record for a courseware from the registry. */
|
|
export async function fetchCoursewareDetail(
|
|
coursewareId: string,
|
|
fetchImpl: typeof fetch = fetch,
|
|
version?: number,
|
|
): Promise<LearnerCoursewareDetail | null> {
|
|
const versionQuery =
|
|
version === undefined ? '' : `?version=${encodeURIComponent(String(version))}`;
|
|
const response = await fetchImpl(
|
|
`/api/coursewares/${encodeURIComponent(coursewareId)}${versionQuery}`,
|
|
);
|
|
if (!response.ok) return null;
|
|
const body = (await response.json()) as {
|
|
success?: boolean;
|
|
courseware?: LearnerCoursewareDetail;
|
|
};
|
|
if (!body.success || !body.courseware) return null;
|
|
return body.courseware;
|
|
}
|
|
|
|
/**
|
|
* Load (or reuse) the local playable copy of a published courseware. Throws
|
|
* with a user-presentable message on registry/network/materialization failure.
|
|
* The returned stage id opens via `/classroom/{stageId}?learner=1&courseware={id}`.
|
|
*/
|
|
export async function loadLearnerCourseware(
|
|
coursewareId: string,
|
|
options: {
|
|
fetchImpl?: typeof fetch;
|
|
version?: number;
|
|
expectedContentHash?: string;
|
|
} = {},
|
|
): Promise<LearnerLoadResult> {
|
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
const hasVersion = options.version !== undefined;
|
|
const hasHash = options.expectedContentHash !== undefined;
|
|
if (hasVersion !== hasHash) {
|
|
throw new Error('Pinned courseware requires both version and content hash');
|
|
}
|
|
if (hasVersion && (!Number.isInteger(options.version) || (options.version ?? 0) < 1)) {
|
|
throw new Error('Invalid pinned courseware version');
|
|
}
|
|
if (hasHash && !/^[0-9a-f]{64}$/.test(options.expectedContentHash ?? '')) {
|
|
throw new Error('Invalid pinned courseware content hash');
|
|
}
|
|
|
|
const detail = await fetchCoursewareDetail(coursewareId, fetchImpl, options.version);
|
|
if (!detail) {
|
|
throw new Error(`Courseware not found: ${coursewareId}`);
|
|
}
|
|
if (
|
|
detail.coursewareId !== coursewareId ||
|
|
!Number.isInteger(detail.version) ||
|
|
!/^[0-9a-f]{64}$/.test(detail.contentHash)
|
|
) {
|
|
throw new Error(`Courseware registry returned an invalid immutable identity: ${coursewareId}`);
|
|
}
|
|
if (options.version !== undefined && detail.version !== options.version) {
|
|
throw new Error(
|
|
`Courseware version mismatch: expected v${options.version}, received v${detail.version}`,
|
|
);
|
|
}
|
|
if (
|
|
options.expectedContentHash !== undefined &&
|
|
detail.contentHash !== options.expectedContentHash
|
|
) {
|
|
throw new Error('Courseware content hash changed before download');
|
|
}
|
|
|
|
const stageId = learnerStageId(coursewareId, detail.version, detail.contentHash);
|
|
|
|
// Already materialized → straight to playback, no network.
|
|
const existing = await accessDocument(stageId);
|
|
if (existing.document) {
|
|
log.info('Learner courseware reused from local cache:', stageId);
|
|
return { stageId, cached: true, detail };
|
|
}
|
|
|
|
// Download + materialize the frozen bundle.
|
|
const bundleResponse = await fetchImpl(detail.bundleUrl);
|
|
if (!bundleResponse.ok) {
|
|
throw new Error(`Bundle download failed (HTTP ${bundleResponse.status})`);
|
|
}
|
|
const zipBlob = await bundleResponse.blob();
|
|
|
|
const now = Date.now();
|
|
const importedPoolIds: string[] = [];
|
|
let committed = false;
|
|
try {
|
|
const documents = await readFrozenBundleDocuments(zipBlob);
|
|
const computedHash = await computeBundleContentHash(zipBlob);
|
|
const legacyBrowserVersionTemplate =
|
|
documents.meta.contentHashVersion === undefined &&
|
|
documents.meta.version === 1 &&
|
|
detail.version > 1;
|
|
if (
|
|
documents.meta.coursewareId !== coursewareId ||
|
|
(documents.meta.version !== detail.version && !legacyBrowserVersionTemplate) ||
|
|
documents.meta.contentHash !== detail.contentHash ||
|
|
computedHash !== detail.contentHash ||
|
|
!documents.completeness.complete
|
|
) {
|
|
throw new Error(`Frozen bundle identity or completeness check failed: ${coursewareId}`);
|
|
}
|
|
const manifest = documents.manifest as ClassroomManifest;
|
|
|
|
// The bundle is complete by publish gate; materialize every audio/media
|
|
// entry into the pool + IndexedDB mirror (the playback engine's resolvers).
|
|
const zip = await loadZip(zipBlob);
|
|
const audioRefToNewId = await materializeImportedAudio(
|
|
zip,
|
|
manifest,
|
|
stageId,
|
|
now,
|
|
importedPoolIds,
|
|
);
|
|
const mediaMappings = await materializeImportedMedia(
|
|
zip,
|
|
manifest,
|
|
stageId,
|
|
now,
|
|
importedPoolIds,
|
|
);
|
|
|
|
const newAgentIds: string[] = (manifest.agents ?? []).map(() => nanoid());
|
|
const studentAgentIndex = manifest.agents?.findIndex((agent) => agent.role === 'student') ?? -1;
|
|
const nonTeacherAgentIndex =
|
|
manifest.agents?.findIndex((agent) => agent.role !== 'teacher') ?? -1;
|
|
const fallbackDiscussionAgentIndex =
|
|
studentAgentIndex >= 0
|
|
? studentAgentIndex
|
|
: nonTeacherAgentIndex >= 0
|
|
? nonTeacherAgentIndex
|
|
: undefined;
|
|
|
|
const document = buildImportedDocument(manifest, {
|
|
stageId,
|
|
now,
|
|
newAgentIds,
|
|
audioRefToNewId,
|
|
mediaMappings,
|
|
fallbackDiscussionAgentIndex,
|
|
});
|
|
|
|
// Commit point: one aggregate write under the per-stage lock.
|
|
await mutateDocument(stageId, async (_existing, store) => store.saveDocument(document));
|
|
committed = true;
|
|
log.info(`Learner courseware materialized: ${stageId}`);
|
|
return { stageId, cached: false, detail };
|
|
} catch (error) {
|
|
// Compensate partial materialization: media cannot join the document
|
|
// transaction, so roll back rows/allocations individually.
|
|
log.error('Learner courseware materialization failed:', error);
|
|
if (!committed) {
|
|
await cleanupFailedLoad(stageId, importedPoolIds);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function loadZip(zipBlob: Blob) {
|
|
const JSZip = (await import('jszip')).default;
|
|
const data = await zipBlob.arrayBuffer();
|
|
return JSZip.loadAsync(data);
|
|
}
|
|
|
|
async function cleanupFailedLoad(stageId: string, poolIds: string[]): Promise<void> {
|
|
try {
|
|
await mutateDocument(stageId, async (_document, store) => store.deleteDocument(stageId));
|
|
await db.mediaFiles.where('stageId').equals(stageId).delete();
|
|
await db.audioFiles.where('stageId').equals(stageId).delete();
|
|
for (const id of poolIds) {
|
|
try {
|
|
await removeAsset(id);
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
} catch (cleanupError) {
|
|
log.error('Learner cleanup failed:', cleanupError);
|
|
}
|
|
}
|