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
@@ -1,169 +0,0 @@
1
- /**
2
- * Database Module
3
- *
4
- * SQLite database connection manager using better-sqlite3.
5
- * Handles schema creation and provides query methods.
6
- *
7
- * @module models/database
8
- */
9
-
10
- const Database = require('better-sqlite3');
11
- const path = require('path');
12
- const fs = require('fs');
13
- const { getDbPath, getDataFolder } = require('../utils/config');
14
- const { DatabaseError } = require('../utils/errors');
15
-
16
- /**
17
- * Database manager class
18
- */
19
- class DatabaseManager {
20
- /**
21
- * Create a new DatabaseManager
22
- * @param {string} coursePath - Path to the course directory
23
- */
24
- constructor(coursePath) {
25
- this._coursePath = coursePath;
26
- this._db = null;
27
- }
28
-
29
- /**
30
- * Initialize the database connection and create schema
31
- * @returns {DatabaseManager} This instance for chaining
32
- */
33
- initialize() {
34
- try {
35
- // Ensure .coursewatcher folder exists
36
- const dataFolder = getDataFolder(this._coursePath);
37
- if (!fs.existsSync(dataFolder)) {
38
- fs.mkdirSync(dataFolder, { recursive: true });
39
- }
40
-
41
- // Open database connection
42
- const dbPath = getDbPath(this._coursePath);
43
- this._db = new Database(dbPath);
44
-
45
- // Enable foreign keys
46
- this._db.pragma('foreign_keys = ON');
47
-
48
- // Create schema
49
- this._createSchema();
50
-
51
- return this;
52
- } catch (err) {
53
- throw new DatabaseError(`Failed to initialize database: ${err.message}`);
54
- }
55
- }
56
-
57
- /**
58
- * Create database schema
59
- * @private
60
- */
61
- _createSchema() {
62
- // Modules table
63
- this._db.exec(`
64
- CREATE TABLE IF NOT EXISTS modules (
65
- id INTEGER PRIMARY KEY AUTOINCREMENT,
66
- name TEXT NOT NULL,
67
- path TEXT NOT NULL UNIQUE,
68
- sort_order INTEGER DEFAULT 0,
69
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
70
- )
71
- `);
72
-
73
- // Videos table
74
- this._db.exec(`
75
- CREATE TABLE IF NOT EXISTS videos (
76
- id INTEGER PRIMARY KEY AUTOINCREMENT,
77
- path TEXT NOT NULL UNIQUE,
78
- filename TEXT NOT NULL,
79
- title TEXT NOT NULL,
80
- duration REAL DEFAULT 0,
81
- position REAL DEFAULT 0,
82
- status TEXT CHECK(status IN ('unwatched', 'in-progress', 'completed')) DEFAULT 'unwatched',
83
- module_id INTEGER,
84
- sort_order INTEGER DEFAULT 0,
85
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
86
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
87
- FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE SET NULL
88
- )
89
- `);
90
-
91
- // Notes table
92
- this._db.exec(`
93
- CREATE TABLE IF NOT EXISTS notes (
94
- id INTEGER PRIMARY KEY AUTOINCREMENT,
95
- video_id INTEGER NOT NULL UNIQUE,
96
- content TEXT DEFAULT '',
97
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
98
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
99
- FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
100
- )
101
- `);
102
-
103
- // Create indexes
104
- this._db.exec(`
105
- CREATE INDEX IF NOT EXISTS idx_videos_module ON videos(module_id);
106
- CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status);
107
- CREATE INDEX IF NOT EXISTS idx_videos_path ON videos(path);
108
- `);
109
- }
110
-
111
- /**
112
- * Get a single row from a query
113
- * @param {string} sql - SQL query
114
- * @param {Array} params - Query parameters
115
- * @returns {Object|undefined} The row or undefined
116
- */
117
- get(sql, params = []) {
118
- return this._db.prepare(sql).get(...params);
119
- }
120
-
121
- /**
122
- * Get all rows from a query
123
- * @param {string} sql - SQL query
124
- * @param {Array} params - Query parameters
125
- * @returns {Array} Array of rows
126
- */
127
- all(sql, params = []) {
128
- return this._db.prepare(sql).all(...params);
129
- }
130
-
131
- /**
132
- * Run an insert/update/delete query
133
- * @param {string} sql - SQL query
134
- * @param {Array} params - Query parameters
135
- * @returns {Object} Result with changes and lastInsertRowid
136
- */
137
- run(sql, params = []) {
138
- return this._db.prepare(sql).run(...params);
139
- }
140
-
141
- /**
142
- * Run multiple statements in a transaction
143
- * @param {Function} fn - Function containing database operations
144
- * @returns {*} Result of the transaction function
145
- */
146
- transaction(fn) {
147
- return this._db.transaction(fn)();
148
- }
149
-
150
- /**
151
- * Close the database connection
152
- */
153
- close() {
154
- if (this._db) {
155
- this._db.close();
156
- this._db = null;
157
- }
158
- }
159
-
160
- /**
161
- * Get the course path
162
- * @returns {string} The course path
163
- */
164
- getCoursePath() {
165
- return this._coursePath;
166
- }
167
- }
168
-
169
- module.exports = { DatabaseManager };
package/src/server.js DELETED
@@ -1,179 +0,0 @@
1
- /**
2
- * Express Server
3
- *
4
- * Main server configuration and startup.
5
- *
6
- * @module server
7
- */
8
-
9
- const express = require('express');
10
- const path = require('path');
11
- const open = require('open');
12
-
13
- const { DatabaseManager } = require('./models/database');
14
- const { VideoService } = require('./services/video-service');
15
- const { ProgressService } = require('./services/progress-service');
16
- const { NotesService } = require('./services/notes-service');
17
- const { createVideoRoutes } = require('./controllers/video-controller');
18
- const { log, success, error } = require('./utils/logger');
19
- const chalk = require('chalk');
20
-
21
- /**
22
- * Start the CourseWatcher server
23
- * @param {Object} options - Server options
24
- * @param {string} options.coursePath - Path to course directory
25
- * @param {number} options.port - Server port
26
- * @param {boolean} options.openBrowser - Whether to open browser
27
- * @returns {Promise<Object>} Server instance and services
28
- */
29
- async function startServer(options) {
30
- const { coursePath, port, openBrowser, allowFallback } = options;
31
-
32
- // Initialize database
33
- log('Initializing database...');
34
- const database = new DatabaseManager(coursePath);
35
- database.initialize();
36
-
37
- // Create services
38
- const videoService = new VideoService(database);
39
- const progressService = new ProgressService(database);
40
- const notesService = new NotesService(database);
41
-
42
- // Scan for videos
43
- log('Scanning for video files...');
44
- const scanResult = videoService.scanVideos();
45
- success(`Found ${scanResult.total} videos (${scanResult.added} new, ${scanResult.existing} existing)`);
46
-
47
- // Create Express app
48
- const app = express();
49
-
50
- // Configure EJS
51
- app.set('view engine', 'ejs');
52
- app.set('views', path.join(__dirname, '..', 'views'));
53
-
54
- // Static files
55
- app.use('/static', express.static(path.join(__dirname, '..', 'public')));
56
- app.use('/lib/plyr', express.static(path.join(__dirname, '..', 'node_modules', 'plyr', 'dist')));
57
-
58
- // Routes
59
- app.use('/', createVideoRoutes({
60
- videoService,
61
- progressService,
62
- notesService,
63
- }));
64
-
65
- // Error handling middleware
66
- app.use((err, req, res, next) => {
67
- const statusCode = err.statusCode || 500;
68
- const message = err.message || 'Internal Server Error';
69
-
70
- // Log error in development
71
- if (process.env.NODE_ENV !== 'production') {
72
- error(`${err.name}: ${message}`);
73
- if (err.stack) {
74
- console.error(err.stack);
75
- }
76
- }
77
-
78
- // API errors return JSON
79
- if (req.path.startsWith('/api/')) {
80
- return res.status(statusCode).json({
81
- error: true,
82
- message,
83
- });
84
- }
85
-
86
- // Page errors render error page
87
- res.status(statusCode).render('pages/error', {
88
- title: 'Error',
89
- statusCode,
90
- message,
91
- });
92
- });
93
-
94
- // Start server
95
- return new Promise((resolve, reject) => {
96
- let currentPort = port;
97
-
98
- const start = (retryPort) => {
99
- const server = app.listen(retryPort, () => {
100
- const url = `http://localhost:${retryPort}`;
101
- success(`Server running at ${chalk.cyan(url)}`);
102
-
103
- // Open browser if requested
104
- if (openBrowser) {
105
- log('Opening browser...');
106
- open(url).catch(() => {
107
- // Ignore browser open errors
108
- });
109
- }
110
-
111
- // Track active connections
112
- const sockets = new Set();
113
- server.on('connection', (socket) => {
114
- sockets.add(socket);
115
- server.once('close', () => sockets.delete(socket));
116
- });
117
-
118
- // Handle graceful shutdown
119
- let isShuttingDown = false;
120
- const shutdown = async (signal) => {
121
- if (isShuttingDown) return;
122
- isShuttingDown = true;
123
-
124
- log(`\nShutting down... (${signal})`);
125
-
126
- // Force exit after timeout if graceful shutdown fails
127
- const forceExitTimeout = setTimeout(() => {
128
- error('Forced shutdown after timeout');
129
- process.exit(1);
130
- }, 5000);
131
-
132
- // Destroy all active connections
133
- for (const socket of sockets) {
134
- socket.destroy();
135
- sockets.delete(socket);
136
- }
137
-
138
- database.close();
139
- server.close(() => {
140
- clearTimeout(forceExitTimeout);
141
- success('Server closed');
142
- process.exit(0);
143
- });
144
- };
145
-
146
- process.on('SIGINT', () => shutdown('SIGINT'));
147
- process.on('SIGTERM', () => shutdown('SIGTERM'));
148
-
149
- resolve({
150
- server,
151
- database,
152
- services: {
153
- videoService,
154
- progressService,
155
- notesService,
156
- },
157
- });
158
- });
159
-
160
- server.on('error', (err) => {
161
- if (err.code === 'EADDRINUSE') {
162
- if (allowFallback) {
163
- log(`Port ${retryPort} is busy, trying ${retryPort + 1}...`);
164
- start(retryPort + 1);
165
- } else {
166
- error(`Port ${retryPort} is already in use`);
167
- reject(err);
168
- }
169
- } else {
170
- reject(err);
171
- }
172
- });
173
- };
174
-
175
- start(currentPort);
176
- });
177
- }
178
-
179
- module.exports = { startServer };
@@ -1,97 +0,0 @@
1
- /**
2
- * Notes Service
3
- *
4
- * Handles per-video notes with Markdown support.
5
- *
6
- * @module services/notes-service
7
- */
8
-
9
- const { NotFoundError } = require('../utils/errors');
10
-
11
- /**
12
- * Notes service class
13
- */
14
- class NotesService {
15
- /**
16
- * Create a new NotesService
17
- * @param {DatabaseManager} database - Database manager instance
18
- */
19
- constructor(database) {
20
- this._db = database;
21
- }
22
-
23
- /**
24
- * Get notes for a video
25
- * @param {number} videoId - Video ID
26
- * @returns {Object} Notes object
27
- */
28
- getNotes(videoId) {
29
- // Verify video exists
30
- const video = this._db.get('SELECT id FROM videos WHERE id = ?', [videoId]);
31
- if (!video) {
32
- throw new NotFoundError(`Video with id ${videoId}`);
33
- }
34
-
35
- const notes = this._db.get(
36
- 'SELECT * FROM notes WHERE video_id = ?',
37
- [videoId]
38
- );
39
-
40
- return notes || { video_id: videoId, content: '' };
41
- }
42
-
43
- /**
44
- * Save notes for a video
45
- * @param {number} videoId - Video ID
46
- * @param {string} content - Note content (Markdown)
47
- * @returns {Object} Updated notes
48
- */
49
- saveNotes(videoId, content) {
50
- // Verify video exists
51
- const video = this._db.get('SELECT id FROM videos WHERE id = ?', [videoId]);
52
- if (!video) {
53
- throw new NotFoundError(`Video with id ${videoId}`);
54
- }
55
-
56
- // Upsert notes
57
- this._db.run(
58
- `INSERT INTO notes (video_id, content, updated_at)
59
- VALUES (?, ?, CURRENT_TIMESTAMP)
60
- ON CONFLICT(video_id) DO UPDATE SET
61
- content = excluded.content,
62
- updated_at = CURRENT_TIMESTAMP`,
63
- [videoId, content]
64
- );
65
-
66
- return this.getNotes(videoId);
67
- }
68
-
69
- /**
70
- * Delete notes for a video
71
- * @param {number} videoId - Video ID
72
- * @returns {boolean} True if deleted
73
- */
74
- deleteNotes(videoId) {
75
- const result = this._db.run(
76
- 'DELETE FROM notes WHERE video_id = ?',
77
- [videoId]
78
- );
79
- return result.changes > 0;
80
- }
81
-
82
- /**
83
- * Get all videos with notes
84
- * @returns {Array} Videos with notes
85
- */
86
- getVideosWithNotes() {
87
- return this._db.all(
88
- `SELECT v.*, n.content as notes_content
89
- FROM videos v
90
- INNER JOIN notes n ON v.id = n.video_id
91
- WHERE n.content != ''
92
- ORDER BY v.sort_order, v.filename`
93
- );
94
- }
95
- }
96
-
97
- module.exports = { NotesService };
@@ -1,169 +0,0 @@
1
- /**
2
- * Progress Service
3
- *
4
- * Handles video playback progress tracking and status updates.
5
- *
6
- * @module services/progress-service
7
- */
8
-
9
- const { config } = require('../utils/config');
10
- const { NotFoundError, ValidationError } = require('../utils/errors');
11
-
12
- /**
13
- * Progress service class
14
- */
15
- class ProgressService {
16
- /**
17
- * Create a new ProgressService
18
- * @param {DatabaseManager} database - Database manager instance
19
- */
20
- constructor(database) {
21
- this._db = database;
22
- }
23
-
24
- /**
25
- * Update the playback position for a video
26
- * @param {number} videoId - Video ID
27
- * @param {number} position - Current position in seconds
28
- * @param {number} [duration] - Video duration in seconds
29
- * @returns {Object} Updated video
30
- */
31
- updatePosition(videoId, position, duration = null) {
32
- // Validate inputs
33
- if (typeof position !== 'number' || position < 0) {
34
- throw new ValidationError('Position must be a non-negative number');
35
- }
36
-
37
- // Get current video
38
- const video = this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
39
- if (!video) {
40
- throw new NotFoundError(`Video with id ${videoId}`);
41
- }
42
-
43
- // Determine new status based on position
44
- let newStatus = video.status;
45
- const videoDuration = typeof duration === 'number' && !Number.isNaN(duration)
46
- ? duration
47
- : video.duration;
48
- const isShortVideo = videoDuration > 0
49
- && videoDuration < config.shortVideoResumeCutoffSeconds;
50
- const completionPosition = videoDuration * config.completionThreshold;
51
-
52
- if (videoDuration > 0) {
53
- const watchPercent = position / videoDuration;
54
-
55
- if (watchPercent >= config.completionThreshold) {
56
- newStatus = 'completed';
57
- } else if (position > 0) {
58
- newStatus = 'in-progress';
59
- }
60
- } else if (position > 0 && video.status === 'unwatched') {
61
- newStatus = 'in-progress';
62
- }
63
-
64
- const shouldPreserveCompletedProgress = video.status === 'completed'
65
- && videoDuration > 0
66
- && position < completionPosition;
67
-
68
- if (shouldPreserveCompletedProgress) {
69
- newStatus = 'completed';
70
- }
71
-
72
- let newPosition = position;
73
-
74
- if (shouldPreserveCompletedProgress) {
75
- newPosition = video.position;
76
- } else if (isShortVideo && newStatus !== 'completed') {
77
- newPosition = 0;
78
- }
79
-
80
- // Update database
81
- this._db.run(
82
- `UPDATE videos
83
- SET position = ?,
84
- duration = COALESCE(?, duration),
85
- status = ?,
86
- updated_at = CURRENT_TIMESTAMP
87
- WHERE id = ?`,
88
- [newPosition, duration, newStatus, videoId]
89
- );
90
-
91
- return this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
92
- }
93
-
94
- /**
95
- * Update video status manually
96
- * @param {number} videoId - Video ID
97
- * @param {string} status - New status
98
- * @returns {Object} Updated video
99
- */
100
- updateStatus(videoId, status) {
101
- const validStatuses = ['unwatched', 'in-progress', 'completed'];
102
-
103
- if (!validStatuses.includes(status)) {
104
- throw new ValidationError(
105
- `Invalid status. Must be one of: ${validStatuses.join(', ')}`
106
- );
107
- }
108
-
109
- const video = this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
110
- if (!video) {
111
- throw new NotFoundError(`Video with id ${videoId}`);
112
- }
113
-
114
- // Reset position if marking as unwatched
115
- const newPosition = status === 'unwatched' ? 0 : video.position;
116
-
117
- this._db.run(
118
- `UPDATE videos
119
- SET status = ?,
120
- position = ?,
121
- updated_at = CURRENT_TIMESTAMP
122
- WHERE id = ?`,
123
- [status, newPosition, videoId]
124
- );
125
-
126
- return this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
127
- }
128
-
129
- /**
130
- * Mark video as completed
131
- * @param {number} videoId - Video ID
132
- * @returns {Object} Updated video
133
- */
134
- markCompleted(videoId) {
135
- return this.updateStatus(videoId, 'completed');
136
- }
137
-
138
- /**
139
- * Mark video as unwatched (reset progress)
140
- * @param {number} videoId - Video ID
141
- * @returns {Object} Updated video
142
- */
143
- markUnwatched(videoId) {
144
- return this.updateStatus(videoId, 'unwatched');
145
- }
146
-
147
- /**
148
- * Get video progress info
149
- * @param {number} videoId - Video ID
150
- * @returns {Object} Progress info
151
- */
152
- getProgress(videoId) {
153
- const video = this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
154
- if (!video) {
155
- throw new NotFoundError(`Video with id ${videoId}`);
156
- }
157
-
158
- return {
159
- position: video.position,
160
- duration: video.duration,
161
- status: video.status,
162
- percentWatched: video.duration > 0
163
- ? Math.round((video.position / video.duration) * 100)
164
- : 0,
165
- };
166
- }
167
- }
168
-
169
- module.exports = { ProgressService };