coursewatcher 1.3.1 → 2.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.
Files changed (47) hide show
  1. package/README.md +26 -23
  2. package/dist/app/cli/main.js +36 -0
  3. package/dist/app/server/create-app.js +151 -0
  4. package/dist/app/server/start-server.js +72 -0
  5. package/dist/modules/catalog/catalog-mappers.js +49 -0
  6. package/dist/modules/catalog/catalog-repository.js +168 -0
  7. package/dist/modules/catalog/catalog-service.js +76 -0
  8. package/dist/modules/notes/notes-repository.js +25 -0
  9. package/dist/modules/notes/notes-service.js +28 -0
  10. package/dist/modules/playback/playback-repository.js +32 -0
  11. package/dist/modules/playback/playback-service.js +89 -0
  12. package/dist/platform/config/app-config.js +24 -0
  13. package/dist/platform/config/package-info.js +21 -0
  14. package/dist/platform/database/database-manager.js +101 -0
  15. package/dist/platform/errors/app-error.js +30 -0
  16. package/dist/platform/logging/logger.js +21 -0
  17. package/dist/shared/contracts/api.js +2 -0
  18. package/dist/web/assets/api-client-hFlLSS3K.js +1 -0
  19. package/dist/web/assets/catalog-route-pOIhR3yd.js +1 -0
  20. package/dist/web/assets/index-CwspbIw1.js +10 -0
  21. package/dist/web/assets/index-VjwsJnuQ.css +1 -0
  22. package/dist/web/assets/jsx-runtime-C2ZT__TU.js +4 -0
  23. package/dist/web/assets/playback-route-Bmy_Z7k7.js +2 -0
  24. package/dist/web/assets/search-route-CeGVOVPT.js +1 -0
  25. package/dist/web/index.html +14 -0
  26. package/package.json +75 -57
  27. package/public/css/styles.css +0 -1375
  28. package/public/js/player.js +0 -359
  29. package/src/cli.js +0 -45
  30. package/src/controllers/video-controller.js +0 -189
  31. package/src/models/database.js +0 -169
  32. package/src/server.js +0 -179
  33. package/src/services/notes-service.js +0 -97
  34. package/src/services/progress-service.js +0 -169
  35. package/src/services/video-service.js +0 -354
  36. package/src/utils/config.js +0 -57
  37. package/src/utils/errors.js +0 -57
  38. package/src/utils/logger.js +0 -48
  39. package/views/layouts/main.ejs +0 -13
  40. package/views/pages/error.ejs +0 -25
  41. package/views/pages/index.ejs +0 -101
  42. package/views/pages/player.ejs +0 -161
  43. package/views/pages/search.ejs +0 -63
  44. package/views/partials/footer.ejs +0 -3
  45. package/views/partials/head.ejs +0 -8
  46. package/views/partials/header.ejs +0 -14
  47. package/views/partials/video-card.ejs +0 -36
package/README.md CHANGED
@@ -5,20 +5,21 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
7
 
8
- **A CLI tool and web interface for tracking progress in downloaded video courses.**
8
+ **A TypeScript CLI and React web app for tracking progress in downloaded video courses.**
9
9
 
10
10
  ## Quick Start
11
11
 
12
- ```bash
13
- # Navigate to your course folder
14
- cd /path/to/my-course
15
-
16
- # Run CourseWatcher
17
- npx coursewatcher
18
-
19
- # Or with options
20
- npx coursewatcher --port 8080 --no-browser
21
- ```
12
+ ```bash
13
+ # Navigate to your course folder
14
+ cd /path/to/my-course
15
+
16
+ # Build once, then run CourseWatcher
17
+ npm run build
18
+ node dist/app/cli/main.js
19
+
20
+ # Or with options
21
+ node dist/app/cli/main.js --port 8080 --no-browser
22
+ ```
22
23
 
23
24
  ## CLI Options
24
25
 
@@ -59,18 +60,20 @@ npm install -g coursewatcher
59
60
  | `M` | Mute/Unmute |
60
61
  | `F` | Fullscreen |
61
62
 
