offcourse 0.0.2 ā 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +50 -0
- package/.husky/commit-msg +2 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +3 -0
- package/.prettierrc +8 -0
- package/.release-it.json +23 -0
- package/ARCHITECTURE.md +233 -0
- package/CHANGELOG.md +78 -0
- package/README.md +255 -20
- package/commitlint.config.js +4 -0
- package/dist/ai/openRouter.d.ts +47 -0
- package/dist/ai/openRouter.d.ts.map +1 -0
- package/dist/ai/openRouter.js +116 -0
- package/dist/ai/openRouter.js.map +1 -0
- package/dist/ai/transcriptPolisher.d.ts +24 -0
- package/dist/ai/transcriptPolisher.d.ts.map +1 -0
- package/dist/ai/transcriptPolisher.js +89 -0
- package/dist/ai/transcriptPolisher.js.map +1 -0
- package/dist/cli/commands/config.d.ts +13 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +66 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/enrich.d.ts +14 -0
- package/dist/cli/commands/enrich.d.ts.map +1 -0
- package/dist/cli/commands/enrich.js +271 -0
- package/dist/cli/commands/enrich.js.map +1 -0
- package/dist/cli/commands/inspect.d.ts +11 -0
- package/dist/cli/commands/inspect.d.ts.map +1 -0
- package/dist/cli/commands/inspect.js +365 -0
- package/dist/cli/commands/inspect.js.map +1 -0
- package/dist/cli/commands/login.d.ts +12 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +55 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/status.d.ts +15 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +118 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/sync.d.ts +16 -0
- package/dist/cli/commands/sync.d.ts.map +1 -0
- package/dist/cli/commands/sync.js +922 -0
- package/dist/cli/commands/sync.js.map +1 -0
- package/dist/cli/commands/syncGhl.d.ts +20 -0
- package/dist/cli/commands/syncGhl.d.ts.map +1 -0
- package/dist/cli/commands/syncGhl.js +483 -0
- package/dist/cli/commands/syncGhl.js.map +1 -0
- package/dist/cli/commands/syncHighLevel.d.ts +24 -0
- package/dist/cli/commands/syncHighLevel.d.ts.map +1 -0
- package/dist/cli/commands/syncHighLevel.js +483 -0
- package/dist/cli/commands/syncHighLevel.js.map +1 -0
- package/dist/cli/commands/syncHighLevel.test.d.ts +2 -0
- package/dist/cli/commands/syncHighLevel.test.d.ts.map +1 -0
- package/dist/cli/commands/syncHighLevel.test.js +102 -0
- package/dist/cli/commands/syncHighLevel.test.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +106 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config/configManager.d.ts +31 -0
- package/dist/config/configManager.d.ts.map +1 -0
- package/dist/config/configManager.js +64 -0
- package/dist/config/configManager.js.map +1 -0
- package/dist/config/paths.d.ts +21 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +33 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/paths.test.d.ts +2 -0
- package/dist/config/paths.test.d.ts.map +1 -0
- package/dist/config/paths.test.js +70 -0
- package/dist/config/paths.test.js.map +1 -0
- package/dist/config/schema.d.ts +60 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +50 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/schema.test.d.ts +2 -0
- package/dist/config/schema.test.d.ts.map +1 -0
- package/dist/config/schema.test.js +151 -0
- package/dist/config/schema.test.js.map +1 -0
- package/dist/downloader/hlsDownloader.d.ts +58 -0
- package/dist/downloader/hlsDownloader.d.ts.map +1 -0
- package/dist/downloader/hlsDownloader.js +254 -0
- package/dist/downloader/hlsDownloader.js.map +1 -0
- package/dist/downloader/hlsDownloader.test.d.ts +2 -0
- package/dist/downloader/hlsDownloader.test.d.ts.map +1 -0
- package/dist/downloader/hlsDownloader.test.js +116 -0
- package/dist/downloader/hlsDownloader.test.js.map +1 -0
- package/dist/downloader/hlsValidator.d.ts +35 -0
- package/dist/downloader/hlsValidator.d.ts.map +1 -0
- package/dist/downloader/hlsValidator.js +148 -0
- package/dist/downloader/hlsValidator.js.map +1 -0
- package/dist/downloader/index.d.ts +26 -0
- package/dist/downloader/index.d.ts.map +1 -0
- package/dist/downloader/index.js +52 -0
- package/dist/downloader/index.js.map +1 -0
- package/dist/downloader/loomDownloader.d.ts +56 -0
- package/dist/downloader/loomDownloader.d.ts.map +1 -0
- package/dist/downloader/loomDownloader.js +559 -0
- package/dist/downloader/loomDownloader.js.map +1 -0
- package/dist/downloader/loomDownloader.test.d.ts +2 -0
- package/dist/downloader/loomDownloader.test.d.ts.map +1 -0
- package/dist/downloader/loomDownloader.test.js +36 -0
- package/dist/downloader/loomDownloader.test.js.map +1 -0
- package/dist/downloader/queue.d.ts +56 -0
- package/dist/downloader/queue.d.ts.map +1 -0
- package/dist/downloader/queue.js +88 -0
- package/dist/downloader/queue.js.map +1 -0
- package/dist/downloader/queue.test.d.ts +2 -0
- package/dist/downloader/queue.test.d.ts.map +1 -0
- package/dist/downloader/queue.test.js +158 -0
- package/dist/downloader/queue.test.js.map +1 -0
- package/dist/downloader/videoDownloader.d.ts +32 -0
- package/dist/downloader/videoDownloader.d.ts.map +1 -0
- package/dist/downloader/videoDownloader.js +173 -0
- package/dist/downloader/videoDownloader.js.map +1 -0
- package/dist/downloader/vimeoDownloader.d.ts +52 -0
- package/dist/downloader/vimeoDownloader.d.ts.map +1 -0
- package/dist/downloader/vimeoDownloader.js +565 -0
- package/dist/downloader/vimeoDownloader.js.map +1 -0
- package/dist/downloader/vimeoDownloader.test.d.ts +2 -0
- package/dist/downloader/vimeoDownloader.test.d.ts.map +1 -0
- package/dist/downloader/vimeoDownloader.test.js +51 -0
- package/dist/downloader/vimeoDownloader.test.js.map +1 -0
- package/dist/scraper/auth.d.ts +29 -0
- package/dist/scraper/auth.d.ts.map +1 -0
- package/dist/scraper/auth.js +115 -0
- package/dist/scraper/auth.js.map +1 -0
- package/dist/scraper/extractor.d.ts +49 -0
- package/dist/scraper/extractor.d.ts.map +1 -0
- package/dist/scraper/extractor.js +627 -0
- package/dist/scraper/extractor.js.map +1 -0
- package/dist/scraper/extractor.test.d.ts +2 -0
- package/dist/scraper/extractor.test.d.ts.map +1 -0
- package/dist/scraper/extractor.test.js +65 -0
- package/dist/scraper/extractor.test.js.map +1 -0
- package/dist/scraper/ghl/auth.d.ts +25 -0
- package/dist/scraper/ghl/auth.d.ts.map +1 -0
- package/dist/scraper/ghl/auth.js +187 -0
- package/dist/scraper/ghl/auth.js.map +1 -0
- package/dist/scraper/ghl/extractor.d.ts +96 -0
- package/dist/scraper/ghl/extractor.d.ts.map +1 -0
- package/dist/scraper/ghl/extractor.js +345 -0
- package/dist/scraper/ghl/extractor.js.map +1 -0
- package/dist/scraper/ghl/index.d.ts +4 -0
- package/dist/scraper/ghl/index.d.ts.map +1 -0
- package/dist/scraper/ghl/index.js +4 -0
- package/dist/scraper/ghl/index.js.map +1 -0
- package/dist/scraper/ghl/navigator.d.ts +93 -0
- package/dist/scraper/ghl/navigator.d.ts.map +1 -0
- package/dist/scraper/ghl/navigator.js +447 -0
- package/dist/scraper/ghl/navigator.js.map +1 -0
- package/dist/scraper/highlevel/auth.d.ts +25 -0
- package/dist/scraper/highlevel/auth.d.ts.map +1 -0
- package/dist/scraper/highlevel/auth.js +189 -0
- package/dist/scraper/highlevel/auth.js.map +1 -0
- package/dist/scraper/highlevel/extractor.d.ts +97 -0
- package/dist/scraper/highlevel/extractor.d.ts.map +1 -0
- package/dist/scraper/highlevel/extractor.js +386 -0
- package/dist/scraper/highlevel/extractor.js.map +1 -0
- package/dist/scraper/highlevel/extractor.test.d.ts +2 -0
- package/dist/scraper/highlevel/extractor.test.d.ts.map +1 -0
- package/dist/scraper/highlevel/extractor.test.js +101 -0
- package/dist/scraper/highlevel/extractor.test.js.map +1 -0
- package/dist/scraper/highlevel/index.d.ts +3 -0
- package/dist/scraper/highlevel/index.d.ts.map +1 -0
- package/dist/scraper/highlevel/index.js +3 -0
- package/dist/scraper/highlevel/index.js.map +1 -0
- package/dist/scraper/highlevel/navigator.d.ts +93 -0
- package/dist/scraper/highlevel/navigator.d.ts.map +1 -0
- package/dist/scraper/highlevel/navigator.js +492 -0
- package/dist/scraper/highlevel/navigator.js.map +1 -0
- package/dist/scraper/highlevel/navigator.test.d.ts +2 -0
- package/dist/scraper/highlevel/navigator.test.d.ts.map +1 -0
- package/dist/scraper/highlevel/navigator.test.js +78 -0
- package/dist/scraper/highlevel/navigator.test.js.map +1 -0
- package/dist/scraper/navigator.d.ts +65 -0
- package/dist/scraper/navigator.d.ts.map +1 -0
- package/dist/scraper/navigator.js +300 -0
- package/dist/scraper/navigator.js.map +1 -0
- package/dist/scraper/navigator.test.d.ts +2 -0
- package/dist/scraper/navigator.test.d.ts.map +1 -0
- package/dist/scraper/navigator.test.js +63 -0
- package/dist/scraper/navigator.test.js.map +1 -0
- package/dist/scraper/skoolApi.d.ts +17 -0
- package/dist/scraper/skoolApi.d.ts.map +1 -0
- package/dist/scraper/skoolApi.js +72 -0
- package/dist/scraper/skoolApi.js.map +1 -0
- package/dist/scraper/videoInterceptor.d.ts +19 -0
- package/dist/scraper/videoInterceptor.d.ts.map +1 -0
- package/dist/scraper/videoInterceptor.js +315 -0
- package/dist/scraper/videoInterceptor.js.map +1 -0
- package/dist/shared/auth.d.ts +58 -0
- package/dist/shared/auth.d.ts.map +1 -0
- package/dist/shared/auth.js +211 -0
- package/dist/shared/auth.js.map +1 -0
- package/dist/shared/fs.d.ts +31 -0
- package/dist/shared/fs.d.ts.map +1 -0
- package/dist/shared/fs.js +73 -0
- package/dist/shared/fs.js.map +1 -0
- package/dist/shared/http.d.ts +15 -0
- package/dist/shared/http.d.ts.map +1 -0
- package/dist/shared/http.js +31 -0
- package/dist/shared/http.js.map +1 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/index.js +4 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/state/database.d.ts +245 -0
- package/dist/state/database.d.ts.map +1 -0
- package/dist/state/database.js +676 -0
- package/dist/state/database.js.map +1 -0
- package/dist/state/database.test.d.ts +2 -0
- package/dist/state/database.test.d.ts.map +1 -0
- package/dist/state/database.test.js +34 -0
- package/dist/state/database.test.js.map +1 -0
- package/dist/state/index.d.ts +2 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +2 -0
- package/dist/state/index.js.map +1 -0
- package/dist/storage/fileSystem.d.ts +56 -0
- package/dist/storage/fileSystem.d.ts.map +1 -0
- package/dist/storage/fileSystem.js +121 -0
- package/dist/storage/fileSystem.js.map +1 -0
- package/dist/transcription/whisperService.d.ts +27 -0
- package/dist/transcription/whisperService.d.ts.map +1 -0
- package/dist/transcription/whisperService.js +102 -0
- package/dist/transcription/whisperService.js.map +1 -0
- package/eslint.config.js +55 -0
- package/package.json +68 -11
- package/src/__fixtures__/highlevel-post-response.json +68 -0
- package/src/__fixtures__/hls-master-playlist.m3u8 +24 -0
- package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +38 -0
- package/src/cli/commands/config.ts +74 -0
- package/src/cli/commands/inspect.ts +441 -0
- package/src/cli/commands/login.ts +68 -0
- package/src/cli/commands/status.ts +147 -0
- package/src/cli/commands/sync.ts +1235 -0
- package/src/cli/commands/syncHighLevel.test.ts +144 -0
- package/src/cli/commands/syncHighLevel.ts +639 -0
- package/src/cli/index.ts +121 -0
- package/src/config/configManager.ts +75 -0
- package/src/config/paths.test.ts +83 -0
- package/src/config/paths.ts +36 -0
- package/src/config/schema.test.ts +173 -0
- package/src/config/schema.ts +65 -0
- package/src/downloader/hlsDownloader.test.ts +148 -0
- package/src/downloader/hlsDownloader.ts +327 -0
- package/src/downloader/hlsValidator.ts +196 -0
- package/src/downloader/index.ts +122 -0
- package/src/downloader/loomDownloader.test.ts +43 -0
- package/src/downloader/loomDownloader.ts +742 -0
- package/src/downloader/queue.test.ts +199 -0
- package/src/downloader/queue.ts +118 -0
- package/src/downloader/vimeoDownloader.test.ts +62 -0
- package/src/downloader/vimeoDownloader.ts +722 -0
- package/src/scraper/extractor.test.ts +124 -0
- package/src/scraper/extractor.ts +757 -0
- package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +41 -0
- package/src/scraper/highlevel/extractor.test.ts +134 -0
- package/src/scraper/highlevel/extractor.ts +537 -0
- package/src/scraper/highlevel/index.ts +2 -0
- package/src/scraper/highlevel/navigator.test.ts +110 -0
- package/src/scraper/highlevel/navigator.ts +668 -0
- package/src/scraper/highlevel/schemas.ts +183 -0
- package/src/scraper/navigator.test.ts +122 -0
- package/src/scraper/navigator.ts +355 -0
- package/src/scraper/schemas.ts +177 -0
- package/src/scraper/videoInterceptor.ts +435 -0
- package/src/shared/auth.test.ts +58 -0
- package/src/shared/auth.ts +251 -0
- package/src/shared/firebase.ts +151 -0
- package/src/shared/fs.ts +80 -0
- package/src/shared/http.ts +34 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/slug.ts +26 -0
- package/src/shared/url.test.ts +122 -0
- package/src/shared/url.ts +57 -0
- package/src/state/database.test.ts +49 -0
- package/src/state/database.ts +919 -0
- package/src/state/index.ts +14 -0
- package/src/storage/fileSystem.test.ts +64 -0
- package/src/storage/fileSystem.ts +175 -0
- package/tsconfig.json +28 -0
- package/vitest.config.ts +29 -0
- package/cli.js +0 -45
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import cliProgress from "cli-progress";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { loadConfig } from "../../config/configManager.js";
|
|
6
|
+
import { downloadVideo, type VideoDownloadTask, validateVideoHls } from "../../downloader/index.js";
|
|
7
|
+
import { getAuthenticatedSession, isSkoolLoginPage } from "../../shared/auth.js";
|
|
8
|
+
import { getFileSize, outputFile } from "../../shared/fs.js";
|
|
9
|
+
import { extractLessonContent, formatMarkdown, extractVideoUrl } from "../../scraper/extractor.js";
|
|
10
|
+
import { buildCourseStructure } from "../../scraper/navigator.js";
|
|
11
|
+
import {
|
|
12
|
+
createCourseDirectory,
|
|
13
|
+
createModuleDirectory,
|
|
14
|
+
downloadFile,
|
|
15
|
+
getDownloadFilePath,
|
|
16
|
+
getMarkdownPath,
|
|
17
|
+
getVideoPath,
|
|
18
|
+
isLessonSynced,
|
|
19
|
+
saveMarkdown,
|
|
20
|
+
} from "../../storage/fileSystem.js";
|
|
21
|
+
import {
|
|
22
|
+
CourseDatabase,
|
|
23
|
+
extractCommunitySlug,
|
|
24
|
+
LessonStatus,
|
|
25
|
+
type LessonWithModule,
|
|
26
|
+
} from "../../state/index.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tracks if shutdown has been requested (Ctrl+C).
|
|
30
|
+
*/
|
|
31
|
+
let isShuttingDown = false;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resources to clean up on shutdown.
|
|
35
|
+
*/
|
|
36
|
+
interface CleanupResources {
|
|
37
|
+
browser?: import("playwright").Browser;
|
|
38
|
+
db?: CourseDatabase;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cleanupResources: CleanupResources = {};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Graceful shutdown handler.
|
|
45
|
+
*/
|
|
46
|
+
function setupShutdownHandlers(): void {
|
|
47
|
+
const shutdown = async (signal: string) => {
|
|
48
|
+
if (isShuttingDown) {
|
|
49
|
+
// Force exit on second signal
|
|
50
|
+
console.log(chalk.red("\n\nā ļø Force exit"));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
isShuttingDown = true;
|
|
55
|
+
console.log(chalk.yellow(`\n\nā¹ļø ${signal} received, shutting down gracefully...`));
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
if (cleanupResources.browser) {
|
|
59
|
+
await cleanupResources.browser.close();
|
|
60
|
+
}
|
|
61
|
+
if (cleanupResources.db) {
|
|
62
|
+
cleanupResources.db.close();
|
|
63
|
+
}
|
|
64
|
+
console.log(chalk.gray(" Cleanup complete. State saved."));
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore cleanup errors during shutdown
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
process.exit(0);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
73
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if we should continue processing or stop due to shutdown.
|
|
78
|
+
*/
|
|
79
|
+
function shouldContinue(): boolean {
|
|
80
|
+
return !isShuttingDown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DownloadAttempt {
|
|
84
|
+
lessonName: string;
|
|
85
|
+
videoUrl: string;
|
|
86
|
+
videoType: string | null;
|
|
87
|
+
success: boolean;
|
|
88
|
+
error?: string | undefined;
|
|
89
|
+
errorCode?: string | undefined;
|
|
90
|
+
details?: string | undefined;
|
|
91
|
+
timestamp: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const SKOOL_DOMAIN = "www.skool.com";
|
|
95
|
+
const SKOOL_LOGIN_URL = "https://www.skool.com/login";
|
|
96
|
+
|
|
97
|
+
export interface SyncOptions {
|
|
98
|
+
skipVideos?: boolean;
|
|
99
|
+
skipContent?: boolean;
|
|
100
|
+
dryRun?: boolean;
|
|
101
|
+
limit?: number;
|
|
102
|
+
force?: boolean;
|
|
103
|
+
retryFailed?: boolean;
|
|
104
|
+
visible?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handles the sync command.
|
|
109
|
+
* Downloads all content from a Skool course with incremental state tracking.
|
|
110
|
+
*/
|
|
111
|
+
export async function syncCommand(url: string, options: SyncOptions): Promise<void> {
|
|
112
|
+
// Setup graceful shutdown handlers
|
|
113
|
+
setupShutdownHandlers();
|
|
114
|
+
|
|
115
|
+
console.log(chalk.blue("\nš Course Sync\n"));
|
|
116
|
+
|
|
117
|
+
// Validate URL
|
|
118
|
+
if (!url.includes("skool.com")) {
|
|
119
|
+
console.log(chalk.red("ā Invalid URL. Please provide a Skool URL."));
|
|
120
|
+
console.log(chalk.gray(" Example: https://www.skool.com/your-community/classroom\n"));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Ensure URL points to classroom
|
|
125
|
+
if (!url.includes("/classroom")) {
|
|
126
|
+
url = url.replace(/\/?$/, "/classroom");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const config = loadConfig();
|
|
130
|
+
const communitySlug = extractCommunitySlug(url);
|
|
131
|
+
|
|
132
|
+
// Initialize database
|
|
133
|
+
const db = new CourseDatabase(communitySlug);
|
|
134
|
+
cleanupResources.db = db;
|
|
135
|
+
console.log(chalk.gray(` State: ~/.offcourse/cache/${communitySlug}.db`));
|
|
136
|
+
|
|
137
|
+
// Force mode: reset all lessons to pending for full rescan
|
|
138
|
+
if (options.force) {
|
|
139
|
+
const resetCount = db.resetAllLessonsToPending();
|
|
140
|
+
if (resetCount > 0) {
|
|
141
|
+
console.log(chalk.yellow(` Force mode: reset ${resetCount} lessons for rescan`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check existing state
|
|
146
|
+
const existingMeta = db.getCourseMetadata();
|
|
147
|
+
const hasExistingData = existingMeta.totalLessons > 0;
|
|
148
|
+
|
|
149
|
+
// Check what work needs to be done BEFORE opening browser
|
|
150
|
+
const initialSummary = hasExistingData ? db.getStatusSummary() : null;
|
|
151
|
+
|
|
152
|
+
if (hasExistingData && initialSummary) {
|
|
153
|
+
console.log(
|
|
154
|
+
chalk.gray(
|
|
155
|
+
` Found: ${existingMeta.totalModules} modules, ${existingMeta.totalLessons} lessons`
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
const lockedInfo = initialSummary.locked > 0 ? `, ${initialSummary.locked} locked` : "";
|
|
159
|
+
console.log(
|
|
160
|
+
chalk.gray(
|
|
161
|
+
` Status: ${initialSummary.downloaded} downloaded, ${initialSummary.validated} ready, ${initialSummary.error} failed, ${initialSummary.pending} to scan${lockedInfo}`
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const needsScan = !hasExistingData || (initialSummary?.pending ?? 0) > 0;
|
|
167
|
+
const needsValidation = hasExistingData ? db.getLessonsToValidate().length > 0 : true;
|
|
168
|
+
const needsDownload = hasExistingData ? db.getLessonsToDownload().length > 0 : true;
|
|
169
|
+
const courseDir = await createCourseDirectory(config.outputDir, communitySlug);
|
|
170
|
+
|
|
171
|
+
// Quick exit if nothing to do (and not retry-failed or dry-run)
|
|
172
|
+
if (
|
|
173
|
+
hasExistingData &&
|
|
174
|
+
!needsScan &&
|
|
175
|
+
!needsValidation &&
|
|
176
|
+
!needsDownload &&
|
|
177
|
+
!options.dryRun &&
|
|
178
|
+
!options.retryFailed
|
|
179
|
+
) {
|
|
180
|
+
console.log(chalk.green("\nā
Already complete! Nothing to do.\n"));
|
|
181
|
+
printStatusSummary(db);
|
|
182
|
+
console.log(chalk.gray(` Output: ${courseDir}\n`));
|
|
183
|
+
db.close();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Get authenticated session (only if we have work to do)
|
|
188
|
+
// --visible flag overrides headless config
|
|
189
|
+
const useHeadless = options.visible ? false : config.headless;
|
|
190
|
+
const spinner = ora("Connecting to Skool...").start();
|
|
191
|
+
|
|
192
|
+
let browser;
|
|
193
|
+
let session;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const result = await getAuthenticatedSession(
|
|
197
|
+
{
|
|
198
|
+
domain: SKOOL_DOMAIN,
|
|
199
|
+
loginUrl: SKOOL_LOGIN_URL,
|
|
200
|
+
isLoginPage: isSkoolLoginPage,
|
|
201
|
+
},
|
|
202
|
+
{ headless: useHeadless }
|
|
203
|
+
);
|
|
204
|
+
browser = result.browser;
|
|
205
|
+
session = result.session;
|
|
206
|
+
cleanupResources.browser = browser;
|
|
207
|
+
spinner.succeed("Connected to Skool");
|
|
208
|
+
} catch {
|
|
209
|
+
spinner.fail("Failed to connect");
|
|
210
|
+
db.close();
|
|
211
|
+
console.log(chalk.red("\nā Authentication failed. Please run: offcourse login\n"));
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// Check if shutdown was requested during connection
|
|
217
|
+
if (!shouldContinue()) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Retry-failed mode: only process lessons that previously failed
|
|
222
|
+
if (options.retryFailed) {
|
|
223
|
+
await retryFailedLessons(session.page, db, courseDir, config, options);
|
|
224
|
+
await browser.close();
|
|
225
|
+
db.close();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Phase 1: Scan course structure (only if needed)
|
|
230
|
+
if (needsScan || options.dryRun) {
|
|
231
|
+
await scanCourseStructure(session.page, url, db, options);
|
|
232
|
+
} else {
|
|
233
|
+
console.log(chalk.gray("\n āļø Scan skipped (already complete)"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (options.dryRun) {
|
|
237
|
+
printStatusSummary(db);
|
|
238
|
+
await browser.close();
|
|
239
|
+
db.close();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
console.log(chalk.gray(`\nš Output: ${courseDir}\n`));
|
|
244
|
+
|
|
245
|
+
// Phase 2: Validate videos (only lessons that need it)
|
|
246
|
+
const lessonsToValidate = db.getLessonsToValidate();
|
|
247
|
+
if (lessonsToValidate.length > 0) {
|
|
248
|
+
await validateVideos(session.page, db, options);
|
|
249
|
+
} else {
|
|
250
|
+
console.log(chalk.gray(" āļø Validation skipped (already complete)"));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Phase 3: Extract content and queue downloads
|
|
254
|
+
let videoTasks = await extractContentAndQueueVideos(session.page, db, courseDir, options);
|
|
255
|
+
|
|
256
|
+
// Phase 4: Download videos with auto-retry
|
|
257
|
+
const MAX_RETRIES = 3;
|
|
258
|
+
let retryRound = 0;
|
|
259
|
+
|
|
260
|
+
while (!options.skipVideos && videoTasks.length > 0) {
|
|
261
|
+
await downloadVideos(db, videoTasks, courseDir, config);
|
|
262
|
+
|
|
263
|
+
// Check for retryable failures
|
|
264
|
+
const retryable = db.getLessonsToRetry(MAX_RETRIES);
|
|
265
|
+
if (retryable.length === 0 || retryRound >= MAX_RETRIES) {
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
retryRound++;
|
|
270
|
+
console.log(
|
|
271
|
+
chalk.yellow(
|
|
272
|
+
`\nš Auto-retry round ${retryRound}: ${retryable.length} lesson(s) to retry\n`
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Queue them for re-validation and re-download
|
|
277
|
+
for (const lesson of retryable) {
|
|
278
|
+
db.incrementRetryCount(lesson.id);
|
|
279
|
+
// If lesson has HLS URL, just re-queue for download
|
|
280
|
+
if (lesson.hlsUrl) {
|
|
281
|
+
db.queueForRetry(lesson.id, LessonStatus.VALIDATED);
|
|
282
|
+
} else {
|
|
283
|
+
// Need to re-validate
|
|
284
|
+
db.queueForRetry(lesson.id, LessonStatus.PENDING);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Re-validate lessons that need it
|
|
289
|
+
const needsValidation = db.getLessonsByStatus(LessonStatus.PENDING);
|
|
290
|
+
if (needsValidation.length > 0) {
|
|
291
|
+
await validateVideos(session.page, db, options);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Get new download tasks
|
|
295
|
+
videoTasks = await buildDownloadTasksFromDb(db, courseDir);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Summary
|
|
299
|
+
printStatusSummary(db);
|
|
300
|
+
console.log(chalk.green("\nā
Sync complete!\n"));
|
|
301
|
+
console.log(chalk.gray(` Output: ${courseDir}\n`));
|
|
302
|
+
} finally {
|
|
303
|
+
await browser.close();
|
|
304
|
+
db.close();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Phase 1: Scan course structure and populate database.
|
|
310
|
+
*/
|
|
311
|
+
async function scanCourseStructure(
|
|
312
|
+
page: import("playwright").Page,
|
|
313
|
+
url: string,
|
|
314
|
+
db: CourseDatabase,
|
|
315
|
+
options: SyncOptions
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
console.log(chalk.blue("\nš Phase 1: Scanning course structure...\n"));
|
|
318
|
+
|
|
319
|
+
let progressBar: cliProgress.SingleBar | undefined;
|
|
320
|
+
let courseName = "";
|
|
321
|
+
let totalModules = 0;
|
|
322
|
+
let lockedModules = 0;
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const courseStructure = await buildCourseStructure(page, url, (progress) => {
|
|
326
|
+
if (progress.phase === "init" && progress.courseName) {
|
|
327
|
+
courseName = progress.courseName;
|
|
328
|
+
console.log(chalk.white(` Course: ${courseName}\n`));
|
|
329
|
+
} else if (progress.phase === "modules" && progress.totalModules) {
|
|
330
|
+
totalModules = progress.totalModules;
|
|
331
|
+
progressBar = new cliProgress.SingleBar(
|
|
332
|
+
{
|
|
333
|
+
format: " {bar} {percentage}% | {value}/{total} | {status}",
|
|
334
|
+
barCompleteChar: "ā",
|
|
335
|
+
barIncompleteChar: "ā",
|
|
336
|
+
barsize: 30,
|
|
337
|
+
hideCursor: true,
|
|
338
|
+
},
|
|
339
|
+
cliProgress.Presets.shades_grey
|
|
340
|
+
);
|
|
341
|
+
progressBar.start(totalModules, 0, { status: "Starting..." });
|
|
342
|
+
} else if (progress.phase === "lessons" && progress.currentModule !== undefined) {
|
|
343
|
+
if (progress.skippedLocked) {
|
|
344
|
+
lockedModules++;
|
|
345
|
+
progressBar?.increment({ status: `š ${progress.currentModule}` });
|
|
346
|
+
} else if (progress.lessonsFound !== undefined) {
|
|
347
|
+
progressBar?.increment({
|
|
348
|
+
status: `${progress.currentModule} (${progress.lessonsFound} lessons)`,
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
const shortName =
|
|
352
|
+
progress.currentModule.length > 35
|
|
353
|
+
? progress.currentModule.substring(0, 32) + "..."
|
|
354
|
+
: progress.currentModule;
|
|
355
|
+
progressBar?.update(progress.currentModuleIndex ?? 0, { status: shortName });
|
|
356
|
+
}
|
|
357
|
+
} else if (progress.phase === "done") {
|
|
358
|
+
progressBar?.stop();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Update metadata
|
|
363
|
+
db.updateCourseMetadata(courseStructure.name, courseStructure.url);
|
|
364
|
+
|
|
365
|
+
// Track new lessons found
|
|
366
|
+
let newLessons = 0;
|
|
367
|
+
|
|
368
|
+
for (let moduleIndex = 0; moduleIndex < courseStructure.modules.length; moduleIndex++) {
|
|
369
|
+
const module = courseStructure.modules[moduleIndex];
|
|
370
|
+
if (!module) continue;
|
|
371
|
+
|
|
372
|
+
// Check if module exists
|
|
373
|
+
const existingModule = db.getModuleBySlug(module.slug);
|
|
374
|
+
const moduleRecord = db.upsertModule(module.slug, module.name, moduleIndex, module.isLocked);
|
|
375
|
+
|
|
376
|
+
// Track new modules (existingModule is null for new ones)
|
|
377
|
+
void existingModule;
|
|
378
|
+
|
|
379
|
+
for (let lessonIndex = 0; lessonIndex < module.lessons.length; lessonIndex++) {
|
|
380
|
+
const lesson = module.lessons[lessonIndex];
|
|
381
|
+
if (!lesson) continue;
|
|
382
|
+
|
|
383
|
+
// Check if lesson exists
|
|
384
|
+
const existingLesson = db.getLessonByUrl(lesson.url);
|
|
385
|
+
db.upsertLesson(
|
|
386
|
+
moduleRecord.id,
|
|
387
|
+
lesson.slug,
|
|
388
|
+
lesson.name,
|
|
389
|
+
lesson.url,
|
|
390
|
+
lessonIndex,
|
|
391
|
+
lesson.isLocked
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
if (!existingLesson) {
|
|
395
|
+
newLessons++;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Check limit
|
|
399
|
+
if (options.limit && db.getLessonCount() >= options.limit) {
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (options.limit && db.getLessonCount() >= options.limit) {
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const meta = db.getCourseMetadata();
|
|
410
|
+
console.log();
|
|
411
|
+
const parts: string[] = [];
|
|
412
|
+
parts.push(`${meta.totalModules} modules`);
|
|
413
|
+
parts.push(`${meta.totalLessons} lessons`);
|
|
414
|
+
if (lockedModules > 0) parts.push(chalk.yellow(`${lockedModules} locked`));
|
|
415
|
+
if (newLessons > 0) parts.push(chalk.green(`+${newLessons} new`));
|
|
416
|
+
console.log(` Found: ${parts.join(", ")}`);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
progressBar?.stop();
|
|
419
|
+
console.log(chalk.red(" Failed to scan course structure"));
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Phase 2: Validate videos and get HLS URLs.
|
|
426
|
+
*/
|
|
427
|
+
async function validateVideos(
|
|
428
|
+
page: import("playwright").Page,
|
|
429
|
+
db: CourseDatabase,
|
|
430
|
+
_options: SyncOptions
|
|
431
|
+
): Promise<void> {
|
|
432
|
+
// Get lessons that need scanning
|
|
433
|
+
const lessonsToScan = db.getLessonsToScan();
|
|
434
|
+
|
|
435
|
+
if (lessonsToScan.length === 0) {
|
|
436
|
+
console.log(chalk.gray(" No new lessons to validate"));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
console.log(chalk.blue(`\nš Phase 2: Validating ${lessonsToScan.length} videos...\n`));
|
|
441
|
+
|
|
442
|
+
// Create progress bar
|
|
443
|
+
const progressBar = new cliProgress.SingleBar(
|
|
444
|
+
{
|
|
445
|
+
format: " {bar} {percentage}% | {value}/{total} | {status}",
|
|
446
|
+
barCompleteChar: "ā",
|
|
447
|
+
barIncompleteChar: "ā",
|
|
448
|
+
barsize: 30,
|
|
449
|
+
hideCursor: true,
|
|
450
|
+
},
|
|
451
|
+
cliProgress.Presets.shades_grey
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
progressBar.start(lessonsToScan.length, 0, { status: "Starting..." });
|
|
455
|
+
|
|
456
|
+
let validated = 0;
|
|
457
|
+
let errors = 0;
|
|
458
|
+
let skipped = 0;
|
|
459
|
+
let currentModule = "";
|
|
460
|
+
let processed = 0;
|
|
461
|
+
|
|
462
|
+
for (const lesson of lessonsToScan) {
|
|
463
|
+
// Check for graceful shutdown
|
|
464
|
+
if (!shouldContinue()) {
|
|
465
|
+
progressBar.stop();
|
|
466
|
+
console.log(chalk.yellow("\n Stopping validation (shutdown requested)"));
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Update module in status
|
|
471
|
+
if (lesson.moduleName !== currentModule) {
|
|
472
|
+
currentModule = lesson.moduleName;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Update progress bar with current lesson
|
|
476
|
+
const shortName = lesson.name.length > 40 ? lesson.name.substring(0, 37) + "..." : lesson.name;
|
|
477
|
+
progressBar.update(processed, { status: shortName });
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
// Navigate to lesson and extract video URL
|
|
481
|
+
await page.goto(lesson.url, { timeout: 30000 });
|
|
482
|
+
await page.waitForLoadState("domcontentloaded");
|
|
483
|
+
// Wait for iframes to potentially load (Skool lazy-loads video iframes)
|
|
484
|
+
try {
|
|
485
|
+
await page.waitForSelector(
|
|
486
|
+
'iframe[src*="loom.com"], iframe[src*="vimeo"], iframe[src*="youtube"], video',
|
|
487
|
+
{
|
|
488
|
+
timeout: 3000,
|
|
489
|
+
}
|
|
490
|
+
);
|
|
491
|
+
} catch {
|
|
492
|
+
// No video element appeared - might not have one, will check below
|
|
493
|
+
}
|
|
494
|
+
await page.waitForTimeout(500);
|
|
495
|
+
|
|
496
|
+
const { url: videoUrl, type: videoType } = await extractVideoUrl(page);
|
|
497
|
+
|
|
498
|
+
if (!videoUrl || !videoType) {
|
|
499
|
+
// No video on this lesson
|
|
500
|
+
db.updateLessonScan(lesson.id, null, null, null, LessonStatus.SKIPPED);
|
|
501
|
+
skipped++;
|
|
502
|
+
} else if (videoType === "youtube" || videoType === "wistia") {
|
|
503
|
+
// Handle unsupported video types
|
|
504
|
+
db.updateLessonScan(
|
|
505
|
+
lesson.id,
|
|
506
|
+
videoType,
|
|
507
|
+
videoUrl,
|
|
508
|
+
null,
|
|
509
|
+
LessonStatus.ERROR,
|
|
510
|
+
`${videoType.charAt(0).toUpperCase() + videoType.slice(1)} videos are not yet supported`,
|
|
511
|
+
"UNSUPPORTED_PROVIDER"
|
|
512
|
+
);
|
|
513
|
+
errors++;
|
|
514
|
+
} else if (videoType === "loom" || videoType === "vimeo") {
|
|
515
|
+
// Validate HLS for video types that support it
|
|
516
|
+
if (page.url() !== lesson.url) {
|
|
517
|
+
await page.goto(lesson.url, { timeout: 30000 });
|
|
518
|
+
await page.waitForLoadState("domcontentloaded");
|
|
519
|
+
await page.waitForTimeout(1000);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const validation = await validateVideoHls(videoUrl, videoType, page, lesson.url);
|
|
523
|
+
|
|
524
|
+
if (validation.isValid) {
|
|
525
|
+
db.updateLessonScan(
|
|
526
|
+
lesson.id,
|
|
527
|
+
videoType,
|
|
528
|
+
videoUrl,
|
|
529
|
+
validation.hlsUrl,
|
|
530
|
+
LessonStatus.VALIDATED
|
|
531
|
+
);
|
|
532
|
+
validated++;
|
|
533
|
+
} else {
|
|
534
|
+
db.updateLessonScan(
|
|
535
|
+
lesson.id,
|
|
536
|
+
videoType,
|
|
537
|
+
videoUrl,
|
|
538
|
+
null,
|
|
539
|
+
LessonStatus.ERROR,
|
|
540
|
+
validation.error,
|
|
541
|
+
validation.errorCode
|
|
542
|
+
);
|
|
543
|
+
errors++;
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
// For native/unknown video types, mark as validated
|
|
547
|
+
db.updateLessonScan(lesson.id, videoType, videoUrl, null, LessonStatus.VALIDATED);
|
|
548
|
+
validated++;
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
552
|
+
db.updateLessonScan(
|
|
553
|
+
lesson.id,
|
|
554
|
+
null,
|
|
555
|
+
null,
|
|
556
|
+
null,
|
|
557
|
+
LessonStatus.ERROR,
|
|
558
|
+
errorMessage,
|
|
559
|
+
"SCAN_ERROR"
|
|
560
|
+
);
|
|
561
|
+
errors++;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
processed++;
|
|
565
|
+
progressBar.update(processed, { status: shortName });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
progressBar.stop();
|
|
569
|
+
|
|
570
|
+
// Print summary
|
|
571
|
+
console.log();
|
|
572
|
+
const parts: string[] = [];
|
|
573
|
+
if (validated > 0) parts.push(chalk.green(`${validated} ready`));
|
|
574
|
+
if (skipped > 0) parts.push(chalk.gray(`${skipped} no video`));
|
|
575
|
+
if (errors > 0) parts.push(chalk.red(`${errors} errors`));
|
|
576
|
+
console.log(` Validation: ${parts.join(", ")}`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Phase 3: Extract content and queue video downloads.
|
|
581
|
+
*/
|
|
582
|
+
async function extractContentAndQueueVideos(
|
|
583
|
+
page: import("playwright").Page,
|
|
584
|
+
db: CourseDatabase,
|
|
585
|
+
courseDir: string,
|
|
586
|
+
options: SyncOptions
|
|
587
|
+
): Promise<VideoDownloadTask[]> {
|
|
588
|
+
// Get lessons ready for download
|
|
589
|
+
const lessonsToProcess = db.getLessonsByStatus(LessonStatus.VALIDATED);
|
|
590
|
+
|
|
591
|
+
if (lessonsToProcess.length === 0) {
|
|
592
|
+
console.log(chalk.gray(" No videos ready for download"));
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
console.log(
|
|
597
|
+
chalk.blue(`\nš Phase 3: Extracting content for ${lessonsToProcess.length} lessons...\n`)
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
// Create progress bar
|
|
601
|
+
const progressBar = new cliProgress.SingleBar(
|
|
602
|
+
{
|
|
603
|
+
format: " {bar} {percentage}% | {value}/{total} | {status}",
|
|
604
|
+
barCompleteChar: "ā",
|
|
605
|
+
barIncompleteChar: "ā",
|
|
606
|
+
barsize: 30,
|
|
607
|
+
hideCursor: true,
|
|
608
|
+
},
|
|
609
|
+
cliProgress.Presets.shades_grey
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
progressBar.start(lessonsToProcess.length, 0, { status: "Starting..." });
|
|
613
|
+
|
|
614
|
+
const videoTasks: VideoDownloadTask[] = [];
|
|
615
|
+
let contentExtracted = 0;
|
|
616
|
+
let contentSkipped = 0;
|
|
617
|
+
let filesDownloadedTotal = 0;
|
|
618
|
+
let processed = 0;
|
|
619
|
+
|
|
620
|
+
// Group lessons by module for directory creation
|
|
621
|
+
const lessonsByModule = new Map<string, LessonWithModule[]>();
|
|
622
|
+
for (const lesson of lessonsToProcess) {
|
|
623
|
+
const key = `${lesson.modulePosition}-${lesson.moduleSlug}`;
|
|
624
|
+
const moduleLessons = lessonsByModule.get(key) ?? [];
|
|
625
|
+
moduleLessons.push(lesson);
|
|
626
|
+
lessonsByModule.set(key, moduleLessons);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
for (const [, lessons] of lessonsByModule) {
|
|
630
|
+
// Check for graceful shutdown
|
|
631
|
+
if (!shouldContinue()) {
|
|
632
|
+
progressBar.stop();
|
|
633
|
+
console.log(chalk.yellow("\n Stopping content extraction (shutdown requested)"));
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const firstLesson = lessons[0];
|
|
638
|
+
if (!firstLesson) continue;
|
|
639
|
+
const moduleDir = await createModuleDirectory(
|
|
640
|
+
courseDir,
|
|
641
|
+
firstLesson.modulePosition,
|
|
642
|
+
firstLesson.moduleName
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
for (const lesson of lessons) {
|
|
646
|
+
// Check for graceful shutdown
|
|
647
|
+
if (!shouldContinue()) {
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const shortName =
|
|
652
|
+
lesson.name.length > 40 ? lesson.name.substring(0, 37) + "..." : lesson.name;
|
|
653
|
+
progressBar.update(processed, { status: shortName });
|
|
654
|
+
|
|
655
|
+
const syncStatus = await isLessonSynced(moduleDir, lesson.position, lesson.name);
|
|
656
|
+
|
|
657
|
+
// Check if content already exists
|
|
658
|
+
if (!options.skipContent && !syncStatus.content) {
|
|
659
|
+
try {
|
|
660
|
+
const content = await extractLessonContent(page, lesson.url);
|
|
661
|
+
const markdown = formatMarkdown(
|
|
662
|
+
content.title,
|
|
663
|
+
content.markdownContent,
|
|
664
|
+
lesson.videoUrl,
|
|
665
|
+
lesson.videoType
|
|
666
|
+
);
|
|
667
|
+
const mdPath = getMarkdownPath(moduleDir, lesson.position, lesson.name);
|
|
668
|
+
await saveMarkdown(dirname(mdPath), basename(mdPath), markdown);
|
|
669
|
+
|
|
670
|
+
// Download any linked files (PDFs, Office documents, etc.)
|
|
671
|
+
if (content.downloadableFiles.length > 0) {
|
|
672
|
+
for (const file of content.downloadableFiles) {
|
|
673
|
+
const filePath = getDownloadFilePath(
|
|
674
|
+
moduleDir,
|
|
675
|
+
lesson.position,
|
|
676
|
+
lesson.name,
|
|
677
|
+
file.filename
|
|
678
|
+
);
|
|
679
|
+
const result = await downloadFile(file.url, filePath);
|
|
680
|
+
if (result.success) {
|
|
681
|
+
filesDownloadedTotal++;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
contentExtracted++;
|
|
686
|
+
} catch {
|
|
687
|
+
// Error extracting content, continue with next lesson
|
|
688
|
+
}
|
|
689
|
+
} else {
|
|
690
|
+
contentSkipped++;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Queue video for download if not already downloaded
|
|
694
|
+
if (!options.skipVideos && !syncStatus.video && lesson.videoUrl && lesson.videoType) {
|
|
695
|
+
videoTasks.push({
|
|
696
|
+
lessonId: lesson.id,
|
|
697
|
+
lessonName: lesson.name,
|
|
698
|
+
videoUrl: lesson.hlsUrl ?? lesson.videoUrl,
|
|
699
|
+
videoType: lesson.videoType as VideoDownloadTask["videoType"],
|
|
700
|
+
outputPath: getVideoPath(moduleDir, lesson.position, lesson.name),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
processed++;
|
|
705
|
+
progressBar.update(processed, { status: shortName });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
progressBar.stop();
|
|
710
|
+
|
|
711
|
+
// Print summary
|
|
712
|
+
console.log();
|
|
713
|
+
const parts: string[] = [];
|
|
714
|
+
if (contentExtracted > 0) parts.push(chalk.green(`${contentExtracted} extracted`));
|
|
715
|
+
if (contentSkipped > 0) parts.push(chalk.gray(`${contentSkipped} cached`));
|
|
716
|
+
if (filesDownloadedTotal > 0) parts.push(chalk.blue(`${filesDownloadedTotal} files`));
|
|
717
|
+
console.log(` Content: ${parts.join(", ")}`);
|
|
718
|
+
|
|
719
|
+
return videoTasks;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Phase 4: Download videos with multi-progress display.
|
|
724
|
+
*/
|
|
725
|
+
async function downloadVideos(
|
|
726
|
+
db: CourseDatabase,
|
|
727
|
+
videoTasks: VideoDownloadTask[],
|
|
728
|
+
courseDir: string,
|
|
729
|
+
config: { concurrency: number; retryAttempts: number },
|
|
730
|
+
_options?: SyncOptions
|
|
731
|
+
): Promise<void> {
|
|
732
|
+
const total = videoTasks.length;
|
|
733
|
+
console.log(chalk.blue(`\nš¬ Phase 4: Downloading ${total} videos...\n`));
|
|
734
|
+
|
|
735
|
+
// Create multi-bar container with auto-clear
|
|
736
|
+
const multibar = new cliProgress.MultiBar(
|
|
737
|
+
{
|
|
738
|
+
clearOnComplete: true,
|
|
739
|
+
hideCursor: true,
|
|
740
|
+
format: " {typeTag} {bar} {percentage}% | {lessonName}",
|
|
741
|
+
barCompleteChar: "ā",
|
|
742
|
+
barIncompleteChar: "ā",
|
|
743
|
+
barsize: 25,
|
|
744
|
+
autopadding: true,
|
|
745
|
+
},
|
|
746
|
+
cliProgress.Presets.shades_grey
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
// Overall progress bar at the top
|
|
750
|
+
const overallBar = multibar.create(total, 0, {
|
|
751
|
+
typeTag: "[TOTAL]".padEnd(8),
|
|
752
|
+
lessonName: `0/${total} completed`,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// Track results
|
|
756
|
+
const downloadAttempts: DownloadAttempt[] = [];
|
|
757
|
+
const errors: { id: string; error: string }[] = [];
|
|
758
|
+
let completed = 0;
|
|
759
|
+
let failed = 0;
|
|
760
|
+
|
|
761
|
+
// Active downloads map: lessonName -> bar
|
|
762
|
+
const activeBars = new Map<string, cliProgress.SingleBar>();
|
|
763
|
+
|
|
764
|
+
// Process downloads with controlled concurrency
|
|
765
|
+
const taskQueue = [...videoTasks];
|
|
766
|
+
const activePromises = new Set<Promise<void>>();
|
|
767
|
+
|
|
768
|
+
const processTask = async (task: VideoDownloadTask): Promise<void> => {
|
|
769
|
+
const typeTag = task.videoType ? `[${task.videoType.toUpperCase()}]` : "[VIDEO]";
|
|
770
|
+
const shortName =
|
|
771
|
+
task.lessonName.length > 40 ? task.lessonName.substring(0, 37) + "..." : task.lessonName;
|
|
772
|
+
|
|
773
|
+
// Create progress bar for this download
|
|
774
|
+
const bar = multibar.create(100, 0, {
|
|
775
|
+
typeTag: typeTag.padEnd(8),
|
|
776
|
+
lessonName: shortName,
|
|
777
|
+
});
|
|
778
|
+
activeBars.set(task.lessonName, bar);
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
const downloadResult = await downloadVideo(task, (progress) => {
|
|
782
|
+
bar.update(Math.round(progress.percent));
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Record the attempt
|
|
786
|
+
const attempt: DownloadAttempt = {
|
|
787
|
+
lessonName: task.lessonName,
|
|
788
|
+
videoUrl: task.videoUrl,
|
|
789
|
+
videoType: task.videoType,
|
|
790
|
+
success: downloadResult.success,
|
|
791
|
+
timestamp: new Date().toISOString(),
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
if (!downloadResult.success) {
|
|
795
|
+
attempt.error = downloadResult.error;
|
|
796
|
+
attempt.errorCode = downloadResult.errorCode;
|
|
797
|
+
attempt.details = downloadResult.details;
|
|
798
|
+
|
|
799
|
+
db.markLessonError(
|
|
800
|
+
task.lessonId,
|
|
801
|
+
downloadResult.error ?? "Download failed",
|
|
802
|
+
downloadResult.errorCode
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
errors.push({
|
|
806
|
+
id: task.lessonName,
|
|
807
|
+
error: downloadResult.error ?? "Download failed",
|
|
808
|
+
});
|
|
809
|
+
failed++;
|
|
810
|
+
} else {
|
|
811
|
+
// Update database with success
|
|
812
|
+
const fileSize = await getFileSize(task.outputPath);
|
|
813
|
+
db.markLessonDownloaded(task.lessonId, fileSize ?? undefined);
|
|
814
|
+
completed++;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
downloadAttempts.push(attempt);
|
|
818
|
+
} catch (error) {
|
|
819
|
+
failed++;
|
|
820
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
821
|
+
errors.push({ id: task.lessonName, error: errorMsg });
|
|
822
|
+
db.markLessonError(task.lessonId, errorMsg);
|
|
823
|
+
} finally {
|
|
824
|
+
// Remove the bar when done (key fix!)
|
|
825
|
+
multibar.remove(bar);
|
|
826
|
+
activeBars.delete(task.lessonName);
|
|
827
|
+
|
|
828
|
+
// Update overall progress
|
|
829
|
+
const done = completed + failed;
|
|
830
|
+
overallBar.update(done, {
|
|
831
|
+
lessonName: `${done}/${total} completed (${failed} failed)`,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
// Run downloads with controlled concurrency
|
|
837
|
+
while (taskQueue.length > 0 || activePromises.size > 0) {
|
|
838
|
+
// Start new downloads up to concurrency limit
|
|
839
|
+
while (taskQueue.length > 0 && activePromises.size < config.concurrency) {
|
|
840
|
+
const task = taskQueue.shift();
|
|
841
|
+
if (task) {
|
|
842
|
+
const promise = processTask(task).finally(() => {
|
|
843
|
+
activePromises.delete(promise);
|
|
844
|
+
});
|
|
845
|
+
activePromises.add(promise);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Wait for at least one to complete
|
|
850
|
+
if (activePromises.size > 0) {
|
|
851
|
+
await Promise.race(activePromises);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Stop multibar
|
|
856
|
+
multibar.stop();
|
|
857
|
+
|
|
858
|
+
// Print summary
|
|
859
|
+
console.log();
|
|
860
|
+
if (failed === 0) {
|
|
861
|
+
console.log(chalk.green(` ā ${completed} videos downloaded successfully`));
|
|
862
|
+
} else {
|
|
863
|
+
console.log(chalk.yellow(` Videos: ${completed} downloaded, ${failed} failed`));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (errors.length > 0) {
|
|
867
|
+
console.log(chalk.yellow("\n Failed downloads:"));
|
|
868
|
+
for (const error of errors) {
|
|
869
|
+
const task = videoTasks.find((t) => t.lessonName === error.id);
|
|
870
|
+
const typeTag = task?.videoType ? `[${task.videoType.toUpperCase()}]` : "";
|
|
871
|
+
console.log(chalk.red(` - ${typeTag} ${error.id}: ${error.error}`));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Save diagnostic log
|
|
875
|
+
const failedAttempts = downloadAttempts.filter((a) => !a.success);
|
|
876
|
+
if (failedAttempts.length > 0) {
|
|
877
|
+
const logPath = join(courseDir, `download-errors-${Date.now()}.json`);
|
|
878
|
+
const logData = {
|
|
879
|
+
timestamp: new Date().toISOString(),
|
|
880
|
+
totalAttempts: videoTasks.length,
|
|
881
|
+
successful: completed,
|
|
882
|
+
failed,
|
|
883
|
+
concurrency: config.concurrency,
|
|
884
|
+
retryAttempts: config.retryAttempts,
|
|
885
|
+
failures: failedAttempts,
|
|
886
|
+
};
|
|
887
|
+
await outputFile(logPath, JSON.stringify(logData, null, 2));
|
|
888
|
+
console.log(chalk.gray(`\n š Detailed error log saved: ${logPath}`));
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Build download tasks from database (for --resume mode).
|
|
895
|
+
* Skips lessons that are already downloaded.
|
|
896
|
+
*/
|
|
897
|
+
async function buildDownloadTasksFromDb(
|
|
898
|
+
db: CourseDatabase,
|
|
899
|
+
courseDir: string
|
|
900
|
+
): Promise<VideoDownloadTask[]> {
|
|
901
|
+
const lessons = db.getLessonsToDownload();
|
|
902
|
+
const videoTasks: VideoDownloadTask[] = [];
|
|
903
|
+
let alreadyOnDisk = 0;
|
|
904
|
+
|
|
905
|
+
console.log(chalk.blue(`\nš¦ Building download list from ${lessons.length} ready lessons...\n`));
|
|
906
|
+
|
|
907
|
+
for (const lesson of lessons) {
|
|
908
|
+
// Create module directory (flat structure - no lesson subdirectories)
|
|
909
|
+
const moduleDir = await createModuleDirectory(
|
|
910
|
+
courseDir,
|
|
911
|
+
lesson.modulePosition,
|
|
912
|
+
lesson.moduleName
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// Check if already downloaded
|
|
916
|
+
const syncStatus = await isLessonSynced(moduleDir, lesson.position, lesson.name);
|
|
917
|
+
if (syncStatus.video) {
|
|
918
|
+
// File exists on disk but DB not updated - fix DB state
|
|
919
|
+
db.markLessonDownloaded(lesson.id);
|
|
920
|
+
alreadyOnDisk++;
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (lesson.hlsUrl && lesson.videoType) {
|
|
925
|
+
videoTasks.push({
|
|
926
|
+
lessonId: lesson.id,
|
|
927
|
+
lessonName: lesson.name,
|
|
928
|
+
videoUrl: lesson.hlsUrl,
|
|
929
|
+
videoType: lesson.videoType as VideoDownloadTask["videoType"],
|
|
930
|
+
outputPath: getVideoPath(moduleDir, lesson.position, lesson.name),
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (alreadyOnDisk > 0) {
|
|
936
|
+
console.log(chalk.green(` ā
${alreadyOnDisk} already on disk (DB updated)`));
|
|
937
|
+
}
|
|
938
|
+
console.log(chalk.gray(` ā¬ļø ${videoTasks.length} videos to download`));
|
|
939
|
+
return videoTasks;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Retry failed lessons with detailed diagnostics.
|
|
944
|
+
*/
|
|
945
|
+
async function retryFailedLessons(
|
|
946
|
+
page: import("playwright").Page,
|
|
947
|
+
db: CourseDatabase,
|
|
948
|
+
courseDir: string,
|
|
949
|
+
_config: { concurrency: number; retryAttempts: number },
|
|
950
|
+
_options: SyncOptions
|
|
951
|
+
): Promise<void> {
|
|
952
|
+
const errorLessons = db.getLessonsByStatus(LessonStatus.ERROR);
|
|
953
|
+
|
|
954
|
+
if (errorLessons.length === 0) {
|
|
955
|
+
console.log(chalk.green("\nā
No failed lessons to retry!\n"));
|
|
956
|
+
printStatusSummary(db);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
console.log(chalk.yellow(`\nš Retry Failed Mode: ${errorLessons.length} lesson(s) to retry\n`));
|
|
961
|
+
|
|
962
|
+
// Group by error type for summary
|
|
963
|
+
const byErrorCode = new Map<string, typeof errorLessons>();
|
|
964
|
+
for (const lesson of errorLessons) {
|
|
965
|
+
const code = lesson.errorCode ?? "UNKNOWN";
|
|
966
|
+
const codeLessons = byErrorCode.get(code) ?? [];
|
|
967
|
+
codeLessons.push(lesson);
|
|
968
|
+
byErrorCode.set(code, codeLessons);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
console.log(chalk.gray(" Error breakdown:"));
|
|
972
|
+
for (const [code, lessons] of byErrorCode) {
|
|
973
|
+
console.log(chalk.gray(` ${code}: ${lessons.length}`));
|
|
974
|
+
}
|
|
975
|
+
console.log();
|
|
976
|
+
|
|
977
|
+
// Results tracking
|
|
978
|
+
const results: {
|
|
979
|
+
lesson: LessonWithModule;
|
|
980
|
+
success: boolean;
|
|
981
|
+
newStatus: string;
|
|
982
|
+
details: string;
|
|
983
|
+
}[] = [];
|
|
984
|
+
|
|
985
|
+
// Progress bar
|
|
986
|
+
const progressBar = new cliProgress.SingleBar(
|
|
987
|
+
{
|
|
988
|
+
format: " {bar} {percentage}% | {value}/{total} | {status}",
|
|
989
|
+
barCompleteChar: "ā",
|
|
990
|
+
barIncompleteChar: "ā",
|
|
991
|
+
barsize: 30,
|
|
992
|
+
hideCursor: true,
|
|
993
|
+
},
|
|
994
|
+
cliProgress.Presets.shades_grey
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
progressBar.start(errorLessons.length, 0, { status: "Starting..." });
|
|
998
|
+
|
|
999
|
+
for (let i = 0; i < errorLessons.length; i++) {
|
|
1000
|
+
const lesson = errorLessons[i];
|
|
1001
|
+
if (!lesson) continue;
|
|
1002
|
+
|
|
1003
|
+
const shortName = lesson.name.length > 30 ? lesson.name.substring(0, 27) + "..." : lesson.name;
|
|
1004
|
+
|
|
1005
|
+
progressBar.update(i, { status: shortName });
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
// Navigate to the lesson page
|
|
1009
|
+
await page.goto(lesson.url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
1010
|
+
await page.waitForTimeout(2000);
|
|
1011
|
+
|
|
1012
|
+
// Try to extract video URL
|
|
1013
|
+
const videoInfo = await extractVideoUrl(page);
|
|
1014
|
+
|
|
1015
|
+
if (!videoInfo.url) {
|
|
1016
|
+
// No video found - mark as skipped (no video) or keep error
|
|
1017
|
+
if (lesson.errorCode === "UNSUPPORTED_PROVIDER") {
|
|
1018
|
+
results.push({
|
|
1019
|
+
lesson,
|
|
1020
|
+
success: false,
|
|
1021
|
+
newStatus: "error",
|
|
1022
|
+
details: `Unsupported provider: ${lesson.videoType ?? "unknown"}`,
|
|
1023
|
+
});
|
|
1024
|
+
} else {
|
|
1025
|
+
db.markLessonSkipped(lesson.id, "No video found on retry");
|
|
1026
|
+
results.push({
|
|
1027
|
+
lesson,
|
|
1028
|
+
success: true,
|
|
1029
|
+
newStatus: "skipped",
|
|
1030
|
+
details: "No video on page",
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Check for unsupported providers
|
|
1037
|
+
if (videoInfo.type === "youtube" || videoInfo.type === "wistia") {
|
|
1038
|
+
db.markLessonError(
|
|
1039
|
+
lesson.id,
|
|
1040
|
+
`${videoInfo.type} videos are not yet supported`,
|
|
1041
|
+
"UNSUPPORTED_PROVIDER"
|
|
1042
|
+
);
|
|
1043
|
+
db.updateLessonVideoType(lesson.id, videoInfo.type);
|
|
1044
|
+
results.push({
|
|
1045
|
+
lesson,
|
|
1046
|
+
success: false,
|
|
1047
|
+
newStatus: "error",
|
|
1048
|
+
details: `Unsupported: ${videoInfo.type}`,
|
|
1049
|
+
});
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Validate and get HLS URL
|
|
1054
|
+
const validation = await validateVideoHls(
|
|
1055
|
+
videoInfo.url,
|
|
1056
|
+
videoInfo.type ?? "native",
|
|
1057
|
+
page,
|
|
1058
|
+
lesson.url
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
if (!validation.isValid || !validation.hlsUrl) {
|
|
1062
|
+
db.markLessonError(
|
|
1063
|
+
lesson.id,
|
|
1064
|
+
validation.error ?? "Validation failed",
|
|
1065
|
+
validation.errorCode ?? "VALIDATION_FAILED"
|
|
1066
|
+
);
|
|
1067
|
+
results.push({
|
|
1068
|
+
lesson,
|
|
1069
|
+
success: false,
|
|
1070
|
+
newStatus: "error",
|
|
1071
|
+
details: validation.error ?? "Could not validate video",
|
|
1072
|
+
});
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Update lesson with HLS URL
|
|
1077
|
+
db.updateLessonScan(
|
|
1078
|
+
lesson.id,
|
|
1079
|
+
videoInfo.type ?? null,
|
|
1080
|
+
videoInfo.url,
|
|
1081
|
+
validation.hlsUrl,
|
|
1082
|
+
LessonStatus.VALIDATED
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
// Try to download
|
|
1086
|
+
const moduleDir = await createModuleDirectory(
|
|
1087
|
+
courseDir,
|
|
1088
|
+
lesson.modulePosition,
|
|
1089
|
+
lesson.moduleName
|
|
1090
|
+
);
|
|
1091
|
+
const outputPath = getVideoPath(moduleDir, lesson.position, lesson.name);
|
|
1092
|
+
|
|
1093
|
+
const downloadResult = await downloadVideo({
|
|
1094
|
+
lessonId: lesson.id,
|
|
1095
|
+
lessonName: lesson.name,
|
|
1096
|
+
videoUrl: validation.hlsUrl,
|
|
1097
|
+
videoType: videoInfo.type as VideoDownloadTask["videoType"],
|
|
1098
|
+
outputPath,
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
if (downloadResult.success) {
|
|
1102
|
+
const fileSize = await getFileSize(outputPath);
|
|
1103
|
+
db.markLessonDownloaded(lesson.id, fileSize ?? undefined);
|
|
1104
|
+
results.push({
|
|
1105
|
+
lesson,
|
|
1106
|
+
success: true,
|
|
1107
|
+
newStatus: "downloaded",
|
|
1108
|
+
details: fileSize ? `Downloaded ${(fileSize / 1024 / 1024).toFixed(1)} MB` : "Downloaded",
|
|
1109
|
+
});
|
|
1110
|
+
} else {
|
|
1111
|
+
db.markLessonError(
|
|
1112
|
+
lesson.id,
|
|
1113
|
+
downloadResult.error ?? "Download failed",
|
|
1114
|
+
downloadResult.errorCode ?? "DOWNLOAD_FAILED"
|
|
1115
|
+
);
|
|
1116
|
+
results.push({
|
|
1117
|
+
lesson,
|
|
1118
|
+
success: false,
|
|
1119
|
+
newStatus: "error",
|
|
1120
|
+
details: downloadResult.error ?? "Download failed",
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1125
|
+
db.markLessonError(lesson.id, errorMsg, "RETRY_ERROR");
|
|
1126
|
+
results.push({
|
|
1127
|
+
lesson,
|
|
1128
|
+
success: false,
|
|
1129
|
+
newStatus: "error",
|
|
1130
|
+
details: errorMsg.substring(0, 100),
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
progressBar.update(errorLessons.length, { status: "Complete" });
|
|
1136
|
+
progressBar.stop();
|
|
1137
|
+
|
|
1138
|
+
// Detailed results
|
|
1139
|
+
console.log(chalk.cyan("\nš Retry Results\n"));
|
|
1140
|
+
|
|
1141
|
+
const successful = results.filter((r) => r.success);
|
|
1142
|
+
const failed = results.filter((r) => !r.success);
|
|
1143
|
+
|
|
1144
|
+
if (successful.length > 0) {
|
|
1145
|
+
console.log(chalk.green(` ā
Fixed: ${successful.length}`));
|
|
1146
|
+
for (const r of successful) {
|
|
1147
|
+
console.log(chalk.gray(` ⢠${r.lesson.name} ā ${r.newStatus} (${r.details})`));
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (failed.length > 0) {
|
|
1152
|
+
console.log(chalk.red(`\n ā Still failing: ${failed.length}\n`));
|
|
1153
|
+
for (const r of failed) {
|
|
1154
|
+
const typeTag = r.lesson.videoType ? `[${r.lesson.videoType.toUpperCase()}]` : "";
|
|
1155
|
+
console.log(chalk.red(` ${typeTag} ${r.lesson.name}`));
|
|
1156
|
+
console.log(chalk.gray(` Module: ${r.lesson.moduleName}`));
|
|
1157
|
+
console.log(chalk.gray(` URL: ${r.lesson.url}`));
|
|
1158
|
+
console.log(chalk.gray(` Error: ${r.details}`));
|
|
1159
|
+
console.log();
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
printStatusSummary(db);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Print status summary from database.
|
|
1168
|
+
*/
|
|
1169
|
+
function printStatusSummary(db: CourseDatabase): void {
|
|
1170
|
+
const meta = db.getCourseMetadata();
|
|
1171
|
+
const summary = db.getStatusSummary();
|
|
1172
|
+
const videoTypes = db.getVideoTypeSummary();
|
|
1173
|
+
|
|
1174
|
+
console.log(chalk.cyan("\nš Status Summary\n"));
|
|
1175
|
+
console.log(chalk.white(` Course: ${meta.name}`));
|
|
1176
|
+
console.log(chalk.gray(` Modules: ${meta.totalModules}`));
|
|
1177
|
+
console.log(chalk.gray(` Lessons: ${meta.totalLessons}`));
|
|
1178
|
+
console.log();
|
|
1179
|
+
|
|
1180
|
+
// Clear status labels
|
|
1181
|
+
console.log(chalk.green(` ā
Downloaded: ${summary.downloaded}`));
|
|
1182
|
+
if (summary.validated > 0) {
|
|
1183
|
+
console.log(chalk.blue(` ā¬ļø Ready to download: ${summary.validated}`));
|
|
1184
|
+
}
|
|
1185
|
+
if (summary.pending > 0) {
|
|
1186
|
+
console.log(chalk.gray(` š Not scanned yet: ${summary.pending}`));
|
|
1187
|
+
}
|
|
1188
|
+
if (summary.skipped > 0) {
|
|
1189
|
+
console.log(chalk.gray(` ā No video: ${summary.skipped}`));
|
|
1190
|
+
}
|
|
1191
|
+
if (summary.locked > 0) {
|
|
1192
|
+
console.log(chalk.yellow(` š Locked: ${summary.locked}`));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (summary.error > 0) {
|
|
1196
|
+
console.log(chalk.red(` ā Failed: ${summary.error}`));
|
|
1197
|
+
|
|
1198
|
+
// Show unsupported providers if any
|
|
1199
|
+
const unsupported = db.getLessonsByErrorCode("UNSUPPORTED_PROVIDER");
|
|
1200
|
+
if (unsupported.length > 0) {
|
|
1201
|
+
console.log(chalk.yellow(`\n ā Unsupported video providers:`));
|
|
1202
|
+
|
|
1203
|
+
// Group by video type
|
|
1204
|
+
const byType = new Map<string, typeof unsupported>();
|
|
1205
|
+
for (const lesson of unsupported) {
|
|
1206
|
+
const type = lesson.videoType ?? "unknown";
|
|
1207
|
+
const typeLessons = byType.get(type) ?? [];
|
|
1208
|
+
typeLessons.push(lesson);
|
|
1209
|
+
byType.set(type, typeLessons);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
for (const [type, lessons] of byType) {
|
|
1213
|
+
console.log(chalk.yellow(` ${type.toUpperCase()}: ${lessons.length} video(s)`));
|
|
1214
|
+
for (const lesson of lessons.slice(0, 3)) {
|
|
1215
|
+
console.log(chalk.gray(` - ${lesson.moduleName} ā ${lesson.name}`));
|
|
1216
|
+
}
|
|
1217
|
+
if (lessons.length > 3) {
|
|
1218
|
+
console.log(chalk.gray(` ... and ${lessons.length - 3} more`));
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
console.log(chalk.gray(`\n š” Tip: Install yt-dlp to download YouTube/Wistia videos`));
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Show video type breakdown
|
|
1226
|
+
if (Object.keys(videoTypes).length > 0) {
|
|
1227
|
+
console.log(chalk.gray(`\n Video types found:`));
|
|
1228
|
+
for (const [type, count] of Object.entries(videoTypes)) {
|
|
1229
|
+
const supported = type === "loom" || type === "vimeo" || type === "native";
|
|
1230
|
+
const icon = supported ? "ā" : "ā";
|
|
1231
|
+
const color = supported ? chalk.green : chalk.yellow;
|
|
1232
|
+
console.log(color(` ${icon} ${type}: ${count}`));
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|