Skip to content

Commit d0c0f0a

Browse files
committed
fix(learningsuite): download encrypted HLS videos via segment capture
- LearningSuite uses encrypted HLS playlists (client-side decryption) - Capture individual .ts segment URLs with their tokens during playback - Implement segment-based download: download all segments then concat with ffmpeg - Add 'segments:' URL scheme for passing captured URLs to downloader - Fix video type mapping for segment-based downloads - Remove debug logging from production code
1 parent 4f4f963 commit d0c0f0a

File tree

3 files changed

+263
-54
lines changed

3 files changed

+263
-54
lines changed

src/cli/commands/syncLearningSuite.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -449,11 +449,12 @@ export async function syncLearningSuiteCommand(
449449

450450
// Queue video download
451451
if (!options.skipVideos && !syncStatus.video && content.video?.url) {
452+
const videoUrl = content.video.hlsUrl ?? content.video.url;
452453
videoTasks.push({
453454
lessonId: lesson.id as unknown as number,
454455
lessonName: lesson.title,
455-
videoUrl: content.video.hlsUrl ?? content.video.url,
456-
videoType: mapVideoType(content.video.type),
456+
videoUrl,
457+
videoType: mapVideoType(content.video.type, videoUrl),
457458
outputPath: getVideoPath(moduleDir, lessonIndex, lesson.title),
458459
preferredQuality: options.quality,
459460
});
@@ -487,11 +488,12 @@ export async function syncLearningSuiteCommand(
487488
);
488489

489490
if (content?.video?.url) {
491+
const videoUrl = content.video.hlsUrl ?? content.video.url;
490492
videoTasks.push({
491493
lessonId: lesson.id as unknown as number,
492494
lessonName: lesson.title,
493-
videoUrl: content.video.hlsUrl ?? content.video.url,
494-
videoType: mapVideoType(content.video.type),
495+
videoUrl,
496+
videoType: mapVideoType(content.video.type, videoUrl),
495497
outputPath: getVideoPath(moduleDir, lessonIndex, lesson.title),
496498
preferredQuality: options.quality,
497499
});
@@ -547,10 +549,15 @@ export async function syncLearningSuiteCommand(
547549
/**
548550
* Maps LearningSuite video type to downloader video type.
549551
*/
550-
function mapVideoType(type: string): VideoDownloadTask["videoType"] {
552+
function mapVideoType(type: string, url?: string): VideoDownloadTask["videoType"] {
553+
// Special case: segments:... URLs use direct HLS segment download
554+
if (url?.startsWith("segments:")) {
555+
return "hls";
556+
}
557+
551558
switch (type) {
552559
case "hls":
553-
return "highlevel"; // Use HLS downloader
560+
return "highlevel"; // Use HLS downloader for standard HLS
554561
case "vimeo":
555562
return "vimeo";
556563
case "loom":

src/downloader/hlsDownloader.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ export async function downloadHLSVideo(
236236
};
237237
}
238238

239+
// Handle special "segments:" URLs (for encrypted HLS with individual tokens)
240+
if (hlsUrl.startsWith("segments:")) {
241+
return downloadHLSSegments(hlsUrl, outputPath, onProgress);
242+
}
243+
239244
// Pre-validate the HLS URL before downloading
240245
try {
241246
const urlObj = new URL(hlsUrl);
@@ -466,3 +471,126 @@ export function parseHighLevelVideoUrl(url: string): {
466471
return null;
467472
}
468473
}
474+
475+
/**
476+
* Downloads HLS video from individual segment URLs (for encrypted HLS with per-segment tokens)
477+
*/
478+
async function downloadHLSSegments(
479+
segmentsUrl: string,
480+
outputPath: string,
481+
onProgress?: (progress: DownloadProgress) => void
482+
): Promise<HLSDownloadResult> {
483+
try {
484+
// Decode segment URLs from base64-encoded JSON
485+
const base64Data = segmentsUrl.replace("segments:", "");
486+
const segmentData = Buffer.from(base64Data, "base64").toString("utf-8");
487+
const segmentUrls: string[] = JSON.parse(segmentData) as string[];
488+
489+
if (segmentUrls.length === 0) {
490+
return {
491+
success: false,
492+
error: "No segment URLs provided",
493+
errorCode: "NO_SEGMENTS",
494+
};
495+
}
496+
497+
// Create temp directory for segments
498+
const tempDir = path.join(path.dirname(outputPath), ".hls-segments");
499+
if (!fs.existsSync(tempDir)) {
500+
fs.mkdirSync(tempDir, { recursive: true });
501+
}
502+
503+
// Download all segments
504+
const segmentPaths: string[] = [];
505+
for (let i = 0; i < segmentUrls.length; i++) {
506+
const segmentUrl = segmentUrls[i];
507+
if (!segmentUrl) continue;
508+
509+
const segmentPath = path.join(tempDir, `segment${String(i).padStart(4, "0")}.ts`);
510+
segmentPaths.push(segmentPath);
511+
512+
// Skip if already downloaded
513+
if (fs.existsSync(segmentPath)) {
514+
continue;
515+
}
516+
517+
const response = await fetch(segmentUrl);
518+
if (!response.ok) {
519+
return {
520+
success: false,
521+
error: `Failed to download segment ${i}: HTTP ${response.status}`,
522+
errorCode: "SEGMENT_FETCH_FAILED",
523+
};
524+
}
525+
526+
const buffer = await response.arrayBuffer();
527+
fs.writeFileSync(segmentPath, Buffer.from(buffer));
528+
529+
// Report progress
530+
if (onProgress) {
531+
onProgress({
532+
percent: Math.round(((i + 1) / segmentUrls.length) * 90), // Leave 10% for merging
533+
phase: "downloading",
534+
});
535+
}
536+
}
537+
538+
// Create concat file for ffmpeg
539+
const concatPath = path.join(tempDir, "concat.txt");
540+
const concatContent = segmentPaths.map((p) => `file '${p}'`).join("\n");
541+
fs.writeFileSync(concatPath, concatContent);
542+
543+
// Merge segments with ffmpeg
544+
if (onProgress) {
545+
onProgress({ percent: 95, phase: "preparing" });
546+
}
547+
548+
await execa("ffmpeg", [
549+
"-y",
550+
"-f",
551+
"concat",
552+
"-safe",
553+
"0",
554+
"-i",
555+
concatPath,
556+
"-c",
557+
"copy",
558+
outputPath,
559+
]);
560+
561+
// Clean up temp files
562+
for (const p of segmentPaths) {
563+
try {
564+
fs.unlinkSync(p);
565+
} catch {
566+
/* ignore */
567+
}
568+
}
569+
try {
570+
fs.unlinkSync(concatPath);
571+
} catch {
572+
/* ignore */
573+
}
574+
try {
575+
fs.rmdirSync(tempDir);
576+
} catch {
577+
/* ignore */
578+
}
579+
580+
if (onProgress) {
581+
onProgress({ percent: 100, phase: "complete" });
582+
}
583+
584+
return {
585+
success: true,
586+
outputPath,
587+
};
588+
} catch (e) {
589+
const error = e instanceof Error ? e.message : String(e);
590+
return {
591+
success: false,
592+
error: `Segment download failed: ${error}`,
593+
errorCode: "SEGMENT_DOWNLOAD_FAILED",
594+
};
595+
}
596+
}

0 commit comments

Comments
 (0)