62
- ## Development
63
-
64
- ```bash
65
- # Install dependencies
66
- npm install
67
-
68
- # Run in development
69
- npm run dev
70
-
71
- # Run tests
72
- npm test
73
- ```
63
+ ## Development
64
+
65
+ ```bash
66
+ # Install dependencies
67
+ npm install
68
+
69
+ # Run the TypeScript CLI + Express server with Vite middleware
70
+ npm run dev
71
+
72
+ # Typecheck, test, and build
73
+ npm run typecheck
74
+ npm test
75
+ npm run build
76
+ ```
74
77
 
75
78
  ## License
76
79
 
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const package_info_1 = require("../../platform/config/package-info");
6
+ const logger_1 = require("../../platform/logging/logger");
7
+ const start_server_1 = require("../server/start-server");
8
+ const packageInfo = (0, package_info_1.getPackageInfo)();
9
+ commander_1.program
10
+ .name('coursewatcher')
11
+ .description(packageInfo.description)
12
+ .version(packageInfo.version)
13
+ .argument('[path]', 'path to course directory', '.')
14
+ .option('-p, --port <number>', 'server port')
15
+ .option('--no-browser', 'do not open browser automatically')
16
+ .action(async (coursePath, options) => {
17
+ try {
18
+ const resolvedCoursePath = (0, start_server_1.resolveCoursePath)(coursePath);
19
+ const isPortSpecified = typeof options.port === 'string';
20
+ const portOption = options.port;
21
+ const port = isPortSpecified && portOption ? Number.parseInt(portOption, 10) : (0, start_server_1.getDefaultPort)();
22
+ (0, logger_1.log)(`Starting CourseWatcher in: ${resolvedCoursePath}`);
23
+ await (0, start_server_1.startServer)({
24
+ coursePath: resolvedCoursePath,
25
+ port,
26
+ allowFallback: !isPortSpecified,
27
+ openBrowser: options.browser,
28
+ });
29
+ }
30
+ catch (caughtError) {
31
+ const message = caughtError instanceof Error ? caughtError.message : 'Unknown error';
32
+ (0, logger_1.error)(`Failed to start CourseWatcher: ${message}`);
33
+ process.exit(1);
34
+ }
35
+ });
36
+ void commander_1.program.parseAsync();
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createCourseWatcherApp = createCourseWatcherApp;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const promises_1 = __importDefault(require("node:fs/promises"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const express_1 = __importDefault(require("express"));
11
+ const catalog_repository_1 = require("../../modules/catalog/catalog-repository");
12
+ const catalog_service_1 = require("../../modules/catalog/catalog-service");
13
+ const notes_repository_1 = require("../../modules/notes/notes-repository");
14
+ const notes_service_1 = require("../../modules/notes/notes-service");
15
+ const playback_repository_1 = require("../../modules/playback/playback-repository");
16
+ const playback_service_1 = require("../../modules/playback/playback-service");
17
+ const app_config_1 = require("../../platform/config/app-config");
18
+ const app_error_1 = require("../../platform/errors/app-error");
19
+ const logger_1 = require("../../platform/logging/logger");
20
+ async function createCourseWatcherApp(database) {
21
+ const catalogRepository = new catalog_repository_1.CatalogRepository(database);
22
+ const notesRepository = new notes_repository_1.NotesRepository(database);
23
+ const playbackRepository = new playback_repository_1.PlaybackRepository(database);
24
+ const notesService = new notes_service_1.NotesService(notesRepository);
25
+ const playbackService = new playback_service_1.PlaybackService(playbackRepository, catalogRepository);
26
+ const catalogService = new catalog_service_1.CatalogService(catalogRepository, playbackService, notesService);
27
+ const app = (0, express_1.default)();
28
+ app.use(express_1.default.json());
29
+ app.get('/api/catalog', (request, response) => {
30
+ response.json(catalogService.getCatalog(getQueryValue(request.query.sort)));
31
+ });
32
+ app.get('/api/stats', (_request, response) => {
33
+ response.json(catalogService.getStats());
34
+ });
35
+ app.get('/api/search', (request, response) => {
36
+ response.json(catalogService.searchVideos(getQueryValue(request.query.q) ?? ''));
37
+ });
38
+ app.get('/api/videos/:id', (request, response) => {
39
+ response.json(catalogService.getPlayerPayload(getNumericRouteParam(request.params.id)));
40
+ });
41
+ app.get('/api/videos/:id/stream', (request, response) => {
42
+ response.sendFile(catalogService.getVideoPath(getNumericRouteParam(request.params.id)));
43
+ });
44
+ app.post('/api/videos/:id/progress', (request, response) => {
45
+ const videoId = getNumericRouteParam(request.params.id);
46
+ const { duration, position } = request.body;
47
+ response.json(playbackService.updatePosition(videoId, position ?? 0, duration ?? null));
48
+ });
49
+ app.post('/api/videos/:id/status', (request, response) => {
50
+ const videoId = getNumericRouteParam(request.params.id);
51
+ const { status } = request.body;
52
+ response.json(playbackService.updateStatus(videoId, status ?? 'unwatched'));
53
+ });
54
+ app.get('/api/videos/:id/notes', (request, response) => {
55
+ response.json(notesService.getNotes(getNumericRouteParam(request.params.id)));
56
+ });
57
+ app.post('/api/videos/:id/notes', (request, response) => {
58
+ const videoId = getNumericRouteParam(request.params.id);
59
+ const { content } = request.body;
60
+ response.json({
61
+ success: true,
62
+ notes: notesService.saveNotes(videoId, content ?? ''),
63
+ });
64
+ });
65
+ await registerWebApplication(app);
66
+ app.use((caughtError, request, response, _next) => {
67
+ const appError = normalizeError(caughtError);
68
+ if (process.env.NODE_ENV !== 'production') {
69
+ (0, logger_1.error)(`${appError.name}: ${appError.message}`);
70
+ if (appError.stack) {
71
+ console.error(appError.stack);
72
+ }
73
+ }
74
+ if (request.path.startsWith('/api/')) {
75
+ response.status(appError.statusCode).json({
76
+ error: true,
77
+ message: appError.message,
78
+ });
79
+ return;
80
+ }
81
+ response.status(appError.statusCode).send(appError.message);
82
+ });
83
+ return {
84
+ app,
85
+ services: {
86
+ catalogService,
87
+ notesService,
88
+ playbackService,
89
+ },
90
+ };
91
+ }
92
+ function getNumericRouteParam(value) {
93
+ const parsed = Number.parseInt(value, 10);
94
+ if (Number.isNaN(parsed)) {
95
+ throw new app_error_1.AppError('Invalid route parameter', 400);
96
+ }
97
+ return parsed;
98
+ }
99
+ function getQueryValue(value) {
100
+ return typeof value === 'string' ? value : undefined;
101
+ }
102
+ function normalizeError(caughtError) {
103
+ if (caughtError instanceof app_error_1.AppError) {
104
+ return caughtError;
105
+ }
106
+ if (caughtError instanceof Error) {
107
+ return new app_error_1.AppError(caughtError.message, 500);
108
+ }
109
+ return new app_error_1.AppError('Internal Server Error', 500);
110
+ }
111
+ async function registerWebApplication(app) {
112
+ if (app_config_1.appConfig.isDevelopment) {
113
+ const { createServer } = await import('vite');
114
+ const vite = await createServer({
115
+ configFile: node_path_1.default.resolve(process.cwd(), 'vite.config.ts'),
116
+ server: { middlewareMode: true },
117
+ appType: 'custom',
118
+ });
119
+ app.use(vite.middlewares);
120
+ app.use(async (request, response, next) => {
121
+ if (request.path.startsWith('/api/')) {
122
+ next();
123
+ return;
124
+ }
125
+ try {
126
+ const templatePath = node_path_1.default.resolve(process.cwd(), 'web/index.html');
127
+ const template = await promises_1.default.readFile(templatePath, 'utf8');
128
+ const html = await vite.transformIndexHtml(request.originalUrl, template);
129
+ response.status(200).set({ 'Content-Type': 'text/html' }).end(html);
130
+ }
131
+ catch (caughtError) {
132
+ next(caughtError);
133
+ }
134
+ });
135
+ return;
136
+ }
137
+ const webDistPath = node_path_1.default.resolve(process.cwd(), 'dist/web');
138
+ app.use(express_1.default.static(webDistPath));
139
+ app.use((request, response, next) => {
140
+ if (request.path.startsWith('/api/')) {
141
+ next();
142
+ return;
143
+ }
144
+ const indexPath = node_path_1.default.join(webDistPath, 'index.html');
145
+ if (!node_fs_1.default.existsSync(indexPath)) {
146
+ response.status(200).type('html').send('<!doctype html><html><body><div id="root"></div></body></html>');
147
+ return;
148
+ }
149
+ response.sendFile(indexPath);
150
+ });
151
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startServer = startServer;
7
+ exports.resolveCoursePath = resolveCoursePath;
8
+ exports.getDefaultPort = getDefaultPort;
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const open_1 = __importDefault(require("open"));
11
+ const create_app_1 = require("./create-app");
12
+ const app_config_1 = require("../../platform/config/app-config");
13
+ const database_manager_1 = require("../../platform/database/database-manager");
14
+ const logger_1 = require("../../platform/logging/logger");
15
+ async function startServer(options) {
16
+ const database = new database_manager_1.DatabaseManager(options.coursePath).initialize();
17
+ const { app, services } = await (0, create_app_1.createCourseWatcherApp)(database);
18
+ (0, logger_1.log)('Scanning for video files...');
19
+ const scanResult = services.catalogService.scanVideos();
20
+ (0, logger_1.success)(`Found ${scanResult.total} videos (${scanResult.added} new, ${scanResult.existing} existing)`);
21
+ return new Promise((resolve, reject) => {
22
+ const sockets = new Set();
23
+ const start = (port) => {
24
+ const server = app.listen(port, () => {
25
+ const url = `http://localhost:${port}`;
26
+ (0, logger_1.success)(`Server running at ${url}`);
27
+ if (options.openBrowser) {
28
+ void (0, open_1.default)(url).catch(() => undefined);
29
+ }
30
+ server.on('connection', (socket) => {
31
+ sockets.add(socket);
32
+ socket.on('close', () => sockets.delete(socket));
33
+ });
34
+ const shutdown = (signal) => {
35
+ (0, logger_1.log)(`Shutting down... (${signal})`);
36
+ for (const socket of sockets) {
37
+ socket.destroy();
38
+ }
39
+ database.close();
40
+ server.close(() => {
41
+ (0, logger_1.success)('Server closed');
42
+ process.exit(0);
43
+ });
44
+ };
45
+ process.once('SIGINT', () => shutdown('SIGINT'));
46
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
47
+ resolve({
48
+ server,
49
+ database,
50
+ });
51
+ });
52
+ server.on('error', (caughtError) => {
53
+ if (caughtError.code === 'EADDRINUSE' && options.allowFallback) {
54
+ (0, logger_1.log)(`Port ${port} is busy, trying ${port + 1}...`);
55
+ start(port + 1);
56
+ return;
57
+ }
58
+ if (caughtError.code === 'EADDRINUSE') {
59
+ (0, logger_1.error)(`Port ${port} is already in use`);
60
+ }
61
+ reject(caughtError);
62
+ });
63
+ };
64
+ start(options.port);
65
+ });
66
+ }
67
+ function resolveCoursePath(inputPath) {
68
+ return node_path_1.default.resolve(inputPath);
69
+ }
70
+ function getDefaultPort() {
71
+ return app_config_1.appConfig.defaultPort;
72
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeSort = normalizeSort;
4
+ exports.toVideoSummary = toVideoSummary;
5
+ exports.toVideoDetails = toVideoDetails;
6
+ exports.toCatalogModule = toCatalogModule;
7
+ exports.toCourseStats = toCourseStats;
8
+ exports.toSearchResult = toSearchResult;
9
+ function normalizeSort(sortBy) {
10
+ const allowedSorts = ['name', 'name_desc', 'date', 'date_desc'];
11
+ return allowedSorts.includes(sortBy) ? sortBy : 'name';
12
+ }
13
+ function toVideoSummary(video, moduleName = null) {
14
+ return {
15
+ id: video.id,
16
+ title: video.title,
17
+ filename: video.filename,
18
+ duration: video.duration,
19
+ position: video.position,
20
+ status: video.status,
21
+ moduleId: video.module_id,
22
+ moduleName,
23
+ };
24
+ }
25
+ function toVideoDetails(video, moduleName = null) {
26
+ return {
27
+ ...toVideoSummary(video, moduleName),
28
+ path: video.path,
29
+ };
30
+ }
31
+ function toCatalogModule(moduleRow, videos) {
32
+ return {
33
+ id: moduleRow?.id ?? null,
34
+ name: moduleRow?.name ?? 'Videos',
35
+ videos,
36
+ };
37
+ }
38
+ function toCourseStats(total, completed, inProgress) {
39
+ return {
40
+ total,
41
+ completed,
42
+ inProgress,
43
+ unwatched: total - completed - inProgress,
44
+ percentComplete: total > 0 ? Math.round((completed / total) * 100) : 0,
45
+ };
46
+ }
47
+ function toSearchResult(video, moduleName) {
48
+ return toVideoSummary(video, moduleName);
49
+ }
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CatalogRepository = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const app_config_1 = require("../../platform/config/app-config");
10
+ class CatalogRepository {
11
+ database;
12
+ constructor(database) {
13
+ this.database = database;
14
+ }
15
+ scanVideos() {
16
+ const coursePath = this.database.getCoursePath();
17
+ const videos = this.findVideoFiles(coursePath);
18
+ let added = 0;
19
+ let existing = 0;
20
+ this.database.transaction(() => {
21
+ for (const video of videos) {
22
+ const current = this.database.get('SELECT id FROM videos WHERE path = ?', [video.path]);
23
+ if (current) {
24
+ this.database.run('UPDATE videos SET title = ? WHERE id = ?', [video.title, current.id]);
25
+ existing += 1;
26
+ continue;
27
+ }
28
+ const moduleId = this.getOrCreateModule(video.modulePath, video.moduleName);
29
+ this.database.run(`
30
+ INSERT INTO videos (path, filename, title, module_id, sort_order)
31
+ VALUES (?, ?, ?, ?, ?)
32
+ `, [video.path, video.filename, video.title, moduleId, video.sortOrder]);
33
+ added += 1;
34
+ }
35
+ });
36
+ return {
37
+ total: videos.length,
38
+ added,
39
+ existing,
40
+ };
41
+ }
42
+ findVideoById(videoId) {
43
+ return this.database.get('SELECT * FROM videos WHERE id = ?', [videoId]);
44
+ }
45
+ findModuleName(moduleId) {
46
+ if (moduleId === null) {
47
+ return null;
48
+ }
49
+ return this.database.get('SELECT name FROM modules WHERE id = ?', [moduleId])?.name ?? null;
50
+ }
51
+ listModules() {
52
+ return this.database.all('SELECT * FROM modules ORDER BY sort_order, name');
53
+ }
54
+ listVideosForModule(moduleId, sortBy) {
55
+ const orderBy = this.getVideoOrderBy(sortBy);
56
+ if (moduleId === null) {
57
+ return this.database.all(`SELECT * FROM videos WHERE module_id IS NULL ${orderBy}`);
58
+ }
59
+ return this.database.all(`SELECT * FROM videos WHERE module_id = ? ${orderBy}`, [moduleId]);
60
+ }
61
+ searchVideos(query) {
62
+ const searchTerm = `%${query}%`;
63
+ return this.database.all(`
64
+ SELECT v.*, m.name AS module_name
65
+ FROM videos v
66
+ LEFT JOIN modules m ON v.module_id = m.id
67
+ WHERE v.title LIKE ? OR v.filename LIKE ?
68
+ ORDER BY v.sort_order, v.filename
69
+ `, [searchTerm, searchTerm]);
70
+ }
71
+ getStatsCounts() {
72
+ const total = this.database.get('SELECT COUNT(*) AS count FROM videos')?.count ?? 0;
73
+ const completed = this.database.get("SELECT COUNT(*) AS count FROM videos WHERE status = 'completed'")?.count ?? 0;
74
+ const inProgress = this.database.get("SELECT COUNT(*) AS count FROM videos WHERE status = 'in-progress'")?.count ?? 0;
75
+ return {
76
+ total,
77
+ completed,
78
+ inProgress,
79
+ };
80
+ }
81
+ listAdjacentVideoIds(video) {
82
+ const videos = video.module_id === null
83
+ ? this.database.all('SELECT id FROM videos WHERE module_id IS NULL ORDER BY sort_order, filename')
84
+ : this.database.all('SELECT id FROM videos WHERE module_id = ? ORDER BY sort_order, filename', [video.module_id]);
85
+ const currentIndex = videos.findIndex(({ id }) => id === video.id);
86
+ return {
87
+ prev: currentIndex > 0 ? videos[currentIndex - 1].id : null,
88
+ next: currentIndex >= 0 && currentIndex < videos.length - 1 ? videos[currentIndex + 1].id : null,
89
+ };
90
+ }
91
+ listQueueVideos(video) {
92
+ if (video.module_id === null) {
93
+ return this.database.all('SELECT * FROM videos WHERE module_id IS NULL ORDER BY sort_order, filename');
94
+ }
95
+ return this.database.all('SELECT * FROM videos WHERE module_id = ? ORDER BY sort_order, filename', [video.module_id]);
96
+ }
97
+ findVideoFiles(dir, relativeTo = dir) {
98
+ const videos = [];
99
+ try {
100
+ const entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
101
+ for (const entry of entries) {
102
+ const fullPath = node_path_1.default.join(dir, entry.name);
103
+ if (entry.name === app_config_1.appConfig.dbFolder) {
104
+ continue;
105
+ }
106
+ if (entry.isDirectory()) {
107
+ videos.push(...this.findVideoFiles(fullPath, relativeTo));
108
+ continue;
109
+ }
110
+ if (!entry.isFile()) {
111
+ continue;
112
+ }
113
+ const extension = node_path_1.default.extname(entry.name).toLowerCase();
114
+ if (!app_config_1.appConfig.videoExtensions.includes(extension)) {
115
+ continue;
116
+ }
117
+ const relativePath = node_path_1.default.relative(relativeTo, fullPath);
118
+ const parts = relativePath.split(node_path_1.default.sep);
119
+ const moduleName = parts.length > 1 ? parts[0] : 'Root';
120
+ const modulePath = parts.length > 1 ? node_path_1.default.join(relativeTo, parts[0]) : relativeTo;
121
+ videos.push({
122
+ path: fullPath,
123
+ filename: entry.name,
124
+ title: this.extractTitle(entry.name),
125
+ moduleName,
126
+ modulePath,
127
+ sortOrder: this.extractSortOrder(entry.name),
128
+ });
129
+ }
130
+ }
131
+ catch {
132
+ return videos;
133
+ }
134
+ return videos.sort((left, right) => left.sortOrder - right.sortOrder);
135
+ }
136
+ extractTitle(filename) {
137
+ return node_path_1.default.basename(filename, node_path_1.default.extname(filename)).replace(/_/g, ' ').trim() || filename;
138
+ }
139
+ extractSortOrder(value) {
140
+ const match = value.match(/^(\d+)/);
141
+ return match ? Number.parseInt(match[1], 10) : 999;
142
+ }
143
+ getOrCreateModule(modulePath, moduleName) {
144
+ if (moduleName === 'Root') {
145
+ return null;
146
+ }
147
+ const existing = this.database.get('SELECT id FROM modules WHERE path = ?', [modulePath]);
148
+ if (existing) {
149
+ return existing.id;
150
+ }
151
+ const result = this.database.run('INSERT INTO modules (name, path, sort_order) VALUES (?, ?, ?)', [moduleName, modulePath, this.extractSortOrder(moduleName)]);
152
+ return Number(result.lastInsertRowid);
153
+ }
154
+ getVideoOrderBy(sortBy) {
155
+ switch (sortBy) {
156
+ case 'name_desc':
157
+ return 'ORDER BY sort_order DESC, filename DESC';
158
+ case 'date':
159
+ return 'ORDER BY created_at ASC';
160
+ case 'date_desc':
161
+ return 'ORDER BY created_at DESC';
162
+ case 'name':
163
+ default:
164
+ return 'ORDER BY sort_order, filename';
165
+ }
166
+ }
167
+ }
168
+ exports.CatalogRepository = CatalogRepository;
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CatalogService = void 0;
4
+ const app_error_1 = require("../../platform/errors/app-error");
5
+ const catalog_mappers_1 = require("./catalog-mappers");
6
+ class CatalogService {
7
+ catalogRepository;
8
+ playbackService;
9
+ notesService;
10
+ constructor(catalogRepository, playbackService, notesService) {
11
+ this.catalogRepository = catalogRepository;
12
+ this.playbackService = playbackService;
13
+ this.notesService = notesService;
14
+ }
15
+ scanVideos() {
16
+ return this.catalogRepository.scanVideos();
17
+ }
18
+ getCatalog(sortBy) {
19
+ const currentSort = (0, catalog_mappers_1.normalizeSort)(sortBy);
20
+ const modules = this.catalogRepository.listModules().map((moduleRow) => (0, catalog_mappers_1.toCatalogModule)(moduleRow, this.catalogRepository
21
+ .listVideosForModule(moduleRow.id, currentSort)
22
+ .map((video) => (0, catalog_mappers_1.toVideoSummary)(video, moduleRow.name))));
23
+ const rootVideos = this.catalogRepository
24
+ .listVideosForModule(null, currentSort)
25
+ .map((video) => (0, catalog_mappers_1.toVideoSummary)(video, null));
26
+ if (rootVideos.length > 0) {
27
+ modules.unshift((0, catalog_mappers_1.toCatalogModule)(null, rootVideos));
28
+ }
29
+ const counts = this.catalogRepository.getStatsCounts();
30
+ return {
31
+ currentSort,
32
+ modules,
33
+ stats: (0, catalog_mappers_1.toCourseStats)(counts.total, counts.completed, counts.inProgress),
34
+ };
35
+ }
36
+ getStats() {
37
+ const counts = this.catalogRepository.getStatsCounts();
38
+ return (0, catalog_mappers_1.toCourseStats)(counts.total, counts.completed, counts.inProgress);
39
+ }
40
+ getPlayerPayload(videoId) {
41
+ const video = this.catalogRepository.findVideoById(videoId);
42
+ if (!video) {
43
+ throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
44
+ }
45
+ const moduleName = this.catalogRepository.findModuleName(video.module_id);
46
+ return {
47
+ video: (0, catalog_mappers_1.toVideoDetails)(video, moduleName),
48
+ adjacent: this.catalogRepository.listAdjacentVideoIds(video),
49
+ notes: this.notesService.getNotes(videoId),
50
+ queue: {
51
+ currentId: video.id,
52
+ moduleName: moduleName ?? 'Videos',
53
+ videos: this.catalogRepository
54
+ .listQueueVideos(video)
55
+ .map((queueVideo) => (0, catalog_mappers_1.toVideoSummary)(queueVideo, moduleName)),
56
+ },
57
+ startPosition: this.playbackService.getPlaybackStartPosition(video),
58
+ };
59
+ }
60
+ searchVideos(query) {
61
+ if (!query) {
62
+ return [];
63
+ }
64
+ return this.catalogRepository
65
+ .searchVideos(query)
66
+ .map((video) => (0, catalog_mappers_1.toSearchResult)(video, video.module_name));
67
+ }
68
+ getVideoPath(videoId) {
69
+ const video = this.catalogRepository.findVideoById(videoId);
70
+ if (!video) {
71
+ throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
72
+ }
73
+ return video.path;
74
+ }
75
+ }
76
+ exports.CatalogService = CatalogService;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NotesRepository = void 0;
4
+ class NotesRepository {
5
+ database;
6
+ constructor(database) {
7
+ this.database = database;
8
+ }
9
+ findVideo(videoId) {
10
+ return this.database.get('SELECT id FROM videos WHERE id = ?', [videoId]);
11
+ }
12
+ findNotes(videoId) {
13
+ return this.database.get('SELECT * FROM notes WHERE video_id = ?', [videoId]);
14
+ }
15
+ saveNotes(videoId, content) {
16
+ this.database.run(`
17
+ INSERT INTO notes (video_id, content, updated_at)
18
+ VALUES (?, ?, CURRENT_TIMESTAMP)
19
+ ON CONFLICT(video_id) DO UPDATE SET
20
+ content = excluded.content,
21
+ updated_at = CURRENT_TIMESTAMP
22
+ `, [videoId, content]);
23
+ }
24
+ }
25
+ exports.NotesRepository = NotesRepository;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NotesService = void 0;
4
+ const app_error_1 = require("../../platform/errors/app-error");
5
+ class NotesService {
6
+ notesRepository;
7
+ constructor(notesRepository) {
8
+ this.notesRepository = notesRepository;
9
+ }
10
+ getNotes(videoId) {
11
+ if (!this.notesRepository.findVideo(videoId)) {
12
+ throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
13
+ }
14
+ const notes = this.notesRepository.findNotes(videoId);
15
+ return {
16
+ videoId,
17
+ content: notes?.content ?? '',
18
+ };
19
+ }
20
+ saveNotes(videoId, content) {
21
+ if (!this.notesRepository.findVideo(videoId)) {
22
+ throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
23
+ }
24
+ this.notesRepository.saveNotes(videoId, content);
25
+ return this.getNotes(videoId);
26
+ }
27
+ }
28
+ exports.NotesService = NotesService;