coursewatcher 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/src/server.js ADDED
@@ -0,0 +1,152 @@
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 } = 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
+
57
+ // Routes
58
+ app.use('/', createVideoRoutes({
59
+ videoService,
60
+ progressService,
61
+ notesService,
62
+ }));
63
+
64
+ // Error handling middleware
65
+ app.use((err, req, res, next) => {
66
+ const statusCode = err.statusCode || 500;
67
+ const message = err.message || 'Internal Server Error';
68
+
69
+ // Log error in development
70
+ if (process.env.NODE_ENV !== 'production') {
71
+ error(`${err.name}: ${message}`);
72
+ if (err.stack) {
73
+ console.error(err.stack);
74
+ }
75
+ }
76
+
77
+ // API errors return JSON
78
+ if (req.path.startsWith('/api/')) {
79
+ return res.status(statusCode).json({
80
+ error: true,
81
+ message,
82
+ });
83
+ }
84
+
85
+ // Page errors render error page
86
+ res.status(statusCode).render('pages/error', {
87
+ title: 'Error',
88
+ statusCode,
89
+ message,
90
+ });
91
+ });
92
+
93
+ // Start server
94
+ return new Promise((resolve, reject) => {
95
+ const server = app.listen(port, () => {
96
+ const url = `http://localhost:${port}`;
97
+ success(`Server running at ${chalk.cyan(url)}`);
98
+
99
+ // Open browser if requested
100
+ if (openBrowser) {
101
+ log('Opening browser...');
102
+ open(url).catch(() => {
103
+ // Ignore browser open errors
104
+ });
105
+ }
106
+
107
+ // Handle graceful shutdown
108
+ let isShuttingDown = false;
109
+ const shutdown = async (signal) => {
110
+ if (isShuttingDown) return;
111
+ isShuttingDown = true;
112
+
113
+ log(`\nShutting down... (${signal})`);
114
+
115
+ // Force exit after timeout if graceful shutdown fails
116
+ const forceExitTimeout = setTimeout(() => {
117
+ error('Forced shutdown after timeout');
118
+ process.exit(1);
119
+ }, 5000);
120
+
121
+ database.close();
122
+ server.close(() => {
123
+ clearTimeout(forceExitTimeout);
124
+ success('Server closed');
125
+ process.exit(0);
126
+ });
127
+ };
128
+
129
+ process.on('SIGINT', () => shutdown('SIGINT'));
130
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
131
+
132
+ resolve({
133
+ server,
134
+ database,
135
+ services: {
136
+ videoService,
137
+ progressService,
138
+ notesService,
139
+ },
140
+ });
141
+ });
142
+
143
+ server.on('error', (err) => {
144
+ if (err.code === 'EADDRINUSE') {
145
+ error(`Port ${port} is already in use`);
146
+ }
147
+ reject(err);
148
+ });
149
+ });
150
+ }
151
+
152
+ module.exports = { startServer };
@@ -0,0 +1,97 @@
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 };
@@ -0,0 +1,148 @@
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 = duration || video.duration;
46
+
47
+ if (videoDuration > 0) {
48
+ const watchPercent = position / videoDuration;
49
+
50
+ if (watchPercent >= config.completionThreshold) {
51
+ newStatus = 'completed';
52
+ } else if (position > 0) {
53
+ newStatus = 'in-progress';
54
+ }
55
+ } else if (position > 0 && video.status === 'unwatched') {
56
+ newStatus = 'in-progress';
57
+ }
58
+
59
+ // Update database
60
+ this._db.run(
61
+ `UPDATE videos
62
+ SET position = ?,
63
+ duration = COALESCE(?, duration),
64
+ status = ?,
65
+ updated_at = CURRENT_TIMESTAMP
66
+ WHERE id = ?`,
67
+ [position, duration, newStatus, videoId]
68
+ );
69
+
70
+ return this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
71
+ }
72
+
73
+ /**
74
+ * Update video status manually
75
+ * @param {number} videoId - Video ID
76
+ * @param {string} status - New status
77
+ * @returns {Object} Updated video
78
+ */
79
+ updateStatus(videoId, status) {
80
+ const validStatuses = ['unwatched', 'in-progress', 'completed'];
81
+
82
+ if (!validStatuses.includes(status)) {
83
+ throw new ValidationError(
84
+ `Invalid status. Must be one of: ${validStatuses.join(', ')}`
85
+ );
86
+ }
87
+
88
+ const video = this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
89
+ if (!video) {
90
+ throw new NotFoundError(`Video with id ${videoId}`);
91
+ }
92
+
93
+ // Reset position if marking as unwatched
94
+ const newPosition = status === 'unwatched' ? 0 : video.position;
95
+
96
+ this._db.run(
97
+ `UPDATE videos
98
+ SET status = ?,
99
+ position = ?,
100
+ updated_at = CURRENT_TIMESTAMP
101
+ WHERE id = ?`,
102
+ [status, newPosition, videoId]
103
+ );
104
+
105
+ return this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
106
+ }
107
+
108
+ /**
109
+ * Mark video as completed
110
+ * @param {number} videoId - Video ID
111
+ * @returns {Object} Updated video
112
+ */
113
+ markCompleted(videoId) {
114
+ return this.updateStatus(videoId, 'completed');
115
+ }
116
+
117
+ /**
118
+ * Mark video as unwatched (reset progress)
119
+ * @param {number} videoId - Video ID
120
+ * @returns {Object} Updated video
121
+ */
122
+ markUnwatched(videoId) {
123
+ return this.updateStatus(videoId, 'unwatched');
124
+ }
125
+
126
+ /**
127
+ * Get video progress info
128
+ * @param {number} videoId - Video ID
129
+ * @returns {Object} Progress info
130
+ */
131
+ getProgress(videoId) {
132
+ const video = this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
133
+ if (!video) {
134
+ throw new NotFoundError(`Video with id ${videoId}`);
135
+ }
136
+
137
+ return {
138
+ position: video.position,
139
+ duration: video.duration,
140
+ status: video.status,
141
+ percentWatched: video.duration > 0
142
+ ? Math.round((video.position / video.duration) * 100)
143
+ : 0,
144
+ };
145
+ }
146
+ }
147
+
148
+ module.exports = { ProgressService };