'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__v` 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 { 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 { 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 { 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); } }