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/LICENSE +21 -0
- package/README.md +68 -0
- package/bin/coursewatcher.js +43 -0
- package/package.json +57 -0
- package/public/css/styles.css +935 -0
- package/public/js/player.js +316 -0
- package/src/controllers/video-controller.js +173 -0
- package/src/models/database.js +169 -0
- package/src/server.js +152 -0
- package/src/services/notes-service.js +97 -0
- package/src/services/progress-service.js +148 -0
- package/src/services/video-service.js +322 -0
- package/src/utils/config.js +56 -0
- package/src/utils/errors.js +57 -0
- package/src/utils/logger.js +48 -0
- package/views/layouts/main.ejs +13 -0
- package/views/pages/error.ejs +25 -0
- package/views/pages/index.ejs +101 -0
- package/views/pages/player.ejs +118 -0
- package/views/pages/search.ejs +63 -0
- package/views/partials/footer.ejs +3 -0
- package/views/partials/head.ejs +8 -0
- package/views/partials/header.ejs +14 -0
- package/views/partials/video-card.ejs +36 -0
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 };
|